From 7b6fb988f410a2352c955cd3681a67f8c8fa5678 Mon Sep 17 00:00:00 2001 From: ADD-SP Date: Sun, 15 Feb 2026 19:12:28 +0000 Subject: [PATCH] feat(onboard): setup auto-start service during the oboarding process --- src/main.rs | 125 ++++++++++++----- src/onboard.rs | 360 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 455 insertions(+), 30 deletions(-) diff --git a/src/main.rs b/src/main.rs index f9d6573..47b2f0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -602,6 +602,36 @@ fn cmd_onboard() -> Result<(), Box> { tui::print_info("Path", &openclaw_path.display().to_string()); } + // Auto-start service setup (ask before step 9 so we can start via service manager) + let exe = std::env::current_exe()?; + let service_path = std::path::Path::new(onboard::autostart_service_path()); + let service_exists = service_path.exists(); + let prompt_msg = if service_exists { + "Auto-start service already exists. Reinstall it?" + } else { + "Install auto-start service so ClawShell starts on boot?" + }; + let installed_service = tui::prompt_confirm(prompt_msg, !service_exists).unwrap_or(false); + if installed_service { + match onboard::install_autostart_service(&exe, &toml_config_path) { + Ok(()) => { + tui::print_success("Auto-start service installed."); + tui::print_info("Service", onboard::autostart_service_path()); + } + Err(e) => { + tui::print_error(&format!("Failed to install auto-start service: {e}")); + } + } + } else { + tui::print_info( + "Skipped", + &format!( + "You can install later by placing a service file at: {}", + onboard::autostart_service_path() + ), + ); + } + // Step 9: Start or skip ClawShell let already_running = process::read_pid_file().is_some_and(process::is_process_running); @@ -610,30 +640,20 @@ fn cmd_onboard() -> Result<(), Box> { } else { tui::print_step(9, TOTAL_STEPS, "Starting ClawShell..."); - let exe = std::env::current_exe()?; - let log_path = process::log_file_path(); - let log_file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path)?; - let log_stderr = log_file.try_clone()?; - - let child = std::process::Command::new(exe) - .args([ - "start", - "--config", - &toml_config_path.to_string_lossy(), - "--foreground", - ]) - .stdout(log_file) - .stderr(log_stderr) - .stdin(std::process::Stdio::null()) - .spawn()?; - - let pid = child.id(); - process::write_pid_file(pid)?; - tui::print_step_done(9, TOTAL_STEPS, "ClawShell started"); - tui::print_info("PID", &pid.to_string()); + if installed_service { + // Start via the service manager so it manages the lifecycle + match onboard::start_autostart_service() { + Ok(()) => { + tui::print_step_done(9, TOTAL_STEPS, "ClawShell started via service manager"); + } + Err(e) => { + tui::print_error(&format!("Failed to start via service manager: {e}")); + } + } + } else { + start_clawshell_direct(&toml_config_path)?; + tui::print_step_done(9, TOTAL_STEPS, "ClawShell started"); + } tui::print_info("Logs", &process::log_file_path().display().to_string()); } @@ -808,11 +828,17 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box> { .unwrap_or_else(|| PathBuf::from("/var/log/clawshell")); let pid_file = process::pid_file_path(); + let service_path = std::path::Path::new(clawshell::onboard::autostart_service_path()); + let service_exists = service_path.exists(); + tui::print_warning("This will remove the following:"); tui::print_info("ClawShell", "Stop if running"); tui::print_info("Config dir", &config_dir.display().to_string()); tui::print_info("Log dir", &log_dir.display().to_string()); tui::print_info("PID file", &pid_file.display().to_string()); + if service_exists { + tui::print_info("Service", &service_path.display().to_string()); + } tui::print_info("Binary", &exe_path.display().to_string()); tui::print_info("System user", "clawshell"); println!(); @@ -860,7 +886,16 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box> { } } - // 1. Stop ClawShell if running + // 1. Stop ClawShell and remove auto-start service + if service_exists { + tui::print_info("Action", "Stopping and removing auto-start service..."); + match clawshell::onboard::remove_autostart_service() { + Ok(()) => tui::print_success("Auto-start service stopped and removed."), + Err(e) => tui::print_warning(&format!("Failed to remove auto-start service: {e}")), + } + } + + // 2. Stop ClawShell if still running (e.g. started without service manager) if let Some(pid) = process::read_pid_file() { if process::is_process_running(pid) { tui::print_info("PID", &pid.to_string()); @@ -872,7 +907,7 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box> { } } - // 2. Remove PID file (in case stop_process didn't clean it) + // 3. Remove PID file (in case stop_process didn't clean it) if pid_file.exists() { let _ = std::fs::remove_file(&pid_file); tui::print_success("PID file removed."); @@ -884,19 +919,19 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box> { let _ = std::fs::remove_dir(pid_dir); } - // 3. Remove log directory + // 4. Remove log directory if log_dir.exists() { std::fs::remove_dir_all(&log_dir)?; tui::print_success("Log directory removed."); } - // 4. Remove configuration directory + // 5. Remove configuration directory if config_dir.exists() { std::fs::remove_dir_all(&config_dir)?; tui::print_success("Configuration directory removed."); } - // 5. Remove the clawshell system user + // 6. Remove the clawshell system user let user_exists = std::process::Command::new("id") .arg("clawshell") .stdout(std::process::Stdio::null()) @@ -914,7 +949,7 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box> { } } - // 6. Remove the binary itself (do this last) + // 7. Remove the binary itself (do this last) if exe_path.exists() { if let Err(e) = std::fs::remove_file(&exe_path) { tui::print_warning(&format!("Could not remove binary: {e}.")); @@ -1020,6 +1055,36 @@ fn create_macos_system_user( Ok(status) } +/// Start ClawShell directly by spawning a child process (no service manager). +fn start_clawshell_direct( + toml_config_path: &std::path::Path, +) -> Result<(), Box> { + let exe = std::env::current_exe()?; + let log_path = process::log_file_path(); + let log_file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path)?; + let log_stderr = log_file.try_clone()?; + + let child = std::process::Command::new(exe) + .args([ + "start", + "--config", + &toml_config_path.to_string_lossy(), + "--foreground", + ]) + .stdout(log_file) + .stderr(log_stderr) + .stdin(std::process::Stdio::null()) + .spawn()?; + + let pid = child.id(); + process::write_pid_file(pid)?; + tui::print_info("PID", &pid.to_string()); + Ok(()) +} + /// Delete a system user, using platform-appropriate commands. fn delete_system_user(name: &str) -> Result> { if cfg!(target_os = "macos") { diff --git a/src/onboard.rs b/src/onboard.rs index 8ba6d75..a323561 100644 --- a/src/onboard.rs +++ b/src/onboard.rs @@ -789,6 +789,213 @@ fn ensure_nested_object(json: &mut Value, keys: &[&str]) { } } +// --------------------------------------------------------------------------- +// Auto-start service management (systemd / launchd) +// --------------------------------------------------------------------------- + +/// Path to the systemd unit file for the ClawShell service. +pub const SYSTEMD_SERVICE_PATH: &str = "/etc/systemd/system/clawshell.service"; + +/// Path to the launchd plist file for the ClawShell service. +pub const LAUNCHD_PLIST_PATH: &str = "/Library/LaunchDaemons/com.clawshell.daemon.plist"; + +/// Return the platform-appropriate service file path. +pub fn autostart_service_path() -> &'static str { + if cfg!(target_os = "macos") { + LAUNCHD_PLIST_PATH + } else { + SYSTEMD_SERVICE_PATH + } +} + +/// Generate a systemd unit file for the ClawShell daemon. +pub fn generate_systemd_unit(exe_path: &Path, config_path: &Path) -> String { + format!( + r#"[Unit] +Description=ClawShell API proxy daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=clawshell +Group=clawshell +ExecStart={exe} start --config {config} --foreground +Restart=on-failure +RestartSec=5 +StandardOutput=append:/var/log/clawshell/clawshell.log +StandardError=append:/var/log/clawshell/clawshell.log + +[Install] +WantedBy=multi-user.target +"#, + exe = exe_path.display(), + config = config_path.display(), + ) +} + +/// Generate a launchd plist file for the ClawShell daemon. +pub fn generate_launchd_plist(exe_path: &Path, config_path: &Path) -> String { + format!( + r#" + + + + Label + com.clawshell.daemon + UserName + clawshell + ProgramArguments + + {exe} + start + --config + {config} + --foreground + + KeepAlive + + RunAtLoad + + StandardOutPath + /var/log/clawshell/clawshell.log + StandardErrorPath + /var/log/clawshell/clawshell.log + + +"#, + exe = exe_path.display(), + config = config_path.display(), + ) +} + +/// Write a service file to the given VFS path (testable with MemoryFS). +pub fn install_autostart_service_vfs( + service_file: &VfsPath, + content: &str, +) -> Result<(), Box> { + service_file.parent().create_dir_all()?; + service_file.create_file()?.write_all(content.as_bytes())?; + Ok(()) +} + +/// Remove a service file from the given VFS path (testable with MemoryFS). +/// +/// Returns `Ok(true)` if the file was removed, `Ok(false)` if it didn't exist. +pub fn remove_autostart_service_vfs( + service_file: &VfsPath, +) -> Result> { + if service_file.exists()? { + service_file.remove_file()?; + Ok(true) + } else { + Ok(false) + } +} + +/// Install the auto-start service on the real filesystem and enable it. +pub fn install_autostart_service( + exe_path: &Path, + config_path: &Path, +) -> Result<(), Box> { + let content = if cfg!(target_os = "macos") { + generate_launchd_plist(exe_path, config_path) + } else { + generate_systemd_unit(exe_path, config_path) + }; + + let service_path = autostart_service_path(); + let root = crate::process::physical_root(); + let vfs_path = root.join(service_path.trim_start_matches('/'))?; + install_autostart_service_vfs(&vfs_path, &content)?; + + if cfg!(target_os = "macos") { + // Unload if already loaded so launchd picks up the new plist. + let _ = std::process::Command::new("launchctl") + .args(["unload", service_path]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + let _ = std::process::Command::new("chown") + .args(["root:wheel", service_path]) + .status(); + let _ = std::process::Command::new("chmod") + .args(["0644", service_path]) + .status(); + // Loading is done separately via start_autostart_service(). + } else { + let status = std::process::Command::new("systemctl") + .args(["daemon-reload"]) + .status()?; + if !status.success() { + return Err("systemctl daemon-reload failed".into()); + } + let status = std::process::Command::new("systemctl") + .args(["enable", "clawshell.service"]) + .status()?; + if !status.success() { + return Err("systemctl enable failed".into()); + } + } + + Ok(()) +} + +/// Start the auto-start service via the platform service manager. +pub fn start_autostart_service() -> Result<(), Box> { + let service_path = autostart_service_path(); + + if cfg!(target_os = "macos") { + let status = std::process::Command::new("launchctl") + .args(["load", service_path]) + .status()?; + if !status.success() { + return Err(format!("launchctl load failed (exit code {})", status).into()); + } + } else { + let status = std::process::Command::new("systemctl") + .args(["start", "clawshell.service"]) + .status()?; + if !status.success() { + return Err(format!("systemctl start failed (exit code {})", status).into()); + } + } + + Ok(()) +} + +/// Remove the auto-start service from the real filesystem and disable it. +pub fn remove_autostart_service() -> Result<(), Box> { + let service_path = autostart_service_path(); + + if cfg!(target_os = "macos") { + let _ = std::process::Command::new("launchctl") + .args(["unload", service_path]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } else { + let _ = std::process::Command::new("systemctl") + .args(["disable", "clawshell.service"]) + .status(); + let _ = std::process::Command::new("systemctl") + .args(["stop", "clawshell.service"]) + .status(); + } + + let root = crate::process::physical_root(); + let vfs_path = root.join(service_path.trim_start_matches('/'))?; + remove_autostart_service_vfs(&vfs_path)?; + + if !cfg!(target_os = "macos") { + let _ = std::process::Command::new("systemctl") + .args(["daemon-reload"]) + .status(); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1497,4 +1704,157 @@ mod tests { assert_eq!(json["extra_field"], 42); } + + // --- Auto-start service tests --- + + #[test] + fn test_generate_systemd_unit_contains_required_fields() { + let content = generate_systemd_unit( + Path::new("/usr/local/bin/clawshell"), + Path::new("/etc/clawshell/clawshell.toml"), + ); + assert!(content.contains("Type=exec")); + assert!(content.contains("User=clawshell")); + assert!(content.contains("Group=clawshell")); + assert!(content.contains("ExecStart=/usr/local/bin/clawshell start --config /etc/clawshell/clawshell.toml --foreground")); + assert!(content.contains("Restart=on-failure")); + assert!(content.contains("RestartSec=5")); + assert!(content.contains("After=network-online.target")); + assert!(content.contains("WantedBy=multi-user.target")); + assert!(content.contains("StandardOutput=append:/var/log/clawshell/clawshell.log")); + assert!(content.contains("StandardError=append:/var/log/clawshell/clawshell.log")); + } + + #[test] + fn test_generate_systemd_unit_custom_paths() { + let content = generate_systemd_unit( + Path::new("/opt/clawshell/bin/cs"), + Path::new("/opt/clawshell/config.toml"), + ); + assert!(content.contains( + "ExecStart=/opt/clawshell/bin/cs start --config /opt/clawshell/config.toml --foreground" + )); + } + + #[test] + fn test_generate_launchd_plist_contains_required_fields() { + let content = generate_launchd_plist( + Path::new("/usr/local/bin/clawshell"), + Path::new("/etc/clawshell/clawshell.toml"), + ); + assert!(content.contains("com.clawshell.daemon")); + assert!(content.contains("clawshell")); // UserName + assert!(content.contains("KeepAlive")); + assert!(content.contains("")); + assert!(content.contains("RunAtLoad")); + assert!(content.contains("/usr/local/bin/clawshell")); + assert!(content.contains("/var/log/clawshell/clawshell.log")); + assert!(content.contains("ProgramArguments")); + } + + #[test] + fn test_generate_launchd_plist_custom_paths() { + let content = generate_launchd_plist( + Path::new("/opt/cs/bin/clawshell"), + Path::new("/opt/cs/config.toml"), + ); + assert!(content.contains("/opt/cs/bin/clawshell")); + assert!(content.contains("/opt/cs/config.toml")); + } + + #[test] + fn test_generate_launchd_plist_valid_xml_structure() { + let content = generate_launchd_plist( + Path::new("/usr/local/bin/clawshell"), + Path::new("/etc/clawshell/clawshell.toml"), + ); + assert!(content.starts_with("")); + } + + #[test] + fn test_install_autostart_service_vfs_writes_file() { + let root = VfsPath::new(MemoryFS::new()); + let service_file = root.join("etc/systemd/system/clawshell.service").unwrap(); + let content = "test service content"; + + install_autostart_service_vfs(&service_file, content).unwrap(); + + assert!(service_file.exists().unwrap()); + assert_eq!(service_file.read_to_string().unwrap(), content); + } + + #[test] + fn test_install_autostart_service_vfs_creates_parent_dirs() { + let root = VfsPath::new(MemoryFS::new()); + let service_file = root + .join("Library/LaunchDaemons/com.clawshell.daemon.plist") + .unwrap(); + + install_autostart_service_vfs(&service_file, "plist content").unwrap(); + + assert!(service_file.exists().unwrap()); + assert!( + root.join("Library/LaunchDaemons") + .unwrap() + .exists() + .unwrap() + ); + } + + #[test] + fn test_install_autostart_service_vfs_overwrites_existing() { + let root = VfsPath::new(MemoryFS::new()); + let service_file = root.join("etc/systemd/system/clawshell.service").unwrap(); + + install_autostart_service_vfs(&service_file, "old content").unwrap(); + install_autostart_service_vfs(&service_file, "new content").unwrap(); + + assert_eq!(service_file.read_to_string().unwrap(), "new content"); + } + + #[test] + fn test_remove_autostart_service_vfs_removes_existing() { + let root = VfsPath::new(MemoryFS::new()); + let service_file = root.join("etc/systemd/system/clawshell.service").unwrap(); + + install_autostart_service_vfs(&service_file, "content").unwrap(); + assert!(service_file.exists().unwrap()); + + let removed = remove_autostart_service_vfs(&service_file).unwrap(); + assert!(removed); + assert!(!service_file.exists().unwrap()); + } + + #[test] + fn test_remove_autostart_service_vfs_missing_file() { + let root = VfsPath::new(MemoryFS::new()); + let service_file = root.join("etc/systemd/system/clawshell.service").unwrap(); + + let removed = remove_autostart_service_vfs(&service_file).unwrap(); + assert!(!removed); + } + + #[test] + fn test_autostart_service_path_is_absolute() { + let path = autostart_service_path(); + assert!(path.starts_with('/')); + } + + #[test] + fn test_systemd_service_path_constant() { + assert_eq!( + SYSTEMD_SERVICE_PATH, + "/etc/systemd/system/clawshell.service" + ); + } + + #[test] + fn test_launchd_plist_path_constant() { + assert_eq!( + LAUNCHD_PLIST_PATH, + "/Library/LaunchDaemons/com.clawshell.daemon.plist" + ); + } }