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 / Windows Scheduled Task that runs
14//!   `node <entry> machine start`, written under `<home>/service/`;
15//!   [`install_service`] and [`uninstall_service`] drive `launchctl` /
16//!   `systemctl --user` / `schtasks` 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, resolve_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 path = teams_home
110        .join(SERVICE_DIR)
111        .join(format!("{label}.{}", connector_unit_suffix()));
112    let node = absolute_program(node);
113    let entry = entry_text;
114    let workspace = workspace_text;
115    let home = supercode_home_text;
116    let supercode = supercode_text;
117    if cfg!(windows) {
118        let log_path = teams_home.join(SERVICE_DIR).join(format!("{label}.log"));
119        return windows_task(
120            &path,
121            &label,
122            &format!("supercode Teams connector ({context})"),
123            &service_env_path(teams_home, &label),
124            &[
125                ("SUPERCODE_HOME", home.as_str()),
126                ("SUPERCODE_BIN", supercode.as_str()),
127                ("SUPERCODE_TEAMS_LOG", &log_path.display().to_string()),
128            ],
129            &node,
130            &[
131                entry.as_str(),
132                "teams",
133                "connect",
134                "--foreground",
135                "--context",
136                context,
137                "--cwd",
138                workspace.as_str(),
139            ],
140            &workspace,
141        );
142    }
143    if cfg!(target_os = "macos") {
144        let node = plist_text(&node);
145        let entry = plist_text(&entry);
146        let workspace = plist_text(&workspace);
147        let home = plist_text(&home);
148        let supercode = plist_text(&supercode);
149        let context = plist_text(context);
150        let search_path = plist_text(&service_path());
151        // `ProcessType Interactive`: without it launchd spawns the connector as a daemon-type job at background
152        // priority (20, against 31 for the user's own processes), and on a busy disk its throttled reads made a
153        // full session discovery of 4,300 sessions miss the 25 s bound (gemini alone 15 s against 2.7 s), so
154        // `discover --fleet` listed the machine unreachable. The connector answers people waiting on it.
155        let text = format!(
156            r#"<?xml version="1.0" encoding="UTF-8"?>
157<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
158<plist version="1.0"><dict>
159  <key>Label</key><string>{label}</string>
160  <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>
161  <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>
162  <key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ProcessType</key><string>Interactive</string>
163  <key>StandardOutPath</key><string>{}/service/{label}.out.log</string>
164  <key>StandardErrorPath</key><string>{}/service/{label}.err.log</string>
165</dict></plist>
166"#,
167            plist_text(&teams_home_text),
168            plist_text(&teams_home_text)
169        );
170        Ok(ServiceUnit {
171            kind: "launchd",
172            path: path.clone(),
173            text,
174            install_command: format!("launchctl bootstrap gui/$(id -u) {}", path.display()),
175            files: Vec::new(),
176        })
177    } else {
178        let environment_home = systemd_arg(&format!("SUPERCODE_HOME={home}"));
179        let environment_bin = systemd_arg(&format!("SUPERCODE_BIN={supercode}"));
180        let environment_path = systemd_arg(&format!("PATH={}", service_path()));
181        let node = systemd_arg(&node);
182        let entry = systemd_arg(&entry);
183        let workspace = systemd_arg(&workspace);
184        let context_description = context.replace('%', "%%").replace('$', "$$");
185        let context = systemd_arg(context);
186        // KillMode=process: the tmux server holding the machine's panes forks into this unit's cgroup, and a
187        // restart must end only the connector, never the panes.
188        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\nKillMode=process\n\n[Install]\nWantedBy=default.target\n");
189        Ok(ServiceUnit {
190            kind: "systemd",
191            path: path.clone(),
192            text,
193            install_command: format!(
194                "systemctl --user link {} && systemctl --user enable --now {label}",
195                path.display()
196            ),
197            files: Vec::new(),
198        })
199    }
200}
201
202/// The connector unit's file suffix on this platform.
203fn connector_unit_suffix() -> &'static str {
204    if cfg!(windows) {
205        "xml"
206    } else if cfg!(target_os = "macos") {
207        "plist"
208    } else {
209        "service"
210    }
211}
212
213/// The environment file a Windows service task hands to node (`--env-file`).
214fn service_env_path(teams_home: &Path, label: &str) -> PathBuf {
215    teams_home.join(SERVICE_DIR).join(format!("{label}.env"))
216}
217
218/// Render a per-user Scheduled Task that keeps `node --env-file=<env_path> <node_args…>` running, written to `path`
219/// with its environment file beside it. Shared by the connector and the machine daemon.
220#[allow(clippy::too_many_arguments)]
221fn windows_task(
222    path: &Path,
223    label: &str,
224    description: &str,
225    env_path: &Path,
226    env: &[(&str, &str)],
227    node: &str,
228    node_args: &[&str],
229    working_directory: &str,
230) -> Result<ServiceUnit, TeamsError> {
231    // A scheduled task sets no environment and keeps no output, so node reads both from a file beside the
232    // task (`--env-file`); the user's own PATH is the task's. `conhost --headless` runs it without a window,
233    // and hides node's exit code too, so restart-on-failure never sees one fail: a trigger every minute
234    // starts the service again instead, and while it runs the task's IgnoreNew makes that tick a no-op.
235    // The trigger's fixed past start keeps the rendered unit the same on every install.
236    let env_text = env
237        .iter()
238        .map(|(key, value)| env_file_value(value).map(|value| format!("{key}={value}\n")))
239        .collect::<Result<String, _>>()?;
240    let env_flag = format!("--env-file={}", env_path.display());
241    let mut arguments = vec![node, env_flag.as_str()];
242    arguments.extend_from_slice(node_args);
243    // Task Scheduler expands `%NAME%` in a task's arguments, and there is no escape for it.
244    for value in arguments.iter().chain([&working_directory]) {
245        if value.contains('%') {
246            return Err(TeamsError::Service {
247                action: "render",
248                detail: format!("a Windows task cannot carry a path containing `%`: {value}"),
249            });
250        }
251    }
252    let arguments = arguments
253        .iter()
254        .map(|argument| windows_arg(argument))
255        .collect::<Vec<_>>()
256        .join(" ");
257    let user = windows_user();
258    let conhost = Path::new(&std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into()))
259        .join(r"System32\conhost.exe")
260        .display()
261        .to_string();
262    // `<Priority>4</Priority>`: a task's default priority is 7, below normal for CPU and I/O. Not measured on
263    // Windows: the same throttling measured on macOS (see the launchd plist) made discovery miss its bound there.
264    let text = format!(
265        r#"<?xml version="1.0" encoding="UTF-16"?>
266<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
267  <RegistrationInfo><Description>{}</Description></RegistrationInfo>
268  <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{}</UserId></LogonTrigger><TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary><Enabled>true</Enabled><Repetition><Interval>PT1M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition></TimeTrigger></Triggers>
269  <Principals><Principal id="Author"><UserId>{}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
270  <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Priority>4</Priority><RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure><StartWhenAvailable>true</StartWhenAvailable></Settings>
271  <Actions Context="Author"><Exec><Command>{}</Command><Arguments>--headless {}</Arguments><WorkingDirectory>{}</WorkingDirectory></Exec></Actions>
272</Task>
273"#,
274        xml_text(description),
275        xml_text(&user),
276        xml_text(&user),
277        xml_text(&conhost),
278        xml_text(&arguments),
279        xml_text(working_directory),
280    );
281    Ok(ServiceUnit {
282        kind: "schtasks",
283        path: path.to_path_buf(),
284        text,
285        install_command: format!("schtasks /Create /TN {label} /XML {} /F", path.display()),
286        files: vec![(env_path.to_path_buf(), env_text)],
287    })
288}
289
290/// Text inside a Task Scheduler XML element or attribute.
291fn xml_text(value: &str) -> String {
292    plist_text(value).replace('"', "&quot;")
293}
294
295/// One argument of a Windows command line, quoted so the C runtime (node.exe's) splits it back out whole:
296/// backslashes are literal except before a quote, where they and the quote are escaped.
297fn windows_arg(value: &str) -> String {
298    if !value.is_empty() && !value.contains([' ', '\t', '"']) {
299        return value.to_string();
300    }
301    let mut quoted = String::from("\"");
302    let mut backslashes = 0;
303    for character in value.chars() {
304        match character {
305            '\\' => backslashes += 1,
306            '"' => {
307                quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
308                quoted.push('"');
309                backslashes = 0;
310            }
311            other => {
312                quoted.push_str(&"\\".repeat(backslashes));
313                quoted.push(other);
314                backslashes = 0;
315            }
316        }
317    }
318    quoted.push_str(&"\\".repeat(backslashes * 2));
319    quoted.push('"');
320    quoted
321}
322
323/// A value in a node `--env-file`, quoted with a quote character the value does not contain. Single and backtick
324/// quotes are literal; double quotes would turn a path's `\n` into a newline, so they are the last choice.
325fn env_file_value(value: &str) -> Result<String, TeamsError> {
326    ['\'', '`']
327        .into_iter()
328        .find(|quote| !value.contains(*quote))
329        .map(|quote| format!("{quote}{value}{quote}"))
330        .or_else(|| (!value.contains(['"', '\\'])).then(|| format!("\"{value}\"")))
331        .ok_or_else(|| TeamsError::Service {
332            action: "render",
333            detail: format!("cannot write `{value}` into a service's environment file"),
334        })
335}
336
337/// The Windows account a task runs as: `DOMAIN\user`, the one installing it.
338fn windows_user() -> String {
339    let user = std::env::var("USERNAME").unwrap_or_default();
340    match std::env::var("USERDOMAIN") {
341        Ok(domain) if !domain.is_empty() => format!("{domain}\\{user}"),
342        _ => user,
343    }
344}
345
346/// The binary an installed connector runs. On Windows a running `.exe` cannot be replaced, so a
347/// connector that ran the npm package's own binary made `npm install -g` fail with EBUSY for as long
348/// as it ran: there the service runs a copy kept per version under the teams service directory, and
349/// installing again after an upgrade moves it to the new copy. Copies of other versions that no
350/// process holds any more are removed. Everywhere else the binary itself is replaceable in place.
351pub fn connector_service_binary(
352    teams_home: &Path,
353    supercode: &Path,
354) -> Result<PathBuf, TeamsError> {
355    if !cfg!(windows) {
356        return Ok(supercode.to_path_buf());
357    }
358    let file = |path: &Path, source: std::io::Error| TeamsError::File {
359        path: path.to_path_buf(),
360        source,
361    };
362    let copies = teams_home.join(SERVICE_DIR).join("bin");
363    let version = env!("CARGO_PKG_VERSION");
364    let dir = copies.join(version);
365    let copy = dir.join(
366        supercode
367            .file_name()
368            .unwrap_or_else(|| "supercode.exe".as_ref()),
369    );
370    let size = |path: &Path| std::fs::metadata(path).map(|meta| meta.len()).ok();
371    if size(&copy).is_none() || size(&copy) != size(supercode) {
372        std::fs::create_dir_all(&dir).map_err(|source| file(&dir, source))?;
373        std::fs::copy(supercode, &copy).map_err(|source| file(&copy, source))?;
374    }
375    if let Ok(entries) = std::fs::read_dir(&copies) {
376        for entry in entries.flatten() {
377            if entry.file_name() != version {
378                let _ = std::fs::remove_dir_all(entry.path());
379            }
380        }
381    }
382    Ok(copy)
383}
384
385/// Why a teams verb could not do its work.
386#[derive(Debug, thiserror::Error)]
387pub enum TeamsError {
388    /// The Node teams entry could not be located.
389    #[error("no teams entry found (looked for `sdk/teams/{TEAMS_ENTRY}` under: {searched}); install it with `npm install -g {TEAMS_PACKAGE}`")]
390    NoEntry {
391        /// The candidate paths that were searched, joined.
392        searched: String,
393    },
394    /// A service manager refused, or there is none on this platform.
395    #[error("teams service: {action} failed: {detail}")]
396    Service {
397        /// What was attempted (`install`, `uninstall`).
398        action: &'static str,
399        /// What the service manager (or this module) said about it.
400        detail: String,
401    },
402    /// A file under the teams home could not be written or removed.
403    #[error("teams file `{}`: {source}", path.display())]
404    File {
405        /// The path involved.
406        path: PathBuf,
407        /// The underlying I/O failure.
408        source: std::io::Error,
409    },
410}
411
412/// The search path a service runs with: the installing shell's own, so a
413/// service finds the same `tmux`, `node` and harness CLIs its installer did
414/// (a launchd or systemd default path has none of them).
415fn service_path() -> String {
416    std::env::var("PATH")
417        .ok()
418        .filter(|path| !path.trim().is_empty())
419        .unwrap_or_else(|| "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".into())
420}
421
422/// The teams home: `SUPERCODE_TEAMS_HOME`, else `<SUPERCODE_HOME>/teams`.
423///
424/// The same precedence `sdk/volter-teams/home.mjs` uses, so a unit installed from
425/// here serves the home the Node CLI reads.
426pub fn teams_home() -> PathBuf {
427    if let Ok(home) = std::env::var("SUPERCODE_TEAMS_HOME") {
428        if !home.is_empty() {
429            return PathBuf::from(home);
430        }
431    }
432    crate::agent::global_instructions_dir().join("teams")
433}
434
435/// Locate the Node teams entry (`sdk/teams/bin/teams.mjs`).
436///
437/// Candidates, in order: `SUPERCODE_TEAMS_ENTRY` (an explicit override, which
438/// is also how a test points at a fake), the repo checkout the running binary
439/// sits in, the workspace this crate was built from,
440/// and the globally installed npm package — an installed binary has no
441/// checkout, so `npm install -g @volter/supercode-teams` is how a
442/// Machine gets its node. The current directory is never a candidate: the
443/// code a binary runs does not change with where it is run.
444pub fn teams_entry() -> Result<PathBuf, TeamsError> {
445    let mut searched = Vec::new();
446    if let Some(explicit) = std::env::var_os("SUPERCODE_TEAMS_ENTRY") {
447        let path = PathBuf::from(explicit);
448        if path.is_file() {
449            return Ok(path);
450        }
451        searched.push(path.display().to_string());
452    }
453    let mut roots: Vec<PathBuf> = Vec::new();
454    if let Ok(exe) = std::env::current_exe() {
455        // target/<profile>/supercode → the workspace root is two levels up.
456        roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
457    }
458    // A locally built binary's target directory can live anywhere (a shared
459    // cargo build dir, another volume), so the checkout it was built from is
460    // the last candidate. On an installed binary this path simply does not
461    // exist and is skipped like any other miss.
462    if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
463        roots.push(workspace.to_path_buf());
464    }
465    for root in roots {
466        let candidate = root.join("sdk/teams").join(TEAMS_ENTRY);
467        if candidate.is_file() {
468            return Ok(candidate);
469        }
470        searched.push(candidate.display().to_string());
471    }
472    // Installed from npm, this binary sits inside the global node_modules that
473    // also holds the Teams package, so that directory is found from the
474    // binary's own path first (`npm root -g` masks path segments it takes for
475    // secrets, a UUID among them).
476    if let Ok(exe) = std::env::current_exe() {
477        for modules in exe
478            .ancestors()
479            .filter(|dir| dir.file_name().is_some_and(|name| name == "node_modules"))
480        {
481            let candidate = modules.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
482            if candidate.is_file() {
483                return Ok(candidate);
484            }
485            searched.push(candidate.display().to_string());
486        }
487    }
488    if let Some(global) = global_npm_root() {
489        let candidate = global.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
490        if candidate.is_file() {
491            return Ok(candidate);
492        }
493        searched.push(candidate.display().to_string());
494    }
495    Err(TeamsError::NoEntry {
496        searched: searched.join(", "),
497    })
498}
499
500/// Where npm installs global packages (`npm root -g`), when npm is present.
501fn global_npm_root() -> Option<PathBuf> {
502    let output = std::process::Command::new(resolve_program("npm"))
503        .args(["root", "-g"])
504        .stdin(std::process::Stdio::null())
505        .stderr(std::process::Stdio::null())
506        .output()
507        .ok()?;
508    if !output.status.success() {
509        return None;
510    }
511    let text = String::from_utf8_lossy(&output.stdout);
512    let root = text.trim();
513    if root.is_empty() {
514        return None;
515    }
516    Some(PathBuf::from(root))
517}
518
519/// Render the per-platform service unit for this machine's teams daemon.
520///
521/// The node takes no `--root`: it serves the home its own environment
522/// resolves (`SUPERCODE_TEAMS_HOME`, else `<SUPERCODE_HOME>/teams`), so the
523/// unit names the listen address and nothing else. A port of `0` means the
524/// node picks one and publishes it in `<home>/node.json`.
525///
526/// On Windows it is a per-user Scheduled Task, rendered exactly as a connector's is (see [`windows_task`]).
527pub fn service_unit(home: &Path, entry: &Path, node: &str) -> Result<ServiceUnit, TeamsError> {
528    let home_display = home.display().to_string();
529    let entry_display = entry.display().to_string();
530    // A control character would break out of any unit's syntax (a plist string, a systemd line, task XML).
531    for value in [home_display.as_str(), entry_display.as_str(), node] {
532        service_text(value)?;
533    }
534    if cfg!(windows) {
535        let log_path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.log"));
536        return windows_task(
537            &home.join(SERVICE_DIR).join(unit_file_name()),
538            SERVICE_NAME,
539            &format!("supercode teams machine daemon ({home_display})"),
540            &service_env_path(home, SERVICE_NAME),
541            &[
542                ("SUPERCODE_TEAMS_HOME", home_display.as_str()),
543                ("SUPERCODE_TEAMS_LOG", &log_path.display().to_string()),
544            ],
545            node,
546            &[entry_display.as_str(), "machine", "start"],
547            &home_display,
548        );
549    }
550    if cfg!(target_os = "macos") {
551        let path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.plist"));
552        let text = format!(
553            r#"<?xml version="1.0" encoding="UTF-8"?>
554<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
555<plist version="1.0">
556<dict>
557  <key>Label</key><string>{SERVICE_NAME}</string>
558  <key>ProgramArguments</key>
559  <array>
560    <string>{node}</string>
561    <string>{entry_display}</string>
562    <string>machine</string>
563    <string>start</string>
564  </array>
565  <key>EnvironmentVariables</key>
566  <dict>
567    <key>SUPERCODE_TEAMS_HOME</key><string>{home_display}</string>
568  </dict>
569  <key>RunAtLoad</key><true/>
570  <key>KeepAlive</key><true/>
571  <key>ProcessType</key><string>Interactive</string>
572  <key>StandardOutPath</key><string>{home_display}/service/teams-machine.out.log</string>
573  <key>StandardErrorPath</key><string>{home_display}/service/teams-machine.err.log</string>
574</dict>
575</plist>
576"#
577        );
578        let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
579        Ok(ServiceUnit {
580            kind: "launchd",
581            path,
582            text,
583            install_command: install,
584            files: Vec::new(),
585        })
586    } else {
587        let path = home
588            .join(SERVICE_DIR)
589            .join(format!("{SERVICE_NAME}.service"));
590        let text = format!(
591            "[Unit]\n\
592             Description=supercode teams machine daemon ({home_display})\n\
593             After=network.target\n\
594             \n\
595             [Service]\n\
596             Environment=SUPERCODE_TEAMS_HOME={home_display}\n\
597             ExecStart={node} {entry_display} machine start\n\
598             Restart=on-failure\n\
599             KillSignal=SIGTERM\n\
600             KillMode=process\n\
601             \n\
602             [Install]\n\
603             WantedBy=default.target\n"
604        );
605        let install = format!(
606            "systemctl --user link {} && systemctl --user enable --now {SERVICE_NAME}",
607            path.display()
608        );
609        Ok(ServiceUnit {
610            kind: "systemd",
611            path,
612            text,
613            install_command: install,
614            files: Vec::new(),
615        })
616    }
617}
618
619/// Write a rendered unit under `<home>/service/`.
620pub fn write_unit(unit: &ServiceUnit) -> Result<(), TeamsError> {
621    if let Some(parent) = unit.path.parent() {
622        std::fs::create_dir_all(parent).map_err(|source| TeamsError::File {
623            path: unit.path.clone(),
624            source,
625        })?;
626    }
627    std::fs::write(&unit.path, &unit.text).map_err(|source| TeamsError::File {
628        path: unit.path.clone(),
629        source,
630    })?;
631    for (path, text) in &unit.files {
632        std::fs::write(path, text).map_err(|source| TeamsError::File {
633            path: path.clone(),
634            source,
635        })?;
636    }
637    Ok(())
638}
639
640/// Run a service-manager command and return (success, stdout+stderr).
641fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
642    let output = std::process::Command::new(program).args(args).output()?;
643    let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
644    text.push_str(&String::from_utf8_lossy(&output.stderr));
645    Ok((output.status.success(), text.trim().to_string()))
646}
647
648#[cfg(target_os = "macos")]
649fn gui_domain() -> String {
650    // SAFETY: `getuid` reads this process's own real user id and cannot fail.
651    format!("gui/{}", unsafe { libc::getuid() })
652}
653
654/// What the platform's service manager says about the teams daemon unit.
655///
656/// Never starts or installs anything.
657pub fn service_status() -> ServiceState {
658    platform_status()
659}
660
661#[cfg(target_os = "macos")]
662fn platform_status() -> ServiceState {
663    let label = SERVICE_NAME.to_string();
664    let target = format!("{}/{SERVICE_NAME}", gui_domain());
665    match run_tool("launchctl", &["print", &target]) {
666        Ok((true, text)) => ServiceState {
667            kind: "launchd",
668            label,
669            installed: true,
670            pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
671            detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
672        },
673        Ok((false, _)) => ServiceState {
674            kind: "launchd",
675            label,
676            installed: false,
677            pid: None,
678            detail: format!("not bootstrapped in {}", gui_domain()),
679        },
680        Err(error) => ServiceState {
681            kind: "launchd",
682            label,
683            installed: false,
684            pid: None,
685            detail: format!("launchctl unavailable: {error}"),
686        },
687    }
688}
689
690#[cfg(all(unix, not(target_os = "macos")))]
691fn platform_status() -> ServiceState {
692    let label = SERVICE_NAME.to_string();
693    match run_tool("systemctl", &["--user", "is-active", SERVICE_NAME]) {
694        Ok((active, text)) => {
695            let known = run_tool("systemctl", &["--user", "is-enabled", SERVICE_NAME])
696                .map(|(ok, _)| ok)
697                .unwrap_or(false);
698            ServiceState {
699                kind: "systemd",
700                label,
701                installed: active || known,
702                pid: None,
703                detail: if text.is_empty() {
704                    "unknown".into()
705                } else {
706                    text
707                },
708            }
709        }
710        Err(error) => ServiceState {
711            kind: "systemd",
712            label,
713            installed: false,
714            pid: None,
715            detail: format!("systemctl unavailable: {error}"),
716        },
717    }
718}
719
720#[cfg(not(unix))]
721fn platform_status() -> ServiceState {
722    named_service_status(SERVICE_NAME)
723}
724
725/// `key = value` out of a service manager's block output.
726#[cfg(target_os = "macos")]
727fn field_of(text: &str, key: &str) -> Option<String> {
728    text.lines()
729        .find_map(|line| line.trim().strip_prefix(key))
730        .map(|value| value.trim().to_string())
731}
732
733/// The file name the unit takes on this platform.
734fn unit_file_name() -> String {
735    if cfg!(windows) {
736        format!("{SERVICE_NAME}.xml")
737    } else if cfg!(target_os = "macos") {
738        format!("{SERVICE_NAME}.plist")
739    } else {
740        format!("{SERVICE_NAME}.service")
741    }
742}
743
744/// Render the unit, hand it to the platform's service manager, and start it.
745///
746/// Refuses a label the manager already holds rather than replacing it: two
747/// homes share one label, so an install that silently took it over would point
748/// a running node at a different folder.
749pub fn install_service(
750    home: &Path,
751    entry: &Path,
752    node: &str,
753) -> Result<(ServiceUnit, ServiceState), TeamsError> {
754    let existing = service_status();
755    if existing.installed {
756        return Err(TeamsError::Service {
757            action: "install",
758            detail: format!(
759                "`{}` is already installed ({}); `supercode teams machine uninstall` first",
760                existing.label, existing.detail
761            ),
762        });
763    }
764    let unit = service_unit(home, entry, &absolute_program(node))?;
765    write_unit(&unit)?;
766    platform_install(&unit)?;
767    Ok((unit, service_status()))
768}
769
770/// A persistent connector must not depend on a mutable checkout or Cargo output.
771/// Resolve symlinks too: an npm-linked SDK is still source, not an installed copy.
772pub fn validate_connector_install_paths(entry: &Path, binary: &Path) -> Result<(), TeamsError> {
773    for (label, path) in [("Teams entry", entry), ("supercode binary", binary)] {
774        let resolved = std::fs::canonicalize(path).map_err(|source| TeamsError::File {
775            path: path.to_path_buf(),
776            source,
777        })?;
778        // npm installs can live inside a checkout of their package manager (Homebrew),
779        // or a user's dotfiles repository. Only ancestors within the installed
780        // package count. Canonicalization above still exposes npm-linked source.
781        let checkout = resolved.ancestors().any(|dir| dir.join(".git").exists());
782        let installed_root = resolved.ancestors().find(|dir| {
783            let Some(scope) = dir.parent() else {
784                return false;
785            };
786            if scope.file_name().and_then(|n| n.to_str()) != Some("@volter")
787                || scope
788                    .parent()
789                    .and_then(Path::file_name)
790                    .and_then(|n| n.to_str())
791                    != Some("node_modules")
792            {
793                return false;
794            }
795            let Ok(text) = std::fs::read_to_string(dir.join("package.json")) else {
796                return false;
797            };
798            let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
799                return false;
800            };
801            let Some(name) = value.get("name").and_then(|v| v.as_str()) else {
802                return false;
803            };
804            let expected = if label == "Teams entry" {
805                name == "@volter/supercode-teams"
806            } else {
807                name.starts_with("@volter/supercode-cli-")
808            };
809            expected && dir.file_name().and_then(|n| n.to_str()) == name.strip_prefix("@volter/")
810        });
811        let checkout = checkout
812            && installed_root.is_none_or(|root| {
813                resolved
814                    .ancestors()
815                    .take_while(|dir| dir.starts_with(root))
816                    .any(|dir| dir.join(".git").exists())
817            });
818        let cargo_output = resolved
819            .parent()
820            .is_some_and(|dir| dir.join("deps").is_dir());
821        if checkout || cargo_output {
822            return Err(TeamsError::Service {
823                action: "install",
824                detail: format!(
825                    "{label} is source/build output at {}; the connector service was not changed. Use an installed package and binary, or teams connect --foreground for source work",
826                    resolved.display()
827                ),
828            });
829        }
830    }
831    Ok(())
832}
833
834/// Install a rendered context connector. An identical installed unit is an
835/// idempotent success. A different unit in this home's own service folder is
836/// this home's connector with new parameters (a new build, PATH or folder), so
837/// it is replaced and restarted; a label the manager holds with no unit here
838/// belongs to another home and is refused.
839pub fn install_connector_service(
840    unit: &ServiceUnit,
841    label: &str,
842) -> Result<ServiceState, TeamsError> {
843    let existing = named_service_status(label);
844    if unit.path.exists() {
845        let old = std::fs::read_to_string(&unit.path).map_err(|source| TeamsError::File {
846            path: unit.path.clone(),
847            source,
848        })?;
849        // A Windows unit's environment lives in its companion file, so that is compared too.
850        let same = old == unit.text
851            && unit
852                .files
853                .iter()
854                .all(|(path, text)| std::fs::read_to_string(path).is_ok_and(|old| &old == text));
855        if same && existing.installed {
856            return Ok(existing);
857        }
858        if !same && existing.installed {
859            named_platform_uninstall(label)?;
860        }
861    } else if existing.installed {
862        return Err(TeamsError::Service {
863            action: "install",
864            detail: format!(
865                "service manager already owns `{label}` without its expected unit file"
866            ),
867        });
868    }
869    write_unit(unit)?;
870    named_platform_install(unit, label)?;
871    Ok(named_service_status(label))
872}
873
874/// Give every installed harness supercode's messaging tools: register
875/// `<supercode> message mcp` as a user-scope MCP server named `supercode`
876/// through each harness's own `mcp add`. An entry that runs another binary
877/// (an older install, a build that is gone) is replaced through the harness's
878/// own `mcp remove`: a session loads only a server that starts. A harness
879/// whose CLI is absent is left as it is. One line per harness says what
880/// happened.
881pub fn register_message_tools(supercode: &Path) -> Vec<String> {
882    let program = supercode.display().to_string();
883    let mut report = Vec::new();
884    for (harness, get, remove, add) in [
885        (
886            "claude",
887            vec!["mcp", "get", "supercode"],
888            vec!["mcp", "remove", "--scope", "user", "supercode"],
889            vec![
890                "mcp",
891                "add",
892                "--scope",
893                "user",
894                "supercode",
895                "--",
896                &program,
897                "message",
898                "mcp",
899            ],
900        ),
901        (
902            "codex",
903            vec!["mcp", "get", "supercode"],
904            vec!["mcp", "remove", "supercode"],
905            vec!["mcp", "add", "supercode", "--", &program, "message", "mcp"],
906        ),
907    ] {
908        let run = |args: &[&str]| {
909            std::process::Command::new(resolve_program(harness))
910                .args(args)
911                .stdin(std::process::Stdio::null())
912                .output()
913        };
914        // Paths compare as text, and Windows paths without regard to case.
915        let names_program = |text: &str| {
916            if cfg!(windows) {
917                text.to_lowercase().contains(&program.to_lowercase())
918            } else {
919                text.contains(&program)
920            }
921        };
922        let replaced = match run(&get) {
923            Err(_) => {
924                report.push(format!("{harness}: not installed"));
925                continue;
926            }
927            Ok(found)
928                if found.status.success()
929                    && names_program(&String::from_utf8_lossy(&found.stdout)) =>
930            {
931                report.push(format!("{harness}: already has supercode's tools"));
932                continue;
933            }
934            Ok(found) if found.status.success() => {
935                let _ = run(&remove);
936                true
937            }
938            Ok(_) => false,
939        };
940        match run(&add) {
941            Ok(added) if added.status.success() => report.push(if replaced {
942                format!("{harness}: supercode's tools now run {program} (new sessions load them)")
943            } else {
944                format!("{harness}: supercode's tools added (new sessions load them)")
945            }),
946            Ok(added) => report.push(format!(
947                "{harness}: could not add supercode's tools: {}",
948                String::from_utf8_lossy(&added.stderr).trim()
949            )),
950            Err(error) => report.push(format!(
951                "{harness}: could not add supercode's tools: {error}"
952            )),
953        }
954    }
955    report
956}
957
958/// Stop and remove exactly one context connector service.
959pub fn uninstall_connector_service(
960    teams_home: &Path,
961    label: &str,
962) -> Result<ServiceState, TeamsError> {
963    named_platform_uninstall(label)?;
964    let unit = teams_home
965        .join(SERVICE_DIR)
966        .join(format!("{label}.{}", connector_unit_suffix()));
967    for path in [unit, service_env_path(teams_home, label)] {
968        match std::fs::remove_file(&path) {
969            Ok(()) => {}
970            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
971            Err(source) => return Err(TeamsError::File { path, source }),
972        }
973    }
974    Ok(named_service_status(label))
975}
976
977/// Read-only service-manager status for one context connector.
978pub fn connector_service_status(label: &str) -> ServiceState {
979    named_service_status(label)
980}
981
982/// Whether a service manager runs this OS user's machine daemon: the machine
983/// unit, or a context connector unit written under the teams home, is
984/// installed. Such a daemon is brought back by its service manager; nothing
985/// else should start one in its place. Never starts or installs anything.
986pub fn service_owns_daemon() -> bool {
987    if service_status().installed {
988        return true;
989    }
990    let Ok(entries) = std::fs::read_dir(teams_home().join(SERVICE_DIR)) else {
991        return false;
992    };
993    entries.flatten().any(|entry| {
994        let name = entry.file_name().to_string_lossy().into_owned();
995        let label = name
996            .strip_suffix(".plist")
997            .or_else(|| name.strip_suffix(".service"))
998            .unwrap_or(&name);
999        label.starts_with("dev.volter.supercode-teams-connector-")
1000            && (name.ends_with(".plist") || name.ends_with(".service"))
1001            && connector_service_status(label).installed
1002    })
1003}
1004
1005#[cfg(target_os = "macos")]
1006fn named_service_status(label: &str) -> ServiceState {
1007    let target = format!("{}/{label}", gui_domain());
1008    match run_tool("launchctl", &["print", &target]) {
1009        Ok((true, text)) => ServiceState {
1010            kind: "launchd",
1011            label: label.into(),
1012            installed: true,
1013            pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
1014            detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
1015        },
1016        Ok((false, _)) => ServiceState {
1017            kind: "launchd",
1018            label: label.into(),
1019            installed: false,
1020            pid: None,
1021            detail: format!("not bootstrapped in {}", gui_domain()),
1022        },
1023        Err(error) => ServiceState {
1024            kind: "launchd",
1025            label: label.into(),
1026            installed: false,
1027            pid: None,
1028            detail: format!("launchctl unavailable: {error}"),
1029        },
1030    }
1031}
1032
1033#[cfg(all(unix, not(target_os = "macos")))]
1034fn named_service_status(label: &str) -> ServiceState {
1035    match run_tool("systemctl", &["--user", "is-active", label]) {
1036        Ok((active, text)) => {
1037            let known = run_tool("systemctl", &["--user", "is-enabled", label])
1038                .map(|(ok, _)| ok)
1039                .unwrap_or(false);
1040            ServiceState {
1041                kind: "systemd",
1042                label: label.into(),
1043                installed: active || known,
1044                pid: None,
1045                detail: if text.is_empty() {
1046                    "unknown".into()
1047                } else {
1048                    text
1049                },
1050            }
1051        }
1052        Err(error) => ServiceState {
1053            kind: "systemd",
1054            label: label.into(),
1055            installed: false,
1056            pid: None,
1057            detail: format!("systemctl unavailable: {error}"),
1058        },
1059    }
1060}
1061
1062#[cfg(not(unix))]
1063fn named_service_status(label: &str) -> ServiceState {
1064    match run_tool("schtasks", &["/Query", "/TN", label, "/FO", "CSV", "/NH"]) {
1065        // `"TaskName","Next Run Time","Status"`: the last field is the task's state, in the system's language.
1066        Ok((true, text)) => ServiceState {
1067            kind: "schtasks",
1068            label: label.into(),
1069            installed: true,
1070            pid: None,
1071            detail: text
1072                .lines()
1073                .next()
1074                .and_then(|line| line.rsplit(',').next())
1075                .map(|state| state.trim_matches('"').to_string())
1076                .filter(|state| !state.is_empty())
1077                .unwrap_or_else(|| "registered".into()),
1078        },
1079        Ok((false, _)) => ServiceState {
1080            kind: "schtasks",
1081            label: label.into(),
1082            installed: false,
1083            pid: None,
1084            detail: "no scheduled task".into(),
1085        },
1086        Err(error) => ServiceState {
1087            kind: "schtasks",
1088            label: label.into(),
1089            installed: false,
1090            pid: None,
1091            detail: format!("schtasks unavailable: {error}"),
1092        },
1093    }
1094}
1095
1096#[cfg(target_os = "macos")]
1097fn named_platform_install(unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
1098    platform_install(unit)
1099}
1100#[cfg(all(unix, not(target_os = "macos")))]
1101fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
1102    let path = unit.path.display().to_string();
1103    for args in [
1104        vec!["--user", "link", path.as_str()],
1105        vec!["--user", "enable", "--now", label],
1106    ] {
1107        let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1108            action: "install",
1109            detail: format!("systemctl: {error}"),
1110        })?;
1111        if !ok {
1112            return Err(TeamsError::Service {
1113                action: "install",
1114                detail: format!("systemctl {}: {text}", args.join(" ")),
1115            });
1116        }
1117    }
1118    Ok(())
1119}
1120#[cfg(not(unix))]
1121fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
1122    // Task Scheduler reads task XML only as UTF-16; the unit itself stays UTF-8 so an install can compare it.
1123    let task = unit.path.with_extension("utf16.xml");
1124    let bytes: Vec<u8> = [0xFF, 0xFE]
1125        .into_iter()
1126        .chain(unit.text.encode_utf16().flat_map(u16::to_le_bytes))
1127        .collect();
1128    std::fs::write(&task, bytes).map_err(|source| TeamsError::File {
1129        path: task.clone(),
1130        source,
1131    })?;
1132    let task_path = task.display().to_string();
1133    let created = run_tool(
1134        "schtasks",
1135        &["/Create", "/TN", label, "/XML", task_path.as_str(), "/F"],
1136    )
1137    .map_err(|error| TeamsError::Service {
1138        action: "install",
1139        detail: format!("schtasks: {error}"),
1140    })
1141    .and_then(|(ok, text)| {
1142        if ok {
1143            Ok(())
1144        } else {
1145            Err(TeamsError::Service {
1146                action: "install",
1147                detail: format!("schtasks /Create: {text}"),
1148            })
1149        }
1150    });
1151    if let Err(error) = created {
1152        let _ = std::fs::remove_file(&task);
1153        return Err(error);
1154    }
1155    // A task already running keeps its old instance: Task Scheduler refuses a
1156    // second one (0x800710E0) while `/Run` still reports success, so the
1157    // replaced connector would go on running the old code. End it first; a
1158    // task that is not running answers an error here, which is fine.
1159    let _ = run_tool("schtasks", &["/End", "/TN", label]);
1160    let result = [vec!["/Run", "/TN", label]].iter().try_for_each(|args| {
1161        let (ok, text) = run_tool("schtasks", args).map_err(|error| TeamsError::Service {
1162            action: "install",
1163            detail: format!("schtasks: {error}"),
1164        })?;
1165        if ok {
1166            Ok(())
1167        } else {
1168            Err(TeamsError::Service {
1169                action: "install",
1170                detail: format!("schtasks {}: {text}", args[0]),
1171            })
1172        }
1173    });
1174    let _ = std::fs::remove_file(&task);
1175    result
1176}
1177
1178#[cfg(target_os = "macos")]
1179fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1180    let target = format!("{}/{label}", gui_domain());
1181    let (ok, text) =
1182        run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1183            action: "uninstall",
1184            detail: format!("launchctl: {error}"),
1185        })?;
1186    if !ok && !text.contains("No such process") && !text.contains("not find") {
1187        return Err(TeamsError::Service {
1188            action: "uninstall",
1189            detail: format!("launchctl bootout {target}: {text}"),
1190        });
1191    }
1192    // bootout returns before launchd has let the label go, and a bootstrap
1193    // in that window fails with an I/O error; wait for it to be released.
1194    for _ in 0..50 {
1195        if !named_service_status(label).installed {
1196            break;
1197        }
1198        std::thread::sleep(std::time::Duration::from_millis(100));
1199    }
1200    Ok(())
1201}
1202#[cfg(all(unix, not(target_os = "macos")))]
1203fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1204    let _ = run_tool("systemctl", &["--user", "disable", "--now", label]);
1205    Ok(())
1206}
1207#[cfg(not(unix))]
1208fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1209    // The task goes first, so its minute trigger cannot start the service again while it is being stopped.
1210    let (ok, text) = run_tool("schtasks", &["/Delete", "/TN", label, "/F"]).map_err(|error| {
1211        TeamsError::Service {
1212            action: "uninstall",
1213            detail: format!("schtasks: {error}"),
1214        }
1215    })?;
1216    if !ok && named_service_status(label).installed {
1217        return Err(TeamsError::Service {
1218            action: "uninstall",
1219            detail: format!("schtasks /Delete: {text}"),
1220        });
1221    }
1222    // Deleting a task leaves what it started running, so the service is stopped by the one thing on its command
1223    // line that is its own: the environment file, on a node or its conhost. The path rides in the environment, not
1224    // the command line, so this query does not match itself.
1225    let env_path = service_env_path(&teams_home(), label);
1226    let stopped = std::process::Command::new("powershell.exe")
1227        .args([
1228            "-NoProfile",
1229            "-NonInteractive",
1230            "-Command",
1231            "Get-CimInstance Win32_Process -Filter \"Name='node.exe' OR Name='conhost.exe'\" | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($env:SUPERCODE_SERVICE_ENV_FILE) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
1232        ])
1233        .env("SUPERCODE_SERVICE_ENV_FILE", env_path.display().to_string())
1234        .stdin(std::process::Stdio::null())
1235        .output();
1236    match stopped {
1237        Ok(output) if output.status.success() => Ok(()),
1238        Ok(output) => Err(TeamsError::Service {
1239            action: "uninstall",
1240            detail: format!(
1241                "the task is gone, but what it started may still run: {}",
1242                String::from_utf8_lossy(&output.stderr).trim()
1243            ),
1244        }),
1245        Err(error) => Err(TeamsError::Service {
1246            action: "uninstall",
1247            detail: format!(
1248                "the task is gone, but what it started may still run: powershell: {error}"
1249            ),
1250        }),
1251    }
1252}
1253
1254#[cfg(target_os = "macos")]
1255fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1256    let path = unit.path.display().to_string();
1257    let (ok, text) =
1258        run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
1259            TeamsError::Service {
1260                action: "install",
1261                detail: format!("launchctl: {error}"),
1262            }
1263        })?;
1264    if !ok {
1265        return Err(TeamsError::Service {
1266            action: "install",
1267            detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
1268        });
1269    }
1270    Ok(())
1271}
1272
1273/// Untested on this box (the receipt is macOS); these are the commands
1274/// `service_unit` prints as its `install_command`.
1275#[cfg(all(unix, not(target_os = "macos")))]
1276fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1277    let path = unit.path.display().to_string();
1278    for args in [
1279        vec!["--user", "link", path.as_str()],
1280        vec!["--user", "enable", "--now", SERVICE_NAME],
1281    ] {
1282        let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1283            action: "install",
1284            detail: format!("systemctl: {error}"),
1285        })?;
1286        if !ok {
1287            return Err(TeamsError::Service {
1288                action: "install",
1289                detail: format!("systemctl {}: {text}", args.join(" ")),
1290            });
1291        }
1292    }
1293    Ok(())
1294}
1295
1296#[cfg(not(unix))]
1297fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1298    named_platform_install(unit, SERVICE_NAME)
1299}
1300
1301/// Stop and unregister the unit, and remove the rendered file.
1302///
1303/// Idempotent: a unit the manager does not hold is not an error, because the
1304/// state the operator asked for is the state they get.
1305pub fn uninstall_service(home: &Path) -> Result<ServiceState, TeamsError> {
1306    platform_uninstall()?;
1307    let unit_path = home.join(SERVICE_DIR).join(unit_file_name());
1308    // A Windows unit's environment file goes with it; elsewhere there is none and its absence is fine.
1309    for path in [unit_path, service_env_path(home, SERVICE_NAME)] {
1310        match std::fs::remove_file(&path) {
1311            Ok(()) => {}
1312            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1313            Err(source) => return Err(TeamsError::File { path, source }),
1314        }
1315    }
1316    // `launchctl bootout` returns before the job is torn down, so the state
1317    // this reports is the settled one, not the manager mid-teardown.
1318    let mut state = service_status();
1319    for _ in 0..40 {
1320        if !state.installed {
1321            break;
1322        }
1323        std::thread::sleep(std::time::Duration::from_millis(100));
1324        state = service_status();
1325    }
1326    Ok(state)
1327}
1328
1329#[cfg(target_os = "macos")]
1330fn platform_uninstall() -> Result<(), TeamsError> {
1331    let target = format!("{}/{SERVICE_NAME}", gui_domain());
1332    let (ok, text) =
1333        run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1334            action: "uninstall",
1335            detail: format!("launchctl: {error}"),
1336        })?;
1337    // `bootout` on a label nobody holds says so and exits non-zero.
1338    if !ok && !text.contains("No such process") && !text.contains("not find") {
1339        return Err(TeamsError::Service {
1340            action: "uninstall",
1341            detail: format!("launchctl bootout {target}: {text}"),
1342        });
1343    }
1344    Ok(())
1345}
1346
1347#[cfg(all(unix, not(target_os = "macos")))]
1348fn platform_uninstall() -> Result<(), TeamsError> {
1349    let _ = run_tool("systemctl", &["--user", "disable", "--now", SERVICE_NAME]);
1350    Ok(())
1351}
1352
1353#[cfg(not(unix))]
1354fn platform_uninstall() -> Result<(), TeamsError> {
1355    named_platform_uninstall(SERVICE_NAME)
1356}