Skip to main content

supercode_harness/
teams.rs

1//! Where supercode-teams lives on this box, and the service unit that keeps
2//! its machine daemon up (`docs/plans/teams-server.md` §11).
3//!
4//! supercode does not implement teams; the `sdk/teams` package does. This
5//! module holds the two facts the Rust CLI needs about it:
6//!
7//! * **where its Node entry is** — [`teams_entry`], resolved exactly the way
8//!   [`crate::orchestrator::daemon_entry`] resolves the orchestrator's:
9//!   `SUPERCODE_TEAMS_ENTRY` first, then the checkout the running binary sits
10//!   in, then the checkout it was built from, then the globally installed
11//!   `@volter/supercode-teams` package (`npm root -g`).
12//! * **what a service unit for its node would say** — [`service_unit`] renders
13//!   the launchd plist / systemd unit that runs `node <entry> machine start`,
14//!   written under `<home>/service/`;
15//!   [`install_service`] and [`uninstall_service`] drive `launchctl` /
16//!   `systemctl --user` over it.
17//!
18//! Everything else about teams — its host key, log, contexts, enrollments — is the Node
19//! package's own state, written by its own CLI. There is no second writer of
20//! that home in this binary.
21
22use std::path::{Path, PathBuf};
23
24use crate::orchestrator::{absolute_program, ServiceState, ServiceUnit};
25
26/// The teams CLI entry inside the `sdk/teams` package.
27pub const TEAMS_ENTRY: &str = "bin/teams.mjs";
28
29/// The npm name the `sdk/teams` package is published under.
30pub const TEAMS_PACKAGE: &str = "@volter/supercode-teams";
31
32/// Directory the rendered service unit is written into, relative to the home.
33pub const SERVICE_DIR: &str = "service";
34
35/// launchd label / systemd unit name for this machine's teams daemon.
36pub const SERVICE_NAME: &str = "dev.volter.supercode-teams-machine";
37
38/// Stable, context-scoped label for one workspace connector service.
39pub fn connector_service_name(server_id: &str, team_id: &str, context: &str) -> String {
40    // FNV-1a is sufficient here: this is a stable filesystem/service label,
41    // not an authorization decision or secret digest.
42    let mut hash = 0xcbf29ce484222325_u64;
43    for byte in [server_id, team_id, context].join("\0").bytes() {
44        hash ^= u64::from(byte);
45        hash = hash.wrapping_mul(0x100000001b3);
46    }
47    format!("dev.volter.supercode-teams-connector-{hash:016x}")
48}
49
50fn plist_text(value: &str) -> String {
51    value
52        .replace('&', "&amp;")
53        .replace('<', "&lt;")
54        .replace('>', "&gt;")
55}
56
57fn service_text(value: &str) -> Result<&str, TeamsError> {
58    if value.chars().any(char::is_control) {
59        return Err(TeamsError::Service {
60            action: "render",
61            detail: "service parameters cannot contain control characters".into(),
62        });
63    }
64    Ok(value)
65}
66
67fn systemd_arg(value: &str) -> String {
68    format!(
69        "\"{}\"",
70        value
71            .replace('\\', "\\\\")
72            .replace('"', "\\\"")
73            .replace('%', "%%")
74            .replace('$', "$$")
75    )
76}
77
78/// Render the persistent foreground connector command for one saved context.
79pub fn connector_service_unit(
80    teams_home: &Path,
81    supercode_home: &Path,
82    entry: &Path,
83    node: &str,
84    supercode: &Path,
85    context: &str,
86    cwd: &Path,
87    server_id: &str,
88    team_id: &str,
89) -> Result<ServiceUnit, TeamsError> {
90    let teams_home_text = teams_home.display().to_string();
91    let supercode_home_text = supercode_home.display().to_string();
92    let entry_text = entry.display().to_string();
93    let supercode_text = supercode.display().to_string();
94    let workspace_text = cwd.display().to_string();
95    for value in [
96        teams_home_text.as_str(),
97        supercode_home_text.as_str(),
98        entry_text.as_str(),
99        node,
100        supercode_text.as_str(),
101        context,
102        workspace_text.as_str(),
103        server_id,
104        team_id,
105    ] {
106        service_text(value)?;
107    }
108    let label = connector_service_name(server_id, team_id, context);
109    let suffix = if cfg!(target_os = "macos") {
110        "plist"
111    } else {
112        "service"
113    };
114    let path = teams_home
115        .join(SERVICE_DIR)
116        .join(format!("{label}.{suffix}"));
117    let node = absolute_program(node);
118    let entry = entry_text;
119    let workspace = workspace_text;
120    let home = supercode_home_text;
121    let supercode = supercode_text;
122    if cfg!(target_os = "macos") {
123        let node = plist_text(&node);
124        let entry = plist_text(&entry);
125        let workspace = plist_text(&workspace);
126        let home = plist_text(&home);
127        let supercode = plist_text(&supercode);
128        let context = plist_text(context);
129        let search_path = plist_text(&service_path());
130        let text = format!(
131            r#"<?xml version="1.0" encoding="UTF-8"?>
132<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
133<plist version="1.0"><dict>
134  <key>Label</key><string>{label}</string>
135  <key>ProgramArguments</key><array><string>{node}</string><string>{entry}</string><string>teams</string><string>connect</string><string>--context</string><string>{context}</string><string>--cwd</string><string>{workspace}</string></array>
136  <key>EnvironmentVariables</key><dict><key>SUPERCODE_HOME</key><string>{home}</string><key>SUPERCODE_BIN</key><string>{supercode}</string><key>PATH</key><string>{search_path}</string></dict>
137  <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
138  <key>StandardOutPath</key><string>{}/service/{label}.out.log</string>
139  <key>StandardErrorPath</key><string>{}/service/{label}.err.log</string>
140</dict></plist>
141"#,
142            plist_text(&teams_home_text),
143            plist_text(&teams_home_text)
144        );
145        Ok(ServiceUnit {
146            kind: "launchd",
147            path: path.clone(),
148            text,
149            install_command: format!("launchctl bootstrap gui/$(id -u) {}", path.display()),
150        })
151    } else {
152        let environment_home = systemd_arg(&format!("SUPERCODE_HOME={home}"));
153        let environment_bin = systemd_arg(&format!("SUPERCODE_BIN={supercode}"));
154        let environment_path = systemd_arg(&format!("PATH={}", service_path()));
155        let node = systemd_arg(&node);
156        let entry = systemd_arg(&entry);
157        let workspace = systemd_arg(&workspace);
158        let context_description = context.replace('%', "%%").replace('$', "$$");
159        let context = systemd_arg(context);
160        let text = format!("[Unit]\nDescription=supercode Teams connector ({context_description})\nAfter=network.target\n\n[Service]\nEnvironment={environment_home}\nEnvironment={environment_bin}\nEnvironment={environment_path}\nExecStart={node} {entry} teams connect --context {context} --cwd {workspace}\nRestart=on-failure\nKillSignal=SIGTERM\n\n[Install]\nWantedBy=default.target\n");
161        Ok(ServiceUnit {
162            kind: "systemd",
163            path: path.clone(),
164            text,
165            install_command: format!(
166                "systemctl --user link {} && systemctl --user enable --now {label}",
167                path.display()
168            ),
169        })
170    }
171}
172
173/// Why a teams verb could not do its work.
174#[derive(Debug, thiserror::Error)]
175pub enum TeamsError {
176    /// The Node teams entry could not be located.
177    #[error("no teams entry found (looked for `sdk/teams/{TEAMS_ENTRY}` under: {searched}); install it with `npm install -g {TEAMS_PACKAGE}`")]
178    NoEntry {
179        /// The candidate paths that were searched, joined.
180        searched: String,
181    },
182    /// A service manager refused, or there is none on this platform.
183    #[error("teams service: {action} failed: {detail}")]
184    Service {
185        /// What was attempted (`install`, `uninstall`).
186        action: &'static str,
187        /// What the service manager (or this module) said about it.
188        detail: String,
189    },
190    /// A file under the teams home could not be written or removed.
191    #[error("teams file `{}`: {source}", path.display())]
192    File {
193        /// The path involved.
194        path: PathBuf,
195        /// The underlying I/O failure.
196        source: std::io::Error,
197    },
198}
199
200/// The search path a service runs with: the installing shell's own, so a
201/// service finds the same `tmux`, `node` and harness CLIs its installer did
202/// (a launchd or systemd default path has none of them).
203fn service_path() -> String {
204    std::env::var("PATH")
205        .ok()
206        .filter(|path| !path.trim().is_empty())
207        .unwrap_or_else(|| "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".into())
208}
209
210/// The teams home: `SUPERCODE_TEAMS_HOME`, else `<SUPERCODE_HOME>/teams`.
211///
212/// The same precedence `sdk/volter-teams/home.mjs` uses, so a unit installed from
213/// here serves the home the Node CLI reads.
214pub fn teams_home() -> PathBuf {
215    if let Ok(home) = std::env::var("SUPERCODE_TEAMS_HOME") {
216        if !home.is_empty() {
217            return PathBuf::from(home);
218        }
219    }
220    crate::agent::global_instructions_dir().join("teams")
221}
222
223/// Locate the Node teams entry (`sdk/teams/bin/teams.mjs`).
224///
225/// Candidates, in order: `SUPERCODE_TEAMS_ENTRY` (an explicit override, which
226/// is also how a test points at a fake), the repo checkout the running binary
227/// sits in, the workspace this crate was built from,
228/// and the globally installed npm package — an installed binary has no
229/// checkout, so `npm install -g @volter/supercode-teams` is how a
230/// Machine gets its node. The current directory is never a candidate: the
231/// code a binary runs does not change with where it is run.
232pub fn teams_entry() -> Result<PathBuf, TeamsError> {
233    let mut searched = Vec::new();
234    if let Some(explicit) = std::env::var_os("SUPERCODE_TEAMS_ENTRY") {
235        let path = PathBuf::from(explicit);
236        if path.is_file() {
237            return Ok(path);
238        }
239        searched.push(path.display().to_string());
240    }
241    let mut roots: Vec<PathBuf> = Vec::new();
242    if let Ok(exe) = std::env::current_exe() {
243        // target/<profile>/supercode → the workspace root is two levels up.
244        roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
245    }
246    // A locally built binary's target directory can live anywhere (a shared
247    // cargo build dir, another volume), so the checkout it was built from is
248    // the last candidate. On an installed binary this path simply does not
249    // exist and is skipped like any other miss.
250    if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
251        roots.push(workspace.to_path_buf());
252    }
253    for root in roots {
254        let candidate = root.join("sdk/teams").join(TEAMS_ENTRY);
255        if candidate.is_file() {
256            return Ok(candidate);
257        }
258        searched.push(candidate.display().to_string());
259    }
260    // Installed from npm, this binary sits inside the global node_modules that
261    // also holds the Teams package, so that directory is found from the
262    // binary's own path first (`npm root -g` masks path segments it takes for
263    // secrets, a UUID among them).
264    if let Ok(exe) = std::env::current_exe() {
265        for modules in exe
266            .ancestors()
267            .filter(|dir| dir.file_name().is_some_and(|name| name == "node_modules"))
268        {
269            let candidate = modules.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
270            if candidate.is_file() {
271                return Ok(candidate);
272            }
273            searched.push(candidate.display().to_string());
274        }
275    }
276    if let Some(global) = global_npm_root() {
277        let candidate = global.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
278        if candidate.is_file() {
279            return Ok(candidate);
280        }
281        searched.push(candidate.display().to_string());
282    }
283    Err(TeamsError::NoEntry {
284        searched: searched.join(", "),
285    })
286}
287
288/// Where npm installs global packages (`npm root -g`), when npm is present.
289fn global_npm_root() -> Option<PathBuf> {
290    let output = std::process::Command::new("npm")
291        .args(["root", "-g"])
292        .stdin(std::process::Stdio::null())
293        .stderr(std::process::Stdio::null())
294        .output()
295        .ok()?;
296    if !output.status.success() {
297        return None;
298    }
299    let text = String::from_utf8_lossy(&output.stdout);
300    let root = text.trim();
301    if root.is_empty() {
302        return None;
303    }
304    Some(PathBuf::from(root))
305}
306
307/// Render the per-platform service unit for this machine's teams daemon.
308///
309/// The node takes no `--root`: it serves the home its own environment
310/// resolves (`SUPERCODE_TEAMS_HOME`, else `<SUPERCODE_HOME>/teams`), so the
311/// unit names the listen address and nothing else. A port of `0` means the
312/// node picks one and publishes it in `<home>/node.json`.
313pub fn service_unit(home: &Path, entry: &Path, node: &str) -> ServiceUnit {
314    let home_display = home.display().to_string();
315    let entry_display = entry.display().to_string();
316    if cfg!(target_os = "macos") {
317        let path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.plist"));
318        let text = format!(
319            r#"<?xml version="1.0" encoding="UTF-8"?>
320<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
321<plist version="1.0">
322<dict>
323  <key>Label</key><string>{SERVICE_NAME}</string>
324  <key>ProgramArguments</key>
325  <array>
326    <string>{node}</string>
327    <string>{entry_display}</string>
328    <string>machine</string>
329    <string>start</string>
330  </array>
331  <key>EnvironmentVariables</key>
332  <dict>
333    <key>SUPERCODE_TEAMS_HOME</key><string>{home_display}</string>
334  </dict>
335  <key>RunAtLoad</key><true/>
336  <key>KeepAlive</key><true/>
337  <key>StandardOutPath</key><string>{home_display}/service/teams-machine.out.log</string>
338  <key>StandardErrorPath</key><string>{home_display}/service/teams-machine.err.log</string>
339</dict>
340</plist>
341"#
342        );
343        let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
344        ServiceUnit {
345            kind: "launchd",
346            path,
347            text,
348            install_command: install,
349        }
350    } else {
351        let path = home
352            .join(SERVICE_DIR)
353            .join(format!("{SERVICE_NAME}.service"));
354        let text = format!(
355            "[Unit]\n\
356             Description=supercode teams machine daemon ({home_display})\n\
357             After=network.target\n\
358             \n\
359             [Service]\n\
360             Environment=SUPERCODE_TEAMS_HOME={home_display}\n\
361             ExecStart={node} {entry_display} machine start\n\
362             Restart=on-failure\n\
363             KillSignal=SIGTERM\n\
364             \n\
365             [Install]\n\
366             WantedBy=default.target\n"
367        );
368        let install = format!(
369            "systemctl --user link {} && systemctl --user enable --now {SERVICE_NAME}",
370            path.display()
371        );
372        ServiceUnit {
373            kind: "systemd",
374            path,
375            text,
376            install_command: install,
377        }
378    }
379}
380
381/// Write a rendered unit under `<home>/service/`.
382pub fn write_unit(unit: &ServiceUnit) -> Result<(), TeamsError> {
383    if let Some(parent) = unit.path.parent() {
384        std::fs::create_dir_all(parent).map_err(|source| TeamsError::File {
385            path: unit.path.clone(),
386            source,
387        })?;
388    }
389    std::fs::write(&unit.path, &unit.text).map_err(|source| TeamsError::File {
390        path: unit.path.clone(),
391        source,
392    })
393}
394
395/// Run a service-manager command and return (success, stdout+stderr).
396fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
397    let output = std::process::Command::new(program).args(args).output()?;
398    let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
399    text.push_str(&String::from_utf8_lossy(&output.stderr));
400    Ok((output.status.success(), text.trim().to_string()))
401}
402
403#[cfg(target_os = "macos")]
404fn gui_domain() -> String {
405    // SAFETY: `getuid` reads this process's own real user id and cannot fail.
406    format!("gui/{}", unsafe { libc::getuid() })
407}
408
409/// What the platform's service manager says about the teams daemon unit.
410///
411/// Never starts or installs anything.
412pub fn service_status() -> ServiceState {
413    platform_status()
414}
415
416#[cfg(target_os = "macos")]
417fn platform_status() -> ServiceState {
418    let label = SERVICE_NAME.to_string();
419    let target = format!("{}/{SERVICE_NAME}", gui_domain());
420    match run_tool("launchctl", &["print", &target]) {
421        Ok((true, text)) => ServiceState {
422            kind: "launchd",
423            label,
424            installed: true,
425            pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
426            detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
427        },
428        Ok((false, _)) => ServiceState {
429            kind: "launchd",
430            label,
431            installed: false,
432            pid: None,
433            detail: format!("not bootstrapped in {}", gui_domain()),
434        },
435        Err(error) => ServiceState {
436            kind: "launchd",
437            label,
438            installed: false,
439            pid: None,
440            detail: format!("launchctl unavailable: {error}"),
441        },
442    }
443}
444
445#[cfg(all(unix, not(target_os = "macos")))]
446fn platform_status() -> ServiceState {
447    let label = SERVICE_NAME.to_string();
448    match run_tool("systemctl", &["--user", "is-active", SERVICE_NAME]) {
449        Ok((active, text)) => {
450            let known = run_tool("systemctl", &["--user", "is-enabled", SERVICE_NAME])
451                .map(|(ok, _)| ok)
452                .unwrap_or(false);
453            ServiceState {
454                kind: "systemd",
455                label,
456                installed: active || known,
457                pid: None,
458                detail: if text.is_empty() {
459                    "unknown".into()
460                } else {
461                    text
462                },
463            }
464        }
465        Err(error) => ServiceState {
466            kind: "systemd",
467            label,
468            installed: false,
469            pid: None,
470            detail: format!("systemctl unavailable: {error}"),
471        },
472    }
473}
474
475#[cfg(not(unix))]
476fn platform_status() -> ServiceState {
477    ServiceState {
478        kind: "none",
479        label: SERVICE_NAME.to_string(),
480        installed: false,
481        pid: None,
482        detail: "no service manager on this platform".into(),
483    }
484}
485
486/// `key = value` out of a service manager's block output.
487#[cfg(target_os = "macos")]
488fn field_of(text: &str, key: &str) -> Option<String> {
489    text.lines()
490        .find_map(|line| line.trim().strip_prefix(key))
491        .map(|value| value.trim().to_string())
492}
493
494/// The file name the unit takes on this platform.
495fn unit_file_name() -> String {
496    if cfg!(target_os = "macos") {
497        format!("{SERVICE_NAME}.plist")
498    } else {
499        format!("{SERVICE_NAME}.service")
500    }
501}
502
503/// Render the unit, hand it to the platform's service manager, and start it.
504///
505/// Refuses a label the manager already holds rather than replacing it: two
506/// homes share one label, so an install that silently took it over would point
507/// a running node at a different folder.
508pub fn install_service(
509    home: &Path,
510    entry: &Path,
511    node: &str,
512) -> Result<(ServiceUnit, ServiceState), TeamsError> {
513    let existing = service_status();
514    if existing.installed {
515        return Err(TeamsError::Service {
516            action: "install",
517            detail: format!(
518                "`{}` is already installed ({}); `supercode teams machine uninstall` first",
519                existing.label, existing.detail
520            ),
521        });
522    }
523    let unit = service_unit(home, entry, &absolute_program(node));
524    write_unit(&unit)?;
525    platform_install(&unit)?;
526    Ok((unit, service_status()))
527}
528
529/// Install a rendered context connector. An identical installed unit is an
530/// idempotent success. A different unit in this home's own service folder is
531/// this home's connector with new parameters (a new build, PATH or folder), so
532/// it is replaced and restarted; a label the manager holds with no unit here
533/// belongs to another home and is refused.
534pub fn install_connector_service(
535    unit: &ServiceUnit,
536    label: &str,
537) -> Result<ServiceState, TeamsError> {
538    let existing = named_service_status(label);
539    if unit.path.exists() {
540        let old = std::fs::read_to_string(&unit.path).map_err(|source| TeamsError::File {
541            path: unit.path.clone(),
542            source,
543        })?;
544        if old == unit.text && existing.installed {
545            return Ok(existing);
546        }
547        if old != unit.text && existing.installed {
548            named_platform_uninstall(label)?;
549        }
550    } else if existing.installed {
551        return Err(TeamsError::Service {
552            action: "install",
553            detail: format!(
554                "service manager already owns `{label}` without its expected unit file"
555            ),
556        });
557    }
558    write_unit(unit)?;
559    named_platform_install(unit, label)?;
560    Ok(named_service_status(label))
561}
562
563/// Give every installed harness supercode's messaging tools: register
564/// `<supercode> message mcp` as a user-scope MCP server named `supercode`
565/// through each harness's own `mcp add`. A harness whose CLI is absent, or
566/// that already has the entry, is left as it is. One line per harness says
567/// what happened.
568pub fn register_message_tools(supercode: &Path) -> Vec<String> {
569    let program = supercode.display().to_string();
570    let mut report = Vec::new();
571    for (harness, get, add) in [
572        (
573            "claude",
574            vec!["mcp", "get", "supercode"],
575            vec![
576                "mcp",
577                "add",
578                "--scope",
579                "user",
580                "supercode",
581                "--",
582                &program,
583                "message",
584                "mcp",
585            ],
586        ),
587        (
588            "codex",
589            vec!["mcp", "get", "supercode"],
590            vec!["mcp", "add", "supercode", "--", &program, "message", "mcp"],
591        ),
592    ] {
593        let run = |args: &[&str]| {
594            std::process::Command::new(harness)
595                .args(args)
596                .stdin(std::process::Stdio::null())
597                .output()
598        };
599        match run(&get) {
600            Err(_) => report.push(format!("{harness}: not installed")),
601            Ok(found) if found.status.success() => {
602                report.push(format!("{harness}: already has supercode's tools"))
603            }
604            Ok(_) => match run(&add) {
605                Ok(added) if added.status.success() => report.push(format!(
606                    "{harness}: supercode's tools added (new sessions load them)"
607                )),
608                Ok(added) => report.push(format!(
609                    "{harness}: could not add supercode's tools: {}",
610                    String::from_utf8_lossy(&added.stderr).trim()
611                )),
612                Err(error) => report.push(format!(
613                    "{harness}: could not add supercode's tools: {error}"
614                )),
615            },
616        }
617    }
618    report
619}
620
621/// Stop and remove exactly one context connector service.
622pub fn uninstall_connector_service(
623    teams_home: &Path,
624    label: &str,
625) -> Result<ServiceState, TeamsError> {
626    named_platform_uninstall(label)?;
627    let suffix = if cfg!(target_os = "macos") {
628        "plist"
629    } else {
630        "service"
631    };
632    let path = teams_home
633        .join(SERVICE_DIR)
634        .join(format!("{label}.{suffix}"));
635    match std::fs::remove_file(&path) {
636        Ok(()) => {}
637        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
638        Err(source) => return Err(TeamsError::File { path, source }),
639    }
640    Ok(named_service_status(label))
641}
642
643/// Read-only service-manager status for one context connector.
644pub fn connector_service_status(label: &str) -> ServiceState {
645    named_service_status(label)
646}
647
648#[cfg(target_os = "macos")]
649fn named_service_status(label: &str) -> ServiceState {
650    let target = format!("{}/{label}", gui_domain());
651    match run_tool("launchctl", &["print", &target]) {
652        Ok((true, text)) => ServiceState {
653            kind: "launchd",
654            label: label.into(),
655            installed: true,
656            pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
657            detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
658        },
659        Ok((false, _)) => ServiceState {
660            kind: "launchd",
661            label: label.into(),
662            installed: false,
663            pid: None,
664            detail: format!("not bootstrapped in {}", gui_domain()),
665        },
666        Err(error) => ServiceState {
667            kind: "launchd",
668            label: label.into(),
669            installed: false,
670            pid: None,
671            detail: format!("launchctl unavailable: {error}"),
672        },
673    }
674}
675
676#[cfg(all(unix, not(target_os = "macos")))]
677fn named_service_status(label: &str) -> ServiceState {
678    match run_tool("systemctl", &["--user", "is-active", label]) {
679        Ok((active, text)) => {
680            let known = run_tool("systemctl", &["--user", "is-enabled", label])
681                .map(|(ok, _)| ok)
682                .unwrap_or(false);
683            ServiceState {
684                kind: "systemd",
685                label: label.into(),
686                installed: active || known,
687                pid: None,
688                detail: if text.is_empty() {
689                    "unknown".into()
690                } else {
691                    text
692                },
693            }
694        }
695        Err(error) => ServiceState {
696            kind: "systemd",
697            label: label.into(),
698            installed: false,
699            pid: None,
700            detail: format!("systemctl unavailable: {error}"),
701        },
702    }
703}
704
705#[cfg(not(unix))]
706fn named_service_status(label: &str) -> ServiceState {
707    ServiceState {
708        kind: "none",
709        label: label.into(),
710        installed: false,
711        pid: None,
712        detail: "no service manager on this platform".into(),
713    }
714}
715
716#[cfg(target_os = "macos")]
717fn named_platform_install(unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
718    platform_install(unit)
719}
720#[cfg(all(unix, not(target_os = "macos")))]
721fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
722    let path = unit.path.display().to_string();
723    for args in [
724        vec!["--user", "link", path.as_str()],
725        vec!["--user", "enable", "--now", label],
726    ] {
727        let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
728            action: "install",
729            detail: format!("systemctl: {error}"),
730        })?;
731        if !ok {
732            return Err(TeamsError::Service {
733                action: "install",
734                detail: format!("systemctl {}: {text}", args.join(" ")),
735            });
736        }
737    }
738    Ok(())
739}
740#[cfg(not(unix))]
741fn named_platform_install(_unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
742    Err(TeamsError::Service {
743        action: "install",
744        detail: "no service manager on this platform".into(),
745    })
746}
747
748#[cfg(target_os = "macos")]
749fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
750    let target = format!("{}/{label}", gui_domain());
751    let (ok, text) =
752        run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
753            action: "uninstall",
754            detail: format!("launchctl: {error}"),
755        })?;
756    if !ok && !text.contains("No such process") && !text.contains("not find") {
757        return Err(TeamsError::Service {
758            action: "uninstall",
759            detail: format!("launchctl bootout {target}: {text}"),
760        });
761    }
762    // bootout returns before launchd has let the label go, and a bootstrap
763    // in that window fails with an I/O error; wait for it to be released.
764    for _ in 0..50 {
765        if !named_service_status(label).installed {
766            break;
767        }
768        std::thread::sleep(std::time::Duration::from_millis(100));
769    }
770    Ok(())
771}
772#[cfg(all(unix, not(target_os = "macos")))]
773fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
774    let _ = run_tool("systemctl", &["--user", "disable", "--now", label]);
775    Ok(())
776}
777#[cfg(not(unix))]
778fn named_platform_uninstall(_label: &str) -> Result<(), TeamsError> {
779    Ok(())
780}
781
782#[cfg(target_os = "macos")]
783fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
784    let path = unit.path.display().to_string();
785    let (ok, text) =
786        run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
787            TeamsError::Service {
788                action: "install",
789                detail: format!("launchctl: {error}"),
790            }
791        })?;
792    if !ok {
793        return Err(TeamsError::Service {
794            action: "install",
795            detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
796        });
797    }
798    Ok(())
799}
800
801/// Untested on this box (the receipt is macOS); these are the commands
802/// `service_unit` prints as its `install_command`.
803#[cfg(all(unix, not(target_os = "macos")))]
804fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
805    let path = unit.path.display().to_string();
806    for args in [
807        vec!["--user", "link", path.as_str()],
808        vec!["--user", "enable", "--now", SERVICE_NAME],
809    ] {
810        let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
811            action: "install",
812            detail: format!("systemctl: {error}"),
813        })?;
814        if !ok {
815            return Err(TeamsError::Service {
816                action: "install",
817                detail: format!("systemctl {}: {text}", args.join(" ")),
818            });
819        }
820    }
821    Ok(())
822}
823
824#[cfg(not(unix))]
825fn platform_install(_unit: &ServiceUnit) -> Result<(), TeamsError> {
826    Err(TeamsError::Service {
827        action: "install",
828        detail: "no service manager on this platform".into(),
829    })
830}
831
832/// Stop and unregister the unit, and remove the rendered file.
833///
834/// Idempotent: a unit the manager does not hold is not an error, because the
835/// state the operator asked for is the state they get.
836pub fn uninstall_service(home: &Path) -> Result<ServiceState, TeamsError> {
837    platform_uninstall()?;
838    let unit_path = home.join(SERVICE_DIR).join(unit_file_name());
839    match std::fs::remove_file(&unit_path) {
840        Ok(()) => {}
841        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
842        Err(source) => {
843            return Err(TeamsError::File {
844                path: unit_path,
845                source,
846            })
847        }
848    }
849    // `launchctl bootout` returns before the job is torn down, so the state
850    // this reports is the settled one, not the manager mid-teardown.
851    let mut state = service_status();
852    for _ in 0..40 {
853        if !state.installed {
854            break;
855        }
856        std::thread::sleep(std::time::Duration::from_millis(100));
857        state = service_status();
858    }
859    Ok(state)
860}
861
862#[cfg(target_os = "macos")]
863fn platform_uninstall() -> Result<(), TeamsError> {
864    let target = format!("{}/{SERVICE_NAME}", gui_domain());
865    let (ok, text) =
866        run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
867            action: "uninstall",
868            detail: format!("launchctl: {error}"),
869        })?;
870    // `bootout` on a label nobody holds says so and exits non-zero.
871    if !ok && !text.contains("No such process") && !text.contains("not find") {
872        return Err(TeamsError::Service {
873            action: "uninstall",
874            detail: format!("launchctl bootout {target}: {text}"),
875        });
876    }
877    Ok(())
878}
879
880#[cfg(all(unix, not(target_os = "macos")))]
881fn platform_uninstall() -> Result<(), TeamsError> {
882    let _ = run_tool("systemctl", &["--user", "disable", "--now", SERVICE_NAME]);
883    Ok(())
884}
885
886#[cfg(not(unix))]
887fn platform_uninstall() -> Result<(), TeamsError> {
888    Ok(())
889}
890
891#[cfg(test)]
892mod connector_service_tests {
893    use super::*;
894
895    #[test]
896    fn connector_unit_is_context_scoped_and_contains_no_credential() {
897        let unit = connector_service_unit(
898            Path::new("/tmp/teams home"),
899            Path::new("/tmp/supercode home"),
900            Path::new("/tmp/sdk/teams/bin/teams.mjs"),
901            "/usr/bin/node",
902            Path::new("/tmp/bin/supercode"),
903            "work",
904            Path::new("/tmp/project with spaces"),
905            "srv_1",
906            "team_1",
907        )
908        .unwrap();
909        assert!(unit.text.contains("teams"));
910        assert!(unit.text.contains("connect"));
911        assert!(unit.text.contains("work"));
912        assert!(!unit.text.contains("credential"));
913        assert!(unit
914            .path
915            .file_name()
916            .unwrap()
917            .to_string_lossy()
918            .contains(&connector_service_name("srv_1", "team_1", "work")));
919    }
920
921    #[test]
922    fn connector_labels_separate_context_and_team() {
923        assert_ne!(
924            connector_service_name("srv", "team-a", "work"),
925            connector_service_name("srv", "team-b", "work")
926        );
927        assert_ne!(
928            connector_service_name("srv", "team-a", "work"),
929            connector_service_name("srv", "team-a", "personal")
930        );
931    }
932
933    #[test]
934    fn connector_unit_rejects_newlines_and_escapes_service_syntax() {
935        let unsafe_unit = connector_service_unit(
936            Path::new("/tmp/teams"),
937            Path::new("/tmp/home"),
938            Path::new("/tmp/entry"),
939            "/usr/bin/node",
940            Path::new("/tmp/supercode"),
941            "bad\ncontext",
942            Path::new("/tmp/work"),
943            "srv",
944            "team",
945        );
946        assert!(unsafe_unit.is_err());
947        let unit = connector_service_unit(
948            Path::new("/tmp/teams & logs"),
949            Path::new("/tmp/home $x"),
950            Path::new("/tmp/entry %i"),
951            "/usr/bin/node",
952            Path::new("/tmp/super\"code"),
953            "work",
954            Path::new("/tmp/a & b % $"),
955            "srv",
956            "team",
957        )
958        .unwrap();
959        if cfg!(target_os = "macos") {
960            assert!(unit.text.contains("&amp;"));
961        } else {
962            assert!(unit.text.contains("%%"));
963            assert!(unit.text.contains("$$"));
964            assert!(unit.text.contains("\\\""));
965        }
966    }
967}