1use std::path::{Path, PathBuf};
23
24use crate::orchestrator::{absolute_program, resolve_program, ServiceState, ServiceUnit};
25
26pub const TEAMS_ENTRY: &str = "bin/teams.mjs";
28
29pub const TEAMS_PACKAGE: &str = "@volter/supercode-teams";
31
32pub const SERVICE_DIR: &str = "service";
34
35pub const SERVICE_NAME: &str = "dev.volter.supercode-teams-machine";
37
38pub fn connector_service_name(server_id: &str, team_id: &str, context: &str) -> String {
40 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('&', "&")
53 .replace('<', "<")
54 .replace('>', ">")
55}
56
57fn service_text(value: &str) -> Result<&str, TeamsError> {
58 if value.chars().any(char::is_control) {
59 return Err(TeamsError::Service {
60 action: "render",
61 detail: "service parameters cannot contain control characters".into(),
62 });
63 }
64 Ok(value)
65}
66
67fn systemd_arg(value: &str) -> String {
68 format!(
69 "\"{}\"",
70 value
71 .replace('\\', "\\\\")
72 .replace('"', "\\\"")
73 .replace('%', "%%")
74 .replace('$', "$$")
75 )
76}
77
78pub fn connector_service_unit(
80 teams_home: &Path,
81 supercode_home: &Path,
82 entry: &Path,
83 node: &str,
84 supercode: &Path,
85 context: &str,
86 cwd: &Path,
87 server_id: &str,
88 team_id: &str,
89) -> Result<ServiceUnit, TeamsError> {
90 let teams_home_text = teams_home.display().to_string();
91 let supercode_home_text = supercode_home.display().to_string();
92 let entry_text = entry.display().to_string();
93 let supercode_text = supercode.display().to_string();
94 let workspace_text = cwd.display().to_string();
95 for value in [
96 teams_home_text.as_str(),
97 supercode_home_text.as_str(),
98 entry_text.as_str(),
99 node,
100 supercode_text.as_str(),
101 context,
102 workspace_text.as_str(),
103 server_id,
104 team_id,
105 ] {
106 service_text(value)?;
107 }
108 let label = connector_service_name(server_id, team_id, context);
109 let path = teams_home
110 .join(SERVICE_DIR)
111 .join(format!("{label}.{}", connector_unit_suffix()));
112 let node = absolute_program(node);
113 let entry = entry_text;
114 let workspace = workspace_text;
115 let home = supercode_home_text;
116 let supercode = supercode_text;
117 if cfg!(windows) {
118 let env_path = connector_env_path(teams_home, &label);
124 let log_path = teams_home
125 .join(SERVICE_DIR)
126 .join(format!("{label}.log"))
127 .display()
128 .to_string();
129 let env_text = [
130 ("SUPERCODE_HOME", home.as_str()),
131 ("SUPERCODE_BIN", supercode.as_str()),
132 ("SUPERCODE_TEAMS_LOG", log_path.as_str()),
133 ]
134 .iter()
135 .map(|(key, value)| env_file_value(value).map(|value| format!("{key}={value}\n")))
136 .collect::<Result<String, _>>()?;
137 for value in [&node, &entry, &workspace, &env_path.display().to_string()] {
139 if value.contains('%') {
140 return Err(TeamsError::Service {
141 action: "render",
142 detail: format!("a Windows task cannot carry a path containing `%`: {value}"),
143 });
144 }
145 }
146 let arguments = [
147 node.as_str(),
148 &format!("--env-file={}", env_path.display()),
149 entry.as_str(),
150 "teams",
151 "connect",
152 "--foreground",
153 "--context",
154 context,
155 "--cwd",
156 workspace.as_str(),
157 ]
158 .iter()
159 .map(|argument| windows_arg(argument))
160 .collect::<Vec<_>>()
161 .join(" ");
162 let user = windows_user();
163 let conhost =
164 Path::new(&std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into()))
165 .join(r"System32\conhost.exe")
166 .display()
167 .to_string();
168 let text = format!(
169 r#"<?xml version="1.0" encoding="UTF-16"?>
170<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
171 <RegistrationInfo><Description>supercode Teams connector ({})</Description></RegistrationInfo>
172 <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>
173 <Principals><Principal id="Author"><UserId>{}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
174 <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure><StartWhenAvailable>true</StartWhenAvailable></Settings>
175 <Actions Context="Author"><Exec><Command>{}</Command><Arguments>--headless {}</Arguments><WorkingDirectory>{}</WorkingDirectory></Exec></Actions>
176</Task>
177"#,
178 xml_text(context),
179 xml_text(&user),
180 xml_text(&user),
181 xml_text(&conhost),
182 xml_text(&arguments),
183 xml_text(&workspace),
184 );
185 return Ok(ServiceUnit {
186 kind: "schtasks",
187 path: path.clone(),
188 text,
189 install_command: format!("schtasks /Create /TN {label} /XML {} /F", path.display()),
190 files: vec![(env_path, env_text)],
191 });
192 }
193 if cfg!(target_os = "macos") {
194 let node = plist_text(&node);
195 let entry = plist_text(&entry);
196 let workspace = plist_text(&workspace);
197 let home = plist_text(&home);
198 let supercode = plist_text(&supercode);
199 let context = plist_text(context);
200 let search_path = plist_text(&service_path());
201 let text = format!(
202 r#"<?xml version="1.0" encoding="UTF-8"?>
203<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
204<plist version="1.0"><dict>
205 <key>Label</key><string>{label}</string>
206 <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>
207 <key>EnvironmentVariables</key><dict><key>SUPERCODE_HOME</key><string>{home}</string><key>SUPERCODE_BIN</key><string>{supercode}</string><key>PATH</key><string>{search_path}</string></dict>
208 <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
209 <key>StandardOutPath</key><string>{}/service/{label}.out.log</string>
210 <key>StandardErrorPath</key><string>{}/service/{label}.err.log</string>
211</dict></plist>
212"#,
213 plist_text(&teams_home_text),
214 plist_text(&teams_home_text)
215 );
216 Ok(ServiceUnit {
217 kind: "launchd",
218 path: path.clone(),
219 text,
220 install_command: format!("launchctl bootstrap gui/$(id -u) {}", path.display()),
221 files: Vec::new(),
222 })
223 } else {
224 let environment_home = systemd_arg(&format!("SUPERCODE_HOME={home}"));
225 let environment_bin = systemd_arg(&format!("SUPERCODE_BIN={supercode}"));
226 let environment_path = systemd_arg(&format!("PATH={}", service_path()));
227 let node = systemd_arg(&node);
228 let entry = systemd_arg(&entry);
229 let workspace = systemd_arg(&workspace);
230 let context_description = context.replace('%', "%%").replace('$', "$$");
231 let context = systemd_arg(context);
232 let text = format!("[Unit]\nDescription=supercode Teams connector ({context_description})\nAfter=network.target\n\n[Service]\nEnvironment={environment_home}\nEnvironment={environment_bin}\nEnvironment={environment_path}\nExecStart={node} {entry} teams connect --context {context} --cwd {workspace}\nRestart=on-failure\nKillSignal=SIGTERM\n\n[Install]\nWantedBy=default.target\n");
233 Ok(ServiceUnit {
234 kind: "systemd",
235 path: path.clone(),
236 text,
237 install_command: format!(
238 "systemctl --user link {} && systemctl --user enable --now {label}",
239 path.display()
240 ),
241 files: Vec::new(),
242 })
243 }
244}
245
246fn connector_unit_suffix() -> &'static str {
248 if cfg!(windows) {
249 "xml"
250 } else if cfg!(target_os = "macos") {
251 "plist"
252 } else {
253 "service"
254 }
255}
256
257fn connector_env_path(teams_home: &Path, label: &str) -> PathBuf {
259 teams_home.join(SERVICE_DIR).join(format!("{label}.env"))
260}
261
262fn xml_text(value: &str) -> String {
264 plist_text(value).replace('"', """)
265}
266
267fn windows_arg(value: &str) -> String {
270 if !value.is_empty() && !value.contains([' ', '\t', '"']) {
271 return value.to_string();
272 }
273 let mut quoted = String::from("\"");
274 let mut backslashes = 0;
275 for character in value.chars() {
276 match character {
277 '\\' => backslashes += 1,
278 '"' => {
279 quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
280 quoted.push('"');
281 backslashes = 0;
282 }
283 other => {
284 quoted.push_str(&"\\".repeat(backslashes));
285 quoted.push(other);
286 backslashes = 0;
287 }
288 }
289 }
290 quoted.push_str(&"\\".repeat(backslashes * 2));
291 quoted.push('"');
292 quoted
293}
294
295fn env_file_value(value: &str) -> Result<String, TeamsError> {
298 ['\'', '`']
299 .into_iter()
300 .find(|quote| !value.contains(*quote))
301 .map(|quote| format!("{quote}{value}{quote}"))
302 .or_else(|| (!value.contains(['"', '\\'])).then(|| format!("\"{value}\"")))
303 .ok_or_else(|| TeamsError::Service {
304 action: "render",
305 detail: format!("cannot write `{value}` into the connector's environment file"),
306 })
307}
308
309fn windows_user() -> String {
311 let user = std::env::var("USERNAME").unwrap_or_default();
312 match std::env::var("USERDOMAIN") {
313 Ok(domain) if !domain.is_empty() => format!("{domain}\\{user}"),
314 _ => user,
315 }
316}
317
318#[derive(Debug, thiserror::Error)]
320pub enum TeamsError {
321 #[error("no teams entry found (looked for `sdk/teams/{TEAMS_ENTRY}` under: {searched}); install it with `npm install -g {TEAMS_PACKAGE}`")]
323 NoEntry {
324 searched: String,
326 },
327 #[error("teams service: {action} failed: {detail}")]
329 Service {
330 action: &'static str,
332 detail: String,
334 },
335 #[error("teams file `{}`: {source}", path.display())]
337 File {
338 path: PathBuf,
340 source: std::io::Error,
342 },
343}
344
345fn service_path() -> String {
349 std::env::var("PATH")
350 .ok()
351 .filter(|path| !path.trim().is_empty())
352 .unwrap_or_else(|| "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".into())
353}
354
355pub fn teams_home() -> PathBuf {
360 if let Ok(home) = std::env::var("SUPERCODE_TEAMS_HOME") {
361 if !home.is_empty() {
362 return PathBuf::from(home);
363 }
364 }
365 crate::agent::global_instructions_dir().join("teams")
366}
367
368pub fn teams_entry() -> Result<PathBuf, TeamsError> {
378 let mut searched = Vec::new();
379 if let Some(explicit) = std::env::var_os("SUPERCODE_TEAMS_ENTRY") {
380 let path = PathBuf::from(explicit);
381 if path.is_file() {
382 return Ok(path);
383 }
384 searched.push(path.display().to_string());
385 }
386 let mut roots: Vec<PathBuf> = Vec::new();
387 if let Ok(exe) = std::env::current_exe() {
388 roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
390 }
391 if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
396 roots.push(workspace.to_path_buf());
397 }
398 for root in roots {
399 let candidate = root.join("sdk/teams").join(TEAMS_ENTRY);
400 if candidate.is_file() {
401 return Ok(candidate);
402 }
403 searched.push(candidate.display().to_string());
404 }
405 if let Ok(exe) = std::env::current_exe() {
410 for modules in exe
411 .ancestors()
412 .filter(|dir| dir.file_name().is_some_and(|name| name == "node_modules"))
413 {
414 let candidate = modules.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
415 if candidate.is_file() {
416 return Ok(candidate);
417 }
418 searched.push(candidate.display().to_string());
419 }
420 }
421 if let Some(global) = global_npm_root() {
422 let candidate = global.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
423 if candidate.is_file() {
424 return Ok(candidate);
425 }
426 searched.push(candidate.display().to_string());
427 }
428 Err(TeamsError::NoEntry {
429 searched: searched.join(", "),
430 })
431}
432
433fn global_npm_root() -> Option<PathBuf> {
435 let output = std::process::Command::new(resolve_program("npm"))
436 .args(["root", "-g"])
437 .stdin(std::process::Stdio::null())
438 .stderr(std::process::Stdio::null())
439 .output()
440 .ok()?;
441 if !output.status.success() {
442 return None;
443 }
444 let text = String::from_utf8_lossy(&output.stdout);
445 let root = text.trim();
446 if root.is_empty() {
447 return None;
448 }
449 Some(PathBuf::from(root))
450}
451
452pub fn service_unit(home: &Path, entry: &Path, node: &str) -> ServiceUnit {
459 let home_display = home.display().to_string();
460 let entry_display = entry.display().to_string();
461 if cfg!(target_os = "macos") {
462 let path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.plist"));
463 let text = format!(
464 r#"<?xml version="1.0" encoding="UTF-8"?>
465<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
466<plist version="1.0">
467<dict>
468 <key>Label</key><string>{SERVICE_NAME}</string>
469 <key>ProgramArguments</key>
470 <array>
471 <string>{node}</string>
472 <string>{entry_display}</string>
473 <string>machine</string>
474 <string>start</string>
475 </array>
476 <key>EnvironmentVariables</key>
477 <dict>
478 <key>SUPERCODE_TEAMS_HOME</key><string>{home_display}</string>
479 </dict>
480 <key>RunAtLoad</key><true/>
481 <key>KeepAlive</key><true/>
482 <key>StandardOutPath</key><string>{home_display}/service/teams-machine.out.log</string>
483 <key>StandardErrorPath</key><string>{home_display}/service/teams-machine.err.log</string>
484</dict>
485</plist>
486"#
487 );
488 let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
489 ServiceUnit {
490 kind: "launchd",
491 path,
492 text,
493 install_command: install,
494 files: Vec::new(),
495 }
496 } else {
497 let path = home
498 .join(SERVICE_DIR)
499 .join(format!("{SERVICE_NAME}.service"));
500 let text = format!(
501 "[Unit]\n\
502 Description=supercode teams machine daemon ({home_display})\n\
503 After=network.target\n\
504 \n\
505 [Service]\n\
506 Environment=SUPERCODE_TEAMS_HOME={home_display}\n\
507 ExecStart={node} {entry_display} machine start\n\
508 Restart=on-failure\n\
509 KillSignal=SIGTERM\n\
510 \n\
511 [Install]\n\
512 WantedBy=default.target\n"
513 );
514 let install = format!(
515 "systemctl --user link {} && systemctl --user enable --now {SERVICE_NAME}",
516 path.display()
517 );
518 ServiceUnit {
519 kind: "systemd",
520 path,
521 text,
522 install_command: install,
523 files: Vec::new(),
524 }
525 }
526}
527
528pub fn write_unit(unit: &ServiceUnit) -> Result<(), TeamsError> {
530 if let Some(parent) = unit.path.parent() {
531 std::fs::create_dir_all(parent).map_err(|source| TeamsError::File {
532 path: unit.path.clone(),
533 source,
534 })?;
535 }
536 std::fs::write(&unit.path, &unit.text).map_err(|source| TeamsError::File {
537 path: unit.path.clone(),
538 source,
539 })?;
540 for (path, text) in &unit.files {
541 std::fs::write(path, text).map_err(|source| TeamsError::File {
542 path: path.clone(),
543 source,
544 })?;
545 }
546 Ok(())
547}
548
549fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
551 let output = std::process::Command::new(program).args(args).output()?;
552 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
553 text.push_str(&String::from_utf8_lossy(&output.stderr));
554 Ok((output.status.success(), text.trim().to_string()))
555}
556
557#[cfg(target_os = "macos")]
558fn gui_domain() -> String {
559 format!("gui/{}", unsafe { libc::getuid() })
561}
562
563pub fn service_status() -> ServiceState {
567 platform_status()
568}
569
570#[cfg(target_os = "macos")]
571fn platform_status() -> ServiceState {
572 let label = SERVICE_NAME.to_string();
573 let target = format!("{}/{SERVICE_NAME}", gui_domain());
574 match run_tool("launchctl", &["print", &target]) {
575 Ok((true, text)) => ServiceState {
576 kind: "launchd",
577 label,
578 installed: true,
579 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
580 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
581 },
582 Ok((false, _)) => ServiceState {
583 kind: "launchd",
584 label,
585 installed: false,
586 pid: None,
587 detail: format!("not bootstrapped in {}", gui_domain()),
588 },
589 Err(error) => ServiceState {
590 kind: "launchd",
591 label,
592 installed: false,
593 pid: None,
594 detail: format!("launchctl unavailable: {error}"),
595 },
596 }
597}
598
599#[cfg(all(unix, not(target_os = "macos")))]
600fn platform_status() -> ServiceState {
601 let label = SERVICE_NAME.to_string();
602 match run_tool("systemctl", &["--user", "is-active", SERVICE_NAME]) {
603 Ok((active, text)) => {
604 let known = run_tool("systemctl", &["--user", "is-enabled", SERVICE_NAME])
605 .map(|(ok, _)| ok)
606 .unwrap_or(false);
607 ServiceState {
608 kind: "systemd",
609 label,
610 installed: active || known,
611 pid: None,
612 detail: if text.is_empty() {
613 "unknown".into()
614 } else {
615 text
616 },
617 }
618 }
619 Err(error) => ServiceState {
620 kind: "systemd",
621 label,
622 installed: false,
623 pid: None,
624 detail: format!("systemctl unavailable: {error}"),
625 },
626 }
627}
628
629#[cfg(not(unix))]
630fn platform_status() -> ServiceState {
631 ServiceState {
632 kind: "none",
633 label: SERVICE_NAME.to_string(),
634 installed: false,
635 pid: None,
636 detail: "no service manager on this platform".into(),
637 }
638}
639
640#[cfg(target_os = "macos")]
642fn field_of(text: &str, key: &str) -> Option<String> {
643 text.lines()
644 .find_map(|line| line.trim().strip_prefix(key))
645 .map(|value| value.trim().to_string())
646}
647
648fn unit_file_name() -> String {
650 if cfg!(target_os = "macos") {
651 format!("{SERVICE_NAME}.plist")
652 } else {
653 format!("{SERVICE_NAME}.service")
654 }
655}
656
657pub fn install_service(
663 home: &Path,
664 entry: &Path,
665 node: &str,
666) -> Result<(ServiceUnit, ServiceState), TeamsError> {
667 let existing = service_status();
668 if existing.installed {
669 return Err(TeamsError::Service {
670 action: "install",
671 detail: format!(
672 "`{}` is already installed ({}); `supercode teams machine uninstall` first",
673 existing.label, existing.detail
674 ),
675 });
676 }
677 let unit = service_unit(home, entry, &absolute_program(node));
678 write_unit(&unit)?;
679 platform_install(&unit)?;
680 Ok((unit, service_status()))
681}
682
683pub fn install_connector_service(
689 unit: &ServiceUnit,
690 label: &str,
691) -> Result<ServiceState, TeamsError> {
692 let existing = named_service_status(label);
693 if unit.path.exists() {
694 let old = std::fs::read_to_string(&unit.path).map_err(|source| TeamsError::File {
695 path: unit.path.clone(),
696 source,
697 })?;
698 let same = old == unit.text
700 && unit
701 .files
702 .iter()
703 .all(|(path, text)| std::fs::read_to_string(path).is_ok_and(|old| &old == text));
704 if same && existing.installed {
705 return Ok(existing);
706 }
707 if !same && existing.installed {
708 named_platform_uninstall(label)?;
709 }
710 } else if existing.installed {
711 return Err(TeamsError::Service {
712 action: "install",
713 detail: format!(
714 "service manager already owns `{label}` without its expected unit file"
715 ),
716 });
717 }
718 write_unit(unit)?;
719 named_platform_install(unit, label)?;
720 Ok(named_service_status(label))
721}
722
723pub fn register_message_tools(supercode: &Path) -> Vec<String> {
729 let program = supercode.display().to_string();
730 let mut report = Vec::new();
731 for (harness, get, add) in [
732 (
733 "claude",
734 vec!["mcp", "get", "supercode"],
735 vec![
736 "mcp",
737 "add",
738 "--scope",
739 "user",
740 "supercode",
741 "--",
742 &program,
743 "message",
744 "mcp",
745 ],
746 ),
747 (
748 "codex",
749 vec!["mcp", "get", "supercode"],
750 vec!["mcp", "add", "supercode", "--", &program, "message", "mcp"],
751 ),
752 ] {
753 let run = |args: &[&str]| {
754 std::process::Command::new(resolve_program(harness))
755 .args(args)
756 .stdin(std::process::Stdio::null())
757 .output()
758 };
759 match run(&get) {
760 Err(_) => report.push(format!("{harness}: not installed")),
761 Ok(found) if found.status.success() => {
762 report.push(format!("{harness}: already has supercode's tools"))
763 }
764 Ok(_) => match run(&add) {
765 Ok(added) if added.status.success() => report.push(format!(
766 "{harness}: supercode's tools added (new sessions load them)"
767 )),
768 Ok(added) => report.push(format!(
769 "{harness}: could not add supercode's tools: {}",
770 String::from_utf8_lossy(&added.stderr).trim()
771 )),
772 Err(error) => report.push(format!(
773 "{harness}: could not add supercode's tools: {error}"
774 )),
775 },
776 }
777 }
778 report
779}
780
781pub fn uninstall_connector_service(
783 teams_home: &Path,
784 label: &str,
785) -> Result<ServiceState, TeamsError> {
786 named_platform_uninstall(label)?;
787 let unit = teams_home
788 .join(SERVICE_DIR)
789 .join(format!("{label}.{}", connector_unit_suffix()));
790 for path in [unit, connector_env_path(teams_home, label)] {
791 match std::fs::remove_file(&path) {
792 Ok(()) => {}
793 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
794 Err(source) => return Err(TeamsError::File { path, source }),
795 }
796 }
797 Ok(named_service_status(label))
798}
799
800pub fn connector_service_status(label: &str) -> ServiceState {
802 named_service_status(label)
803}
804
805#[cfg(target_os = "macos")]
806fn named_service_status(label: &str) -> ServiceState {
807 let target = format!("{}/{label}", gui_domain());
808 match run_tool("launchctl", &["print", &target]) {
809 Ok((true, text)) => ServiceState {
810 kind: "launchd",
811 label: label.into(),
812 installed: true,
813 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
814 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
815 },
816 Ok((false, _)) => ServiceState {
817 kind: "launchd",
818 label: label.into(),
819 installed: false,
820 pid: None,
821 detail: format!("not bootstrapped in {}", gui_domain()),
822 },
823 Err(error) => ServiceState {
824 kind: "launchd",
825 label: label.into(),
826 installed: false,
827 pid: None,
828 detail: format!("launchctl unavailable: {error}"),
829 },
830 }
831}
832
833#[cfg(all(unix, not(target_os = "macos")))]
834fn named_service_status(label: &str) -> ServiceState {
835 match run_tool("systemctl", &["--user", "is-active", label]) {
836 Ok((active, text)) => {
837 let known = run_tool("systemctl", &["--user", "is-enabled", label])
838 .map(|(ok, _)| ok)
839 .unwrap_or(false);
840 ServiceState {
841 kind: "systemd",
842 label: label.into(),
843 installed: active || known,
844 pid: None,
845 detail: if text.is_empty() {
846 "unknown".into()
847 } else {
848 text
849 },
850 }
851 }
852 Err(error) => ServiceState {
853 kind: "systemd",
854 label: label.into(),
855 installed: false,
856 pid: None,
857 detail: format!("systemctl unavailable: {error}"),
858 },
859 }
860}
861
862#[cfg(not(unix))]
863fn named_service_status(label: &str) -> ServiceState {
864 match run_tool("schtasks", &["/Query", "/TN", label, "/FO", "CSV", "/NH"]) {
865 Ok((true, text)) => ServiceState {
867 kind: "schtasks",
868 label: label.into(),
869 installed: true,
870 pid: None,
871 detail: text
872 .lines()
873 .next()
874 .and_then(|line| line.rsplit(',').next())
875 .map(|state| state.trim_matches('"').to_string())
876 .filter(|state| !state.is_empty())
877 .unwrap_or_else(|| "registered".into()),
878 },
879 Ok((false, _)) => ServiceState {
880 kind: "schtasks",
881 label: label.into(),
882 installed: false,
883 pid: None,
884 detail: "no scheduled task".into(),
885 },
886 Err(error) => ServiceState {
887 kind: "schtasks",
888 label: label.into(),
889 installed: false,
890 pid: None,
891 detail: format!("schtasks unavailable: {error}"),
892 },
893 }
894}
895
896#[cfg(target_os = "macos")]
897fn named_platform_install(unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
898 platform_install(unit)
899}
900#[cfg(all(unix, not(target_os = "macos")))]
901fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
902 let path = unit.path.display().to_string();
903 for args in [
904 vec!["--user", "link", path.as_str()],
905 vec!["--user", "enable", "--now", label],
906 ] {
907 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
908 action: "install",
909 detail: format!("systemctl: {error}"),
910 })?;
911 if !ok {
912 return Err(TeamsError::Service {
913 action: "install",
914 detail: format!("systemctl {}: {text}", args.join(" ")),
915 });
916 }
917 }
918 Ok(())
919}
920#[cfg(not(unix))]
921fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
922 let task = unit.path.with_extension("utf16.xml");
924 let bytes: Vec<u8> = [0xFF, 0xFE]
925 .into_iter()
926 .chain(unit.text.encode_utf16().flat_map(u16::to_le_bytes))
927 .collect();
928 std::fs::write(&task, bytes).map_err(|source| TeamsError::File {
929 path: task.clone(),
930 source,
931 })?;
932 let task_path = task.display().to_string();
933 let result = [
934 vec!["/Create", "/TN", label, "/XML", task_path.as_str(), "/F"],
935 vec!["/Run", "/TN", label],
936 ]
937 .iter()
938 .try_for_each(|args| {
939 let (ok, text) = run_tool("schtasks", args).map_err(|error| TeamsError::Service {
940 action: "install",
941 detail: format!("schtasks: {error}"),
942 })?;
943 if ok {
944 Ok(())
945 } else {
946 Err(TeamsError::Service {
947 action: "install",
948 detail: format!("schtasks {}: {text}", args[0]),
949 })
950 }
951 });
952 let _ = std::fs::remove_file(&task);
953 result
954}
955
956#[cfg(target_os = "macos")]
957fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
958 let target = format!("{}/{label}", gui_domain());
959 let (ok, text) =
960 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
961 action: "uninstall",
962 detail: format!("launchctl: {error}"),
963 })?;
964 if !ok && !text.contains("No such process") && !text.contains("not find") {
965 return Err(TeamsError::Service {
966 action: "uninstall",
967 detail: format!("launchctl bootout {target}: {text}"),
968 });
969 }
970 for _ in 0..50 {
973 if !named_service_status(label).installed {
974 break;
975 }
976 std::thread::sleep(std::time::Duration::from_millis(100));
977 }
978 Ok(())
979}
980#[cfg(all(unix, not(target_os = "macos")))]
981fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
982 let _ = run_tool("systemctl", &["--user", "disable", "--now", label]);
983 Ok(())
984}
985#[cfg(not(unix))]
986fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
987 let (ok, text) = run_tool("schtasks", &["/Delete", "/TN", label, "/F"]).map_err(|error| {
989 TeamsError::Service {
990 action: "uninstall",
991 detail: format!("schtasks: {error}"),
992 }
993 })?;
994 if !ok && named_service_status(label).installed {
995 return Err(TeamsError::Service {
996 action: "uninstall",
997 detail: format!("schtasks /Delete: {text}"),
998 });
999 }
1000 let env_path = connector_env_path(&teams_home(), label);
1004 let stopped = std::process::Command::new("powershell.exe")
1005 .args([
1006 "-NoProfile",
1007 "-NonInteractive",
1008 "-Command",
1009 "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 }",
1010 ])
1011 .env("SUPERCODE_SERVICE_ENV_FILE", env_path.display().to_string())
1012 .stdin(std::process::Stdio::null())
1013 .output();
1014 match stopped {
1015 Ok(output) if output.status.success() => Ok(()),
1016 Ok(output) => Err(TeamsError::Service {
1017 action: "uninstall",
1018 detail: format!(
1019 "the task is gone, but its connector may still run: {}",
1020 String::from_utf8_lossy(&output.stderr).trim()
1021 ),
1022 }),
1023 Err(error) => Err(TeamsError::Service {
1024 action: "uninstall",
1025 detail: format!(
1026 "the task is gone, but its connector may still run: powershell: {error}"
1027 ),
1028 }),
1029 }
1030}
1031
1032#[cfg(target_os = "macos")]
1033fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1034 let path = unit.path.display().to_string();
1035 let (ok, text) =
1036 run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
1037 TeamsError::Service {
1038 action: "install",
1039 detail: format!("launchctl: {error}"),
1040 }
1041 })?;
1042 if !ok {
1043 return Err(TeamsError::Service {
1044 action: "install",
1045 detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
1046 });
1047 }
1048 Ok(())
1049}
1050
1051#[cfg(all(unix, not(target_os = "macos")))]
1054fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1055 let path = unit.path.display().to_string();
1056 for args in [
1057 vec!["--user", "link", path.as_str()],
1058 vec!["--user", "enable", "--now", SERVICE_NAME],
1059 ] {
1060 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1061 action: "install",
1062 detail: format!("systemctl: {error}"),
1063 })?;
1064 if !ok {
1065 return Err(TeamsError::Service {
1066 action: "install",
1067 detail: format!("systemctl {}: {text}", args.join(" ")),
1068 });
1069 }
1070 }
1071 Ok(())
1072}
1073
1074#[cfg(not(unix))]
1075fn platform_install(_unit: &ServiceUnit) -> Result<(), TeamsError> {
1076 Err(TeamsError::Service {
1077 action: "install",
1078 detail: "no service manager on this platform".into(),
1079 })
1080}
1081
1082pub fn uninstall_service(home: &Path) -> Result<ServiceState, TeamsError> {
1087 platform_uninstall()?;
1088 let unit_path = home.join(SERVICE_DIR).join(unit_file_name());
1089 match std::fs::remove_file(&unit_path) {
1090 Ok(()) => {}
1091 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1092 Err(source) => {
1093 return Err(TeamsError::File {
1094 path: unit_path,
1095 source,
1096 })
1097 }
1098 }
1099 let mut state = service_status();
1102 for _ in 0..40 {
1103 if !state.installed {
1104 break;
1105 }
1106 std::thread::sleep(std::time::Duration::from_millis(100));
1107 state = service_status();
1108 }
1109 Ok(state)
1110}
1111
1112#[cfg(target_os = "macos")]
1113fn platform_uninstall() -> Result<(), TeamsError> {
1114 let target = format!("{}/{SERVICE_NAME}", gui_domain());
1115 let (ok, text) =
1116 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1117 action: "uninstall",
1118 detail: format!("launchctl: {error}"),
1119 })?;
1120 if !ok && !text.contains("No such process") && !text.contains("not find") {
1122 return Err(TeamsError::Service {
1123 action: "uninstall",
1124 detail: format!("launchctl bootout {target}: {text}"),
1125 });
1126 }
1127 Ok(())
1128}
1129
1130#[cfg(all(unix, not(target_os = "macos")))]
1131fn platform_uninstall() -> Result<(), TeamsError> {
1132 let _ = run_tool("systemctl", &["--user", "disable", "--now", SERVICE_NAME]);
1133 Ok(())
1134}
1135
1136#[cfg(not(unix))]
1137fn platform_uninstall() -> Result<(), TeamsError> {
1138 Ok(())
1139}
1140
1141#[cfg(test)]
1142mod connector_service_tests {
1143 use super::*;
1144
1145 #[test]
1146 fn connector_unit_is_context_scoped_and_contains_no_credential() {
1147 let unit = connector_service_unit(
1148 Path::new("/tmp/teams home"),
1149 Path::new("/tmp/supercode home"),
1150 Path::new("/tmp/sdk/teams/bin/teams.mjs"),
1151 "/usr/bin/node",
1152 Path::new("/tmp/bin/supercode"),
1153 "work",
1154 Path::new("/tmp/project with spaces"),
1155 "srv_1",
1156 "team_1",
1157 )
1158 .unwrap();
1159 assert!(unit.text.contains("teams"));
1160 assert!(unit.text.contains("connect"));
1161 assert!(unit.text.contains("work"));
1162 assert!(!unit.text.contains("credential"));
1163 assert!(unit
1164 .path
1165 .file_name()
1166 .unwrap()
1167 .to_string_lossy()
1168 .contains(&connector_service_name("srv_1", "team_1", "work")));
1169 }
1170
1171 #[test]
1172 fn connector_labels_separate_context_and_team() {
1173 assert_ne!(
1174 connector_service_name("srv", "team-a", "work"),
1175 connector_service_name("srv", "team-b", "work")
1176 );
1177 assert_ne!(
1178 connector_service_name("srv", "team-a", "work"),
1179 connector_service_name("srv", "team-a", "personal")
1180 );
1181 }
1182
1183 #[test]
1184 fn connector_unit_rejects_newlines_and_escapes_service_syntax() {
1185 let unsafe_unit = connector_service_unit(
1186 Path::new("/tmp/teams"),
1187 Path::new("/tmp/home"),
1188 Path::new("/tmp/entry"),
1189 "/usr/bin/node",
1190 Path::new("/tmp/supercode"),
1191 "bad\ncontext",
1192 Path::new("/tmp/work"),
1193 "srv",
1194 "team",
1195 );
1196 assert!(unsafe_unit.is_err());
1197 let render = |entry: &str, cwd: &str| {
1198 connector_service_unit(
1199 Path::new("/tmp/teams & logs"),
1200 Path::new("/tmp/home $x"),
1201 Path::new(entry),
1202 "/usr/bin/node",
1203 Path::new("/tmp/super\"code"),
1204 "work",
1205 Path::new(cwd),
1206 "srv",
1207 "team",
1208 )
1209 };
1210 if cfg!(windows) {
1211 assert!(render("/tmp/entry %i", "/tmp/a & b $").is_err());
1213 let unit = render("/tmp/entry", "/tmp/a & b $").unwrap();
1214 assert!(unit.text.contains("--cwd "/tmp/a & b $""));
1215 let (env_path, env_text) = &unit.files[0];
1216 assert!(env_path.ends_with(format!(
1217 "{}.env",
1218 connector_service_name("srv", "team", "work")
1219 )));
1220 assert!(env_text.contains("SUPERCODE_BIN='/tmp/super\"code'\n"));
1221 assert!(!unit.text.contains("super\"code"));
1222 } else if cfg!(target_os = "macos") {
1223 let unit = render("/tmp/entry %i", "/tmp/a & b % $").unwrap();
1224 assert!(unit.text.contains("&"));
1225 } else {
1226 let unit = render("/tmp/entry %i", "/tmp/a & b % $").unwrap();
1227 assert!(unit.text.contains("%%"));
1228 assert!(unit.text.contains("$$"));
1229 assert!(unit.text.contains("\\\""));
1230 }
1231 }
1232
1233 #[test]
1234 fn windows_arguments_split_back_out_whole() {
1235 assert_eq!(windows_arg("plain"), "plain");
1236 assert_eq!(windows_arg(""), "\"\"");
1237 assert_eq!(
1238 windows_arg(r"C:\Program Files\nodejs\node.exe"),
1239 r#""C:\Program Files\nodejs\node.exe""#
1240 );
1241 assert_eq!(windows_arg(r#"say "hi""#), r#""say \"hi\"""#);
1242 assert_eq!(windows_arg(r#"a\"b"#), r#""a\\\"b""#);
1244 assert_eq!(windows_arg(r"C:\my dir\"), r#""C:\my dir\\""#);
1245 }
1246
1247 #[test]
1248 fn env_file_values_are_quoted_literally() {
1249 assert_eq!(env_file_value(r"C:\new\tab").unwrap(), r"'C:\new\tab'");
1250 assert_eq!(env_file_value("O'Brien").unwrap(), "`O'Brien`");
1251 assert_eq!(env_file_value("it's `x`").unwrap(), "\"it's `x`\"");
1252 assert!(env_file_value(r"it's `x` \n").is_err());
1253 }
1254}