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 connector_plist_environment(
71 path: &Path,
72 managed: &[(&str, &str)],
73) -> Result<String, TeamsError> {
74 let mut values = std::collections::BTreeMap::<String, String>::new();
75 if path.exists() {
76 let output = std::process::Command::new("/usr/bin/plutil")
77 .args(["-convert", "json", "-o", "-"])
78 .arg(path)
79 .output()
80 .map_err(|source| TeamsError::File {
81 path: path.to_path_buf(),
82 source,
83 })?;
84 let refused = || TeamsError::Service {
85 action: "render",
86 detail: format!(
87 "cannot read existing EnvironmentVariables in {}; service unchanged",
88 path.display()
89 ),
90 };
91 if !output.status.success() {
92 return Err(refused());
93 }
94 let old: serde_json::Value =
95 serde_json::from_slice(&output.stdout).map_err(|_| refused())?;
96 if let Some(env) = old.get("EnvironmentVariables") {
97 let env = env.as_object().ok_or_else(refused)?;
98 for (key, value) in env {
99 values.insert(key.clone(), value.as_str().ok_or_else(refused)?.to_owned());
100 }
101 }
102 }
103 for (key, value) in managed {
104 values.insert((*key).to_owned(), (*value).to_owned());
105 }
106 values
107 .entry("SUPERCODE_POPUPS".into())
108 .or_insert_with(|| std::env::var("SUPERCODE_POPUPS").unwrap_or_else(|_| "0".into()));
109 Ok(values
110 .into_iter()
111 .map(|(key, value)| {
112 format!(
113 "<key>{}</key><string>{}</string>",
114 plist_text(&key),
115 plist_text(&value)
116 )
117 })
118 .collect())
119}
120
121fn systemd_arg(value: &str) -> String {
122 format!(
123 "\"{}\"",
124 value
125 .replace('\\', "\\\\")
126 .replace('"', "\\\"")
127 .replace('%', "%%")
128 .replace('$', "$$")
129 )
130}
131
132pub fn connector_service_unit(
134 teams_home: &Path,
135 supercode_home: &Path,
136 entry: &Path,
137 node: &str,
138 supercode: &Path,
139 context: &str,
140 cwd: &Path,
141 server_id: &str,
142 team_id: &str,
143) -> Result<ServiceUnit, TeamsError> {
144 let teams_home_text = teams_home.display().to_string();
145 let supercode_home_text = supercode_home.display().to_string();
146 let entry_text = entry.display().to_string();
147 let supercode_text = supercode.display().to_string();
148 let workspace_text = cwd.display().to_string();
149 for value in [
150 teams_home_text.as_str(),
151 supercode_home_text.as_str(),
152 entry_text.as_str(),
153 node,
154 supercode_text.as_str(),
155 context,
156 workspace_text.as_str(),
157 server_id,
158 team_id,
159 ] {
160 service_text(value)?;
161 }
162 let label = connector_service_name(server_id, team_id, context);
163 let path = teams_home
164 .join(SERVICE_DIR)
165 .join(format!("{label}.{}", connector_unit_suffix()));
166 let node = absolute_program(node);
167 let entry = entry_text;
168 let workspace = workspace_text;
169 let home = supercode_home_text;
170 let supercode = supercode_text;
171 let popups = std::env::var("SUPERCODE_POPUPS").unwrap_or_else(|_| "0".into());
172 if cfg!(windows) {
173 let log_path = teams_home.join(SERVICE_DIR).join(format!("{label}.log"));
174 return windows_task(
175 &path,
176 &label,
177 &format!("supercode Teams connector ({context})"),
178 &service_env_path(teams_home, &label),
179 &[
180 ("SUPERCODE_HOME", home.as_str()),
181 ("SUPERCODE_BIN", supercode.as_str()),
182 ("SUPERCODE_POPUPS", popups.as_str()),
183 ("SUPERCODE_TEAMS_LOG", &log_path.display().to_string()),
184 ],
185 &node,
186 &[
187 entry.as_str(),
188 "teams",
189 "connect",
190 "--foreground",
191 "--context",
192 context,
193 "--cwd",
194 workspace.as_str(),
195 ],
196 &workspace,
197 );
198 }
199 if cfg!(target_os = "macos") {
200 let node = plist_text(&node);
201 let entry = plist_text(&entry);
202 let workspace = plist_text(&workspace);
203 let environment = connector_plist_environment(
204 &path,
205 &[
206 ("SUPERCODE_HOME", &home),
207 ("SUPERCODE_BIN", &supercode),
208 ("PATH", &service_path()),
209 ],
210 )?;
211 let context = plist_text(context);
212 let text = format!(
217 r#"<?xml version="1.0" encoding="UTF-8"?>
218<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
219<plist version="1.0"><dict>
220 <key>Label</key><string>{label}</string>
221 <key>ProgramArguments</key><array><string>{node}</string><string>{entry}</string><string>teams</string><string>connect</string><string>--context</string><string>{context}</string><string>--cwd</string><string>{workspace}</string></array>
222 <key>EnvironmentVariables</key><dict>{environment}</dict>
223 <key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ProcessType</key><string>Interactive</string>
224 <key>StandardOutPath</key><string>{}/service/{label}.out.log</string>
225 <key>StandardErrorPath</key><string>{}/service/{label}.err.log</string>
226</dict></plist>
227"#,
228 plist_text(&teams_home_text),
229 plist_text(&teams_home_text)
230 );
231 Ok(ServiceUnit {
232 kind: "launchd",
233 path: path.clone(),
234 text,
235 install_command: format!("launchctl bootstrap gui/$(id -u) {}", path.display()),
236 files: Vec::new(),
237 })
238 } else {
239 let environment_home = systemd_arg(&format!("SUPERCODE_HOME={home}"));
240 let environment_bin = systemd_arg(&format!("SUPERCODE_BIN={supercode}"));
241 let environment_path = systemd_arg(&format!("PATH={}", service_path()));
242 let environment_popups = systemd_arg(&format!("SUPERCODE_POPUPS={popups}"));
243 let node = systemd_arg(&node);
244 let entry = systemd_arg(&entry);
245 let workspace = systemd_arg(&workspace);
246 let context_description = context.replace('%', "%%").replace('$', "$$");
247 let context = systemd_arg(context);
248 let text = format!("[Unit]\nDescription=supercode Teams connector ({context_description})\nAfter=network.target\n\n[Service]\nEnvironment={environment_home}\nEnvironment={environment_bin}\nEnvironment={environment_path}\nEnvironment={environment_popups}\nExecStart={node} {entry} teams connect --context {context} --cwd {workspace}\nRestart=on-failure\nKillSignal=SIGTERM\nKillMode=process\n\n[Install]\nWantedBy=default.target\n");
251 Ok(ServiceUnit {
252 kind: "systemd",
253 path: path.clone(),
254 text,
255 install_command: format!(
256 "systemctl --user link {} && systemctl --user enable --now {label}",
257 path.display()
258 ),
259 files: Vec::new(),
260 })
261 }
262}
263
264fn connector_unit_suffix() -> &'static str {
266 if cfg!(windows) {
267 "xml"
268 } else if cfg!(target_os = "macos") {
269 "plist"
270 } else {
271 "service"
272 }
273}
274
275fn service_env_path(teams_home: &Path, label: &str) -> PathBuf {
277 teams_home.join(SERVICE_DIR).join(format!("{label}.env"))
278}
279
280#[allow(clippy::too_many_arguments)]
283fn windows_task(
284 path: &Path,
285 label: &str,
286 description: &str,
287 env_path: &Path,
288 env: &[(&str, &str)],
289 node: &str,
290 node_args: &[&str],
291 working_directory: &str,
292) -> Result<ServiceUnit, TeamsError> {
293 let env_text = env
299 .iter()
300 .map(|(key, value)| env_file_value(value).map(|value| format!("{key}={value}\n")))
301 .collect::<Result<String, _>>()?;
302 let env_flag = format!("--env-file={}", env_path.display());
303 let mut arguments = vec![node, env_flag.as_str()];
304 arguments.extend_from_slice(node_args);
305 for value in arguments.iter().chain([&working_directory]) {
307 if value.contains('%') {
308 return Err(TeamsError::Service {
309 action: "render",
310 detail: format!("a Windows task cannot carry a path containing `%`: {value}"),
311 });
312 }
313 }
314 let arguments = arguments
315 .iter()
316 .map(|argument| windows_arg(argument))
317 .collect::<Vec<_>>()
318 .join(" ");
319 let user = windows_user();
320 let conhost = Path::new(&std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into()))
321 .join(r"System32\conhost.exe")
322 .display()
323 .to_string();
324 let text = format!(
327 r#"<?xml version="1.0" encoding="UTF-16"?>
328<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
329 <RegistrationInfo><Description>{}</Description></RegistrationInfo>
330 <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{}</UserId></LogonTrigger><TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary><Enabled>true</Enabled><Repetition><Interval>PT1M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition></TimeTrigger></Triggers>
331 <Principals><Principal id="Author"><UserId>{}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
332 <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Priority>4</Priority><RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure><StartWhenAvailable>true</StartWhenAvailable></Settings>
333 <Actions Context="Author"><Exec><Command>{}</Command><Arguments>--headless {}</Arguments><WorkingDirectory>{}</WorkingDirectory></Exec></Actions>
334</Task>
335"#,
336 xml_text(description),
337 xml_text(&user),
338 xml_text(&user),
339 xml_text(&conhost),
340 xml_text(&arguments),
341 xml_text(working_directory),
342 );
343 Ok(ServiceUnit {
344 kind: "schtasks",
345 path: path.to_path_buf(),
346 text,
347 install_command: format!("schtasks /Create /TN {label} /XML {} /F", path.display()),
348 files: vec![(env_path.to_path_buf(), env_text)],
349 })
350}
351
352fn xml_text(value: &str) -> String {
354 plist_text(value).replace('"', """)
355}
356
357fn windows_arg(value: &str) -> String {
360 if !value.is_empty() && !value.contains([' ', '\t', '"']) {
361 return value.to_string();
362 }
363 let mut quoted = String::from("\"");
364 let mut backslashes = 0;
365 for character in value.chars() {
366 match character {
367 '\\' => backslashes += 1,
368 '"' => {
369 quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
370 quoted.push('"');
371 backslashes = 0;
372 }
373 other => {
374 quoted.push_str(&"\\".repeat(backslashes));
375 quoted.push(other);
376 backslashes = 0;
377 }
378 }
379 }
380 quoted.push_str(&"\\".repeat(backslashes * 2));
381 quoted.push('"');
382 quoted
383}
384
385fn env_file_value(value: &str) -> Result<String, TeamsError> {
388 ['\'', '`']
389 .into_iter()
390 .find(|quote| !value.contains(*quote))
391 .map(|quote| format!("{quote}{value}{quote}"))
392 .or_else(|| (!value.contains(['"', '\\'])).then(|| format!("\"{value}\"")))
393 .ok_or_else(|| TeamsError::Service {
394 action: "render",
395 detail: format!("cannot write `{value}` into a service's environment file"),
396 })
397}
398
399fn windows_user() -> String {
401 let user = std::env::var("USERNAME").unwrap_or_default();
402 match std::env::var("USERDOMAIN") {
403 Ok(domain) if !domain.is_empty() => format!("{domain}\\{user}"),
404 _ => user,
405 }
406}
407
408pub fn connector_service_binary(
414 teams_home: &Path,
415 supercode: &Path,
416) -> Result<PathBuf, TeamsError> {
417 if !cfg!(windows) {
418 return Ok(supercode.to_path_buf());
419 }
420 let file = |path: &Path, source: std::io::Error| TeamsError::File {
421 path: path.to_path_buf(),
422 source,
423 };
424 let copies = teams_home.join(SERVICE_DIR).join("bin");
425 let version = env!("CARGO_PKG_VERSION");
426 let dir = copies.join(version);
427 let copy = dir.join(
428 supercode
429 .file_name()
430 .unwrap_or_else(|| "supercode.exe".as_ref()),
431 );
432 let size = |path: &Path| std::fs::metadata(path).map(|meta| meta.len()).ok();
433 if size(©).is_none() || size(©) != size(supercode) {
434 std::fs::create_dir_all(&dir).map_err(|source| file(&dir, source))?;
435 std::fs::copy(supercode, ©).map_err(|source| file(©, source))?;
436 }
437 if let Ok(entries) = std::fs::read_dir(&copies) {
438 for entry in entries.flatten() {
439 if entry.file_name() != version {
440 let _ = std::fs::remove_dir_all(entry.path());
441 }
442 }
443 }
444 Ok(copy)
445}
446
447#[derive(Debug, thiserror::Error)]
449pub enum TeamsError {
450 #[error("no teams entry found (looked for `sdk/teams/{TEAMS_ENTRY}` under: {searched}); install it with `npm install -g {TEAMS_PACKAGE}`")]
452 NoEntry {
453 searched: String,
455 },
456 #[error("teams service: {action} failed: {detail}")]
458 Service {
459 action: &'static str,
461 detail: String,
463 },
464 #[error("teams file `{}`: {source}", path.display())]
466 File {
467 path: PathBuf,
469 source: std::io::Error,
471 },
472}
473
474fn service_path() -> String {
478 std::env::var("PATH")
479 .ok()
480 .filter(|path| !path.trim().is_empty())
481 .unwrap_or_else(|| "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".into())
482}
483
484pub fn teams_home() -> PathBuf {
489 if let Ok(home) = std::env::var("SUPERCODE_TEAMS_HOME") {
490 if !home.is_empty() {
491 return PathBuf::from(home);
492 }
493 }
494 crate::agent::global_instructions_dir().join("teams")
495}
496
497pub fn teams_entry() -> Result<PathBuf, TeamsError> {
507 let mut searched = Vec::new();
508 if let Some(explicit) = std::env::var_os("SUPERCODE_TEAMS_ENTRY") {
509 let path = PathBuf::from(explicit);
510 if path.is_file() {
511 return Ok(path);
512 }
513 searched.push(path.display().to_string());
514 }
515 let mut roots: Vec<PathBuf> = Vec::new();
516 if let Ok(exe) = std::env::current_exe() {
517 roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
519 }
520 if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
525 roots.push(workspace.to_path_buf());
526 }
527 for root in roots {
528 let candidate = root.join("sdk/teams").join(TEAMS_ENTRY);
529 if candidate.is_file() {
530 return Ok(candidate);
531 }
532 searched.push(candidate.display().to_string());
533 }
534 if let Ok(exe) = std::env::current_exe() {
539 for modules in exe
540 .ancestors()
541 .filter(|dir| dir.file_name().is_some_and(|name| name == "node_modules"))
542 {
543 let candidate = modules.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
544 if candidate.is_file() {
545 return Ok(candidate);
546 }
547 searched.push(candidate.display().to_string());
548 }
549 }
550 if let Some(global) = global_npm_root() {
551 let candidate = global.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
552 if candidate.is_file() {
553 return Ok(candidate);
554 }
555 searched.push(candidate.display().to_string());
556 }
557 Err(TeamsError::NoEntry {
558 searched: searched.join(", "),
559 })
560}
561
562fn global_npm_root() -> Option<PathBuf> {
564 let output = std::process::Command::new(resolve_program("npm"))
565 .args(["root", "-g"])
566 .stdin(std::process::Stdio::null())
567 .stderr(std::process::Stdio::null())
568 .output()
569 .ok()?;
570 if !output.status.success() {
571 return None;
572 }
573 let text = String::from_utf8_lossy(&output.stdout);
574 let root = text.trim();
575 if root.is_empty() {
576 return None;
577 }
578 Some(PathBuf::from(root))
579}
580
581pub fn service_unit(home: &Path, entry: &Path, node: &str) -> Result<ServiceUnit, TeamsError> {
590 let home_display = home.display().to_string();
591 let entry_display = entry.display().to_string();
592 for value in [home_display.as_str(), entry_display.as_str(), node] {
594 service_text(value)?;
595 }
596 if cfg!(windows) {
597 let log_path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.log"));
598 return windows_task(
599 &home.join(SERVICE_DIR).join(unit_file_name()),
600 SERVICE_NAME,
601 &format!("supercode teams machine daemon ({home_display})"),
602 &service_env_path(home, SERVICE_NAME),
603 &[
604 ("SUPERCODE_TEAMS_HOME", home_display.as_str()),
605 ("SUPERCODE_TEAMS_LOG", &log_path.display().to_string()),
606 ],
607 node,
608 &[entry_display.as_str(), "machine", "start"],
609 &home_display,
610 );
611 }
612 if cfg!(target_os = "macos") {
613 let path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.plist"));
614 let text = format!(
615 r#"<?xml version="1.0" encoding="UTF-8"?>
616<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
617<plist version="1.0">
618<dict>
619 <key>Label</key><string>{SERVICE_NAME}</string>
620 <key>ProgramArguments</key>
621 <array>
622 <string>{node}</string>
623 <string>{entry_display}</string>
624 <string>machine</string>
625 <string>start</string>
626 </array>
627 <key>EnvironmentVariables</key>
628 <dict>
629 <key>SUPERCODE_TEAMS_HOME</key><string>{home_display}</string>
630 </dict>
631 <key>RunAtLoad</key><true/>
632 <key>KeepAlive</key><true/>
633 <key>ProcessType</key><string>Interactive</string>
634 <key>StandardOutPath</key><string>{home_display}/service/teams-machine.out.log</string>
635 <key>StandardErrorPath</key><string>{home_display}/service/teams-machine.err.log</string>
636</dict>
637</plist>
638"#
639 );
640 let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
641 Ok(ServiceUnit {
642 kind: "launchd",
643 path,
644 text,
645 install_command: install,
646 files: Vec::new(),
647 })
648 } else {
649 let path = home
650 .join(SERVICE_DIR)
651 .join(format!("{SERVICE_NAME}.service"));
652 let text = format!(
653 "[Unit]\n\
654 Description=supercode teams machine daemon ({home_display})\n\
655 After=network.target\n\
656 \n\
657 [Service]\n\
658 Environment=SUPERCODE_TEAMS_HOME={home_display}\n\
659 ExecStart={node} {entry_display} machine start\n\
660 Restart=on-failure\n\
661 KillSignal=SIGTERM\n\
662 KillMode=process\n\
663 \n\
664 [Install]\n\
665 WantedBy=default.target\n"
666 );
667 let install = format!(
668 "systemctl --user link {} && systemctl --user enable --now {SERVICE_NAME}",
669 path.display()
670 );
671 Ok(ServiceUnit {
672 kind: "systemd",
673 path,
674 text,
675 install_command: install,
676 files: Vec::new(),
677 })
678 }
679}
680
681pub fn write_unit(unit: &ServiceUnit) -> Result<(), TeamsError> {
683 if let Some(parent) = unit.path.parent() {
684 std::fs::create_dir_all(parent).map_err(|source| TeamsError::File {
685 path: unit.path.clone(),
686 source,
687 })?;
688 }
689 std::fs::write(&unit.path, &unit.text).map_err(|source| TeamsError::File {
690 path: unit.path.clone(),
691 source,
692 })?;
693 for (path, text) in &unit.files {
694 std::fs::write(path, text).map_err(|source| TeamsError::File {
695 path: path.clone(),
696 source,
697 })?;
698 }
699 Ok(())
700}
701
702fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
704 let output = std::process::Command::new(program).args(args).output()?;
705 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
706 text.push_str(&String::from_utf8_lossy(&output.stderr));
707 Ok((output.status.success(), text.trim().to_string()))
708}
709
710#[cfg(target_os = "macos")]
711fn gui_domain() -> String {
712 format!("gui/{}", unsafe { libc::getuid() })
714}
715
716pub fn service_status() -> ServiceState {
720 platform_status()
721}
722
723#[cfg(target_os = "macos")]
724fn platform_status() -> ServiceState {
725 let label = SERVICE_NAME.to_string();
726 let target = format!("{}/{SERVICE_NAME}", gui_domain());
727 match run_tool("launchctl", &["print", &target]) {
728 Ok((true, text)) => ServiceState {
729 kind: "launchd",
730 label,
731 installed: true,
732 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
733 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
734 },
735 Ok((false, _)) => ServiceState {
736 kind: "launchd",
737 label,
738 installed: false,
739 pid: None,
740 detail: format!("not bootstrapped in {}", gui_domain()),
741 },
742 Err(error) => ServiceState {
743 kind: "launchd",
744 label,
745 installed: false,
746 pid: None,
747 detail: format!("launchctl unavailable: {error}"),
748 },
749 }
750}
751
752#[cfg(all(unix, not(target_os = "macos")))]
753fn platform_status() -> ServiceState {
754 let label = SERVICE_NAME.to_string();
755 match run_tool("systemctl", &["--user", "is-active", SERVICE_NAME]) {
756 Ok((active, text)) => {
757 let known = run_tool("systemctl", &["--user", "is-enabled", SERVICE_NAME])
758 .map(|(ok, _)| ok)
759 .unwrap_or(false);
760 ServiceState {
761 kind: "systemd",
762 label,
763 installed: active || known,
764 pid: None,
765 detail: if text.is_empty() {
766 "unknown".into()
767 } else {
768 text
769 },
770 }
771 }
772 Err(error) => ServiceState {
773 kind: "systemd",
774 label,
775 installed: false,
776 pid: None,
777 detail: format!("systemctl unavailable: {error}"),
778 },
779 }
780}
781
782#[cfg(not(unix))]
783fn platform_status() -> ServiceState {
784 named_service_status(SERVICE_NAME)
785}
786
787#[cfg(target_os = "macos")]
789fn field_of(text: &str, key: &str) -> Option<String> {
790 text.lines()
791 .find_map(|line| line.trim().strip_prefix(key))
792 .map(|value| value.trim().to_string())
793}
794
795fn unit_file_name() -> String {
797 if cfg!(windows) {
798 format!("{SERVICE_NAME}.xml")
799 } else if cfg!(target_os = "macos") {
800 format!("{SERVICE_NAME}.plist")
801 } else {
802 format!("{SERVICE_NAME}.service")
803 }
804}
805
806pub fn install_service(
812 home: &Path,
813 entry: &Path,
814 node: &str,
815) -> Result<(ServiceUnit, ServiceState), TeamsError> {
816 let existing = service_status();
817 if existing.installed {
818 return Err(TeamsError::Service {
819 action: "install",
820 detail: format!(
821 "`{}` is already installed ({}); `supercode teams machine uninstall` first",
822 existing.label, existing.detail
823 ),
824 });
825 }
826 let unit = service_unit(home, entry, &absolute_program(node))?;
827 write_unit(&unit)?;
828 platform_install(&unit)?;
829 Ok((unit, service_status()))
830}
831
832pub fn validate_connector_install_paths(entry: &Path, binary: &Path) -> Result<(), TeamsError> {
835 for (label, path) in [("Teams entry", entry), ("supercode binary", binary)] {
836 let resolved = std::fs::canonicalize(path).map_err(|source| TeamsError::File {
837 path: path.to_path_buf(),
838 source,
839 })?;
840 let checkout = resolved.ancestors().any(|dir| dir.join(".git").exists());
844 let installed_root = resolved.ancestors().find(|dir| {
845 let Some(scope) = dir.parent() else {
846 return false;
847 };
848 if scope.file_name().and_then(|n| n.to_str()) != Some("@volter")
849 || scope
850 .parent()
851 .and_then(Path::file_name)
852 .and_then(|n| n.to_str())
853 != Some("node_modules")
854 {
855 return false;
856 }
857 let Ok(text) = std::fs::read_to_string(dir.join("package.json")) else {
858 return false;
859 };
860 let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
861 return false;
862 };
863 let Some(name) = value.get("name").and_then(|v| v.as_str()) else {
864 return false;
865 };
866 let expected = if label == "Teams entry" {
867 name == "@volter/supercode-teams"
868 } else {
869 name.starts_with("@volter/supercode-cli-")
870 };
871 expected && dir.file_name().and_then(|n| n.to_str()) == name.strip_prefix("@volter/")
872 });
873 let checkout = checkout
874 && installed_root.is_none_or(|root| {
875 resolved
876 .ancestors()
877 .take_while(|dir| dir.starts_with(root))
878 .any(|dir| dir.join(".git").exists())
879 });
880 let cargo_output = resolved
881 .parent()
882 .is_some_and(|dir| dir.join("deps").is_dir());
883 if checkout || cargo_output {
884 return Err(TeamsError::Service {
885 action: "install",
886 detail: format!(
887 "{label} is source/build output at {}; the connector service was not changed. Use an installed package and binary, or teams connect --foreground for source work",
888 resolved.display()
889 ),
890 });
891 }
892 }
893 Ok(())
894}
895
896pub fn install_connector_service(
902 unit: &ServiceUnit,
903 label: &str,
904) -> Result<ServiceState, TeamsError> {
905 let existing = named_service_status(label);
906 if unit.path.exists() {
907 let old = std::fs::read_to_string(&unit.path).map_err(|source| TeamsError::File {
908 path: unit.path.clone(),
909 source,
910 })?;
911 let same = old == unit.text
913 && unit
914 .files
915 .iter()
916 .all(|(path, text)| std::fs::read_to_string(path).is_ok_and(|old| &old == text));
917 if same && existing.installed {
918 return Ok(existing);
919 }
920 if !same && existing.installed {
921 named_platform_uninstall(label)?;
922 }
923 } else if existing.installed {
924 return Err(TeamsError::Service {
925 action: "install",
926 detail: format!(
927 "service manager already owns `{label}` without its expected unit file"
928 ),
929 });
930 }
931 write_unit(unit)?;
932 named_platform_install(unit, label)?;
933 Ok(named_service_status(label))
934}
935
936pub fn register_message_tools(supercode: &Path) -> Vec<String> {
944 let program = supercode.display().to_string();
945 let mut report = Vec::new();
946 for (harness, get, remove, add) in [
947 (
948 "claude",
949 vec!["mcp", "get", "supercode"],
950 vec!["mcp", "remove", "--scope", "user", "supercode"],
951 vec![
952 "mcp",
953 "add",
954 "--scope",
955 "user",
956 "supercode",
957 "--",
958 &program,
959 "message",
960 "mcp",
961 ],
962 ),
963 (
964 "codex",
965 vec!["mcp", "get", "supercode"],
966 vec!["mcp", "remove", "supercode"],
967 vec!["mcp", "add", "supercode", "--", &program, "message", "mcp"],
968 ),
969 ] {
970 let run = |args: &[&str]| {
971 std::process::Command::new(resolve_program(harness))
972 .args(args)
973 .stdin(std::process::Stdio::null())
974 .output()
975 };
976 let names_program = |text: &str| {
978 if cfg!(windows) {
979 text.to_lowercase().contains(&program.to_lowercase())
980 } else {
981 text.contains(&program)
982 }
983 };
984 let replaced = match run(&get) {
985 Err(_) => {
986 report.push(format!("{harness}: not installed"));
987 continue;
988 }
989 Ok(found)
990 if found.status.success()
991 && names_program(&String::from_utf8_lossy(&found.stdout)) =>
992 {
993 report.push(format!("{harness}: already has supercode's tools"));
994 continue;
995 }
996 Ok(found) if found.status.success() => {
997 let _ = run(&remove);
998 true
999 }
1000 Ok(_) => false,
1001 };
1002 match run(&add) {
1003 Ok(added) if added.status.success() => report.push(if replaced {
1004 format!("{harness}: supercode's tools now run {program} (new sessions load them)")
1005 } else {
1006 format!("{harness}: supercode's tools added (new sessions load them)")
1007 }),
1008 Ok(added) => report.push(format!(
1009 "{harness}: could not add supercode's tools: {}",
1010 String::from_utf8_lossy(&added.stderr).trim()
1011 )),
1012 Err(error) => report.push(format!(
1013 "{harness}: could not add supercode's tools: {error}"
1014 )),
1015 }
1016 }
1017 report
1018}
1019
1020pub fn uninstall_connector_service(
1022 teams_home: &Path,
1023 label: &str,
1024) -> Result<ServiceState, TeamsError> {
1025 named_platform_uninstall(label)?;
1026 let unit = teams_home
1027 .join(SERVICE_DIR)
1028 .join(format!("{label}.{}", connector_unit_suffix()));
1029 for path in [unit, service_env_path(teams_home, label)] {
1030 match std::fs::remove_file(&path) {
1031 Ok(()) => {}
1032 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1033 Err(source) => return Err(TeamsError::File { path, source }),
1034 }
1035 }
1036 Ok(named_service_status(label))
1037}
1038
1039pub fn connector_service_status(label: &str) -> ServiceState {
1041 named_service_status(label)
1042}
1043
1044pub fn service_owns_daemon() -> bool {
1049 if service_status().installed {
1050 return true;
1051 }
1052 let Ok(entries) = std::fs::read_dir(teams_home().join(SERVICE_DIR)) else {
1053 return false;
1054 };
1055 entries.flatten().any(|entry| {
1056 let name = entry.file_name().to_string_lossy().into_owned();
1057 let label = name
1058 .strip_suffix(".plist")
1059 .or_else(|| name.strip_suffix(".service"))
1060 .unwrap_or(&name);
1061 label.starts_with("dev.volter.supercode-teams-connector-")
1062 && (name.ends_with(".plist") || name.ends_with(".service"))
1063 && connector_service_status(label).installed
1064 })
1065}
1066
1067#[cfg(target_os = "macos")]
1068fn named_service_status(label: &str) -> ServiceState {
1069 let target = format!("{}/{label}", gui_domain());
1070 match run_tool("launchctl", &["print", &target]) {
1071 Ok((true, text)) => ServiceState {
1072 kind: "launchd",
1073 label: label.into(),
1074 installed: true,
1075 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
1076 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
1077 },
1078 Ok((false, _)) => ServiceState {
1079 kind: "launchd",
1080 label: label.into(),
1081 installed: false,
1082 pid: None,
1083 detail: format!("not bootstrapped in {}", gui_domain()),
1084 },
1085 Err(error) => ServiceState {
1086 kind: "launchd",
1087 label: label.into(),
1088 installed: false,
1089 pid: None,
1090 detail: format!("launchctl unavailable: {error}"),
1091 },
1092 }
1093}
1094
1095#[cfg(all(unix, not(target_os = "macos")))]
1096fn named_service_status(label: &str) -> ServiceState {
1097 match run_tool("systemctl", &["--user", "is-active", label]) {
1098 Ok((active, text)) => {
1099 let known = run_tool("systemctl", &["--user", "is-enabled", label])
1100 .map(|(ok, _)| ok)
1101 .unwrap_or(false);
1102 ServiceState {
1103 kind: "systemd",
1104 label: label.into(),
1105 installed: active || known,
1106 pid: None,
1107 detail: if text.is_empty() {
1108 "unknown".into()
1109 } else {
1110 text
1111 },
1112 }
1113 }
1114 Err(error) => ServiceState {
1115 kind: "systemd",
1116 label: label.into(),
1117 installed: false,
1118 pid: None,
1119 detail: format!("systemctl unavailable: {error}"),
1120 },
1121 }
1122}
1123
1124#[cfg(not(unix))]
1125fn named_service_status(label: &str) -> ServiceState {
1126 match run_tool("schtasks", &["/Query", "/TN", label, "/FO", "CSV", "/NH"]) {
1127 Ok((true, text)) => ServiceState {
1129 kind: "schtasks",
1130 label: label.into(),
1131 installed: true,
1132 pid: None,
1133 detail: text
1134 .lines()
1135 .next()
1136 .and_then(|line| line.rsplit(',').next())
1137 .map(|state| state.trim_matches('"').to_string())
1138 .filter(|state| !state.is_empty())
1139 .unwrap_or_else(|| "registered".into()),
1140 },
1141 Ok((false, _)) => ServiceState {
1142 kind: "schtasks",
1143 label: label.into(),
1144 installed: false,
1145 pid: None,
1146 detail: "no scheduled task".into(),
1147 },
1148 Err(error) => ServiceState {
1149 kind: "schtasks",
1150 label: label.into(),
1151 installed: false,
1152 pid: None,
1153 detail: format!("schtasks unavailable: {error}"),
1154 },
1155 }
1156}
1157
1158#[cfg(target_os = "macos")]
1159fn named_platform_install(unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
1160 platform_install(unit)
1161}
1162#[cfg(all(unix, not(target_os = "macos")))]
1163fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
1164 let path = unit.path.display().to_string();
1165 for args in [
1166 vec!["--user", "link", path.as_str()],
1167 vec!["--user", "enable", "--now", label],
1168 ] {
1169 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1170 action: "install",
1171 detail: format!("systemctl: {error}"),
1172 })?;
1173 if !ok {
1174 return Err(TeamsError::Service {
1175 action: "install",
1176 detail: format!("systemctl {}: {text}", args.join(" ")),
1177 });
1178 }
1179 }
1180 Ok(())
1181}
1182#[cfg(not(unix))]
1183fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
1184 let task = unit.path.with_extension("utf16.xml");
1186 let bytes: Vec<u8> = [0xFF, 0xFE]
1187 .into_iter()
1188 .chain(unit.text.encode_utf16().flat_map(u16::to_le_bytes))
1189 .collect();
1190 std::fs::write(&task, bytes).map_err(|source| TeamsError::File {
1191 path: task.clone(),
1192 source,
1193 })?;
1194 let task_path = task.display().to_string();
1195 let created = run_tool(
1196 "schtasks",
1197 &["/Create", "/TN", label, "/XML", task_path.as_str(), "/F"],
1198 )
1199 .map_err(|error| TeamsError::Service {
1200 action: "install",
1201 detail: format!("schtasks: {error}"),
1202 })
1203 .and_then(|(ok, text)| {
1204 if ok {
1205 Ok(())
1206 } else {
1207 Err(TeamsError::Service {
1208 action: "install",
1209 detail: format!("schtasks /Create: {text}"),
1210 })
1211 }
1212 });
1213 if let Err(error) = created {
1214 let _ = std::fs::remove_file(&task);
1215 return Err(error);
1216 }
1217 let _ = run_tool("schtasks", &["/End", "/TN", label]);
1222 let result = [vec!["/Run", "/TN", label]].iter().try_for_each(|args| {
1223 let (ok, text) = run_tool("schtasks", args).map_err(|error| TeamsError::Service {
1224 action: "install",
1225 detail: format!("schtasks: {error}"),
1226 })?;
1227 if ok {
1228 Ok(())
1229 } else {
1230 Err(TeamsError::Service {
1231 action: "install",
1232 detail: format!("schtasks {}: {text}", args[0]),
1233 })
1234 }
1235 });
1236 let _ = std::fs::remove_file(&task);
1237 result
1238}
1239
1240#[cfg(target_os = "macos")]
1241fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1242 let target = format!("{}/{label}", gui_domain());
1243 let (ok, text) =
1244 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1245 action: "uninstall",
1246 detail: format!("launchctl: {error}"),
1247 })?;
1248 if !ok && !text.contains("No such process") && !text.contains("not find") {
1249 return Err(TeamsError::Service {
1250 action: "uninstall",
1251 detail: format!("launchctl bootout {target}: {text}"),
1252 });
1253 }
1254 for _ in 0..50 {
1257 if !named_service_status(label).installed {
1258 break;
1259 }
1260 std::thread::sleep(std::time::Duration::from_millis(100));
1261 }
1262 Ok(())
1263}
1264#[cfg(all(unix, not(target_os = "macos")))]
1265fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1266 let _ = run_tool("systemctl", &["--user", "disable", "--now", label]);
1267 Ok(())
1268}
1269#[cfg(not(unix))]
1270fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1271 let (ok, text) = run_tool("schtasks", &["/Delete", "/TN", label, "/F"]).map_err(|error| {
1273 TeamsError::Service {
1274 action: "uninstall",
1275 detail: format!("schtasks: {error}"),
1276 }
1277 })?;
1278 if !ok && named_service_status(label).installed {
1279 return Err(TeamsError::Service {
1280 action: "uninstall",
1281 detail: format!("schtasks /Delete: {text}"),
1282 });
1283 }
1284 let env_path = service_env_path(&teams_home(), label);
1288 let stopped = std::process::Command::new("powershell.exe")
1289 .args([
1290 "-NoProfile",
1291 "-NonInteractive",
1292 "-Command",
1293 "Get-CimInstance Win32_Process -Filter \"Name='node.exe' OR Name='conhost.exe'\" | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($env:SUPERCODE_SERVICE_ENV_FILE) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
1294 ])
1295 .env("SUPERCODE_SERVICE_ENV_FILE", env_path.display().to_string())
1296 .stdin(std::process::Stdio::null())
1297 .output();
1298 match stopped {
1299 Ok(output) if output.status.success() => Ok(()),
1300 Ok(output) => Err(TeamsError::Service {
1301 action: "uninstall",
1302 detail: format!(
1303 "the task is gone, but what it started may still run: {}",
1304 String::from_utf8_lossy(&output.stderr).trim()
1305 ),
1306 }),
1307 Err(error) => Err(TeamsError::Service {
1308 action: "uninstall",
1309 detail: format!(
1310 "the task is gone, but what it started may still run: powershell: {error}"
1311 ),
1312 }),
1313 }
1314}
1315
1316#[cfg(target_os = "macos")]
1317fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1318 let path = unit.path.display().to_string();
1319 let (ok, text) =
1320 run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
1321 TeamsError::Service {
1322 action: "install",
1323 detail: format!("launchctl: {error}"),
1324 }
1325 })?;
1326 if !ok {
1327 return Err(TeamsError::Service {
1328 action: "install",
1329 detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
1330 });
1331 }
1332 Ok(())
1333}
1334
1335#[cfg(all(unix, not(target_os = "macos")))]
1338fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1339 let path = unit.path.display().to_string();
1340 for args in [
1341 vec!["--user", "link", path.as_str()],
1342 vec!["--user", "enable", "--now", SERVICE_NAME],
1343 ] {
1344 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1345 action: "install",
1346 detail: format!("systemctl: {error}"),
1347 })?;
1348 if !ok {
1349 return Err(TeamsError::Service {
1350 action: "install",
1351 detail: format!("systemctl {}: {text}", args.join(" ")),
1352 });
1353 }
1354 }
1355 Ok(())
1356}
1357
1358#[cfg(not(unix))]
1359fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1360 named_platform_install(unit, SERVICE_NAME)
1361}
1362
1363pub fn uninstall_service(home: &Path) -> Result<ServiceState, TeamsError> {
1368 platform_uninstall()?;
1369 let unit_path = home.join(SERVICE_DIR).join(unit_file_name());
1370 for path in [unit_path, service_env_path(home, SERVICE_NAME)] {
1372 match std::fs::remove_file(&path) {
1373 Ok(()) => {}
1374 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1375 Err(source) => return Err(TeamsError::File { path, source }),
1376 }
1377 }
1378 let mut state = service_status();
1381 for _ in 0..40 {
1382 if !state.installed {
1383 break;
1384 }
1385 std::thread::sleep(std::time::Duration::from_millis(100));
1386 state = service_status();
1387 }
1388 Ok(state)
1389}
1390
1391#[cfg(target_os = "macos")]
1392fn platform_uninstall() -> Result<(), TeamsError> {
1393 let target = format!("{}/{SERVICE_NAME}", gui_domain());
1394 let (ok, text) =
1395 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1396 action: "uninstall",
1397 detail: format!("launchctl: {error}"),
1398 })?;
1399 if !ok && !text.contains("No such process") && !text.contains("not find") {
1401 return Err(TeamsError::Service {
1402 action: "uninstall",
1403 detail: format!("launchctl bootout {target}: {text}"),
1404 });
1405 }
1406 Ok(())
1407}
1408
1409#[cfg(all(unix, not(target_os = "macos")))]
1410fn platform_uninstall() -> Result<(), TeamsError> {
1411 let _ = run_tool("systemctl", &["--user", "disable", "--now", SERVICE_NAME]);
1412 Ok(())
1413}
1414
1415#[cfg(not(unix))]
1416fn platform_uninstall() -> Result<(), TeamsError> {
1417 named_platform_uninstall(SERVICE_NAME)
1418}