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