diff --git a/src/service/mod.rs b/src/service/mod.rs index eb933ad..4b3d2b3 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -5,6 +5,11 @@ use std::path::PathBuf; use std::process::Command; const SERVICE_LABEL: &str = "com.zeroclaw.daemon"; +const WINDOWS_TASK_NAME: &str = "ZeroClaw Daemon"; + +fn windows_task_name() -> &'static str { + WINDOWS_TASK_NAME +} pub fn handle_command(command: &super::ServiceCommands, config: &Config) -> Result<()> { match command { @@ -21,6 +26,8 @@ fn install(config: &Config) -> Result<()> { install_macos(config) } else if cfg!(target_os = "linux") { install_linux(config) + } else if cfg!(target_os = "windows") { + install_windows(config) } else { anyhow::bail!("Service management is supported on macOS and Linux only"); } @@ -38,6 +45,11 @@ fn start(config: &Config) -> Result<()> { run_checked(Command::new("systemctl").args(["--user", "start", "zeroclaw.service"]))?; println!("✅ Service started"); Ok(()) + } else if cfg!(target_os = "windows") { + let _ = config; + run_checked(Command::new("schtasks").args(["/Run", "/TN", windows_task_name()]))?; + println!("✅ Service started"); + Ok(()) } else { let _ = config; anyhow::bail!("Service management is supported on macOS and Linux only") @@ -60,6 +72,12 @@ fn stop(config: &Config) -> Result<()> { let _ = run_checked(Command::new("systemctl").args(["--user", "stop", "zeroclaw.service"])); println!("✅ Service stopped"); Ok(()) + } else if cfg!(target_os = "windows") { + let _ = config; + let task_name = windows_task_name(); + let _ = run_checked(Command::new("schtasks").args(["/End", "/TN", task_name])); + println!("✅ Service stopped"); + Ok(()) } else { let _ = config; anyhow::bail!("Service management is supported on macOS and Linux only") @@ -94,6 +112,32 @@ fn status(config: &Config) -> Result<()> { return Ok(()); } + if cfg!(target_os = "windows") { + let _ = config; + let task_name = windows_task_name(); + let out = run_capture( + Command::new("schtasks").args(["/Query", "/TN", task_name, "/FO", "LIST"]), + ); + match out { + Ok(text) => { + let running = text.contains("Running"); + println!( + "Service: {}", + if running { + "✅ running" + } else { + "❌ not running" + } + ); + println!("Task: {}", task_name); + } + Err(_) => { + println!("Service: ❌ not installed"); + } + } + return Ok(()); + } + anyhow::bail!("Service management is supported on macOS and Linux only") } @@ -121,6 +165,25 @@ fn uninstall(config: &Config) -> Result<()> { return Ok(()); } + if cfg!(target_os = "windows") { + let task_name = windows_task_name(); + let _ = run_checked( + Command::new("schtasks").args(["/Delete", "/TN", task_name, "/F"]), + ); + // Remove the wrapper script + let wrapper = config + .config_path + .parent() + .map_or_else(|| PathBuf::from("."), PathBuf::from) + .join("logs") + .join("zeroclaw-daemon.cmd"); + if wrapper.exists() { + fs::remove_file(&wrapper).ok(); + } + println!("✅ Service uninstalled"); + return Ok(()); + } + anyhow::bail!("Service management is supported on macOS and Linux only") } @@ -196,6 +259,57 @@ fn install_linux(config: &Config) -> Result<()> { Ok(()) } +fn install_windows(config: &Config) -> Result<()> { + 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)?; + + // Create a wrapper script that redirects output to log files + let wrapper = logs_dir.join("zeroclaw-daemon.cmd"); + let stdout_log = logs_dir.join("daemon.stdout.log"); + let stderr_log = logs_dir.join("daemon.stderr.log"); + + let wrapper_content = format!( + "@echo off\r\n\"{}\" daemon >>\"{}\" 2>>\"{}\"", + exe.display(), + stdout_log.display(), + stderr_log.display() + ); + fs::write(&wrapper, &wrapper_content)?; + + let task_name = windows_task_name(); + + // Remove any existing task first (ignore errors if it doesn't exist) + let _ = Command::new("schtasks") + .args(["/Delete", "/TN", task_name, "/F"]) + .output(); + + run_checked( + Command::new("schtasks").args([ + "/Create", + "/TN", + task_name, + "/SC", + "ONLOGON", + "/TR", + &format!("\"{}\"", wrapper.display()), + "/RL", + "HIGHEST", + "/F", + ]), + )?; + + println!("✅ Installed Windows scheduled task: {}", task_name); + println!(" Wrapper: {}", wrapper.display()); + println!(" Logs: {}", logs_dir.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()) @@ -254,6 +368,7 @@ mod tests { assert_eq!(escaped, "<&>"' and text"); } + #[cfg(not(target_os = "windows"))] #[test] fn run_capture_reads_stdout() { let out = run_capture(Command::new("sh").args(["-lc", "echo hello"])) @@ -261,6 +376,7 @@ mod tests { assert_eq!(out.trim(), "hello"); } + #[cfg(not(target_os = "windows"))] #[test] fn run_capture_falls_back_to_stderr() { let out = run_capture(Command::new("sh").args(["-lc", "echo warn 1>&2"])) @@ -268,6 +384,7 @@ mod tests { assert_eq!(out.trim(), "warn"); } + #[cfg(not(target_os = "windows"))] #[test] fn run_checked_errors_on_non_zero_status() { let err = run_checked(Command::new("sh").args(["-lc", "exit 17"])) @@ -275,10 +392,32 @@ mod tests { assert!(err.to_string().contains("Command failed")); } + #[cfg(not(target_os = "windows"))] #[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")); } + + #[test] + fn windows_task_name_is_constant() { + assert_eq!(windows_task_name(), "ZeroClaw Daemon"); + } + + #[cfg(target_os = "windows")] + #[test] + fn run_capture_reads_stdout_windows() { + let out = run_capture(Command::new("cmd").args(["/C", "echo hello"])) + .expect("stdout capture should succeed"); + assert_eq!(out.trim(), "hello"); + } + + #[cfg(target_os = "windows")] + #[test] + fn run_checked_errors_on_non_zero_status_windows() { + let err = run_checked(Command::new("cmd").args(["/C", "exit /b 17"])) + .expect_err("non-zero exit should error"); + assert!(err.to_string().contains("Command failed")); + } } diff --git a/src/skills/symlink_tests.rs b/src/skills/symlink_tests.rs index d768f59..c77393a 100644 --- a/src/skills/symlink_tests.rs +++ b/src/skills/symlink_tests.rs @@ -50,19 +50,22 @@ mod tests { } // Test case 3: Non-Unix platforms should handle symlink errors gracefully - #[cfg(not(unix))] + #[cfg(windows)] { let source_dir = tmp.path().join("source_skill"); std::fs::create_dir_all(&source_dir).unwrap(); let dest_link = skills_path.join("linked_skill"); - // Symlink should fail on non-Unix - let result = std::os::unix::fs::symlink(&source_dir, &dest_link); - assert!(result.is_err()); - - // Directory should not exist - assert!(!dest_link.exists()); + // On Windows, creating directory symlinks may require elevated privileges + let result = std::os::windows::fs::symlink_dir(&source_dir, &dest_link); + // If symlink creation fails (no privileges), the directory should not exist + if result.is_err() { + assert!(!dest_link.exists()); + } else { + // Clean up if it succeeded + let _ = std::fs::remove_dir(&dest_link); + } } // Test case 4: skills_dir function edge cases