- 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.
285 lines
9.1 KiB
Rust
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('&', "&")
|
|
.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"));
|
|
}
|
|
}
|