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 log_path = teams_home.join(SERVICE_DIR).join(format!("{label}.log"));
119 return windows_task(
120 &path,
121 &label,
122 &format!("supercode Teams connector ({context})"),
123 &service_env_path(teams_home, &label),
124 &[
125 ("SUPERCODE_HOME", home.as_str()),
126 ("SUPERCODE_BIN", supercode.as_str()),
127 ("SUPERCODE_TEAMS_LOG", &log_path.display().to_string()),
128 ],
129 &node,
130 &[
131 entry.as_str(),
132 "teams",
133 "connect",
134 "--foreground",
135 "--context",
136 context,
137 "--cwd",
138 workspace.as_str(),
139 ],
140 &workspace,
141 );
142 }
143 if cfg!(target_os = "macos") {
144 let node = plist_text(&node);
145 let entry = plist_text(&entry);
146 let workspace = plist_text(&workspace);
147 let home = plist_text(&home);
148 let supercode = plist_text(&supercode);
149 let context = plist_text(context);
150 let search_path = plist_text(&service_path());
151 let text = format!(
152 r#"<?xml version="1.0" encoding="UTF-8"?>
153<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
154<plist version="1.0"><dict>
155 <key>Label</key><string>{label}</string>
156 <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>
157 <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>
158 <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
159 <key>StandardOutPath</key><string>{}/service/{label}.out.log</string>
160 <key>StandardErrorPath</key><string>{}/service/{label}.err.log</string>
161</dict></plist>
162"#,
163 plist_text(&teams_home_text),
164 plist_text(&teams_home_text)
165 );
166 Ok(ServiceUnit {
167 kind: "launchd",
168 path: path.clone(),
169 text,
170 install_command: format!("launchctl bootstrap gui/$(id -u) {}", path.display()),
171 files: Vec::new(),
172 })
173 } else {
174 let environment_home = systemd_arg(&format!("SUPERCODE_HOME={home}"));
175 let environment_bin = systemd_arg(&format!("SUPERCODE_BIN={supercode}"));
176 let environment_path = systemd_arg(&format!("PATH={}", service_path()));
177 let node = systemd_arg(&node);
178 let entry = systemd_arg(&entry);
179 let workspace = systemd_arg(&workspace);
180 let context_description = context.replace('%', "%%").replace('$', "$$");
181 let context = systemd_arg(context);
182 let text = format!("[Unit]\nDescription=supercode Teams connector ({context_description})\nAfter=network.target\n\n[Service]\nEnvironment={environment_home}\nEnvironment={environment_bin}\nEnvironment={environment_path}\nExecStart={node} {entry} teams connect --context {context} --cwd {workspace}\nRestart=on-failure\nKillSignal=SIGTERM\nKillMode=process\n\n[Install]\nWantedBy=default.target\n");
185 Ok(ServiceUnit {
186 kind: "systemd",
187 path: path.clone(),
188 text,
189 install_command: format!(
190 "systemctl --user link {} && systemctl --user enable --now {label}",
191 path.display()
192 ),
193 files: Vec::new(),
194 })
195 }
196}
197
198fn connector_unit_suffix() -> &'static str {
200 if cfg!(windows) {
201 "xml"
202 } else if cfg!(target_os = "macos") {
203 "plist"
204 } else {
205 "service"
206 }
207}
208
209fn service_env_path(teams_home: &Path, label: &str) -> PathBuf {
211 teams_home.join(SERVICE_DIR).join(format!("{label}.env"))
212}
213
214#[allow(clippy::too_many_arguments)]
217fn windows_task(
218 path: &Path,
219 label: &str,
220 description: &str,
221 env_path: &Path,
222 env: &[(&str, &str)],
223 node: &str,
224 node_args: &[&str],
225 working_directory: &str,
226) -> Result<ServiceUnit, TeamsError> {
227 let env_text = env
233 .iter()
234 .map(|(key, value)| env_file_value(value).map(|value| format!("{key}={value}\n")))
235 .collect::<Result<String, _>>()?;
236 let env_flag = format!("--env-file={}", env_path.display());
237 let mut arguments = vec![node, env_flag.as_str()];
238 arguments.extend_from_slice(node_args);
239 for value in arguments.iter().chain([&working_directory]) {
241 if value.contains('%') {
242 return Err(TeamsError::Service {
243 action: "render",
244 detail: format!("a Windows task cannot carry a path containing `%`: {value}"),
245 });
246 }
247 }
248 let arguments = arguments
249 .iter()
250 .map(|argument| windows_arg(argument))
251 .collect::<Vec<_>>()
252 .join(" ");
253 let user = windows_user();
254 let conhost = Path::new(&std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into()))
255 .join(r"System32\conhost.exe")
256 .display()
257 .to_string();
258 let text = format!(
259 r#"<?xml version="1.0" encoding="UTF-16"?>
260<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
261 <RegistrationInfo><Description>{}</Description></RegistrationInfo>
262 <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>
263 <Principals><Principal id="Author"><UserId>{}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
264 <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>
265 <Actions Context="Author"><Exec><Command>{}</Command><Arguments>--headless {}</Arguments><WorkingDirectory>{}</WorkingDirectory></Exec></Actions>
266</Task>
267"#,
268 xml_text(description),
269 xml_text(&user),
270 xml_text(&user),
271 xml_text(&conhost),
272 xml_text(&arguments),
273 xml_text(working_directory),
274 );
275 Ok(ServiceUnit {
276 kind: "schtasks",
277 path: path.to_path_buf(),
278 text,
279 install_command: format!("schtasks /Create /TN {label} /XML {} /F", path.display()),
280 files: vec![(env_path.to_path_buf(), env_text)],
281 })
282}
283
284fn xml_text(value: &str) -> String {
286 plist_text(value).replace('"', """)
287}
288
289fn windows_arg(value: &str) -> String {
292 if !value.is_empty() && !value.contains([' ', '\t', '"']) {
293 return value.to_string();
294 }
295 let mut quoted = String::from("\"");
296 let mut backslashes = 0;
297 for character in value.chars() {
298 match character {
299 '\\' => backslashes += 1,
300 '"' => {
301 quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
302 quoted.push('"');
303 backslashes = 0;
304 }
305 other => {
306 quoted.push_str(&"\\".repeat(backslashes));
307 quoted.push(other);
308 backslashes = 0;
309 }
310 }
311 }
312 quoted.push_str(&"\\".repeat(backslashes * 2));
313 quoted.push('"');
314 quoted
315}
316
317fn env_file_value(value: &str) -> Result<String, TeamsError> {
320 ['\'', '`']
321 .into_iter()
322 .find(|quote| !value.contains(*quote))
323 .map(|quote| format!("{quote}{value}{quote}"))
324 .or_else(|| (!value.contains(['"', '\\'])).then(|| format!("\"{value}\"")))
325 .ok_or_else(|| TeamsError::Service {
326 action: "render",
327 detail: format!("cannot write `{value}` into a service's environment file"),
328 })
329}
330
331fn windows_user() -> String {
333 let user = std::env::var("USERNAME").unwrap_or_default();
334 match std::env::var("USERDOMAIN") {
335 Ok(domain) if !domain.is_empty() => format!("{domain}\\{user}"),
336 _ => user,
337 }
338}
339
340pub fn connector_service_binary(
346 teams_home: &Path,
347 supercode: &Path,
348) -> Result<PathBuf, TeamsError> {
349 if !cfg!(windows) {
350 return Ok(supercode.to_path_buf());
351 }
352 let file = |path: &Path, source: std::io::Error| TeamsError::File {
353 path: path.to_path_buf(),
354 source,
355 };
356 let copies = teams_home.join(SERVICE_DIR).join("bin");
357 let version = env!("CARGO_PKG_VERSION");
358 let dir = copies.join(version);
359 let copy = dir.join(
360 supercode
361 .file_name()
362 .unwrap_or_else(|| "supercode.exe".as_ref()),
363 );
364 let size = |path: &Path| std::fs::metadata(path).map(|meta| meta.len()).ok();
365 if size(©).is_none() || size(©) != size(supercode) {
366 std::fs::create_dir_all(&dir).map_err(|source| file(&dir, source))?;
367 std::fs::copy(supercode, ©).map_err(|source| file(©, source))?;
368 }
369 if let Ok(entries) = std::fs::read_dir(&copies) {
370 for entry in entries.flatten() {
371 if entry.file_name() != version {
372 let _ = std::fs::remove_dir_all(entry.path());
373 }
374 }
375 }
376 Ok(copy)
377}
378
379#[derive(Debug, thiserror::Error)]
381pub enum TeamsError {
382 #[error("no teams entry found (looked for `sdk/teams/{TEAMS_ENTRY}` under: {searched}); install it with `npm install -g {TEAMS_PACKAGE}`")]
384 NoEntry {
385 searched: String,
387 },
388 #[error("teams service: {action} failed: {detail}")]
390 Service {
391 action: &'static str,
393 detail: String,
395 },
396 #[error("teams file `{}`: {source}", path.display())]
398 File {
399 path: PathBuf,
401 source: std::io::Error,
403 },
404}
405
406fn service_path() -> String {
410 std::env::var("PATH")
411 .ok()
412 .filter(|path| !path.trim().is_empty())
413 .unwrap_or_else(|| "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".into())
414}
415
416pub fn teams_home() -> PathBuf {
421 if let Ok(home) = std::env::var("SUPERCODE_TEAMS_HOME") {
422 if !home.is_empty() {
423 return PathBuf::from(home);
424 }
425 }
426 crate::agent::global_instructions_dir().join("teams")
427}
428
429pub fn teams_entry() -> Result<PathBuf, TeamsError> {
439 let mut searched = Vec::new();
440 if let Some(explicit) = std::env::var_os("SUPERCODE_TEAMS_ENTRY") {
441 let path = PathBuf::from(explicit);
442 if path.is_file() {
443 return Ok(path);
444 }
445 searched.push(path.display().to_string());
446 }
447 let mut roots: Vec<PathBuf> = Vec::new();
448 if let Ok(exe) = std::env::current_exe() {
449 roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
451 }
452 if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
457 roots.push(workspace.to_path_buf());
458 }
459 for root in roots {
460 let candidate = root.join("sdk/teams").join(TEAMS_ENTRY);
461 if candidate.is_file() {
462 return Ok(candidate);
463 }
464 searched.push(candidate.display().to_string());
465 }
466 if let Ok(exe) = std::env::current_exe() {
471 for modules in exe
472 .ancestors()
473 .filter(|dir| dir.file_name().is_some_and(|name| name == "node_modules"))
474 {
475 let candidate = modules.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
476 if candidate.is_file() {
477 return Ok(candidate);
478 }
479 searched.push(candidate.display().to_string());
480 }
481 }
482 if let Some(global) = global_npm_root() {
483 let candidate = global.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
484 if candidate.is_file() {
485 return Ok(candidate);
486 }
487 searched.push(candidate.display().to_string());
488 }
489 Err(TeamsError::NoEntry {
490 searched: searched.join(", "),
491 })
492}
493
494fn global_npm_root() -> Option<PathBuf> {
496 let output = std::process::Command::new(resolve_program("npm"))
497 .args(["root", "-g"])
498 .stdin(std::process::Stdio::null())
499 .stderr(std::process::Stdio::null())
500 .output()
501 .ok()?;
502 if !output.status.success() {
503 return None;
504 }
505 let text = String::from_utf8_lossy(&output.stdout);
506 let root = text.trim();
507 if root.is_empty() {
508 return None;
509 }
510 Some(PathBuf::from(root))
511}
512
513pub fn service_unit(home: &Path, entry: &Path, node: &str) -> Result<ServiceUnit, TeamsError> {
522 let home_display = home.display().to_string();
523 let entry_display = entry.display().to_string();
524 for value in [home_display.as_str(), entry_display.as_str(), node] {
526 service_text(value)?;
527 }
528 if cfg!(windows) {
529 let log_path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.log"));
530 return windows_task(
531 &home.join(SERVICE_DIR).join(unit_file_name()),
532 SERVICE_NAME,
533 &format!("supercode teams machine daemon ({home_display})"),
534 &service_env_path(home, SERVICE_NAME),
535 &[
536 ("SUPERCODE_TEAMS_HOME", home_display.as_str()),
537 ("SUPERCODE_TEAMS_LOG", &log_path.display().to_string()),
538 ],
539 node,
540 &[entry_display.as_str(), "machine", "start"],
541 &home_display,
542 );
543 }
544 if cfg!(target_os = "macos") {
545 let path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.plist"));
546 let text = format!(
547 r#"<?xml version="1.0" encoding="UTF-8"?>
548<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
549<plist version="1.0">
550<dict>
551 <key>Label</key><string>{SERVICE_NAME}</string>
552 <key>ProgramArguments</key>
553 <array>
554 <string>{node}</string>
555 <string>{entry_display}</string>
556 <string>machine</string>
557 <string>start</string>
558 </array>
559 <key>EnvironmentVariables</key>
560 <dict>
561 <key>SUPERCODE_TEAMS_HOME</key><string>{home_display}</string>
562 </dict>
563 <key>RunAtLoad</key><true/>
564 <key>KeepAlive</key><true/>
565 <key>StandardOutPath</key><string>{home_display}/service/teams-machine.out.log</string>
566 <key>StandardErrorPath</key><string>{home_display}/service/teams-machine.err.log</string>
567</dict>
568</plist>
569"#
570 );
571 let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
572 Ok(ServiceUnit {
573 kind: "launchd",
574 path,
575 text,
576 install_command: install,
577 files: Vec::new(),
578 })
579 } else {
580 let path = home
581 .join(SERVICE_DIR)
582 .join(format!("{SERVICE_NAME}.service"));
583 let text = format!(
584 "[Unit]\n\
585 Description=supercode teams machine daemon ({home_display})\n\
586 After=network.target\n\
587 \n\
588 [Service]\n\
589 Environment=SUPERCODE_TEAMS_HOME={home_display}\n\
590 ExecStart={node} {entry_display} machine start\n\
591 Restart=on-failure\n\
592 KillSignal=SIGTERM\n\
593 KillMode=process\n\
594 \n\
595 [Install]\n\
596 WantedBy=default.target\n"
597 );
598 let install = format!(
599 "systemctl --user link {} && systemctl --user enable --now {SERVICE_NAME}",
600 path.display()
601 );
602 Ok(ServiceUnit {
603 kind: "systemd",
604 path,
605 text,
606 install_command: install,
607 files: Vec::new(),
608 })
609 }
610}
611
612pub fn write_unit(unit: &ServiceUnit) -> Result<(), TeamsError> {
614 if let Some(parent) = unit.path.parent() {
615 std::fs::create_dir_all(parent).map_err(|source| TeamsError::File {
616 path: unit.path.clone(),
617 source,
618 })?;
619 }
620 std::fs::write(&unit.path, &unit.text).map_err(|source| TeamsError::File {
621 path: unit.path.clone(),
622 source,
623 })?;
624 for (path, text) in &unit.files {
625 std::fs::write(path, text).map_err(|source| TeamsError::File {
626 path: path.clone(),
627 source,
628 })?;
629 }
630 Ok(())
631}
632
633fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
635 let output = std::process::Command::new(program).args(args).output()?;
636 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
637 text.push_str(&String::from_utf8_lossy(&output.stderr));
638 Ok((output.status.success(), text.trim().to_string()))
639}
640
641#[cfg(target_os = "macos")]
642fn gui_domain() -> String {
643 format!("gui/{}", unsafe { libc::getuid() })
645}
646
647pub fn service_status() -> ServiceState {
651 platform_status()
652}
653
654#[cfg(target_os = "macos")]
655fn platform_status() -> ServiceState {
656 let label = SERVICE_NAME.to_string();
657 let target = format!("{}/{SERVICE_NAME}", gui_domain());
658 match run_tool("launchctl", &["print", &target]) {
659 Ok((true, text)) => ServiceState {
660 kind: "launchd",
661 label,
662 installed: true,
663 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
664 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
665 },
666 Ok((false, _)) => ServiceState {
667 kind: "launchd",
668 label,
669 installed: false,
670 pid: None,
671 detail: format!("not bootstrapped in {}", gui_domain()),
672 },
673 Err(error) => ServiceState {
674 kind: "launchd",
675 label,
676 installed: false,
677 pid: None,
678 detail: format!("launchctl unavailable: {error}"),
679 },
680 }
681}
682
683#[cfg(all(unix, not(target_os = "macos")))]
684fn platform_status() -> ServiceState {
685 let label = SERVICE_NAME.to_string();
686 match run_tool("systemctl", &["--user", "is-active", SERVICE_NAME]) {
687 Ok((active, text)) => {
688 let known = run_tool("systemctl", &["--user", "is-enabled", SERVICE_NAME])
689 .map(|(ok, _)| ok)
690 .unwrap_or(false);
691 ServiceState {
692 kind: "systemd",
693 label,
694 installed: active || known,
695 pid: None,
696 detail: if text.is_empty() {
697 "unknown".into()
698 } else {
699 text
700 },
701 }
702 }
703 Err(error) => ServiceState {
704 kind: "systemd",
705 label,
706 installed: false,
707 pid: None,
708 detail: format!("systemctl unavailable: {error}"),
709 },
710 }
711}
712
713#[cfg(not(unix))]
714fn platform_status() -> ServiceState {
715 named_service_status(SERVICE_NAME)
716}
717
718#[cfg(target_os = "macos")]
720fn field_of(text: &str, key: &str) -> Option<String> {
721 text.lines()
722 .find_map(|line| line.trim().strip_prefix(key))
723 .map(|value| value.trim().to_string())
724}
725
726fn unit_file_name() -> String {
728 if cfg!(windows) {
729 format!("{SERVICE_NAME}.xml")
730 } else if cfg!(target_os = "macos") {
731 format!("{SERVICE_NAME}.plist")
732 } else {
733 format!("{SERVICE_NAME}.service")
734 }
735}
736
737pub fn install_service(
743 home: &Path,
744 entry: &Path,
745 node: &str,
746) -> Result<(ServiceUnit, ServiceState), TeamsError> {
747 let existing = service_status();
748 if existing.installed {
749 return Err(TeamsError::Service {
750 action: "install",
751 detail: format!(
752 "`{}` is already installed ({}); `supercode teams machine uninstall` first",
753 existing.label, existing.detail
754 ),
755 });
756 }
757 let unit = service_unit(home, entry, &absolute_program(node))?;
758 write_unit(&unit)?;
759 platform_install(&unit)?;
760 Ok((unit, service_status()))
761}
762
763pub fn install_connector_service(
769 unit: &ServiceUnit,
770 label: &str,
771) -> Result<ServiceState, TeamsError> {
772 let existing = named_service_status(label);
773 if unit.path.exists() {
774 let old = std::fs::read_to_string(&unit.path).map_err(|source| TeamsError::File {
775 path: unit.path.clone(),
776 source,
777 })?;
778 let same = old == unit.text
780 && unit
781 .files
782 .iter()
783 .all(|(path, text)| std::fs::read_to_string(path).is_ok_and(|old| &old == text));
784 if same && existing.installed {
785 return Ok(existing);
786 }
787 if !same && existing.installed {
788 named_platform_uninstall(label)?;
789 }
790 } else if existing.installed {
791 return Err(TeamsError::Service {
792 action: "install",
793 detail: format!(
794 "service manager already owns `{label}` without its expected unit file"
795 ),
796 });
797 }
798 write_unit(unit)?;
799 named_platform_install(unit, label)?;
800 Ok(named_service_status(label))
801}
802
803pub fn register_message_tools(supercode: &Path) -> Vec<String> {
811 let program = supercode.display().to_string();
812 let mut report = Vec::new();
813 for (harness, get, remove, add) in [
814 (
815 "claude",
816 vec!["mcp", "get", "supercode"],
817 vec!["mcp", "remove", "--scope", "user", "supercode"],
818 vec![
819 "mcp",
820 "add",
821 "--scope",
822 "user",
823 "supercode",
824 "--",
825 &program,
826 "message",
827 "mcp",
828 ],
829 ),
830 (
831 "codex",
832 vec!["mcp", "get", "supercode"],
833 vec!["mcp", "remove", "supercode"],
834 vec!["mcp", "add", "supercode", "--", &program, "message", "mcp"],
835 ),
836 ] {
837 let run = |args: &[&str]| {
838 std::process::Command::new(resolve_program(harness))
839 .args(args)
840 .stdin(std::process::Stdio::null())
841 .output()
842 };
843 let names_program = |text: &str| {
845 if cfg!(windows) {
846 text.to_lowercase().contains(&program.to_lowercase())
847 } else {
848 text.contains(&program)
849 }
850 };
851 let replaced = match run(&get) {
852 Err(_) => {
853 report.push(format!("{harness}: not installed"));
854 continue;
855 }
856 Ok(found)
857 if found.status.success()
858 && names_program(&String::from_utf8_lossy(&found.stdout)) =>
859 {
860 report.push(format!("{harness}: already has supercode's tools"));
861 continue;
862 }
863 Ok(found) if found.status.success() => {
864 let _ = run(&remove);
865 true
866 }
867 Ok(_) => false,
868 };
869 match run(&add) {
870 Ok(added) if added.status.success() => report.push(if replaced {
871 format!("{harness}: supercode's tools now run {program} (new sessions load them)")
872 } else {
873 format!("{harness}: supercode's tools added (new sessions load them)")
874 }),
875 Ok(added) => report.push(format!(
876 "{harness}: could not add supercode's tools: {}",
877 String::from_utf8_lossy(&added.stderr).trim()
878 )),
879 Err(error) => report.push(format!(
880 "{harness}: could not add supercode's tools: {error}"
881 )),
882 }
883 }
884 report
885}
886
887pub fn uninstall_connector_service(
889 teams_home: &Path,
890 label: &str,
891) -> Result<ServiceState, TeamsError> {
892 named_platform_uninstall(label)?;
893 let unit = teams_home
894 .join(SERVICE_DIR)
895 .join(format!("{label}.{}", connector_unit_suffix()));
896 for path in [unit, service_env_path(teams_home, label)] {
897 match std::fs::remove_file(&path) {
898 Ok(()) => {}
899 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
900 Err(source) => return Err(TeamsError::File { path, source }),
901 }
902 }
903 Ok(named_service_status(label))
904}
905
906pub fn connector_service_status(label: &str) -> ServiceState {
908 named_service_status(label)
909}
910
911pub fn service_owns_daemon() -> bool {
916 if service_status().installed {
917 return true;
918 }
919 let Ok(entries) = std::fs::read_dir(teams_home().join(SERVICE_DIR)) else {
920 return false;
921 };
922 entries.flatten().any(|entry| {
923 let name = entry.file_name().to_string_lossy().into_owned();
924 let label = name
925 .strip_suffix(".plist")
926 .or_else(|| name.strip_suffix(".service"))
927 .unwrap_or(&name);
928 label.starts_with("dev.volter.supercode-teams-connector-")
929 && (name.ends_with(".plist") || name.ends_with(".service"))
930 && connector_service_status(label).installed
931 })
932}
933
934#[cfg(target_os = "macos")]
935fn named_service_status(label: &str) -> ServiceState {
936 let target = format!("{}/{label}", gui_domain());
937 match run_tool("launchctl", &["print", &target]) {
938 Ok((true, text)) => ServiceState {
939 kind: "launchd",
940 label: label.into(),
941 installed: true,
942 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
943 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
944 },
945 Ok((false, _)) => ServiceState {
946 kind: "launchd",
947 label: label.into(),
948 installed: false,
949 pid: None,
950 detail: format!("not bootstrapped in {}", gui_domain()),
951 },
952 Err(error) => ServiceState {
953 kind: "launchd",
954 label: label.into(),
955 installed: false,
956 pid: None,
957 detail: format!("launchctl unavailable: {error}"),
958 },
959 }
960}
961
962#[cfg(all(unix, not(target_os = "macos")))]
963fn named_service_status(label: &str) -> ServiceState {
964 match run_tool("systemctl", &["--user", "is-active", label]) {
965 Ok((active, text)) => {
966 let known = run_tool("systemctl", &["--user", "is-enabled", label])
967 .map(|(ok, _)| ok)
968 .unwrap_or(false);
969 ServiceState {
970 kind: "systemd",
971 label: label.into(),
972 installed: active || known,
973 pid: None,
974 detail: if text.is_empty() {
975 "unknown".into()
976 } else {
977 text
978 },
979 }
980 }
981 Err(error) => ServiceState {
982 kind: "systemd",
983 label: label.into(),
984 installed: false,
985 pid: None,
986 detail: format!("systemctl unavailable: {error}"),
987 },
988 }
989}
990
991#[cfg(not(unix))]
992fn named_service_status(label: &str) -> ServiceState {
993 match run_tool("schtasks", &["/Query", "/TN", label, "/FO", "CSV", "/NH"]) {
994 Ok((true, text)) => ServiceState {
996 kind: "schtasks",
997 label: label.into(),
998 installed: true,
999 pid: None,
1000 detail: text
1001 .lines()
1002 .next()
1003 .and_then(|line| line.rsplit(',').next())
1004 .map(|state| state.trim_matches('"').to_string())
1005 .filter(|state| !state.is_empty())
1006 .unwrap_or_else(|| "registered".into()),
1007 },
1008 Ok((false, _)) => ServiceState {
1009 kind: "schtasks",
1010 label: label.into(),
1011 installed: false,
1012 pid: None,
1013 detail: "no scheduled task".into(),
1014 },
1015 Err(error) => ServiceState {
1016 kind: "schtasks",
1017 label: label.into(),
1018 installed: false,
1019 pid: None,
1020 detail: format!("schtasks unavailable: {error}"),
1021 },
1022 }
1023}
1024
1025#[cfg(target_os = "macos")]
1026fn named_platform_install(unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
1027 platform_install(unit)
1028}
1029#[cfg(all(unix, not(target_os = "macos")))]
1030fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
1031 let path = unit.path.display().to_string();
1032 for args in [
1033 vec!["--user", "link", path.as_str()],
1034 vec!["--user", "enable", "--now", label],
1035 ] {
1036 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1037 action: "install",
1038 detail: format!("systemctl: {error}"),
1039 })?;
1040 if !ok {
1041 return Err(TeamsError::Service {
1042 action: "install",
1043 detail: format!("systemctl {}: {text}", args.join(" ")),
1044 });
1045 }
1046 }
1047 Ok(())
1048}
1049#[cfg(not(unix))]
1050fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
1051 let task = unit.path.with_extension("utf16.xml");
1053 let bytes: Vec<u8> = [0xFF, 0xFE]
1054 .into_iter()
1055 .chain(unit.text.encode_utf16().flat_map(u16::to_le_bytes))
1056 .collect();
1057 std::fs::write(&task, bytes).map_err(|source| TeamsError::File {
1058 path: task.clone(),
1059 source,
1060 })?;
1061 let task_path = task.display().to_string();
1062 let created = run_tool(
1063 "schtasks",
1064 &["/Create", "/TN", label, "/XML", task_path.as_str(), "/F"],
1065 )
1066 .map_err(|error| TeamsError::Service {
1067 action: "install",
1068 detail: format!("schtasks: {error}"),
1069 })
1070 .and_then(|(ok, text)| {
1071 if ok {
1072 Ok(())
1073 } else {
1074 Err(TeamsError::Service {
1075 action: "install",
1076 detail: format!("schtasks /Create: {text}"),
1077 })
1078 }
1079 });
1080 if let Err(error) = created {
1081 let _ = std::fs::remove_file(&task);
1082 return Err(error);
1083 }
1084 let _ = run_tool("schtasks", &["/End", "/TN", label]);
1089 let result = [vec!["/Run", "/TN", label]].iter().try_for_each(|args| {
1090 let (ok, text) = run_tool("schtasks", args).map_err(|error| TeamsError::Service {
1091 action: "install",
1092 detail: format!("schtasks: {error}"),
1093 })?;
1094 if ok {
1095 Ok(())
1096 } else {
1097 Err(TeamsError::Service {
1098 action: "install",
1099 detail: format!("schtasks {}: {text}", args[0]),
1100 })
1101 }
1102 });
1103 let _ = std::fs::remove_file(&task);
1104 result
1105}
1106
1107#[cfg(target_os = "macos")]
1108fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1109 let target = format!("{}/{label}", gui_domain());
1110 let (ok, text) =
1111 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1112 action: "uninstall",
1113 detail: format!("launchctl: {error}"),
1114 })?;
1115 if !ok && !text.contains("No such process") && !text.contains("not find") {
1116 return Err(TeamsError::Service {
1117 action: "uninstall",
1118 detail: format!("launchctl bootout {target}: {text}"),
1119 });
1120 }
1121 for _ in 0..50 {
1124 if !named_service_status(label).installed {
1125 break;
1126 }
1127 std::thread::sleep(std::time::Duration::from_millis(100));
1128 }
1129 Ok(())
1130}
1131#[cfg(all(unix, not(target_os = "macos")))]
1132fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1133 let _ = run_tool("systemctl", &["--user", "disable", "--now", label]);
1134 Ok(())
1135}
1136#[cfg(not(unix))]
1137fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1138 let (ok, text) = run_tool("schtasks", &["/Delete", "/TN", label, "/F"]).map_err(|error| {
1140 TeamsError::Service {
1141 action: "uninstall",
1142 detail: format!("schtasks: {error}"),
1143 }
1144 })?;
1145 if !ok && named_service_status(label).installed {
1146 return Err(TeamsError::Service {
1147 action: "uninstall",
1148 detail: format!("schtasks /Delete: {text}"),
1149 });
1150 }
1151 let env_path = service_env_path(&teams_home(), label);
1155 let stopped = std::process::Command::new("powershell.exe")
1156 .args([
1157 "-NoProfile",
1158 "-NonInteractive",
1159 "-Command",
1160 "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 }",
1161 ])
1162 .env("SUPERCODE_SERVICE_ENV_FILE", env_path.display().to_string())
1163 .stdin(std::process::Stdio::null())
1164 .output();
1165 match stopped {
1166 Ok(output) if output.status.success() => Ok(()),
1167 Ok(output) => Err(TeamsError::Service {
1168 action: "uninstall",
1169 detail: format!(
1170 "the task is gone, but what it started may still run: {}",
1171 String::from_utf8_lossy(&output.stderr).trim()
1172 ),
1173 }),
1174 Err(error) => Err(TeamsError::Service {
1175 action: "uninstall",
1176 detail: format!(
1177 "the task is gone, but what it started may still run: powershell: {error}"
1178 ),
1179 }),
1180 }
1181}
1182
1183#[cfg(target_os = "macos")]
1184fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1185 let path = unit.path.display().to_string();
1186 let (ok, text) =
1187 run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
1188 TeamsError::Service {
1189 action: "install",
1190 detail: format!("launchctl: {error}"),
1191 }
1192 })?;
1193 if !ok {
1194 return Err(TeamsError::Service {
1195 action: "install",
1196 detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
1197 });
1198 }
1199 Ok(())
1200}
1201
1202#[cfg(all(unix, not(target_os = "macos")))]
1205fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1206 let path = unit.path.display().to_string();
1207 for args in [
1208 vec!["--user", "link", path.as_str()],
1209 vec!["--user", "enable", "--now", SERVICE_NAME],
1210 ] {
1211 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1212 action: "install",
1213 detail: format!("systemctl: {error}"),
1214 })?;
1215 if !ok {
1216 return Err(TeamsError::Service {
1217 action: "install",
1218 detail: format!("systemctl {}: {text}", args.join(" ")),
1219 });
1220 }
1221 }
1222 Ok(())
1223}
1224
1225#[cfg(not(unix))]
1226fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1227 named_platform_install(unit, SERVICE_NAME)
1228}
1229
1230pub fn uninstall_service(home: &Path) -> Result<ServiceState, TeamsError> {
1235 platform_uninstall()?;
1236 let unit_path = home.join(SERVICE_DIR).join(unit_file_name());
1237 for path in [unit_path, service_env_path(home, SERVICE_NAME)] {
1239 match std::fs::remove_file(&path) {
1240 Ok(()) => {}
1241 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1242 Err(source) => return Err(TeamsError::File { path, source }),
1243 }
1244 }
1245 let mut state = service_status();
1248 for _ in 0..40 {
1249 if !state.installed {
1250 break;
1251 }
1252 std::thread::sleep(std::time::Duration::from_millis(100));
1253 state = service_status();
1254 }
1255 Ok(state)
1256}
1257
1258#[cfg(target_os = "macos")]
1259fn platform_uninstall() -> Result<(), TeamsError> {
1260 let target = format!("{}/{SERVICE_NAME}", gui_domain());
1261 let (ok, text) =
1262 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1263 action: "uninstall",
1264 detail: format!("launchctl: {error}"),
1265 })?;
1266 if !ok && !text.contains("No such process") && !text.contains("not find") {
1268 return Err(TeamsError::Service {
1269 action: "uninstall",
1270 detail: format!("launchctl bootout {target}: {text}"),
1271 });
1272 }
1273 Ok(())
1274}
1275
1276#[cfg(all(unix, not(target_os = "macos")))]
1277fn platform_uninstall() -> Result<(), TeamsError> {
1278 let _ = run_tool("systemctl", &["--user", "disable", "--now", SERVICE_NAME]);
1279 Ok(())
1280}
1281
1282#[cfg(not(unix))]
1283fn platform_uninstall() -> Result<(), TeamsError> {
1284 named_platform_uninstall(SERVICE_NAME)
1285}