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