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