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