use crate::config::Config; use anyhow::{Context, Result}; use std::fs; use std::path::PathBuf; use std::process::Command; const SERVICE_LABEL: &str = "com.zeroclaw.daemon"; #[allow(clippy::needless_pass_by_value)] pub fn handle_command(command: super::ServiceCommands, config: &Config) -> Result<()> { match command { super::ServiceCommands::Install => install(config), super::ServiceCommands::Start => start(config), super::ServiceCommands::Stop => stop(config), super::ServiceCommands::Status => status(config), super::ServiceCommands::Uninstall => uninstall(config), } } fn install(config: &Config) -> Result<()> { if cfg!(target_os = "macos") { install_macos(config) } else if cfg!(target_os = "linux") { install_linux(config) } else { anyhow::bail!("Service management is supported on macOS and Linux only"); } } fn start(config: &Config) -> Result<()> { if cfg!(target_os = "macos") { let plist = macos_service_file()?; run_checked(Command::new("launchctl").arg("load").arg("-w").arg(&plist))?; run_checked(Command::new("launchctl").arg("start").arg(SERVICE_LABEL))?; println!("✅ Service started"); Ok(()) } else if cfg!(target_os = "linux") { run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]))?; run_checked(Command::new("systemctl").args(["--user", "start", "zeroclaw.service"]))?; println!("✅ Service started"); Ok(()) } else { let _ = config; anyhow::bail!("Service management is supported on macOS and Linux only") } } fn stop(config: &Config) -> Result<()> { if cfg!(target_os = "macos") { let plist = macos_service_file()?; let _ = run_checked(Command::new("launchctl").arg("stop").arg(SERVICE_LABEL)); let _ = run_checked( Command::new("launchctl") .arg("unload") .arg("-w") .arg(&plist), ); println!("✅ Service stopped"); Ok(()) } else if cfg!(target_os = "linux") { let _ = run_checked(Command::new("systemctl").args(["--user", "stop", "zeroclaw.service"])); println!("✅ Service stopped"); Ok(()) } else { let _ = config; anyhow::bail!("Service management is supported on macOS and Linux only") } } fn status(config: &Config) -> Result<()> { if cfg!(target_os = "macos") { let out = run_capture(Command::new("launchctl").arg("list"))?; let running = out.lines().any(|line| line.contains(SERVICE_LABEL)); println!( "Service: {}", if running { "✅ running/loaded" } else { "❌ not loaded" } ); println!("Unit: {}", macos_service_file()?.display()); return Ok(()); } if cfg!(target_os = "linux") { let out = run_capture(Command::new("systemctl").args([ "--user", "is-active", "zeroclaw.service", ])) .unwrap_or_else(|_| "unknown".into()); println!("Service state: {}", out.trim()); println!("Unit: {}", linux_service_file(config)?.display()); return Ok(()); } anyhow::bail!("Service management is supported on macOS and Linux only") } fn uninstall(config: &Config) -> Result<()> { stop(config)?; if cfg!(target_os = "macos") { let file = macos_service_file()?; if file.exists() { fs::remove_file(&file) .with_context(|| format!("Failed to remove {}", file.display()))?; } println!("✅ Service uninstalled ({})", file.display()); return Ok(()); } if cfg!(target_os = "linux") { let file = linux_service_file(config)?; if file.exists() { fs::remove_file(&file) .with_context(|| format!("Failed to remove {}", file.display()))?; } let _ = run_checked(Command::new("systemctl").args(["--user", "daemon-reload"])); println!("✅ Service uninstalled ({})", file.display()); return Ok(()); } anyhow::bail!("Service management is supported on macOS and Linux only") } fn install_macos(config: &Config) -> Result<()> { let file = macos_service_file()?; if let Some(parent) = file.parent() { fs::create_dir_all(parent)?; } let exe = std::env::current_exe().context("Failed to resolve current executable")?; let logs_dir = config .config_path .parent() .map_or_else(|| PathBuf::from("."), PathBuf::from) .join("logs"); fs::create_dir_all(&logs_dir)?; let stdout = logs_dir.join("daemon.stdout.log"); let stderr = logs_dir.join("daemon.stderr.log"); let plist = format!( r#" Label {label} ProgramArguments {exe} daemon RunAtLoad KeepAlive StandardOutPath {stdout} StandardErrorPath {stderr} "#, label = SERVICE_LABEL, exe = xml_escape(&exe.display().to_string()), stdout = xml_escape(&stdout.display().to_string()), stderr = xml_escape(&stderr.display().to_string()) ); fs::write(&file, plist)?; println!("✅ Installed launchd service: {}", file.display()); println!(" Start with: zeroclaw service start"); Ok(()) } fn install_linux(config: &Config) -> Result<()> { let file = linux_service_file(config)?; if let Some(parent) = file.parent() { fs::create_dir_all(parent)?; } let exe = std::env::current_exe().context("Failed to resolve current executable")?; let unit = format!( "[Unit]\nDescription=ZeroClaw daemon\nAfter=network.target\n\n[Service]\nType=simple\nExecStart={} daemon\nRestart=always\nRestartSec=3\n\n[Install]\nWantedBy=default.target\n", exe.display() ); fs::write(&file, unit)?; let _ = run_checked(Command::new("systemctl").args(["--user", "daemon-reload"])); let _ = run_checked(Command::new("systemctl").args(["--user", "enable", "zeroclaw.service"])); println!("✅ Installed systemd user service: {}", file.display()); println!(" Start with: zeroclaw service start"); Ok(()) } fn macos_service_file() -> Result { let home = directories::UserDirs::new() .map(|u| u.home_dir().to_path_buf()) .context("Could not find home directory")?; Ok(home .join("Library") .join("LaunchAgents") .join(format!("{SERVICE_LABEL}.plist"))) } fn linux_service_file(config: &Config) -> Result { let home = directories::UserDirs::new() .map(|u| u.home_dir().to_path_buf()) .context("Could not find home directory")?; let _ = config; Ok(home .join(".config") .join("systemd") .join("user") .join("zeroclaw.service")) } fn run_checked(command: &mut Command) -> Result<()> { let output = command.output().context("Failed to spawn command")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("Command failed: {}", stderr.trim()); } Ok(()) } fn run_capture(command: &mut Command) -> Result { let output = command.output().context("Failed to spawn command")?; let mut text = String::from_utf8_lossy(&output.stdout).to_string(); if text.trim().is_empty() { text = String::from_utf8_lossy(&output.stderr).to_string(); } Ok(text) } fn xml_escape(raw: &str) -> String { raw.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } #[cfg(test)] mod tests { use super::*; #[test] fn xml_escape_escapes_reserved_chars() { let escaped = xml_escape("<&>\"' and text"); assert_eq!(escaped, "<&>"' and text"); } #[test] fn run_capture_reads_stdout() { let out = run_capture(Command::new("sh").args(["-lc", "echo hello"])) .expect("stdout capture should succeed"); assert_eq!(out.trim(), "hello"); } #[test] fn run_capture_falls_back_to_stderr() { let out = run_capture(Command::new("sh").args(["-lc", "echo warn 1>&2"])) .expect("stderr capture should succeed"); assert_eq!(out.trim(), "warn"); } #[test] fn run_checked_errors_on_non_zero_status() { let err = run_checked(Command::new("sh").args(["-lc", "exit 17"])) .expect_err("non-zero exit should error"); assert!(err.to_string().contains("Command failed")); } #[test] fn linux_service_file_has_expected_suffix() { let file = linux_service_file(&Config::default()).unwrap(); let path = file.to_string_lossy(); assert!(path.ends_with(".config/systemd/user/zeroclaw.service")); } }