1use std::path::{Path, PathBuf};
23
24use crate::orchestrator::{absolute_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 suffix = if cfg!(target_os = "macos") {
110 "plist"
111 } else {
112 "service"
113 };
114 let path = teams_home
115 .join(SERVICE_DIR)
116 .join(format!("{label}.{suffix}"));
117 let node = absolute_program(node);
118 let entry = entry_text;
119 let workspace = workspace_text;
120 let home = supercode_home_text;
121 let supercode = supercode_text;
122 if cfg!(target_os = "macos") {
123 let node = plist_text(&node);
124 let entry = plist_text(&entry);
125 let workspace = plist_text(&workspace);
126 let home = plist_text(&home);
127 let supercode = plist_text(&supercode);
128 let context = plist_text(context);
129 let search_path = plist_text(&service_path());
130 let text = format!(
131 r#"<?xml version="1.0" encoding="UTF-8"?>
132<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
133<plist version="1.0"><dict>
134 <key>Label</key><string>{label}</string>
135 <key>ProgramArguments</key><array><string>{node}</string><string>{entry}</string><string>teams</string><string>connect</string><string>--context</string><string>{context}</string><string>--cwd</string><string>{workspace}</string></array>
136 <key>EnvironmentVariables</key><dict><key>SUPERCODE_HOME</key><string>{home}</string><key>SUPERCODE_BIN</key><string>{supercode}</string><key>PATH</key><string>{search_path}</string></dict>
137 <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
138 <key>StandardOutPath</key><string>{}/service/{label}.out.log</string>
139 <key>StandardErrorPath</key><string>{}/service/{label}.err.log</string>
140</dict></plist>
141"#,
142 plist_text(&teams_home_text),
143 plist_text(&teams_home_text)
144 );
145 Ok(ServiceUnit {
146 kind: "launchd",
147 path: path.clone(),
148 text,
149 install_command: format!("launchctl bootstrap gui/$(id -u) {}", path.display()),
150 })
151 } else {
152 let environment_home = systemd_arg(&format!("SUPERCODE_HOME={home}"));
153 let environment_bin = systemd_arg(&format!("SUPERCODE_BIN={supercode}"));
154 let environment_path = systemd_arg(&format!("PATH={}", service_path()));
155 let node = systemd_arg(&node);
156 let entry = systemd_arg(&entry);
157 let workspace = systemd_arg(&workspace);
158 let context_description = context.replace('%', "%%").replace('$', "$$");
159 let context = systemd_arg(context);
160 let text = format!("[Unit]\nDescription=supercode Teams connector ({context_description})\nAfter=network.target\n\n[Service]\nEnvironment={environment_home}\nEnvironment={environment_bin}\nEnvironment={environment_path}\nExecStart={node} {entry} teams connect --context {context} --cwd {workspace}\nRestart=on-failure\nKillSignal=SIGTERM\n\n[Install]\nWantedBy=default.target\n");
161 Ok(ServiceUnit {
162 kind: "systemd",
163 path: path.clone(),
164 text,
165 install_command: format!(
166 "systemctl --user link {} && systemctl --user enable --now {label}",
167 path.display()
168 ),
169 })
170 }
171}
172
173#[derive(Debug, thiserror::Error)]
175pub enum TeamsError {
176 #[error("no teams entry found (looked for `sdk/teams/{TEAMS_ENTRY}` under: {searched}); install it with `npm install -g {TEAMS_PACKAGE}`")]
178 NoEntry {
179 searched: String,
181 },
182 #[error("teams service: {action} failed: {detail}")]
184 Service {
185 action: &'static str,
187 detail: String,
189 },
190 #[error("teams file `{}`: {source}", path.display())]
192 File {
193 path: PathBuf,
195 source: std::io::Error,
197 },
198}
199
200fn service_path() -> String {
204 std::env::var("PATH")
205 .ok()
206 .filter(|path| !path.trim().is_empty())
207 .unwrap_or_else(|| "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".into())
208}
209
210pub fn teams_home() -> PathBuf {
215 if let Ok(home) = std::env::var("SUPERCODE_TEAMS_HOME") {
216 if !home.is_empty() {
217 return PathBuf::from(home);
218 }
219 }
220 crate::agent::global_instructions_dir().join("teams")
221}
222
223pub fn teams_entry() -> Result<PathBuf, TeamsError> {
233 let mut searched = Vec::new();
234 if let Some(explicit) = std::env::var_os("SUPERCODE_TEAMS_ENTRY") {
235 let path = PathBuf::from(explicit);
236 if path.is_file() {
237 return Ok(path);
238 }
239 searched.push(path.display().to_string());
240 }
241 let mut roots: Vec<PathBuf> = Vec::new();
242 if let Ok(exe) = std::env::current_exe() {
243 roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
245 }
246 if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
251 roots.push(workspace.to_path_buf());
252 }
253 for root in roots {
254 let candidate = root.join("sdk/teams").join(TEAMS_ENTRY);
255 if candidate.is_file() {
256 return Ok(candidate);
257 }
258 searched.push(candidate.display().to_string());
259 }
260 if let Ok(exe) = std::env::current_exe() {
265 for modules in exe
266 .ancestors()
267 .filter(|dir| dir.file_name().is_some_and(|name| name == "node_modules"))
268 {
269 let candidate = modules.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
270 if candidate.is_file() {
271 return Ok(candidate);
272 }
273 searched.push(candidate.display().to_string());
274 }
275 }
276 if let Some(global) = global_npm_root() {
277 let candidate = global.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
278 if candidate.is_file() {
279 return Ok(candidate);
280 }
281 searched.push(candidate.display().to_string());
282 }
283 Err(TeamsError::NoEntry {
284 searched: searched.join(", "),
285 })
286}
287
288fn global_npm_root() -> Option<PathBuf> {
290 let output = std::process::Command::new("npm")
291 .args(["root", "-g"])
292 .stdin(std::process::Stdio::null())
293 .stderr(std::process::Stdio::null())
294 .output()
295 .ok()?;
296 if !output.status.success() {
297 return None;
298 }
299 let text = String::from_utf8_lossy(&output.stdout);
300 let root = text.trim();
301 if root.is_empty() {
302 return None;
303 }
304 Some(PathBuf::from(root))
305}
306
307pub fn service_unit(home: &Path, entry: &Path, node: &str) -> ServiceUnit {
314 let home_display = home.display().to_string();
315 let entry_display = entry.display().to_string();
316 if cfg!(target_os = "macos") {
317 let path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.plist"));
318 let text = format!(
319 r#"<?xml version="1.0" encoding="UTF-8"?>
320<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
321<plist version="1.0">
322<dict>
323 <key>Label</key><string>{SERVICE_NAME}</string>
324 <key>ProgramArguments</key>
325 <array>
326 <string>{node}</string>
327 <string>{entry_display}</string>
328 <string>machine</string>
329 <string>start</string>
330 </array>
331 <key>EnvironmentVariables</key>
332 <dict>
333 <key>SUPERCODE_TEAMS_HOME</key><string>{home_display}</string>
334 </dict>
335 <key>RunAtLoad</key><true/>
336 <key>KeepAlive</key><true/>
337 <key>StandardOutPath</key><string>{home_display}/service/teams-machine.out.log</string>
338 <key>StandardErrorPath</key><string>{home_display}/service/teams-machine.err.log</string>
339</dict>
340</plist>
341"#
342 );
343 let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
344 ServiceUnit {
345 kind: "launchd",
346 path,
347 text,
348 install_command: install,
349 }
350 } else {
351 let path = home
352 .join(SERVICE_DIR)
353 .join(format!("{SERVICE_NAME}.service"));
354 let text = format!(
355 "[Unit]\n\
356 Description=supercode teams machine daemon ({home_display})\n\
357 After=network.target\n\
358 \n\
359 [Service]\n\
360 Environment=SUPERCODE_TEAMS_HOME={home_display}\n\
361 ExecStart={node} {entry_display} machine start\n\
362 Restart=on-failure\n\
363 KillSignal=SIGTERM\n\
364 \n\
365 [Install]\n\
366 WantedBy=default.target\n"
367 );
368 let install = format!(
369 "systemctl --user link {} && systemctl --user enable --now {SERVICE_NAME}",
370 path.display()
371 );
372 ServiceUnit {
373 kind: "systemd",
374 path,
375 text,
376 install_command: install,
377 }
378 }
379}
380
381pub fn write_unit(unit: &ServiceUnit) -> Result<(), TeamsError> {
383 if let Some(parent) = unit.path.parent() {
384 std::fs::create_dir_all(parent).map_err(|source| TeamsError::File {
385 path: unit.path.clone(),
386 source,
387 })?;
388 }
389 std::fs::write(&unit.path, &unit.text).map_err(|source| TeamsError::File {
390 path: unit.path.clone(),
391 source,
392 })
393}
394
395fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
397 let output = std::process::Command::new(program).args(args).output()?;
398 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
399 text.push_str(&String::from_utf8_lossy(&output.stderr));
400 Ok((output.status.success(), text.trim().to_string()))
401}
402
403#[cfg(target_os = "macos")]
404fn gui_domain() -> String {
405 format!("gui/{}", unsafe { libc::getuid() })
407}
408
409pub fn service_status() -> ServiceState {
413 platform_status()
414}
415
416#[cfg(target_os = "macos")]
417fn platform_status() -> ServiceState {
418 let label = SERVICE_NAME.to_string();
419 let target = format!("{}/{SERVICE_NAME}", gui_domain());
420 match run_tool("launchctl", &["print", &target]) {
421 Ok((true, text)) => ServiceState {
422 kind: "launchd",
423 label,
424 installed: true,
425 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
426 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
427 },
428 Ok((false, _)) => ServiceState {
429 kind: "launchd",
430 label,
431 installed: false,
432 pid: None,
433 detail: format!("not bootstrapped in {}", gui_domain()),
434 },
435 Err(error) => ServiceState {
436 kind: "launchd",
437 label,
438 installed: false,
439 pid: None,
440 detail: format!("launchctl unavailable: {error}"),
441 },
442 }
443}
444
445#[cfg(all(unix, not(target_os = "macos")))]
446fn platform_status() -> ServiceState {
447 let label = SERVICE_NAME.to_string();
448 match run_tool("systemctl", &["--user", "is-active", SERVICE_NAME]) {
449 Ok((active, text)) => {
450 let known = run_tool("systemctl", &["--user", "is-enabled", SERVICE_NAME])
451 .map(|(ok, _)| ok)
452 .unwrap_or(false);
453 ServiceState {
454 kind: "systemd",
455 label,
456 installed: active || known,
457 pid: None,
458 detail: if text.is_empty() {
459 "unknown".into()
460 } else {
461 text
462 },
463 }
464 }
465 Err(error) => ServiceState {
466 kind: "systemd",
467 label,
468 installed: false,
469 pid: None,
470 detail: format!("systemctl unavailable: {error}"),
471 },
472 }
473}
474
475#[cfg(not(unix))]
476fn platform_status() -> ServiceState {
477 ServiceState {
478 kind: "none",
479 label: SERVICE_NAME.to_string(),
480 installed: false,
481 pid: None,
482 detail: "no service manager on this platform".into(),
483 }
484}
485
486#[cfg(target_os = "macos")]
488fn field_of(text: &str, key: &str) -> Option<String> {
489 text.lines()
490 .find_map(|line| line.trim().strip_prefix(key))
491 .map(|value| value.trim().to_string())
492}
493
494fn unit_file_name() -> String {
496 if cfg!(target_os = "macos") {
497 format!("{SERVICE_NAME}.plist")
498 } else {
499 format!("{SERVICE_NAME}.service")
500 }
501}
502
503pub fn install_service(
509 home: &Path,
510 entry: &Path,
511 node: &str,
512) -> Result<(ServiceUnit, ServiceState), TeamsError> {
513 let existing = service_status();
514 if existing.installed {
515 return Err(TeamsError::Service {
516 action: "install",
517 detail: format!(
518 "`{}` is already installed ({}); `supercode teams machine uninstall` first",
519 existing.label, existing.detail
520 ),
521 });
522 }
523 let unit = service_unit(home, entry, &absolute_program(node));
524 write_unit(&unit)?;
525 platform_install(&unit)?;
526 Ok((unit, service_status()))
527}
528
529pub fn install_connector_service(
535 unit: &ServiceUnit,
536 label: &str,
537) -> Result<ServiceState, TeamsError> {
538 let existing = named_service_status(label);
539 if unit.path.exists() {
540 let old = std::fs::read_to_string(&unit.path).map_err(|source| TeamsError::File {
541 path: unit.path.clone(),
542 source,
543 })?;
544 if old == unit.text && existing.installed {
545 return Ok(existing);
546 }
547 if old != unit.text && existing.installed {
548 named_platform_uninstall(label)?;
549 }
550 } else if existing.installed {
551 return Err(TeamsError::Service {
552 action: "install",
553 detail: format!(
554 "service manager already owns `{label}` without its expected unit file"
555 ),
556 });
557 }
558 write_unit(unit)?;
559 named_platform_install(unit, label)?;
560 Ok(named_service_status(label))
561}
562
563pub fn register_message_tools(supercode: &Path) -> Vec<String> {
569 let program = supercode.display().to_string();
570 let mut report = Vec::new();
571 for (harness, get, add) in [
572 (
573 "claude",
574 vec!["mcp", "get", "supercode"],
575 vec![
576 "mcp",
577 "add",
578 "--scope",
579 "user",
580 "supercode",
581 "--",
582 &program,
583 "message",
584 "mcp",
585 ],
586 ),
587 (
588 "codex",
589 vec!["mcp", "get", "supercode"],
590 vec!["mcp", "add", "supercode", "--", &program, "message", "mcp"],
591 ),
592 ] {
593 let run = |args: &[&str]| {
594 std::process::Command::new(harness)
595 .args(args)
596 .stdin(std::process::Stdio::null())
597 .output()
598 };
599 match run(&get) {
600 Err(_) => report.push(format!("{harness}: not installed")),
601 Ok(found) if found.status.success() => {
602 report.push(format!("{harness}: already has supercode's tools"))
603 }
604 Ok(_) => match run(&add) {
605 Ok(added) if added.status.success() => report.push(format!(
606 "{harness}: supercode's tools added (new sessions load them)"
607 )),
608 Ok(added) => report.push(format!(
609 "{harness}: could not add supercode's tools: {}",
610 String::from_utf8_lossy(&added.stderr).trim()
611 )),
612 Err(error) => report.push(format!(
613 "{harness}: could not add supercode's tools: {error}"
614 )),
615 },
616 }
617 }
618 report
619}
620
621pub fn uninstall_connector_service(
623 teams_home: &Path,
624 label: &str,
625) -> Result<ServiceState, TeamsError> {
626 named_platform_uninstall(label)?;
627 let suffix = if cfg!(target_os = "macos") {
628 "plist"
629 } else {
630 "service"
631 };
632 let path = teams_home
633 .join(SERVICE_DIR)
634 .join(format!("{label}.{suffix}"));
635 match std::fs::remove_file(&path) {
636 Ok(()) => {}
637 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
638 Err(source) => return Err(TeamsError::File { path, source }),
639 }
640 Ok(named_service_status(label))
641}
642
643pub fn connector_service_status(label: &str) -> ServiceState {
645 named_service_status(label)
646}
647
648#[cfg(target_os = "macos")]
649fn named_service_status(label: &str) -> ServiceState {
650 let target = format!("{}/{label}", gui_domain());
651 match run_tool("launchctl", &["print", &target]) {
652 Ok((true, text)) => ServiceState {
653 kind: "launchd",
654 label: label.into(),
655 installed: true,
656 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
657 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
658 },
659 Ok((false, _)) => ServiceState {
660 kind: "launchd",
661 label: label.into(),
662 installed: false,
663 pid: None,
664 detail: format!("not bootstrapped in {}", gui_domain()),
665 },
666 Err(error) => ServiceState {
667 kind: "launchd",
668 label: label.into(),
669 installed: false,
670 pid: None,
671 detail: format!("launchctl unavailable: {error}"),
672 },
673 }
674}
675
676#[cfg(all(unix, not(target_os = "macos")))]
677fn named_service_status(label: &str) -> ServiceState {
678 match run_tool("systemctl", &["--user", "is-active", label]) {
679 Ok((active, text)) => {
680 let known = run_tool("systemctl", &["--user", "is-enabled", label])
681 .map(|(ok, _)| ok)
682 .unwrap_or(false);
683 ServiceState {
684 kind: "systemd",
685 label: label.into(),
686 installed: active || known,
687 pid: None,
688 detail: if text.is_empty() {
689 "unknown".into()
690 } else {
691 text
692 },
693 }
694 }
695 Err(error) => ServiceState {
696 kind: "systemd",
697 label: label.into(),
698 installed: false,
699 pid: None,
700 detail: format!("systemctl unavailable: {error}"),
701 },
702 }
703}
704
705#[cfg(not(unix))]
706fn named_service_status(label: &str) -> ServiceState {
707 ServiceState {
708 kind: "none",
709 label: label.into(),
710 installed: false,
711 pid: None,
712 detail: "no service manager on this platform".into(),
713 }
714}
715
716#[cfg(target_os = "macos")]
717fn named_platform_install(unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
718 platform_install(unit)
719}
720#[cfg(all(unix, not(target_os = "macos")))]
721fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
722 let path = unit.path.display().to_string();
723 for args in [
724 vec!["--user", "link", path.as_str()],
725 vec!["--user", "enable", "--now", label],
726 ] {
727 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
728 action: "install",
729 detail: format!("systemctl: {error}"),
730 })?;
731 if !ok {
732 return Err(TeamsError::Service {
733 action: "install",
734 detail: format!("systemctl {}: {text}", args.join(" ")),
735 });
736 }
737 }
738 Ok(())
739}
740#[cfg(not(unix))]
741fn named_platform_install(_unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
742 Err(TeamsError::Service {
743 action: "install",
744 detail: "no service manager on this platform".into(),
745 })
746}
747
748#[cfg(target_os = "macos")]
749fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
750 let target = format!("{}/{label}", gui_domain());
751 let (ok, text) =
752 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
753 action: "uninstall",
754 detail: format!("launchctl: {error}"),
755 })?;
756 if !ok && !text.contains("No such process") && !text.contains("not find") {
757 return Err(TeamsError::Service {
758 action: "uninstall",
759 detail: format!("launchctl bootout {target}: {text}"),
760 });
761 }
762 for _ in 0..50 {
765 if !named_service_status(label).installed {
766 break;
767 }
768 std::thread::sleep(std::time::Duration::from_millis(100));
769 }
770 Ok(())
771}
772#[cfg(all(unix, not(target_os = "macos")))]
773fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
774 let _ = run_tool("systemctl", &["--user", "disable", "--now", label]);
775 Ok(())
776}
777#[cfg(not(unix))]
778fn named_platform_uninstall(_label: &str) -> Result<(), TeamsError> {
779 Ok(())
780}
781
782#[cfg(target_os = "macos")]
783fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
784 let path = unit.path.display().to_string();
785 let (ok, text) =
786 run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
787 TeamsError::Service {
788 action: "install",
789 detail: format!("launchctl: {error}"),
790 }
791 })?;
792 if !ok {
793 return Err(TeamsError::Service {
794 action: "install",
795 detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
796 });
797 }
798 Ok(())
799}
800
801#[cfg(all(unix, not(target_os = "macos")))]
804fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
805 let path = unit.path.display().to_string();
806 for args in [
807 vec!["--user", "link", path.as_str()],
808 vec!["--user", "enable", "--now", SERVICE_NAME],
809 ] {
810 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
811 action: "install",
812 detail: format!("systemctl: {error}"),
813 })?;
814 if !ok {
815 return Err(TeamsError::Service {
816 action: "install",
817 detail: format!("systemctl {}: {text}", args.join(" ")),
818 });
819 }
820 }
821 Ok(())
822}
823
824#[cfg(not(unix))]
825fn platform_install(_unit: &ServiceUnit) -> Result<(), TeamsError> {
826 Err(TeamsError::Service {
827 action: "install",
828 detail: "no service manager on this platform".into(),
829 })
830}
831
832pub fn uninstall_service(home: &Path) -> Result<ServiceState, TeamsError> {
837 platform_uninstall()?;
838 let unit_path = home.join(SERVICE_DIR).join(unit_file_name());
839 match std::fs::remove_file(&unit_path) {
840 Ok(()) => {}
841 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
842 Err(source) => {
843 return Err(TeamsError::File {
844 path: unit_path,
845 source,
846 })
847 }
848 }
849 let mut state = service_status();
852 for _ in 0..40 {
853 if !state.installed {
854 break;
855 }
856 std::thread::sleep(std::time::Duration::from_millis(100));
857 state = service_status();
858 }
859 Ok(state)
860}
861
862#[cfg(target_os = "macos")]
863fn platform_uninstall() -> Result<(), TeamsError> {
864 let target = format!("{}/{SERVICE_NAME}", gui_domain());
865 let (ok, text) =
866 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
867 action: "uninstall",
868 detail: format!("launchctl: {error}"),
869 })?;
870 if !ok && !text.contains("No such process") && !text.contains("not find") {
872 return Err(TeamsError::Service {
873 action: "uninstall",
874 detail: format!("launchctl bootout {target}: {text}"),
875 });
876 }
877 Ok(())
878}
879
880#[cfg(all(unix, not(target_os = "macos")))]
881fn platform_uninstall() -> Result<(), TeamsError> {
882 let _ = run_tool("systemctl", &["--user", "disable", "--now", SERVICE_NAME]);
883 Ok(())
884}
885
886#[cfg(not(unix))]
887fn platform_uninstall() -> Result<(), TeamsError> {
888 Ok(())
889}
890
891#[cfg(test)]
892mod connector_service_tests {
893 use super::*;
894
895 #[test]
896 fn connector_unit_is_context_scoped_and_contains_no_credential() {
897 let unit = connector_service_unit(
898 Path::new("/tmp/teams home"),
899 Path::new("/tmp/supercode home"),
900 Path::new("/tmp/sdk/teams/bin/teams.mjs"),
901 "/usr/bin/node",
902 Path::new("/tmp/bin/supercode"),
903 "work",
904 Path::new("/tmp/project with spaces"),
905 "srv_1",
906 "team_1",
907 )
908 .unwrap();
909 assert!(unit.text.contains("teams"));
910 assert!(unit.text.contains("connect"));
911 assert!(unit.text.contains("work"));
912 assert!(!unit.text.contains("credential"));
913 assert!(unit
914 .path
915 .file_name()
916 .unwrap()
917 .to_string_lossy()
918 .contains(&connector_service_name("srv_1", "team_1", "work")));
919 }
920
921 #[test]
922 fn connector_labels_separate_context_and_team() {
923 assert_ne!(
924 connector_service_name("srv", "team-a", "work"),
925 connector_service_name("srv", "team-b", "work")
926 );
927 assert_ne!(
928 connector_service_name("srv", "team-a", "work"),
929 connector_service_name("srv", "team-a", "personal")
930 );
931 }
932
933 #[test]
934 fn connector_unit_rejects_newlines_and_escapes_service_syntax() {
935 let unsafe_unit = connector_service_unit(
936 Path::new("/tmp/teams"),
937 Path::new("/tmp/home"),
938 Path::new("/tmp/entry"),
939 "/usr/bin/node",
940 Path::new("/tmp/supercode"),
941 "bad\ncontext",
942 Path::new("/tmp/work"),
943 "srv",
944 "team",
945 );
946 assert!(unsafe_unit.is_err());
947 let unit = connector_service_unit(
948 Path::new("/tmp/teams & logs"),
949 Path::new("/tmp/home $x"),
950 Path::new("/tmp/entry %i"),
951 "/usr/bin/node",
952 Path::new("/tmp/super\"code"),
953 "work",
954 Path::new("/tmp/a & b % $"),
955 "srv",
956 "team",
957 )
958 .unwrap();
959 if cfg!(target_os = "macos") {
960 assert!(unit.text.contains("&"));
961 } else {
962 assert!(unit.text.contains("%%"));
963 assert!(unit.text.contains("$$"));
964 assert!(unit.text.contains("\\\""));
965 }
966 }
967}