zeroclaw/src/service/mod.rs
argenis de la rosa a310e178db fix: add missing port/host fields to GatewayConfig and apply_env_overrides method
- Add port and host fields to GatewayConfig struct
- Add default_gateway_port() and default_gateway_host() functions
- Add apply_env_overrides() method to Config for env var support
- Fix test to include new GatewayConfig fields

All tests pass.
2026-02-14 16:05:13 -05:00

285 lines
9.1 KiB
Rust

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#"<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
<plist version=\"1.0\">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
<string>{exe}</string>
<string>daemon</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{stdout}</string>
<key>StandardErrorPath</key>
<string>{stderr}</string>
</dict>
</plist>
"#,
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<PathBuf> {
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<PathBuf> {
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<String> {
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xml_escape_escapes_reserved_chars() {
let escaped = xml_escape("<&>\"' and text");
assert_eq!(escaped, "&lt;&amp;&gt;&quot;&apos; 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"));
}
}