1use std::path::{Path, PathBuf};
23
24use crate::orchestrator::{absolute_program, ServiceState, ServiceUnit};
25
26pub const TEAMS_ENTRY: &str = "bin/teams.mjs";
28
29pub const TEAMS_PACKAGE: &str = "@volter-ai-dev/supercode-teams";
31
32pub const SERVICE_DIR: &str = "service";
34
35pub const SERVICE_NAME: &str = "dev.volter.supercode-teams-node";
37
38#[derive(Debug, thiserror::Error)]
40pub enum TeamsError {
41 #[error("no teams entry found (looked for `sdk/teams/{TEAMS_ENTRY}` under: {searched}); install it with `npm install -g {TEAMS_PACKAGE}`")]
43 NoEntry {
44 searched: String,
46 },
47 #[error("teams service: {action} failed: {detail}")]
49 Service {
50 action: &'static str,
52 detail: String,
54 },
55 #[error("teams file `{}`: {source}", path.display())]
57 File {
58 path: PathBuf,
60 source: std::io::Error,
62 },
63}
64
65pub fn teams_home() -> PathBuf {
70 if let Ok(home) = std::env::var("SUPERCODE_TEAMS_HOME") {
71 if !home.is_empty() {
72 return PathBuf::from(home);
73 }
74 }
75 crate::agent::global_instructions_dir().join("teams")
76}
77
78pub fn teams_entry() -> Result<PathBuf, TeamsError> {
87 let mut searched = Vec::new();
88 if let Some(explicit) = std::env::var_os("SUPERCODE_TEAMS_ENTRY") {
89 let path = PathBuf::from(explicit);
90 if path.is_file() {
91 return Ok(path);
92 }
93 searched.push(path.display().to_string());
94 }
95 let mut roots: Vec<PathBuf> = Vec::new();
96 if let Ok(exe) = std::env::current_exe() {
97 roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
99 }
100 if let Ok(cwd) = std::env::current_dir() {
101 roots.push(cwd);
102 }
103 if let Some(workspace) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
108 roots.push(workspace.to_path_buf());
109 }
110 for root in roots {
111 let candidate = root.join("sdk/teams").join(TEAMS_ENTRY);
112 if candidate.is_file() {
113 return Ok(candidate);
114 }
115 searched.push(candidate.display().to_string());
116 }
117 if let Some(global) = global_npm_root() {
118 let candidate = global.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
119 if candidate.is_file() {
120 return Ok(candidate);
121 }
122 searched.push(candidate.display().to_string());
123 }
124 Err(TeamsError::NoEntry {
125 searched: searched.join(", "),
126 })
127}
128
129fn global_npm_root() -> Option<PathBuf> {
131 let output = std::process::Command::new("npm")
132 .args(["root", "-g"])
133 .stdin(std::process::Stdio::null())
134 .stderr(std::process::Stdio::null())
135 .output()
136 .ok()?;
137 if !output.status.success() {
138 return None;
139 }
140 let text = String::from_utf8_lossy(&output.stdout);
141 let root = text.trim();
142 if root.is_empty() {
143 return None;
144 }
145 Some(PathBuf::from(root))
146}
147
148pub fn service_unit(home: &Path, entry: &Path, node: &str) -> ServiceUnit {
155 let home_display = home.display().to_string();
156 let entry_display = entry.display().to_string();
157 if cfg!(target_os = "macos") {
158 let path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.plist"));
159 let text = format!(
160 r#"<?xml version="1.0" encoding="UTF-8"?>
161<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
162<plist version="1.0">
163<dict>
164 <key>Label</key><string>{SERVICE_NAME}</string>
165 <key>ProgramArguments</key>
166 <array>
167 <string>{node}</string>
168 <string>{entry_display}</string>
169 <string>node</string>
170 <string>start</string>
171 <string>--listen</string>
172 <string>127.0.0.1:0</string>
173 </array>
174 <key>EnvironmentVariables</key>
175 <dict>
176 <key>SUPERCODE_TEAMS_HOME</key><string>{home_display}</string>
177 </dict>
178 <key>RunAtLoad</key><true/>
179 <key>KeepAlive</key><true/>
180 <key>StandardOutPath</key><string>{home_display}/service/teams-node.out.log</string>
181 <key>StandardErrorPath</key><string>{home_display}/service/teams-node.err.log</string>
182</dict>
183</plist>
184"#
185 );
186 let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
187 ServiceUnit {
188 kind: "launchd",
189 path,
190 text,
191 install_command: install,
192 }
193 } else {
194 let path = home
195 .join(SERVICE_DIR)
196 .join(format!("{SERVICE_NAME}.service"));
197 let text = format!(
198 "[Unit]\n\
199 Description=supercode teams node ({home_display})\n\
200 After=network.target\n\
201 \n\
202 [Service]\n\
203 Environment=SUPERCODE_TEAMS_HOME={home_display}\n\
204 ExecStart={node} {entry_display} node start --listen 127.0.0.1:0\n\
205 Restart=on-failure\n\
206 KillSignal=SIGTERM\n\
207 \n\
208 [Install]\n\
209 WantedBy=default.target\n"
210 );
211 let install = format!(
212 "systemctl --user link {} && systemctl --user enable --now {SERVICE_NAME}",
213 path.display()
214 );
215 ServiceUnit {
216 kind: "systemd",
217 path,
218 text,
219 install_command: install,
220 }
221 }
222}
223
224pub fn write_unit(unit: &ServiceUnit) -> Result<(), TeamsError> {
226 if let Some(parent) = unit.path.parent() {
227 std::fs::create_dir_all(parent).map_err(|source| TeamsError::File {
228 path: unit.path.clone(),
229 source,
230 })?;
231 }
232 std::fs::write(&unit.path, &unit.text).map_err(|source| TeamsError::File {
233 path: unit.path.clone(),
234 source,
235 })
236}
237
238fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
240 let output = std::process::Command::new(program).args(args).output()?;
241 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
242 text.push_str(&String::from_utf8_lossy(&output.stderr));
243 Ok((output.status.success(), text.trim().to_string()))
244}
245
246#[cfg(target_os = "macos")]
247fn gui_domain() -> String {
248 format!("gui/{}", unsafe { libc::getuid() })
250}
251
252pub fn service_status() -> ServiceState {
256 platform_status()
257}
258
259#[cfg(target_os = "macos")]
260fn platform_status() -> ServiceState {
261 let label = SERVICE_NAME.to_string();
262 let target = format!("{}/{SERVICE_NAME}", gui_domain());
263 match run_tool("launchctl", &["print", &target]) {
264 Ok((true, text)) => ServiceState {
265 kind: "launchd",
266 label,
267 installed: true,
268 pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
269 detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
270 },
271 Ok((false, _)) => ServiceState {
272 kind: "launchd",
273 label,
274 installed: false,
275 pid: None,
276 detail: format!("not bootstrapped in {}", gui_domain()),
277 },
278 Err(error) => ServiceState {
279 kind: "launchd",
280 label,
281 installed: false,
282 pid: None,
283 detail: format!("launchctl unavailable: {error}"),
284 },
285 }
286}
287
288#[cfg(all(unix, not(target_os = "macos")))]
289fn platform_status() -> ServiceState {
290 let label = SERVICE_NAME.to_string();
291 match run_tool("systemctl", &["--user", "is-active", SERVICE_NAME]) {
292 Ok((active, text)) => {
293 let known = run_tool("systemctl", &["--user", "is-enabled", SERVICE_NAME])
294 .map(|(ok, _)| ok)
295 .unwrap_or(false);
296 ServiceState {
297 kind: "systemd",
298 label,
299 installed: active || known,
300 pid: None,
301 detail: if text.is_empty() {
302 "unknown".into()
303 } else {
304 text
305 },
306 }
307 }
308 Err(error) => ServiceState {
309 kind: "systemd",
310 label,
311 installed: false,
312 pid: None,
313 detail: format!("systemctl unavailable: {error}"),
314 },
315 }
316}
317
318#[cfg(not(unix))]
319fn platform_status() -> ServiceState {
320 ServiceState {
321 kind: "none",
322 label: SERVICE_NAME.to_string(),
323 installed: false,
324 pid: None,
325 detail: "no service manager on this platform".into(),
326 }
327}
328
329#[cfg(target_os = "macos")]
331fn field_of(text: &str, key: &str) -> Option<String> {
332 text.lines()
333 .find_map(|line| line.trim().strip_prefix(key))
334 .map(|value| value.trim().to_string())
335}
336
337fn unit_file_name() -> String {
339 if cfg!(target_os = "macos") {
340 format!("{SERVICE_NAME}.plist")
341 } else {
342 format!("{SERVICE_NAME}.service")
343 }
344}
345
346pub fn install_service(
352 home: &Path,
353 entry: &Path,
354 node: &str,
355) -> Result<(ServiceUnit, ServiceState), TeamsError> {
356 let existing = service_status();
357 if existing.installed {
358 return Err(TeamsError::Service {
359 action: "install",
360 detail: format!(
361 "`{}` is already installed ({}); `supercode teams node uninstall` first",
362 existing.label, existing.detail
363 ),
364 });
365 }
366 let unit = service_unit(home, entry, &absolute_program(node));
367 write_unit(&unit)?;
368 platform_install(&unit)?;
369 Ok((unit, service_status()))
370}
371
372#[cfg(target_os = "macos")]
373fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
374 let path = unit.path.display().to_string();
375 let (ok, text) =
376 run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
377 TeamsError::Service {
378 action: "install",
379 detail: format!("launchctl: {error}"),
380 }
381 })?;
382 if !ok {
383 return Err(TeamsError::Service {
384 action: "install",
385 detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
386 });
387 }
388 Ok(())
389}
390
391#[cfg(all(unix, not(target_os = "macos")))]
394fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
395 let path = unit.path.display().to_string();
396 for args in [
397 vec!["--user", "link", path.as_str()],
398 vec!["--user", "enable", "--now", SERVICE_NAME],
399 ] {
400 let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
401 action: "install",
402 detail: format!("systemctl: {error}"),
403 })?;
404 if !ok {
405 return Err(TeamsError::Service {
406 action: "install",
407 detail: format!("systemctl {}: {text}", args.join(" ")),
408 });
409 }
410 }
411 Ok(())
412}
413
414#[cfg(not(unix))]
415fn platform_install(_unit: &ServiceUnit) -> Result<(), TeamsError> {
416 Err(TeamsError::Service {
417 action: "install",
418 detail: "no service manager on this platform".into(),
419 })
420}
421
422pub fn uninstall_service(home: &Path) -> Result<ServiceState, TeamsError> {
427 platform_uninstall()?;
428 let unit_path = home.join(SERVICE_DIR).join(unit_file_name());
429 match std::fs::remove_file(&unit_path) {
430 Ok(()) => {}
431 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
432 Err(source) => {
433 return Err(TeamsError::File {
434 path: unit_path,
435 source,
436 })
437 }
438 }
439 let mut state = service_status();
442 for _ in 0..40 {
443 if !state.installed {
444 break;
445 }
446 std::thread::sleep(std::time::Duration::from_millis(100));
447 state = service_status();
448 }
449 Ok(state)
450}
451
452#[cfg(target_os = "macos")]
453fn platform_uninstall() -> Result<(), TeamsError> {
454 let target = format!("{}/{SERVICE_NAME}", gui_domain());
455 let (ok, text) =
456 run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
457 action: "uninstall",
458 detail: format!("launchctl: {error}"),
459 })?;
460 if !ok && !text.contains("No such process") && !text.contains("not find") {
462 return Err(TeamsError::Service {
463 action: "uninstall",
464 detail: format!("launchctl bootout {target}: {text}"),
465 });
466 }
467 Ok(())
468}
469
470#[cfg(all(unix, not(target_os = "macos")))]
471fn platform_uninstall() -> Result<(), TeamsError> {
472 let _ = run_tool("systemctl", &["--user", "disable", "--now", SERVICE_NAME]);
473 Ok(())
474}
475
476#[cfg(not(unix))]
477fn platform_uninstall() -> Result<(), TeamsError> {
478 Ok(())
479}