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