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!(
156 r#"<?xml version="1.0" encoding="UTF-8"?>
157<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
158<plist version="1.0"><dict>
159 <key>Label</key><string>{label}</string>
160 <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>
161 <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>
162 <key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ProcessType</key><string>Interactive</string>
163 <key>StandardOutPath</key><string>{}/service/{label}.out.log</string>
164 <key>StandardErrorPath</key><string>{}/service/{label}.err.log</string>
165</dict></plist>
166"#,
167 plist_text(&teams_home_text),
168 plist_text(&teams_home_text)
169 );
170 Ok(ServiceUnit {
171 kind: "launchd",
172 path: path.clone(),
173 text,
174 install_command: format!("launchctl bootstrap gui/$(id -u) {}", path.display()),
175 files: Vec::new(),
176 })
177 } else {
178 let environment_home = systemd_arg(&format!("SUPERCODE_HOME={home}"));
179 let environment_bin = systemd_arg(&format!("SUPERCODE_BIN={supercode}"));
180 let environment_path = systemd_arg(&format!("PATH={}", service_path()));
181 let node = systemd_arg(&node);
182 let entry = systemd_arg(&entry);
183 let workspace = systemd_arg(&workspace);
184 let context_description = context.replace('%', "%%").replace('$', "$$");
185 let context = systemd_arg(context);
186 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");
189 Ok(ServiceUnit {
190 kind: "systemd",
191 path: path.clone(),
192 text,
193 install_command: format!(
194 "systemctl --user link {} && systemctl --user enable --now {label}",
195 path.display()
196 ),
197 files: Vec::new(),
198 })
199 }
200}
201
202fn connector_unit_suffix() -> &'static str {
204 if cfg!(windows) {
205 "xml"
206 } else if cfg!(target_os = "macos") {
207 "plist"
208 } else {
209 "service"
210 }
211}
212
213fn service_env_path(teams_home: &Path, label: &str) -> PathBuf {
215 teams_home.join(SERVICE_DIR).join(format!("{label}.env"))
216}
217
218#[allow(clippy::too_many_arguments)]
221fn windows_task(
222 path: &Path,
223 label: &str,
224 description: &str,
225 env_path: &Path,
226 env: &[(&str, &str)],
227 node: &str,
228 node_args: &[&str],
229 working_directory: &str,
230) -> Result<ServiceUnit, TeamsError> {
231 let env_text = env
237 .iter()
238 .map(|(key, value)| env_file_value(value).map(|value| format!("{key}={value}\n")))
239 .collect::<Result<String, _>>()?;
240 let env_flag = format!("--env-file={}", env_path.display());
241 let mut arguments = vec![node, env_flag.as_str()];
242 arguments.extend_from_slice(node_args);
243 for value in arguments.iter().chain([&working_directory]) {
245 if value.contains('%') {
246 return Err(TeamsError::Service {
247 action: "render",
248 detail: format!("a Windows task cannot carry a path containing `%`: {value}"),
249 });
250 }
251 }
252 let arguments = arguments
253 .iter()
254 .map(|argument| windows_arg(argument))
255 .collect::<Vec<_>>()
256 .join(" ");
257 let user = windows_user();
258 let conhost = Path::new(&std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into()))
259 .join(r"System32\conhost.exe")
260 .display()
261 .to_string();
262 let text = format!(
265 r#"<?xml version="1.0" encoding="UTF-16"?>
266<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
267 <RegistrationInfo><Description>{}</Description></RegistrationInfo>
268 <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>
269 <Principals><Principal id="Author"><UserId>{}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
270 <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Priority>4</Priority><RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure><StartWhenAvailable>true</StartWhenAvailable></Settings>
271 <Actions Context="Author"><Exec><Command>{}</Command><Arguments>--headless {}</Arguments><WorkingDirectory>{}</WorkingDirectory></Exec></Actions>
272</Task>
273"#,
274 xml_text(description),
275 xml_text(&user),
276 xml_text(&user),
277 xml_text(&conhost),
278 xml_text(&arguments),
279 xml_text(working_directory),
280 );
281 Ok(ServiceUnit {
282 kind: "schtasks",
283 path: path.to_path_buf(),
284 text,
285 install_command: format!("schtasks /Create /TN {label} /XML {} /F", path.display()),
286 files: vec![(env_path.to_path_buf(), env_text)],
287 })
288}
289
290fn xml_text(value: &str) -> String {
292 plist_text(value).replace('"', """)
293}
294
295fn windows_arg(value: &str) -> String {
298 if !value.is_empty() && !value.contains([' ', '\t', '"']) {
299 return value.to_string();
300 }
301 let mut quoted = String::from("\"");
302 let mut backslashes = 0;
303 for character in value.chars() {
304 match character {
305 '\\' => backslashes += 1,
306 '"' => {
307 quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
308 quoted.push('"');
309 backslashes = 0;
310 }
311 other => {
312 quoted.push_str(&"\\".repeat(backslashes));
313 quoted.push(other);
314 backslashes = 0;
315 }
316 }
317 }
318 quoted.push_str(&"\\".repeat(backslashes * 2));
319 quoted.push('"');
320 quoted
321}
322
323fn env_file_value(value: &str) -> Result<String, TeamsError> {
326 ['\'', '`']
327 .into_iter()
328 .find(|quote| !value.contains(*quote))
329 .map(|quote| format!("{quote}{value}{quote}"))
330 .or_else(|| (!value.contains(['"', '\\'])).then(|| format!("\"{value}\"")))
331 .ok_or_else(|| TeamsError::Service {
332 action: "render",
333 detail: format!("cannot write `{value}` into a service's environment file"),
334 })
335}
336
337fn windows_user() -> String {
339 let user = std::env::var("USERNAME").unwrap_or_default();
340 match std::env::var("USERDOMAIN") {
341 Ok(domain) if !domain.is_empty() => format!("{domain}\\{user}"),
342 _ => user,
343 }
344}
345
346pub fn connector_service_binary(
352 teams_home: &Path,
353 supercode: &Path,
354) -> Result<PathBuf, TeamsError> {
355 if !cfg!(windows) {
356 return Ok(supercode.to_path_buf());
357 }
358 let file = |path: &Path, source: std::io::Error| TeamsError::File {
359 path: path.to_path_buf(),
360 source,
361 };
362 let copies = teams_home.join(SERVICE_DIR).join("bin");
363 let version = env!("CARGO_PKG_VERSION");
364 let dir = copies.join(version);
365 let copy = dir.join(
366 supercode
367 .file_name()
368 .unwrap_or_else(|| "supercode.exe".as_ref()),
369 );
370 let size = |path: &Path| std::fs::metadata(path).map(|meta| meta.len()).ok();
371 if size(©).is_none() || size(©) != size(supercode) {
372 std::fs::create_dir_all(&dir).map_err(|source| file(&dir, source))?;
373 std::fs::copy(supercode, ©).map_err(|source| file(©, source))?;
374 }
375 if let Ok(entries) = std::fs::read_dir(&copies) {
376 for entry in entries.flatten() {
377 if entry.file_name() != version {
378 let _ = std::fs::remove_dir_all(entry.path());
379 }
380 }
381 }
382 Ok(copy)
383}
384
385#[derive(Debug, thiserror::Error)]
387pub enum TeamsError {
388 #[error("no teams entry found (looked for `sdk/teams/{TEAMS_ENTRY}` under: {searched}); install it with `npm install -g {TEAMS_PACKAGE}`")]
390 NoEntry {
391 searched: String,
393 },
394 #[error("teams service: {action} failed: {detail}")]
396 Service {
397 action: &'static str,
399 detail: String,
401 },
402 #[error("teams file `{}`: {source}", path.display())]
404 File {
405 path: PathBuf,
407 source: std::io::Error,
409 },
410}
411
412fn service_path() -> String {
416 std::env::var("PATH")
417 .ok()
418 .filter(|path| !path.trim().is_empty())
419 .unwrap_or_else(|| "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".into())
420}
421
422pub fn teams_home() -> PathBuf {
427 if let Ok(home) = std::env::var("SUPERCODE_TEAMS_HOME") {
428 if !home.is_empty() {
429 return PathBuf::from(home);
430 }
431 }
432 crate::agent::global_instructions_dir().join("teams")
433}
434
435pub fn teams_entry() -> Result<PathBuf, TeamsError> {
445 let mut searched = Vec::new();
446 if let Some(explicit) = std::env::var_os("SUPERCODE_TEAMS_ENTRY") {
447 let path = PathBuf::from(explicit);
448 if path.is_file() {
449 return Ok(path);
450 }
451 searched.push(path.display().to_string());
452 }
453 let mut roots: Vec<PathBuf> = Vec::new();
454 if let Ok(exe) = std::env::current_exe() {
455 roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
457 }
458 if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
463 roots.push(workspace.to_path_buf());
464 }
465 for root in roots {
466 let candidate = root.join("sdk/teams").join(TEAMS_ENTRY);
467 if candidate.is_file() {
468 return Ok(candidate);
469 }
470 searched.push(candidate.display().to_string());
471 }
472 if let Ok(exe) = std::env::current_exe() {
477 for modules in exe
478 .ancestors()
479 .filter(|dir| dir.file_name().is_some_and(|name| name == "node_modules"))
480 {
481 let candidate = modules.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
482 if candidate.is_file() {
483 return Ok(candidate);
484 }
485 searched.push(candidate.display().to_string());
486 }
487 }
488 if let Some(global) = global_npm_root() {
489 let candidate = global.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
490 if candidate.is_file() {
491 return Ok(candidate);
492 }
493 searched.push(candidate.display().to_string());
494 }
495 Err(TeamsError::NoEntry {
496 searched: searched.join(", "),
497 })
498}
499
500fn global_npm_root() -> Option<PathBuf> {
502 let output = std::process::Command::new(resolve_program("npm"))
503 .args(["root", "-g"])
504 .stdin(std::process::Stdio::null())
505 .stderr(std::process::Stdio::null())
506 .output()
507 .ok()?;
508 if !output.status.success() {
509 return None;
510 }
511 let text = String::from_utf8_lossy(&output.stdout);
512 let root = text.trim();
513 if root.is_empty() {
514 return None;
515 }
516 Some(PathBuf::from(root))
517}
518
519pub fn service_unit(home: &Path, entry: &Path, node: &str) -> Result<ServiceUnit, TeamsError> {
528 let home_display = home.display().to_string();
529 let entry_display = entry.display().to_string();
530 for value in [home_display.as_str(), entry_display.as_str(), node] {
532 service_text(value)?;
533 }
534 if cfg!(windows) {
535 let log_path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.log"));
536 return windows_task(
537 &home.join(SERVICE_DIR).join(unit_file_name()),
538 SERVICE_NAME,
539 &format!("supercode teams machine daemon ({home_display})"),
540 &service_env_path(home, SERVICE_NAME),
541 &[
542 ("SUPERCODE_TEAMS_HOME", home_display.as_str()),
543 ("SUPERCODE_TEAMS_LOG", &log_path.display().to_string()),
544 ],
545 node,
546 &[entry_display.as_str(), "machine", "start"],
547 &home_display,
548 );
549 }
550 if cfg!(target_os = "macos") {
551 let path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.plist"));
552 let text = format!(
553 r#"<?xml version="1.0" encoding="UTF-8"?>
554<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
555<plist version="1.0">
556<dict>
557 <key>Label</key><string>{SERVICE_NAME}</string>
558 <key>ProgramArguments</key>
559 <array>
560 <string>{node}</string>
561 <string>{entry_display}</string>
562 <string>machine</string>
563 <string>start</string>
564 </array>
565 <key>EnvironmentVariables</key>
566 <dict>
567 <key>SUPERCODE_TEAMS_HOME</key><string>{home_display}</string>
568 </dict>
569 <key>RunAtLoad</key><true/>
570 <key>KeepAlive</key><true/>
571 <key>ProcessType</key><string>Interactive</string>
572 <key>StandardOutPath</key><string>{home_display}/service/teams-machine.out.log</string>
573 <key>StandardErrorPath</key><string>{home_display}/service/teams-machine.err.log</string>
574</dict>
575</plist>
576"#
577 );
578 let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
579 Ok(ServiceUnit {
580 kind: "launchd",
581 path,
582 text,
583 install_command: install,
584 files: Vec::new(),
585 })
586 } else {
587 let path = home
588 .join(SERVICE_DIR)
589 .join(format!("{SERVICE_NAME}.service"));
590 let text = format!(
591 "[Unit]\n\
592 Description=supercode teams machine daemon ({home_display})\n\
593 After=network.target\n\
594 \n\
595 [Service]\n\
596 Environment=SUPERCODE_TEAMS_HOME={home_display}\n\
597 ExecStart={node} {entry_display} machine start\n\
598 Restart=on-failure\n\
599 KillSignal=SIGTERM\n\
600 KillMode=process\n\
601 \n\
602 [Install]\n\
603 WantedBy=default.target\n"
604 );
605 let install = format!(
606 "systemctl --user link {} && systemctl --user enable --now {SERVICE_NAME}",
607 path.display()
608 );
609 Ok(ServiceUnit {
610 kind: "systemd",
611 path,
612 text,
613 install_command: install,
614 files: Vec::new(),
615 })
616 }
617}
618
619pub fn write_unit(unit: &ServiceUnit) -> Result<(), TeamsError> {
621 if let Some(parent) = unit.path.parent() {
622 std::fs::create_dir_all(parent).map_err(|source| TeamsError::File {
623 path: unit.path.clone(),
624 source,
625 })?;
626 }
627 std::fs::write(&unit.path, &unit.text).map_err(|source| TeamsError::File {
628 path: unit.path.clone(),
629 source,
630 })?;
631 for (path, text) in &unit.files {
632 std::fs::write(path, text).map_err(|source| TeamsError::File {
633 path: path.clone(),
634 source,
635 })?;
636 }
637 Ok(())
638}
639
640fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
642 let output = std::process::Command::new(program).args(args).output()?;
643 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
644 text.push_str(&String::from_utf8_lossy(&output.stderr));
645 Ok((output.status.success(), text.trim().to_string()))
646}
647
648#[cfg(target_os = "macos")]
649fn gui_domain() -> String {
650 format!("gui/{}", unsafe { libc::getuid() })
652}
653
654pub fn service_status() -> ServiceState {
658 platform_status()
659}
660
661#[cfg(target_os = "macos")]
662fn platform_status() -> ServiceState {
663 let label = SERVICE_NAME.to_string();
664 let target = format!("{}/{SERVICE_NAME}", gui_domain());
665 match run_tool("launchctl", &["print", &target]) {
666 Ok((true, text)) => ServiceState {
667 kind: "launchd",
668 label,
669 installed: true,
670 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
671 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
672 },
673 Ok((false, _)) => ServiceState {
674 kind: "launchd",
675 label,
676 installed: false,
677 pid: None,
678 detail: format!("not bootstrapped in {}", gui_domain()),
679 },
680 Err(error) => ServiceState {
681 kind: "launchd",
682 label,
683 installed: false,
684 pid: None,
685 detail: format!("launchctl unavailable: {error}"),
686 },
687 }
688}
689
690#[cfg(all(unix, not(target_os = "macos")))]
691fn platform_status() -> ServiceState {
692 let label = SERVICE_NAME.to_string();
693 match run_tool("systemctl", &["--user", "is-active", SERVICE_NAME]) {
694 Ok((active, text)) => {
695 let known = run_tool("systemctl", &["--user", "is-enabled", SERVICE_NAME])
696 .map(|(ok, _)| ok)
697 .unwrap_or(false);
698 ServiceState {
699 kind: "systemd",
700 label,
701 installed: active || known,
702 pid: None,
703 detail: if text.is_empty() {
704 "unknown".into()
705 } else {
706 text
707 },
708 }
709 }
710 Err(error) => ServiceState {
711 kind: "systemd",
712 label,
713 installed: false,
714 pid: None,
715 detail: format!("systemctl unavailable: {error}"),
716 },
717 }
718}
719
720#[cfg(not(unix))]
721fn platform_status() -> ServiceState {
722 named_service_status(SERVICE_NAME)
723}
724
725#[cfg(target_os = "macos")]
727fn field_of(text: &str, key: &str) -> Option<String> {
728 text.lines()
729 .find_map(|line| line.trim().strip_prefix(key))
730 .map(|value| value.trim().to_string())
731}
732
733fn unit_file_name() -> String {
735 if cfg!(windows) {
736 format!("{SERVICE_NAME}.xml")
737 } else if cfg!(target_os = "macos") {
738 format!("{SERVICE_NAME}.plist")
739 } else {
740 format!("{SERVICE_NAME}.service")
741 }
742}
743
744pub fn install_service(
750 home: &Path,
751 entry: &Path,
752 node: &str,
753) -> Result<(ServiceUnit, ServiceState), TeamsError> {
754 let existing = service_status();
755 if existing.installed {
756 return Err(TeamsError::Service {
757 action: "install",
758 detail: format!(
759 "`{}` is already installed ({}); `supercode teams machine uninstall` first",
760 existing.label, existing.detail
761 ),
762 });
763 }
764 let unit = service_unit(home, entry, &absolute_program(node))?;
765 write_unit(&unit)?;
766 platform_install(&unit)?;
767 Ok((unit, service_status()))
768}
769
770pub fn validate_connector_install_paths(entry: &Path, binary: &Path) -> Result<(), TeamsError> {
773 for (label, path) in [("Teams entry", entry), ("supercode binary", binary)] {
774 let resolved = std::fs::canonicalize(path).map_err(|source| TeamsError::File {
775 path: path.to_path_buf(),
776 source,
777 })?;
778 let checkout = resolved.ancestors().any(|dir| dir.join(".git").exists());
782 let installed_root = resolved.ancestors().find(|dir| {
783 let Some(scope) = dir.parent() else {
784 return false;
785 };
786 if scope.file_name().and_then(|n| n.to_str()) != Some("@volter")
787 || scope
788 .parent()
789 .and_then(Path::file_name)
790 .and_then(|n| n.to_str())
791 != Some("node_modules")
792 {
793 return false;
794 }
795 let Ok(text) = std::fs::read_to_string(dir.join("package.json")) else {
796 return false;
797 };
798 let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
799 return false;
800 };
801 let Some(name) = value.get("name").and_then(|v| v.as_str()) else {
802 return false;
803 };
804 let expected = if label == "Teams entry" {
805 name == "@volter/supercode-teams"
806 } else {
807 name.starts_with("@volter/supercode-cli-")
808 };
809 expected && dir.file_name().and_then(|n| n.to_str()) == name.strip_prefix("@volter/")
810 });
811 let checkout = checkout
812 && installed_root.is_none_or(|root| {
813 resolved
814 .ancestors()
815 .take_while(|dir| dir.starts_with(root))
816 .any(|dir| dir.join(".git").exists())
817 });
818 let cargo_output = resolved
819 .parent()
820 .is_some_and(|dir| dir.join("deps").is_dir());
821 if checkout || cargo_output {
822 return Err(TeamsError::Service {
823 action: "install",
824 detail: format!(
825 "{label} is source/build output at {}; the connector service was not changed. Use an installed package and binary, or teams connect --foreground for source work",
826 resolved.display()
827 ),
828 });
829 }
830 }
831 Ok(())
832}
833
834pub fn install_connector_service(
840 unit: &ServiceUnit,
841 label: &str,
842) -> Result<ServiceState, TeamsError> {
843 let existing = named_service_status(label);
844 if unit.path.exists() {
845 let old = std::fs::read_to_string(&unit.path).map_err(|source| TeamsError::File {
846 path: unit.path.clone(),
847 source,
848 })?;
849 let same = old == unit.text
851 && unit
852 .files
853 .iter()
854 .all(|(path, text)| std::fs::read_to_string(path).is_ok_and(|old| &old == text));
855 if same && existing.installed {
856 return Ok(existing);
857 }
858 if !same && existing.installed {
859 named_platform_uninstall(label)?;
860 }
861 } else if existing.installed {
862 return Err(TeamsError::Service {
863 action: "install",
864 detail: format!(
865 "service manager already owns `{label}` without its expected unit file"
866 ),
867 });
868 }
869 write_unit(unit)?;
870 named_platform_install(unit, label)?;
871 Ok(named_service_status(label))
872}
873
874pub fn register_message_tools(supercode: &Path) -> Vec<String> {
882 let program = supercode.display().to_string();
883 let mut report = Vec::new();
884 for (harness, get, remove, add) in [
885 (
886 "claude",
887 vec!["mcp", "get", "supercode"],
888 vec!["mcp", "remove", "--scope", "user", "supercode"],
889 vec![
890 "mcp",
891 "add",
892 "--scope",
893 "user",
894 "supercode",
895 "--",
896 &program,
897 "message",
898 "mcp",
899 ],
900 ),
901 (
902 "codex",
903 vec!["mcp", "get", "supercode"],
904 vec!["mcp", "remove", "supercode"],
905 vec!["mcp", "add", "supercode", "--", &program, "message", "mcp"],
906 ),
907 ] {
908 let run = |args: &[&str]| {
909 std::process::Command::new(resolve_program(harness))
910 .args(args)
911 .stdin(std::process::Stdio::null())
912 .output()
913 };
914 let names_program = |text: &str| {
916 if cfg!(windows) {
917 text.to_lowercase().contains(&program.to_lowercase())
918 } else {
919 text.contains(&program)
920 }
921 };
922 let replaced = match run(&get) {
923 Err(_) => {
924 report.push(format!("{harness}: not installed"));
925 continue;
926 }
927 Ok(found)
928 if found.status.success()
929 && names_program(&String::from_utf8_lossy(&found.stdout)) =>
930 {
931 report.push(format!("{harness}: already has supercode's tools"));
932 continue;
933 }
934 Ok(found) if found.status.success() => {
935 let _ = run(&remove);
936 true
937 }
938 Ok(_) => false,
939 };
940 match run(&add) {
941 Ok(added) if added.status.success() => report.push(if replaced {
942 format!("{harness}: supercode's tools now run {program} (new sessions load them)")
943 } else {
944 format!("{harness}: supercode's tools added (new sessions load them)")
945 }),
946 Ok(added) => report.push(format!(
947 "{harness}: could not add supercode's tools: {}",
948 String::from_utf8_lossy(&added.stderr).trim()
949 )),
950 Err(error) => report.push(format!(
951 "{harness}: could not add supercode's tools: {error}"
952 )),
953 }
954 }
955 report
956}
957
958pub fn uninstall_connector_service(
960 teams_home: &Path,
961 label: &str,
962) -> Result<ServiceState, TeamsError> {
963 named_platform_uninstall(label)?;
964 let unit = teams_home
965 .join(SERVICE_DIR)
966 .join(format!("{label}.{}", connector_unit_suffix()));
967 for path in [unit, service_env_path(teams_home, label)] {
968 match std::fs::remove_file(&path) {
969 Ok(()) => {}
970 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
971 Err(source) => return Err(TeamsError::File { path, source }),
972 }
973 }
974 Ok(named_service_status(label))
975}
976
977pub fn connector_service_status(label: &str) -> ServiceState {
979 named_service_status(label)
980}
981
982pub fn service_owns_daemon() -> bool {
987 if service_status().installed {
988 return true;
989 }
990 let Ok(entries) = std::fs::read_dir(teams_home().join(SERVICE_DIR)) else {
991 return false;
992 };
993 entries.flatten().any(|entry| {
994 let name = entry.file_name().to_string_lossy().into_owned();
995 let label = name
996 .strip_suffix(".plist")
997 .or_else(|| name.strip_suffix(".service"))
998 .unwrap_or(&name);
999 label.starts_with("dev.volter.supercode-teams-connector-")
1000 && (name.ends_with(".plist") || name.ends_with(".service"))
1001 && connector_service_status(label).installed
1002 })
1003}
1004
1005#[cfg(target_os = "macos")]
1006fn named_service_status(label: &str) -> ServiceState {
1007 let target = format!("{}/{label}", gui_domain());
1008 match run_tool("launchctl", &["print", &target]) {
1009 Ok((true, text)) => ServiceState {
1010 kind: "launchd",
1011 label: label.into(),
1012 installed: true,
1013 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
1014 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
1015 },
1016 Ok((false, _)) => ServiceState {
1017 kind: "launchd",
1018 label: label.into(),
1019 installed: false,
1020 pid: None,
1021 detail: format!("not bootstrapped in {}", gui_domain()),
1022 },
1023 Err(error) => ServiceState {
1024 kind: "launchd",
1025 label: label.into(),
1026 installed: false,
1027 pid: None,
1028 detail: format!("launchctl unavailable: {error}"),
1029 },
1030 }
1031}
1032
1033#[cfg(all(unix, not(target_os = "macos")))]
1034fn named_service_status(label: &str) -> ServiceState {
1035 match run_tool("systemctl", &["--user", "is-active", label]) {
1036 Ok((active, text)) => {
1037 let known = run_tool("systemctl", &["--user", "is-enabled", label])
1038 .map(|(ok, _)| ok)
1039 .unwrap_or(false);
1040 ServiceState {
1041 kind: "systemd",
1042 label: label.into(),
1043 installed: active || known,
1044 pid: None,
1045 detail: if text.is_empty() {
1046 "unknown".into()
1047 } else {
1048 text
1049 },
1050 }
1051 }
1052 Err(error) => ServiceState {
1053 kind: "systemd",
1054 label: label.into(),
1055 installed: false,
1056 pid: None,
1057 detail: format!("systemctl unavailable: {error}"),
1058 },
1059 }
1060}
1061
1062#[cfg(not(unix))]
1063fn named_service_status(label: &str) -> ServiceState {
1064 match run_tool("schtasks", &["/Query", "/TN", label, "/FO", "CSV", "/NH"]) {
1065 Ok((true, text)) => ServiceState {
1067 kind: "schtasks",
1068 label: label.into(),
1069 installed: true,
1070 pid: None,
1071 detail: text
1072 .lines()
1073 .next()
1074 .and_then(|line| line.rsplit(',').next())
1075 .map(|state| state.trim_matches('"').to_string())
1076 .filter(|state| !state.is_empty())
1077 .unwrap_or_else(|| "registered".into()),
1078 },
1079 Ok((false, _)) => ServiceState {
1080 kind: "schtasks",
1081 label: label.into(),
1082 installed: false,
1083 pid: None,
1084 detail: "no scheduled task".into(),
1085 },
1086 Err(error) => ServiceState {
1087 kind: "schtasks",
1088 label: label.into(),
1089 installed: false,
1090 pid: None,
1091 detail: format!("schtasks unavailable: {error}"),
1092 },
1093 }
1094}
1095
1096#[cfg(target_os = "macos")]
1097fn named_platform_install(unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
1098 platform_install(unit)
1099}
1100#[cfg(all(unix, not(target_os = "macos")))]
1101fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
1102 let path = unit.path.display().to_string();
1103 for args in [
1104 vec!["--user", "link", path.as_str()],
1105 vec!["--user", "enable", "--now", label],
1106 ] {
1107 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1108 action: "install",
1109 detail: format!("systemctl: {error}"),
1110 })?;
1111 if !ok {
1112 return Err(TeamsError::Service {
1113 action: "install",
1114 detail: format!("systemctl {}: {text}", args.join(" ")),
1115 });
1116 }
1117 }
1118 Ok(())
1119}
1120#[cfg(not(unix))]
1121fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
1122 let task = unit.path.with_extension("utf16.xml");
1124 let bytes: Vec<u8> = [0xFF, 0xFE]
1125 .into_iter()
1126 .chain(unit.text.encode_utf16().flat_map(u16::to_le_bytes))
1127 .collect();
1128 std::fs::write(&task, bytes).map_err(|source| TeamsError::File {
1129 path: task.clone(),
1130 source,
1131 })?;
1132 let task_path = task.display().to_string();
1133 let created = run_tool(
1134 "schtasks",
1135 &["/Create", "/TN", label, "/XML", task_path.as_str(), "/F"],
1136 )
1137 .map_err(|error| TeamsError::Service {
1138 action: "install",
1139 detail: format!("schtasks: {error}"),
1140 })
1141 .and_then(|(ok, text)| {
1142 if ok {
1143 Ok(())
1144 } else {
1145 Err(TeamsError::Service {
1146 action: "install",
1147 detail: format!("schtasks /Create: {text}"),
1148 })
1149 }
1150 });
1151 if let Err(error) = created {
1152 let _ = std::fs::remove_file(&task);
1153 return Err(error);
1154 }
1155 let _ = run_tool("schtasks", &["/End", "/TN", label]);
1160 let result = [vec!["/Run", "/TN", label]].iter().try_for_each(|args| {
1161 let (ok, text) = run_tool("schtasks", args).map_err(|error| TeamsError::Service {
1162 action: "install",
1163 detail: format!("schtasks: {error}"),
1164 })?;
1165 if ok {
1166 Ok(())
1167 } else {
1168 Err(TeamsError::Service {
1169 action: "install",
1170 detail: format!("schtasks {}: {text}", args[0]),
1171 })
1172 }
1173 });
1174 let _ = std::fs::remove_file(&task);
1175 result
1176}
1177
1178#[cfg(target_os = "macos")]
1179fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1180 let target = format!("{}/{label}", gui_domain());
1181 let (ok, text) =
1182 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1183 action: "uninstall",
1184 detail: format!("launchctl: {error}"),
1185 })?;
1186 if !ok && !text.contains("No such process") && !text.contains("not find") {
1187 return Err(TeamsError::Service {
1188 action: "uninstall",
1189 detail: format!("launchctl bootout {target}: {text}"),
1190 });
1191 }
1192 for _ in 0..50 {
1195 if !named_service_status(label).installed {
1196 break;
1197 }
1198 std::thread::sleep(std::time::Duration::from_millis(100));
1199 }
1200 Ok(())
1201}
1202#[cfg(all(unix, not(target_os = "macos")))]
1203fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1204 let _ = run_tool("systemctl", &["--user", "disable", "--now", label]);
1205 Ok(())
1206}
1207#[cfg(not(unix))]
1208fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1209 let (ok, text) = run_tool("schtasks", &["/Delete", "/TN", label, "/F"]).map_err(|error| {
1211 TeamsError::Service {
1212 action: "uninstall",
1213 detail: format!("schtasks: {error}"),
1214 }
1215 })?;
1216 if !ok && named_service_status(label).installed {
1217 return Err(TeamsError::Service {
1218 action: "uninstall",
1219 detail: format!("schtasks /Delete: {text}"),
1220 });
1221 }
1222 let env_path = service_env_path(&teams_home(), label);
1226 let stopped = std::process::Command::new("powershell.exe")
1227 .args([
1228 "-NoProfile",
1229 "-NonInteractive",
1230 "-Command",
1231 "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 }",
1232 ])
1233 .env("SUPERCODE_SERVICE_ENV_FILE", env_path.display().to_string())
1234 .stdin(std::process::Stdio::null())
1235 .output();
1236 match stopped {
1237 Ok(output) if output.status.success() => Ok(()),
1238 Ok(output) => Err(TeamsError::Service {
1239 action: "uninstall",
1240 detail: format!(
1241 "the task is gone, but what it started may still run: {}",
1242 String::from_utf8_lossy(&output.stderr).trim()
1243 ),
1244 }),
1245 Err(error) => Err(TeamsError::Service {
1246 action: "uninstall",
1247 detail: format!(
1248 "the task is gone, but what it started may still run: powershell: {error}"
1249 ),
1250 }),
1251 }
1252}
1253
1254#[cfg(target_os = "macos")]
1255fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1256 let path = unit.path.display().to_string();
1257 let (ok, text) =
1258 run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
1259 TeamsError::Service {
1260 action: "install",
1261 detail: format!("launchctl: {error}"),
1262 }
1263 })?;
1264 if !ok {
1265 return Err(TeamsError::Service {
1266 action: "install",
1267 detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
1268 });
1269 }
1270 Ok(())
1271}
1272
1273#[cfg(all(unix, not(target_os = "macos")))]
1276fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1277 let path = unit.path.display().to_string();
1278 for args in [
1279 vec!["--user", "link", path.as_str()],
1280 vec!["--user", "enable", "--now", SERVICE_NAME],
1281 ] {
1282 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1283 action: "install",
1284 detail: format!("systemctl: {error}"),
1285 })?;
1286 if !ok {
1287 return Err(TeamsError::Service {
1288 action: "install",
1289 detail: format!("systemctl {}: {text}", args.join(" ")),
1290 });
1291 }
1292 }
1293 Ok(())
1294}
1295
1296#[cfg(not(unix))]
1297fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1298 named_platform_install(unit, SERVICE_NAME)
1299}
1300
1301pub fn uninstall_service(home: &Path) -> Result<ServiceState, TeamsError> {
1306 platform_uninstall()?;
1307 let unit_path = home.join(SERVICE_DIR).join(unit_file_name());
1308 for path in [unit_path, service_env_path(home, SERVICE_NAME)] {
1310 match std::fs::remove_file(&path) {
1311 Ok(()) => {}
1312 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1313 Err(source) => return Err(TeamsError::File { path, source }),
1314 }
1315 }
1316 let mut state = service_status();
1319 for _ in 0..40 {
1320 if !state.installed {
1321 break;
1322 }
1323 std::thread::sleep(std::time::Duration::from_millis(100));
1324 state = service_status();
1325 }
1326 Ok(state)
1327}
1328
1329#[cfg(target_os = "macos")]
1330fn platform_uninstall() -> Result<(), TeamsError> {
1331 let target = format!("{}/{SERVICE_NAME}", gui_domain());
1332 let (ok, text) =
1333 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1334 action: "uninstall",
1335 detail: format!("launchctl: {error}"),
1336 })?;
1337 if !ok && !text.contains("No such process") && !text.contains("not find") {
1339 return Err(TeamsError::Service {
1340 action: "uninstall",
1341 detail: format!("launchctl bootout {target}: {text}"),
1342 });
1343 }
1344 Ok(())
1345}
1346
1347#[cfg(all(unix, not(target_os = "macos")))]
1348fn platform_uninstall() -> Result<(), TeamsError> {
1349 let _ = run_tool("systemctl", &["--user", "disable", "--now", SERVICE_NAME]);
1350 Ok(())
1351}
1352
1353#[cfg(not(unix))]
1354fn platform_uninstall() -> Result<(), TeamsError> {
1355 named_platform_uninstall(SERVICE_NAME)
1356}