//! OS service management for running IronClaw as a daemon. //! //! Generates and manages platform-native service definitions: //! - **macOS**: launchd plist at `~/Library/LaunchAgents/com.ironclaw.daemon.plist` //! - **Linux**: systemd user unit at `~/.config/systemd/user/ironclaw.service` //! //! The installed service runs `ironclaw run` (the default agent mode) and is //! configured to restart automatically on failure. use std::path::PathBuf; use std::process::Command; use anyhow::{Context, Result, bail}; use crate::bootstrap::ironclaw_base_dir; const SERVICE_LABEL: &str = "com.ironclaw.daemon"; const SYSTEMD_UNIT: &str = "ironclaw.service"; // ── Public dispatch ───────────────────────────────────────────── /// Route a service subcommand to the appropriate handler. pub fn handle_command(command: &ServiceAction) -> Result<()> { match command { ServiceAction::Install => install(), ServiceAction::Start => start(), ServiceAction::Stop => stop(), ServiceAction::Status => status(), ServiceAction::Uninstall => uninstall(), } } /// The five service lifecycle actions. #[derive(Debug, Clone)] pub enum ServiceAction { Install, Start, Stop, Status, Uninstall, } // ── Install ───────────────────────────────────────────────────── fn install() -> Result<()> { if cfg!(target_os = "macos") { install_macos() } else if cfg!(target_os = "linux") { install_linux() } else { bail!("Service management is only supported on macOS and Linux"); } } fn install_macos() -> Result<()> { let file = macos_plist_path()?; if let Some(parent) = file.parent() { std::fs::create_dir_all(parent)?; } let exe = std::env::current_exe().context("failed to resolve current executable")?; let logs_dir = ironclaw_logs_dir(); std::fs::create_dir_all(&logs_dir)?; let stdout = logs_dir.join("daemon.stdout.log"); let stderr = logs_dir.join("daemon.stderr.log"); let plist = macos_plist_content( &exe.display().to_string(), &stdout.display().to_string(), &stderr.display().to_string(), ); std::fs::write(&file, plist)?; println!("Installed launchd service: {}", file.display()); println!(" Start with: ironclaw service start"); Ok(()) } fn macos_plist_content(exe: &str, stdout: &str, stderr: &str) -> String { format!( r#" Label {label} ProgramArguments {exe} run RunAtLoad KeepAlive EnvironmentVariables CLI_ENABLED false StandardOutPath {stdout} StandardErrorPath {stderr} "#, label = SERVICE_LABEL, exe = xml_escape(exe), stdout = xml_escape(stdout), stderr = xml_escape(stderr), ) } fn install_linux() -> Result<()> { let file = linux_unit_path()?; if let Some(parent) = file.parent() { std::fs::create_dir_all(parent)?; } let exe = std::env::current_exe().context("failed to resolve current executable")?; let unit = format!( "[Unit]\n\ Description=IronClaw daemon\n\ After=network.target\n\ \n\ [Service]\n\ Type=simple\n\ Environment=\"CLI_ENABLED=false\"\n\ ExecStart=\"{exe}\" run\n\ Restart=always\n\ RestartSec=3\n\ \n\ [Install]\n\ WantedBy=default.target\n", exe = exe.display(), ); std::fs::write(&file, unit)?; run_checked(Command::new("systemctl").args(["--user", "daemon-reload"])).ok(); run_checked(Command::new("systemctl").args(["--user", "enable", SYSTEMD_UNIT])).ok(); println!("Installed systemd user service: {}", file.display()); println!(" Start with: ironclaw service start"); Ok(()) } // ── Start ─────────────────────────────────────────────────────── fn start() -> Result<()> { if cfg!(target_os = "macos") { let plist = macos_plist_path()?; if !plist.exists() { bail!("Service not installed. Run `ironclaw service install` first."); } 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", SYSTEMD_UNIT]))?; println!("Service started"); Ok(()) } else { bail!("Service management is only supported on macOS and Linux"); } } // ── Stop ──────────────────────────────────────────────────────── fn stop() -> Result<()> { if cfg!(target_os = "macos") { let plist = macos_plist_path()?; run_checked(Command::new("launchctl").arg("stop").arg(SERVICE_LABEL)).ok(); run_checked( Command::new("launchctl") .arg("unload") .arg("-w") .arg(&plist), ) .ok(); println!("Service stopped"); Ok(()) } else if cfg!(target_os = "linux") { run_checked(Command::new("systemctl").args(["--user", "stop", SYSTEMD_UNIT])).ok(); println!("Service stopped"); Ok(()) } else { bail!("Service management is only supported on macOS and Linux"); } } // ── Status ────────────────────────────────────────────────────── fn status() -> 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_plist_path()?.display()); Ok(()) } else if cfg!(target_os = "linux") { let state = run_capture(Command::new("systemctl").args(["--user", "is-active", SYSTEMD_UNIT])) .unwrap_or_else(|_| "unknown".into()); println!("Service state: {}", state.trim()); println!("Unit: {}", linux_unit_path()?.display()); Ok(()) } else { bail!("Service management is only supported on macOS and Linux"); } } // ── Uninstall ─────────────────────────────────────────────────── fn uninstall() -> Result<()> { // Stop first (ignore errors, service might not be running) stop().ok(); if cfg!(target_os = "macos") { let file = macos_plist_path()?; if file.exists() { std::fs::remove_file(&file) .with_context(|| format!("failed to remove {}", file.display()))?; } println!("Service uninstalled ({})", file.display()); Ok(()) } else if cfg!(target_os = "linux") { let file = linux_unit_path()?; if file.exists() { std::fs::remove_file(&file) .with_context(|| format!("failed to remove {}", file.display()))?; } run_checked(Command::new("systemctl").args(["--user", "daemon-reload"])).ok(); println!("Service uninstalled ({})", file.display()); Ok(()) } else { bail!("Service management is only supported on macOS and Linux"); } } // ── Path helpers ──────────────────────────────────────────────── fn macos_plist_path() -> Result { let home = dirs::home_dir().context("could not find home directory")?; Ok(home .join("Library") .join("LaunchAgents") .join(format!("{SERVICE_LABEL}.plist"))) } fn linux_unit_path() -> Result { let home = dirs::home_dir().context("could not find home directory")?; Ok(home .join(".config") .join("systemd") .join("user") .join(SYSTEMD_UNIT)) } fn ironclaw_logs_dir() -> PathBuf { ironclaw_base_dir().join("logs") } // ── Shell helpers ─────────────────────────────────────────────── 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); 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('\'', "'") } // ── Tests ─────────────────────────────────────────────────────── #[cfg(test)] mod tests { use crate::service::*; #[test] fn xml_escape_handles_reserved_chars() { let escaped = xml_escape("<&>\"' and text"); assert_eq!(escaped, "<&>"' and text"); } #[test] fn xml_escape_passes_through_plain_text() { assert_eq!(xml_escape("hello world"), "hello world"); } #[test] fn run_capture_reads_stdout() { let out = run_capture(Command::new("sh").args(["-c", "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(["-c", "echo warn 1>&2"])) .expect("stderr capture should succeed"); assert_eq!(out.trim(), "warn"); } #[test] fn run_checked_errors_on_non_zero_exit() { let err = run_checked(Command::new("sh").args(["-c", "exit 17"])) .expect_err("non-zero exit should error"); assert!(err.to_string().contains("command failed")); } #[test] fn run_checked_succeeds_on_zero_exit() { assert!(run_checked(Command::new("sh").args(["-c", "exit 0"])).is_ok()); } #[cfg(target_os = "macos")] #[test] fn macos_plist_path_has_expected_suffix() { let path = macos_plist_path().unwrap(); let s = path.to_string_lossy(); assert!( s.ends_with("Library/LaunchAgents/com.ironclaw.daemon.plist"), "unexpected path: {s}" ); } #[cfg(target_os = "linux")] #[test] fn linux_unit_path_has_expected_suffix() { let path = linux_unit_path().unwrap(); let s = path.to_string_lossy(); assert!( s.ends_with(".config/systemd/user/ironclaw.service"), "unexpected path: {s}" ); } #[test] fn logs_dir_under_ironclaw() { let path = ironclaw_logs_dir(); let s = path.to_string_lossy(); assert!(s.ends_with(".ironclaw/logs"), "unexpected path: {s}"); } #[test] fn macos_plist_sets_cli_enabled_false() { let plist = macos_plist_content("/tmp/ironclaw", "/tmp/stdout.log", "/tmp/stderr.log"); assert!(plist.contains("EnvironmentVariables")); assert!(plist.contains(" CLI_ENABLED\n false")); } }