1use anyhow::{Context, Result, bail};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::str::FromStr;
6use zeroclaw_config::schema::Config;
7
8const SERVICE_LABEL: &str = "com.zeroclaw.daemon";
9const WINDOWS_TASK_NAME: &str = "ZeroClaw Daemon";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum InitSystem {
14 #[default]
16 Auto,
17 Systemd,
19 Openrc,
21}
22
23impl FromStr for InitSystem {
24 type Err = anyhow::Error;
25
26 fn from_str(s: &str) -> Result<Self> {
27 match s.to_lowercase().as_str() {
28 "auto" => Ok(Self::Auto),
29 "systemd" => Ok(Self::Systemd),
30 "openrc" => Ok(Self::Openrc),
31 other => bail!(
32 "Unknown init system: '{}'. Supported: auto, systemd, openrc",
33 other
34 ),
35 }
36 }
37}
38
39impl InitSystem {
40 #[cfg(target_os = "linux")]
47 pub fn resolve(self) -> Result<Self> {
48 match self {
49 Self::Auto => detect_init_system(),
50 concrete => Ok(concrete),
51 }
52 }
53
54 #[cfg(not(target_os = "linux"))]
55 pub fn resolve(self) -> Result<Self> {
56 match self {
57 Self::Auto => Ok(Self::Systemd),
58 concrete => Ok(concrete),
59 }
60 }
61}
62
63#[cfg(target_os = "linux")]
68fn detect_init_system() -> Result<InitSystem> {
69 if Path::new("/run/systemd/system").exists() {
71 return Ok(InitSystem::Systemd);
72 }
73
74 if Path::new("/run/openrc").exists() {
76 if Path::new("/sbin/openrc-run").exists() || which::which("rc-service").is_ok() {
78 return Ok(InitSystem::Openrc);
79 }
80 }
81
82 bail!(
83 "Could not detect init system. Supported: systemd, OpenRC. \
84 Use --service-init to specify manually."
85 );
86}
87
88fn windows_task_name() -> &'static str {
89 WINDOWS_TASK_NAME
90}
91
92pub fn is_running() -> bool {
94 if cfg!(target_os = "macos") {
95 run_capture(Command::new("launchctl").arg("list"))
96 .map(|out| out.lines().any(|l| l.contains(SERVICE_LABEL)))
97 .unwrap_or(false)
98 } else if cfg!(target_os = "linux") {
99 is_running_linux()
100 } else if cfg!(target_os = "windows") {
101 run_capture(Command::new("schtasks").args([
102 "/Query",
103 "/TN",
104 WINDOWS_TASK_NAME,
105 "/FO",
106 "LIST",
107 ]))
108 .map(|out| out.contains("Running"))
109 .unwrap_or(false)
110 } else {
111 false
112 }
113}
114
115fn is_running_linux() -> bool {
116 if run_capture(Command::new("systemctl").args(["--user", "is-active", "zeroclaw.service"]))
118 .map(|out| out.trim() == "active")
119 .unwrap_or(false)
120 {
121 return true;
122 }
123 run_capture(Command::new("rc-service").args(["zeroclaw", "status"]))
124 .map(|out| out.contains("started"))
125 .unwrap_or(false)
126}
127
128pub fn install(config: &Config, init_system: InitSystem) -> Result<()> {
129 if cfg!(target_os = "macos") {
130 install_macos(config)
131 } else if cfg!(target_os = "linux") {
132 let resolved = init_system.resolve()?;
133 install_linux(config, resolved)
134 } else if cfg!(target_os = "windows") {
135 install_windows(config)
136 } else {
137 anyhow::bail!("Service management is supported on macOS and Linux only");
138 }
139}
140
141pub fn start(config: &Config, init_system: InitSystem) -> Result<()> {
142 if cfg!(target_os = "macos") {
143 let exe = std::env::current_exe().ok();
146 if let Some(ref exe_path) = exe
147 && let Some(var_dir) = homebrew_var_dir_from_exe(exe_path)
148 {
149 let _ = fs::create_dir_all(&var_dir);
150 }
151 let plist = macos_service_file()?;
152 run_checked(Command::new("launchctl").arg("load").arg("-w").arg(&plist))?;
153 run_checked(Command::new("launchctl").arg("start").arg(SERVICE_LABEL))?;
154 println!("✅ Service started");
155 Ok(())
156 } else if cfg!(target_os = "linux") {
157 let resolved = init_system.resolve()?;
158 start_linux(resolved)
159 } else if cfg!(target_os = "windows") {
160 let _ = config;
161 run_checked(Command::new("schtasks").args(["/Run", "/TN", windows_task_name()]))?;
162 println!("✅ Service started");
163 Ok(())
164 } else {
165 let _ = config;
166 anyhow::bail!("Service management is supported on macOS and Linux only")
167 }
168}
169
170fn start_linux(init_system: InitSystem) -> Result<()> {
171 match init_system {
172 InitSystem::Systemd => {
173 run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]))?;
174 run_checked(Command::new("systemctl").args(["--user", "start", "zeroclaw.service"]))?;
175 }
176 InitSystem::Openrc => {
177 run_checked(Command::new("rc-service").args(["zeroclaw", "start"]))?;
178 }
179 InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
180 }
181 println!("✅ Service started");
182 Ok(())
183}
184
185pub fn stop(config: &Config, init_system: InitSystem) -> Result<()> {
186 if cfg!(target_os = "macos") {
187 let plist = macos_service_file()?;
188 let _ = run_checked(Command::new("launchctl").arg("stop").arg(SERVICE_LABEL));
189 let _ = run_checked(
190 Command::new("launchctl")
191 .arg("unload")
192 .arg("-w")
193 .arg(&plist),
194 );
195 println!("✅ Service stopped");
196 Ok(())
197 } else if cfg!(target_os = "linux") {
198 let resolved = init_system.resolve()?;
199 stop_linux(resolved)
200 } else if cfg!(target_os = "windows") {
201 let _ = config;
202 let task_name = windows_task_name();
203 let _ = run_checked(Command::new("schtasks").args(["/End", "/TN", task_name]));
204 println!("✅ Service stopped");
205 Ok(())
206 } else {
207 let _ = config;
208 anyhow::bail!("Service management is supported on macOS and Linux only")
209 }
210}
211
212fn stop_linux(init_system: InitSystem) -> Result<()> {
213 match init_system {
214 InitSystem::Systemd => {
215 let _ =
216 run_checked(Command::new("systemctl").args(["--user", "stop", "zeroclaw.service"]));
217 }
218 InitSystem::Openrc => {
219 let _ = run_checked(Command::new("rc-service").args(["zeroclaw", "stop"]));
220 }
221 InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
222 }
223 println!("✅ Service stopped");
224 Ok(())
225}
226
227pub fn restart(config: &Config, init_system: InitSystem) -> Result<()> {
228 if cfg!(target_os = "macos") {
229 stop(config, init_system)?;
230 start(config, init_system)?;
231 println!("✅ Service restarted");
232 return Ok(());
233 }
234
235 if cfg!(target_os = "linux") {
236 let resolved = init_system.resolve()?;
237 return restart_linux(resolved);
238 }
239
240 if cfg!(target_os = "windows") {
241 stop(config, init_system)?;
242 start(config, init_system)?;
243 println!("✅ Service restarted");
244 return Ok(());
245 }
246
247 anyhow::bail!("Service management is supported on macOS and Linux only")
248}
249
250fn restart_linux(init_system: InitSystem) -> Result<()> {
251 match init_system {
252 InitSystem::Systemd => {
253 run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]))?;
254 run_checked(Command::new("systemctl").args(["--user", "restart", "zeroclaw.service"]))?;
255 }
256 InitSystem::Openrc => {
257 run_checked(Command::new("rc-service").args(["zeroclaw", "restart"]))?;
258 }
259 InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
260 }
261 println!("✅ Service restarted");
262 Ok(())
263}
264
265pub fn status(config: &Config, init_system: InitSystem) -> Result<()> {
266 if cfg!(target_os = "macos") {
267 let out = run_capture(Command::new("launchctl").arg("list"))?;
268 let running = out.lines().any(|line| line.contains(SERVICE_LABEL));
269 println!(
270 "Service: {}",
271 if running {
272 "✅ running/loaded"
273 } else {
274 "❌ not loaded"
275 }
276 );
277 println!("Unit: {}", macos_service_file()?.display().to_string());
278 return Ok(());
279 }
280
281 if cfg!(target_os = "linux") {
282 let resolved = init_system.resolve()?;
283 return status_linux(config, resolved);
284 }
285
286 if cfg!(target_os = "windows") {
287 let _ = config;
288 let task_name = windows_task_name();
289 let out =
290 run_capture(Command::new("schtasks").args(["/Query", "/TN", task_name, "/FO", "LIST"]));
291 match out {
292 Ok(text) => {
293 let running = text.contains("Running");
294 println!(
295 "Service: {}",
296 if running {
297 "✅ running"
298 } else {
299 "❌ not running"
300 }
301 );
302 println!("Task: {}", task_name);
303 }
304 Err(_) => {
305 println!("Service: ❌ not installed");
306 }
307 }
308 return Ok(());
309 }
310
311 anyhow::bail!("Service management is supported on macOS and Linux only")
312}
313
314fn status_linux(config: &Config, init_system: InitSystem) -> Result<()> {
315 match init_system {
316 InitSystem::Systemd => {
317 let out = run_capture(Command::new("systemctl").args([
318 "--user",
319 "is-active",
320 "zeroclaw.service",
321 ]))
322 .unwrap_or_else(|_| "unknown".into());
323 println!("Service state: {}", out.trim());
324 println!(
325 "Unit: {}",
326 linux_service_file(config)?.display().to_string()
327 );
328 }
329 InitSystem::Openrc => {
330 let out = run_capture(Command::new("rc-service").args(["zeroclaw", "status"]))
331 .unwrap_or_else(|_| "unknown".into());
332 println!("Service state: {}", out.trim());
333 println!("Unit: /etc/init.d/zeroclaw");
334 }
335 InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
336 }
337 Ok(())
338}
339
340pub fn logs(config: &Config, init_system: InitSystem, lines: usize, follow: bool) -> Result<()> {
341 if cfg!(target_os = "macos") {
342 return logs_macos(config, lines, follow);
343 }
344 if cfg!(target_os = "linux") {
345 let resolved = init_system.resolve()?;
346 return logs_linux(config, resolved, lines, follow);
347 }
348 if cfg!(target_os = "windows") {
349 return logs_windows(config, lines, follow);
350 }
351 anyhow::bail!("Service log viewing is supported on macOS, Linux, and Windows only")
352}
353
354fn logs_macos(config: &Config, lines: usize, follow: bool) -> Result<()> {
355 let exe = std::env::current_exe().ok();
358 let homebrew_var_dir = exe.as_ref().and_then(|e| homebrew_var_dir_from_exe(e));
359 let logs_dir = if let Some(ref var_dir) = homebrew_var_dir {
360 var_dir.join("logs")
361 } else {
362 config
363 .config_path
364 .parent()
365 .map_or_else(|| PathBuf::from("."), PathBuf::from)
366 .join("logs")
367 };
368
369 let stderr_log = logs_dir.join("daemon.stderr.log");
370 let stdout_log = logs_dir.join("daemon.stdout.log");
371
372 let log_file = if stderr_log.exists() {
374 stderr_log
375 } else if stdout_log.exists() {
376 stdout_log
377 } else {
378 bail!(
379 "No log files found in {}. Is the service installed?",
380 logs_dir.display()
381 );
382 };
383
384 if follow {
385 let status = Command::new("tail")
386 .args(["-n", &lines.to_string(), "-f"])
387 .arg(&log_file)
388 .status()
389 .context("Failed to run tail")?;
390 if !status.success() {
391 bail!("tail exited with non-zero status");
392 }
393 } else {
394 let status = Command::new("tail")
395 .args(["-n", &lines.to_string()])
396 .arg(&log_file)
397 .status()
398 .context("Failed to run tail")?;
399 if !status.success() {
400 bail!("tail exited with non-zero status");
401 }
402 }
403 Ok(())
404}
405
406fn logs_linux(config: &Config, init_system: InitSystem, lines: usize, follow: bool) -> Result<()> {
407 match init_system {
408 InitSystem::Systemd => {
409 let mut args = vec![
410 "--user".to_string(),
411 "-u".to_string(),
412 "zeroclaw.service".to_string(),
413 "-n".to_string(),
414 lines.to_string(),
415 "--no-pager".to_string(),
416 ];
417 if follow {
418 args.push("-f".to_string());
419 }
420 let status = Command::new("journalctl")
421 .args(&args)
422 .status()
423 .context("Failed to run journalctl")?;
424 if !status.success() {
425 bail!("journalctl exited with non-zero status");
426 }
427 }
428 InitSystem::Openrc => {
429 let log_file = Path::new("/var/log/zeroclaw/error.log");
431 if !log_file.exists() {
432 let access_log = Path::new("/var/log/zeroclaw/access.log");
434 if !access_log.exists() {
435 bail!("No log files found at /var/log/zeroclaw/. Is the service installed?");
436 }
437 return tail_file(access_log, lines, follow);
438 }
439 tail_file(log_file, lines, follow)?;
440 }
441 InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
442 }
443 let _ = config;
444 Ok(())
445}
446
447fn logs_windows(config: &Config, lines: usize, follow: bool) -> Result<()> {
448 let logs_dir = config
449 .config_path
450 .parent()
451 .map_or_else(|| PathBuf::from("."), PathBuf::from)
452 .join("logs");
453
454 let stderr_log = logs_dir.join("daemon.stderr.log");
455 let stdout_log = logs_dir.join("daemon.stdout.log");
456
457 let log_file = if stderr_log.exists() {
458 stderr_log
459 } else if stdout_log.exists() {
460 stdout_log
461 } else {
462 bail!(
463 "No log files found in {}. Is the service installed?",
464 logs_dir.display()
465 );
466 };
467
468 if follow {
469 let status = Command::new("powershell")
471 .args([
472 "-Command",
473 &format!(
474 "Get-Content -Path '{}' -Tail {} -Wait",
475 log_file.display().to_string(),
476 lines
477 ),
478 ])
479 .status()
480 .context("Failed to run PowerShell Get-Content")?;
481 if !status.success() {
482 bail!("PowerShell Get-Content exited with non-zero status");
483 }
484 } else {
485 let status = Command::new("powershell")
486 .args([
487 "-Command",
488 &format!(
489 "Get-Content -Path '{}' -Tail {}",
490 log_file.display().to_string(),
491 lines
492 ),
493 ])
494 .status()
495 .context("Failed to run PowerShell Get-Content")?;
496 if !status.success() {
497 bail!("PowerShell Get-Content exited with non-zero status");
498 }
499 }
500 Ok(())
501}
502
503fn tail_file(path: &Path, lines: usize, follow: bool) -> Result<()> {
505 let mut args = vec!["-n".to_string(), lines.to_string()];
506 if follow {
507 args.push("-f".to_string());
508 }
509 let status = Command::new("tail")
510 .args(&args)
511 .arg(path)
512 .status()
513 .context("Failed to run tail")?;
514 if !status.success() {
515 bail!("tail exited with non-zero status");
516 }
517 Ok(())
518}
519
520pub fn uninstall(config: &Config, init_system: InitSystem) -> Result<()> {
521 stop(config, init_system)?;
522
523 if cfg!(target_os = "macos") {
524 let file = macos_service_file()?;
525 if file.exists() {
526 fs::remove_file(&file)
527 .with_context(|| format!("Failed to remove {}", file.display().to_string()))?;
528 }
529 println!("✅ Service uninstalled ({})", file.display().to_string());
530 return Ok(());
531 }
532
533 if cfg!(target_os = "linux") {
534 let resolved = init_system.resolve()?;
535 return uninstall_linux(config, resolved);
536 }
537
538 if cfg!(target_os = "windows") {
539 let task_name = windows_task_name();
540 let _ = run_checked(Command::new("schtasks").args(["/Delete", "/TN", task_name, "/F"]));
541 let base_dir = config
545 .config_path
546 .parent()
547 .map_or_else(|| PathBuf::from("."), PathBuf::from);
548 for wrapper in [
549 base_dir.join("zeroclaw-daemon.cmd"),
550 base_dir.join("logs").join("zeroclaw-daemon.cmd"),
551 ] {
552 if wrapper.exists() {
553 fs::remove_file(&wrapper).ok();
554 }
555 }
556 println!("✅ Service uninstalled");
557 return Ok(());
558 }
559
560 anyhow::bail!("Service management is supported on macOS and Linux only")
561}
562
563fn uninstall_linux(config: &Config, init_system: InitSystem) -> Result<()> {
564 match init_system {
565 InitSystem::Systemd => {
566 let file = linux_service_file(config)?;
567 if file.exists() {
568 fs::remove_file(&file)
569 .with_context(|| format!("Failed to remove {}", file.display().to_string()))?;
570 }
571 let _ = run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]));
572 println!("✅ Service uninstalled ({})", file.display().to_string());
573 }
574 InitSystem::Openrc => {
575 let init_script = Path::new("/etc/init.d/zeroclaw");
576 if init_script.exists() {
577 if let Err(err) =
578 run_checked(Command::new("rc-update").args(["del", "zeroclaw", "default"]))
579 {
580 eprintln!(
581 "⚠️ Warning: Could not remove zeroclaw from OpenRC default runlevel: {err}"
582 );
583 }
584 fs::remove_file(init_script).with_context(|| {
585 format!("Failed to remove {}", init_script.display().to_string())
586 })?;
587 }
588 println!("✅ Service uninstalled (/etc/init.d/zeroclaw)");
589 }
590 InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
591 }
592 Ok(())
593}
594
595pub fn homebrew_var_dir_from_exe(exe: &Path) -> Option<PathBuf> {
602 let resolved = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
603 let exe = resolved.as_path();
604
605 if let Some(cellar) = exe
606 .ancestors()
607 .find(|path| path.file_name().is_some_and(|name| name == "Cellar"))
608 {
609 return cellar
610 .parent()
611 .map(|prefix| prefix.join("var").join("zeroclaw"));
612 }
613
614 let prefix = exe.parent()?.parent()?;
615 prefix
616 .join("Cellar")
617 .is_dir()
618 .then(|| prefix.join("var").join("zeroclaw"))
619}
620
621#[cfg(test)]
622mod homebrew_tests {
623 use super::*;
624
625 #[test]
626 fn homebrew_var_dir_from_exe_detects_cellar_path() {
627 let exe = PathBuf::from("/opt/homebrew/Cellar/zeroclaw/1.2.3/bin/zeroclaw");
628 let var_dir = homebrew_var_dir_from_exe(&exe);
629 assert_eq!(var_dir, Some(PathBuf::from("/opt/homebrew/var/zeroclaw")));
630 }
631
632 #[test]
633 fn homebrew_var_dir_from_exe_detects_intel_cellar_path() {
634 let exe = PathBuf::from("/usr/local/Cellar/zeroclaw/1.0.0/bin/zeroclaw");
635 let var_dir = homebrew_var_dir_from_exe(&exe);
636 assert_eq!(var_dir, Some(PathBuf::from("/usr/local/var/zeroclaw")));
637 }
638
639 #[test]
640 fn homebrew_var_dir_from_exe_ignores_non_homebrew_path() {
641 let exe = PathBuf::from("/home/user/.cargo/bin/zeroclaw");
642 let var_dir = homebrew_var_dir_from_exe(&exe);
643 assert_eq!(var_dir, None);
644 }
645
646 #[cfg(unix)]
647 #[test]
648 fn homebrew_var_dir_from_exe_detects_opt_symlink_layout() {
649 let temp = tempfile::tempdir().expect("tempdir");
650 let prefix = temp.path().join("homebrew");
651 let cellar_bin = prefix.join("Cellar/zeroclaw/1.2.3/bin");
652 std::fs::create_dir_all(&cellar_bin).expect("create Cellar binary dir");
653 let cellar_exe = cellar_bin.join("zeroclaw");
654 std::fs::write(&cellar_exe, "").expect("create fake executable");
655
656 let opt_parent = prefix.join("opt");
657 std::fs::create_dir_all(&opt_parent).expect("create opt dir");
658 std::os::unix::fs::symlink(
659 prefix.join("Cellar/zeroclaw/1.2.3"),
660 opt_parent.join("zeroclaw"),
661 )
662 .expect("create opt symlink");
663
664 let expected_prefix = prefix
665 .canonicalize()
666 .expect("canonicalize fake Homebrew prefix");
667 let var_dir = homebrew_var_dir_from_exe(&prefix.join("opt/zeroclaw/bin/zeroclaw"));
668 assert_eq!(var_dir, Some(expected_prefix.join("var/zeroclaw")));
669 }
670}
671
672fn install_macos(config: &Config) -> Result<()> {
673 let file = macos_service_file()?;
674 if let Some(parent) = file.parent() {
675 fs::create_dir_all(parent)?;
676 }
677
678 let exe = std::env::current_exe().context("Failed to resolve current executable")?;
679
680 let homebrew_var_dir = homebrew_var_dir_from_exe(&exe);
683 if let Some(ref var_dir) = homebrew_var_dir {
684 fs::create_dir_all(var_dir).with_context(|| {
685 format!(
686 "Failed to create Homebrew var directory: {}",
687 var_dir.display()
688 )
689 })?;
690 }
691
692 let logs_dir = if let Some(ref var_dir) = homebrew_var_dir {
693 var_dir.join("logs")
694 } else {
695 config
696 .config_path
697 .parent()
698 .map_or_else(|| PathBuf::from("."), PathBuf::from)
699 .join("logs")
700 };
701 fs::create_dir_all(&logs_dir)?;
702
703 let stdout = logs_dir.join("daemon.stdout.log");
704 let stderr = logs_dir.join("daemon.stderr.log");
705
706 let plist =
707 render_macos_launch_agent_plist(&exe, &stdout, &stderr, homebrew_var_dir.as_deref());
708
709 fs::write(&file, plist)?;
710 println!("✅ Installed launchd service: {}", file.display());
711 if let Some(ref var_dir) = homebrew_var_dir {
712 println!(" Homebrew var: {}", var_dir.display());
713 }
714 println!(" Start with: zeroclaw service start");
715 Ok(())
716}
717
718fn render_macos_launch_agent_plist(
721 exe: &Path,
722 stdout: &Path,
723 stderr: &Path,
724 homebrew_var_dir: Option<&Path>,
725) -> String {
726 let env_section = if let Some(var_dir) = homebrew_var_dir {
729 format!(
730 r#" <key>EnvironmentVariables</key>
731 <dict>
732 <key>ZEROCLAW_CONFIG_DIR</key>
733 <string>{config_dir}</string>
734 </dict>
735 <key>WorkingDirectory</key>
736 <string>{working_dir}</string>
737"#,
738 config_dir = xml_escape(&var_dir.display().to_string()),
739 working_dir = xml_escape(&var_dir.display().to_string()),
740 )
741 } else {
742 String::new()
743 };
744
745 format!(
746 r#"<?xml version="1.0" encoding="UTF-8"?>
747<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
748<plist version="1.0">
749<dict>
750 <key>Label</key>
751 <string>{label}</string>
752 <key>ProgramArguments</key>
753 <array>
754 <string>{exe}</string>
755 <string>daemon</string>
756 </array>
757 <key>RunAtLoad</key>
758 <true/>
759 <key>KeepAlive</key>
760 <true/>
761{env_section} <key>StandardOutPath</key>
762 <string>{stdout}</string>
763 <key>StandardErrorPath</key>
764 <string>{stderr}</string>
765</dict>
766</plist>
767"#,
768 label = SERVICE_LABEL,
769 exe = xml_escape(&exe.display().to_string()),
770 env_section = env_section,
771 stdout = xml_escape(&stdout.display().to_string()),
772 stderr = xml_escape(&stderr.display().to_string())
773 )
774}
775
776fn install_linux(config: &Config, init_system: InitSystem) -> Result<()> {
777 match init_system {
778 InitSystem::Systemd => install_linux_systemd(config),
779 InitSystem::Openrc => install_linux_openrc(config),
780 InitSystem::Auto => unreachable!("Auto should be resolved before this point"),
781 }
782}
783
784fn install_linux_systemd(config: &Config) -> Result<()> {
785 let file = linux_service_file(config)?;
786 if let Some(parent) = file.parent() {
787 fs::create_dir_all(parent)?;
788 }
789
790 let exe = std::env::current_exe().context("Failed to resolve current executable")?;
791 let unit = format!(
792 "[Unit]\n\
793 Description=ZeroClaw daemon\n\
794 After=network.target\n\
795 \n\
796 [Service]\n\
797 Type=simple\n\
798 ExecStart={exe} daemon\n\
799 Restart=always\n\
800 RestartSec=3\n\
801 # Ensure HOME is set so headless browsers can create profile/cache dirs.\n\
802 Environment=HOME=%h\n\
803 # Allow inheriting DISPLAY and XDG_RUNTIME_DIR from the user session\n\
804 # so graphical/headless browsers can function correctly.\n\
805 PassEnvironment=DISPLAY XDG_RUNTIME_DIR\n\
806 \n\
807 [Install]\n\
808 WantedBy=default.target\n",
809 exe = exe.display()
810 );
811
812 fs::write(&file, unit)?;
813 let _ = run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]));
814 let _ = run_checked(Command::new("systemctl").args(["--user", "enable", "zeroclaw.service"]));
815 println!(
816 "✅ Installed systemd user service: {}",
817 file.display().to_string()
818 );
819 println!(" Start with: zeroclaw service start");
820 Ok(())
821}
822
823#[cfg(unix)]
825fn is_root() -> bool {
826 unsafe { libc::getuid() == 0 }
830}
831
832#[cfg(not(unix))]
833fn is_root() -> bool {
834 false
835}
836
837fn check_zeroclaw_user() -> Result<()> {
841 let output = Command::new("getent").args(["passwd", "zeroclaw"]).output();
842 let is_alpine = Path::new("/etc/alpine-release").exists();
843
844 let (del_cmd, add_cmd) = if is_alpine {
845 (
846 "deluser zeroclaw && delgroup zeroclaw",
847 "addgroup -S zeroclaw && adduser -S -s /sbin/nologin -H -D -G zeroclaw zeroclaw",
848 )
849 } else {
850 ("userdel zeroclaw", "useradd -r -s /sbin/nologin zeroclaw")
851 };
852
853 match output {
854 Ok(output) if output.status.success() => {
855 let passwd_entry = String::from_utf8_lossy(&output.stdout);
856 let parts: Vec<&str> = passwd_entry.split(':').collect();
857 if parts.len() >= 7 {
858 let uid = parts[2];
859 let gid = parts[3];
860 let home = parts[5];
861 let shell = parts[6];
862
863 if uid.parse::<u32>().unwrap_or(999) >= 1000 {
864 bail!(
865 "User 'zeroclaw' exists but has unexpected UID {} (expected system UID < 1000).\n\
866 Recreate with: sudo {} && sudo {}",
867 uid,
868 del_cmd,
869 add_cmd
870 );
871 }
872
873 if !shell.contains("nologin") && !shell.contains("false") {
874 bail!(
875 "User 'zeroclaw' exists but has unexpected shell '{}'.\n\
876 Expected nologin/false for security. Fix with: sudo {} && sudo {}",
877 shell,
878 del_cmd,
879 add_cmd
880 );
881 }
882
883 if home != "/var/lib/zeroclaw" && home != "/nonexistent" {
884 eprintln!(
885 "⚠️ Warning: zeroclaw user has home directory '{}' (expected /var/lib/zeroclaw or /nonexistent)",
886 home
887 );
888 }
889
890 let _ = gid;
891 }
892 Ok(())
893 }
894 _ => Ok(()),
895 }
896}
897
898fn ensure_zeroclaw_user() -> Result<()> {
899 let output = Command::new("getent").args(["passwd", "zeroclaw"]).output();
900 if let Ok(output) = output
901 && output.status.success()
902 {
903 return check_zeroclaw_user();
904 }
905
906 let is_alpine = Path::new("/etc/alpine-release").exists();
907
908 if is_alpine {
909 let group_output = Command::new("getent").args(["group", "zeroclaw"]).output();
910 let group_exists = group_output.map(|o| o.status.success()).unwrap_or(false);
911
912 if !group_exists {
913 let output = Command::new("addgroup")
914 .args(["-S", "zeroclaw"])
915 .output()
916 .context("Failed to create zeroclaw group")?;
917
918 if !output.status.success() {
919 let stderr = String::from_utf8_lossy(&output.stderr);
920 bail!("Failed to create zeroclaw group: {}", stderr.trim());
921 }
922 println!("✅ Created system group: zeroclaw");
923 }
924
925 let output = Command::new("adduser")
926 .args([
927 "-S",
928 "-s",
929 "/sbin/nologin",
930 "-H",
931 "-D",
932 "-G",
933 "zeroclaw",
934 "zeroclaw",
935 ])
936 .output()
937 .context("Failed to create zeroclaw user")?;
938
939 if !output.status.success() {
940 let stderr = String::from_utf8_lossy(&output.stderr);
941 bail!("Failed to create zeroclaw user: {}", stderr.trim());
942 }
943 } else {
944 let output = Command::new("useradd")
945 .args(["-r", "-s", "/sbin/nologin", "zeroclaw"])
946 .output()
947 .context("Failed to create zeroclaw user")?;
948
949 if !output.status.success() {
950 let stderr = String::from_utf8_lossy(&output.stderr);
951 bail!("Failed to create zeroclaw user: {}", stderr.trim());
952 }
953 }
954
955 println!("✅ Created system user: zeroclaw");
956 Ok(())
957}
958
959#[cfg(unix)]
961fn chown_to_zeroclaw(path: &Path) -> Result<()> {
962 let output = Command::new("chown")
963 .args(["zeroclaw:zeroclaw", &path.to_string_lossy()])
964 .output()
965 .context("Failed to run chown")?;
966
967 if !output.status.success() {
968 let stderr = String::from_utf8_lossy(&output.stderr);
969 bail!(
970 "Failed to change ownership of {} to zeroclaw:zeroclaw: {}",
971 path.display().to_string(),
972 stderr.trim(),
973 );
974 }
975 Ok(())
976}
977
978#[cfg(not(unix))]
979fn chown_to_zeroclaw(_path: &Path) -> Result<()> {
980 Ok(())
981}
982
983#[cfg(unix)]
984fn chown_recursive_to_zeroclaw(path: &Path) -> Result<()> {
985 let output = Command::new("chown")
986 .args(["-R", "zeroclaw:zeroclaw", &path.to_string_lossy()])
987 .output()
988 .context("Failed to run recursive chown")?;
989
990 if !output.status.success() {
991 let stderr = String::from_utf8_lossy(&output.stderr);
992 bail!(
993 "Failed to recursively change ownership of {} to zeroclaw:zeroclaw: {}",
994 path.display().to_string(),
995 stderr.trim(),
996 );
997 }
998
999 Ok(())
1000}
1001
1002#[cfg(not(unix))]
1003fn chown_recursive_to_zeroclaw(_path: &Path) -> Result<()> {
1004 Ok(())
1005}
1006
1007fn copy_dir_recursive(source: &Path, target: &Path) -> Result<()> {
1008 fs::create_dir_all(target).with_context(|| {
1009 format!(
1010 "Failed to create directory {}",
1011 target.display().to_string()
1012 )
1013 })?;
1014
1015 for entry in fs::read_dir(source)
1016 .with_context(|| format!("Failed to read directory {}", source.display().to_string()))?
1017 {
1018 let entry = entry?;
1019 let source_path = entry.path();
1020 let target_path = target.join(entry.file_name());
1021 let file_type = entry
1022 .file_type()
1023 .with_context(|| format!("Failed to inspect {}", source_path.display().to_string()))?;
1024
1025 if file_type.is_dir() {
1026 copy_dir_recursive(&source_path, &target_path)?;
1027 } else if file_type.is_file() {
1028 if target_path.exists() {
1029 continue;
1030 }
1031 fs::copy(&source_path, &target_path).with_context(|| {
1032 format!(
1033 "Failed to copy file {} -> {}",
1034 source_path.display().to_string(),
1035 target_path.display()
1036 )
1037 })?;
1038 }
1039 }
1040
1041 Ok(())
1042}
1043
1044fn resolve_invoking_user_config_dir() -> Option<PathBuf> {
1045 let sudo_user = std::env::var("SUDO_USER")
1046 .ok()
1047 .map(|value| value.trim().to_string())
1048 .filter(|value| !value.is_empty() && value != "root");
1049
1050 if let Some(user) = sudo_user
1051 && let Ok(output) = Command::new("getent").args(["passwd", &user]).output()
1052 && output.status.success()
1053 {
1054 let entry = String::from_utf8_lossy(&output.stdout);
1055 let fields: Vec<&str> = entry.trim().split(':').collect();
1056 if fields.len() >= 6 {
1057 return Some(PathBuf::from(fields[5]).join(".zeroclaw"));
1058 }
1059 }
1060
1061 std::env::var("HOME")
1062 .ok()
1063 .map(PathBuf::from)
1064 .map(|home| home.join(".zeroclaw"))
1065}
1066
1067fn migrate_openrc_runtime_state_if_needed(config_dir: &Path) -> Result<()> {
1068 let target_config = config_dir.join("config.toml");
1069 if target_config.exists() {
1070 println!(
1071 "✅ Reusing existing OpenRC config at {}",
1072 target_config.display()
1073 );
1074 return Ok(());
1075 }
1076
1077 let Some(source_dir) = resolve_invoking_user_config_dir() else {
1078 return Ok(());
1079 };
1080
1081 let source_config = source_dir.join("config.toml");
1082 if !source_config.exists() {
1083 return Ok(());
1084 }
1085
1086 copy_dir_recursive(&source_dir, config_dir)?;
1087 println!(
1088 "✅ Migrated runtime state from {} to {}",
1089 source_dir.display().to_string(),
1090 config_dir.display()
1091 );
1092 Ok(())
1093}
1094
1095#[cfg(unix)]
1096fn shell_single_quote(raw: &str) -> String {
1097 format!("'{}'", raw.replace('\'', "'\"'\"'"))
1098}
1099
1100#[cfg(unix)]
1101fn build_openrc_writability_probe_command(path: &Path, has_runuser: bool) -> (String, Vec<String>) {
1102 let probe = format!("test -w {}", shell_single_quote(&path.to_string_lossy()));
1103 if has_runuser {
1104 (
1105 "runuser".to_string(),
1106 vec![
1107 "-u".to_string(),
1108 "zeroclaw".to_string(),
1109 "--".to_string(),
1110 "sh".to_string(),
1111 "-c".to_string(),
1112 probe,
1113 ],
1114 )
1115 } else {
1116 (
1117 "su".to_string(),
1118 vec![
1119 "-s".to_string(),
1120 "/bin/sh".to_string(),
1121 "-c".to_string(),
1122 probe,
1123 "zeroclaw".to_string(),
1124 ],
1125 )
1126 }
1127}
1128
1129#[cfg(unix)]
1130fn ensure_openrc_runtime_path_writable(path: &Path) -> Result<()> {
1131 let has_runuser = which::which("runuser").is_ok();
1132 let (program, args) = build_openrc_writability_probe_command(path, has_runuser);
1133 let output = Command::new(&program)
1134 .args(args.iter().map(String::as_str))
1135 .output()
1136 .with_context(|| {
1137 format!(
1138 "Failed to verify OpenRC runtime write access for {}",
1139 path.display()
1140 )
1141 })?;
1142
1143 if !output.status.success() {
1144 let stderr = String::from_utf8_lossy(&output.stderr);
1145 let details = if stderr.trim().is_empty() {
1146 "write-access probe failed"
1147 } else {
1148 stderr.trim()
1149 };
1150 bail!(
1151 "OpenRC runtime user 'zeroclaw' cannot write {} ({details}). \
1152 Re-run `sudo zeroclaw service install` and ensure ownership is zeroclaw:zeroclaw.",
1153 path.display().to_string(),
1154 );
1155 }
1156
1157 Ok(())
1158}
1159
1160#[cfg(unix)]
1161fn ensure_openrc_runtime_dirs_writable(
1162 config_dir: &Path,
1163 workspace_dir: &Path,
1164 log_dir: &Path,
1165) -> Result<()> {
1166 for path in [config_dir, workspace_dir, log_dir] {
1167 ensure_openrc_runtime_path_writable(path)?;
1168 }
1169 Ok(())
1170}
1171
1172#[cfg(not(unix))]
1173fn ensure_openrc_runtime_dirs_writable(
1174 _config_dir: &Path,
1175 _workspace_dir: &Path,
1176 _log_dir: &Path,
1177) -> Result<()> {
1178 Ok(())
1179}
1180
1181fn warn_if_binary_in_home(exe_path: &Path) {
1183 let path_str = exe_path.to_string_lossy();
1184 if path_str.contains("/home/") || path_str.contains(".cargo/bin") {
1185 eprintln!(
1186 "⚠️ Warning: Binary path '{}' appears to be in a user home directory.\n\
1187 For system-wide OpenRC service, consider installing to /usr/local/bin:\n\
1188 sudo cp '{}' /usr/local/bin/zeroclaw",
1189 exe_path.display().to_string(),
1190 exe_path.display()
1191 );
1192 }
1193}
1194
1195fn generate_openrc_script(exe_path: &Path, config_dir: &Path) -> String {
1197 format!(
1198 r#"#!/sbin/openrc-run
1199
1200name="zeroclaw"
1201description="ZeroClaw daemon"
1202
1203command="{exe}"
1204command_args="--config-dir {config_dir} daemon"
1205command_background="yes"
1206command_user="zeroclaw:zeroclaw"
1207pidfile="/run/${{RC_SVCNAME}}.pid"
1208umask 027
1209output_log="/var/log/zeroclaw/access.log"
1210error_log="/var/log/zeroclaw/error.log"
1211
1212# Provide HOME so headless browsers can create profile/cache directories.
1213# Without this, Chromium/Firefox fail with sandbox or profile errors.
1214export HOME="/var/lib/zeroclaw"
1215
1216depend() {{
1217 need net
1218 after firewall
1219}}
1220
1221start_pre() {{
1222 checkpath --directory --owner zeroclaw:zeroclaw --mode 0750 /var/lib/zeroclaw
1223}}
1224"#,
1225 exe = exe_path.display().to_string(),
1226 config_dir = config_dir.display().to_string(),
1227 )
1228}
1229
1230fn resolve_openrc_executable() -> Result<PathBuf> {
1231 let preferred = Path::new("/usr/local/bin/zeroclaw");
1232 if preferred.exists() {
1233 return Ok(preferred.to_path_buf());
1234 }
1235
1236 let exe = std::env::current_exe().context("Failed to resolve current executable")?;
1237 Ok(exe)
1238}
1239
1240fn install_linux_openrc(config: &Config) -> Result<()> {
1241 if !is_root() {
1242 bail!(
1243 "OpenRC service installation requires root privileges.\n\
1244 Please run with sudo: sudo zeroclaw service install"
1245 );
1246 }
1247
1248 ensure_zeroclaw_user()?;
1249
1250 let exe = resolve_openrc_executable()?;
1251 warn_if_binary_in_home(&exe);
1252
1253 let config_dir = Path::new("/etc/zeroclaw");
1254 let workspace_dir = config_dir.join("workspace");
1255 let log_dir = Path::new("/var/log/zeroclaw");
1256
1257 if !config_dir.exists() {
1258 fs::create_dir_all(config_dir)
1259 .with_context(|| format!("Failed to create {}", config_dir.display().to_string()))?;
1260 #[cfg(unix)]
1261 {
1262 use std::os::unix::fs::PermissionsExt;
1263 fs::set_permissions(config_dir, fs::Permissions::from_mode(0o755)).with_context(
1264 || {
1265 format!(
1266 "Failed to set permissions on {}",
1267 config_dir.display().to_string()
1268 )
1269 },
1270 )?;
1271 }
1272 println!("✅ Created directory: {}", config_dir.display().to_string());
1273 }
1274
1275 migrate_openrc_runtime_state_if_needed(config_dir)?;
1276
1277 if !workspace_dir.exists() {
1278 fs::create_dir_all(&workspace_dir)
1279 .with_context(|| format!("Failed to create {}", workspace_dir.display().to_string()))?;
1280 #[cfg(unix)]
1281 {
1282 use std::os::unix::fs::PermissionsExt;
1283 fs::set_permissions(&workspace_dir, fs::Permissions::from_mode(0o750)).with_context(
1284 || {
1285 format!(
1286 "Failed to set permissions on {}",
1287 workspace_dir.display().to_string()
1288 )
1289 },
1290 )?;
1291 }
1292 chown_to_zeroclaw(&workspace_dir)?;
1293 println!(
1294 "✅ Created directory: {} (owned by zeroclaw:zeroclaw)",
1295 workspace_dir.display()
1296 );
1297 }
1298
1299 #[cfg(unix)]
1300 {
1301 use std::os::unix::fs::PermissionsExt;
1302 fs::set_permissions(&workspace_dir, fs::Permissions::from_mode(0o750)).with_context(
1303 || {
1304 format!(
1305 "Failed to set permissions on {}",
1306 workspace_dir.display().to_string()
1307 )
1308 },
1309 )?;
1310 }
1311
1312 #[cfg(unix)]
1313 {
1314 use std::os::unix::fs::PermissionsExt;
1315 fs::set_permissions(config_dir, fs::Permissions::from_mode(0o755)).with_context(|| {
1316 format!(
1317 "Failed to set permissions on {}",
1318 config_dir.display().to_string()
1319 )
1320 })?;
1321 let config_path = config_dir.join("config.toml");
1322 if config_path.exists() {
1323 fs::set_permissions(&config_path, fs::Permissions::from_mode(0o600)).with_context(
1324 || {
1325 format!(
1326 "Failed to set permissions on {}",
1327 config_path.display().to_string()
1328 )
1329 },
1330 )?;
1331 }
1332 let secret_key_path = config_dir.join(".secret_key");
1333 if secret_key_path.exists() {
1334 fs::set_permissions(&secret_key_path, fs::Permissions::from_mode(0o600)).with_context(
1335 || {
1336 format!(
1337 "Failed to set permissions on {}",
1338 secret_key_path.display().to_string()
1339 )
1340 },
1341 )?;
1342 }
1343 }
1344
1345 chown_recursive_to_zeroclaw(config_dir)?;
1346
1347 let created_log_dir = !log_dir.exists();
1348 if created_log_dir {
1349 fs::create_dir_all(log_dir)
1350 .with_context(|| format!("Failed to create {}", log_dir.display().to_string()))?;
1351 #[cfg(unix)]
1352 {
1353 use std::os::unix::fs::PermissionsExt;
1354 fs::set_permissions(log_dir, fs::Permissions::from_mode(0o750)).with_context(|| {
1355 format!(
1356 "Failed to set permissions on {}",
1357 log_dir.display().to_string()
1358 )
1359 })?;
1360 }
1361 }
1362
1363 chown_to_zeroclaw(log_dir)?;
1364
1365 ensure_openrc_runtime_dirs_writable(config_dir, &workspace_dir, log_dir)?;
1366
1367 if created_log_dir {
1368 println!(
1369 "✅ Created directory: {} (owned by zeroclaw:zeroclaw)",
1370 log_dir.display()
1371 );
1372 }
1373
1374 let init_script = generate_openrc_script(&exe, config_dir);
1375 let init_path = Path::new("/etc/init.d/zeroclaw");
1376 fs::write(init_path, init_script)
1377 .with_context(|| format!("Failed to write {}", init_path.display().to_string()))?;
1378
1379 #[cfg(unix)]
1380 {
1381 use std::os::unix::fs::PermissionsExt;
1382 fs::set_permissions(init_path, fs::Permissions::from_mode(0o755)).with_context(|| {
1383 format!(
1384 "Failed to set permissions on {}",
1385 init_path.display().to_string()
1386 )
1387 })?;
1388 }
1389
1390 run_checked(Command::new("rc-update").args(["add", "zeroclaw", "default"]))?;
1391 println!("✅ Installed OpenRC service: /etc/init.d/zeroclaw");
1392 println!(" Config path: /etc/zeroclaw/config.toml");
1393 println!(" Start with: sudo zeroclaw service start");
1394 let _ = config;
1395 Ok(())
1396}
1397
1398fn install_windows(config: &Config) -> Result<()> {
1399 let exe = std::env::current_exe().context("Failed to resolve current executable")?;
1400 let base_dir = config
1401 .config_path
1402 .parent()
1403 .map_or_else(|| PathBuf::from("."), PathBuf::from);
1404 let logs_dir = base_dir.join("logs");
1405 fs::create_dir_all(&logs_dir)?;
1406
1407 let wrapper = base_dir.join("zeroclaw-daemon.cmd");
1412 let stdout_log = logs_dir.join("daemon.stdout.log");
1413 let stderr_log = logs_dir.join("daemon.stderr.log");
1414
1415 let wrapper_content = format!(
1416 "@echo off\r\n\"{}\" daemon >>\"{}\" 2>>\"{}\"",
1417 exe.display().to_string(),
1418 stdout_log.display().to_string(),
1419 stderr_log.display()
1420 );
1421 fs::write(&wrapper, &wrapper_content)?;
1422
1423 let task_name = windows_task_name();
1424
1425 let _ = Command::new("schtasks")
1427 .args(["/Delete", "/TN", task_name, "/F"])
1428 .output();
1429
1430 run_checked(Command::new("schtasks").args([
1437 "/Create",
1438 "/TN",
1439 task_name,
1440 "/SC",
1441 "ONLOGON",
1442 "/TR",
1443 &format!("\"{}\"", wrapper.display().to_string()),
1444 "/RL",
1445 "LIMITED",
1446 "/F",
1447 ]))?;
1448
1449 println!("✅ Installed Windows scheduled task: {}", task_name);
1450 println!(" Wrapper: {}", wrapper.display().to_string());
1451 println!(" Logs: {}", logs_dir.display().to_string());
1452 println!(" Start with: zeroclaw service start");
1453 Ok(())
1454}
1455
1456fn macos_service_file() -> Result<PathBuf> {
1457 let home = directories::UserDirs::new()
1458 .map(|u| u.home_dir().to_path_buf())
1459 .context("Could not find home directory")?;
1460 Ok(home
1461 .join("Library")
1462 .join("LaunchAgents")
1463 .join(format!("{SERVICE_LABEL}.plist")))
1464}
1465
1466fn linux_service_file(config: &Config) -> Result<PathBuf> {
1467 let home = directories::UserDirs::new()
1468 .map(|u| u.home_dir().to_path_buf())
1469 .context("Could not find home directory")?;
1470 let _ = config;
1471 Ok(home
1472 .join(".config")
1473 .join("systemd")
1474 .join("user")
1475 .join("zeroclaw.service"))
1476}
1477
1478fn run_checked(command: &mut Command) -> Result<()> {
1479 let output = command.output().context("Failed to spawn command")?;
1480 if !output.status.success() {
1481 let stderr = String::from_utf8_lossy(&output.stderr);
1482 anyhow::bail!("Command failed: {}", stderr.trim());
1483 }
1484 Ok(())
1485}
1486
1487pub fn run_capture(command: &mut Command) -> Result<String> {
1488 let output = command.output().context("Failed to spawn command")?;
1489 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
1490 if text.trim().is_empty() {
1491 text = String::from_utf8_lossy(&output.stderr).to_string();
1492 }
1493 Ok(text)
1494}
1495
1496pub fn xml_escape(raw: &str) -> String {
1497 raw.replace('&', "&")
1498 .replace('<', "<")
1499 .replace('>', ">")
1500 .replace('"', """)
1501 .replace('\'', "'")
1502}
1503
1504#[cfg(test)]
1507mod macos_plist_tests {
1508 use super::*;
1509
1510 #[test]
1511 fn macos_plist_renderer_uses_plain_xml_quotes() {
1512 let plist = render_macos_launch_agent_plist(
1513 Path::new("/opt/homebrew/bin/zeroclaw"),
1514 Path::new("/opt/homebrew/var/zeroclaw/logs/daemon.stdout.log"),
1515 Path::new("/opt/homebrew/var/zeroclaw/logs/daemon.stderr.log"),
1516 Some(Path::new("/opt/homebrew/var/zeroclaw")),
1517 );
1518
1519 assert!(!plist.contains(r#"\""#));
1520 assert!(plist.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
1521 assert!(plist.contains(
1522 r#"<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">"#
1523 ));
1524 assert!(plist.contains(r#"<plist version="1.0">"#));
1525 assert!(plist.contains("<key>EnvironmentVariables</key>"));
1526 }
1527
1528 #[test]
1529 fn macos_plist_renderer_escapes_paths_and_omits_homebrew_section_when_absent() {
1530 let plist = render_macos_launch_agent_plist(
1531 Path::new("/tmp/Zero<&>\"'Claw/bin/zeroclaw"),
1532 Path::new("/tmp/Zero<&>\"'Claw/logs/daemon.stdout.log"),
1533 Path::new("/tmp/Zero<&>\"'Claw/logs/daemon.stderr.log"),
1534 None,
1535 );
1536
1537 assert!(plist.contains("/tmp/Zero<&>"'Claw/bin/zeroclaw"));
1538 assert!(plist.contains("/tmp/Zero<&>"'Claw/logs/daemon.stdout.log"));
1539 assert!(plist.contains("/tmp/Zero<&>"'Claw/logs/daemon.stderr.log"));
1540 assert!(!plist.contains("<key>EnvironmentVariables</key>"));
1541 assert!(!plist.contains("<key>WorkingDirectory</key>"));
1542 }
1543
1544 #[cfg(target_os = "macos")]
1545 #[test]
1546 fn macos_plist_renderer_emits_plutil_parseable_xml() {
1547 let plist = render_macos_launch_agent_plist(
1548 Path::new("/tmp/Zero<&>\"'Claw/bin/zeroclaw"),
1549 Path::new("/tmp/Zero<&>\"'Claw/logs/daemon.stdout.log"),
1550 Path::new("/tmp/Zero<&>\"'Claw/logs/daemon.stderr.log"),
1551 Some(Path::new("/tmp/Zero<&>\"'Claw/var/zeroclaw")),
1552 );
1553
1554 let file = std::env::temp_dir().join(format!(
1555 "zeroclaw-launch-agent-plist-{}.plist",
1556 std::process::id()
1557 ));
1558 fs::write(&file, plist).expect("write plist fixture");
1559
1560 let output = Command::new("plutil")
1561 .arg("-lint")
1562 .arg(&file)
1563 .output()
1564 .expect("run plutil");
1565 let _ = fs::remove_file(&file);
1566
1567 assert!(
1568 output.status.success(),
1569 "plutil failed\nstdout:\n{}\nstderr:\n{}",
1570 String::from_utf8_lossy(&output.stdout),
1571 String::from_utf8_lossy(&output.stderr)
1572 );
1573 }
1574}
1575
1576#[cfg(all(test, zeroclaw_root_crate))]
1577mod tests {
1578 use super::*;
1579
1580 #[test]
1581 fn xml_escape_escapes_reserved_chars() {
1582 let escaped = xml_escape("<&>\"' and text");
1583 assert_eq!(escaped, "<&>"' and text");
1584 }
1585
1586 #[cfg(not(target_os = "windows"))]
1587 #[test]
1588 fn run_capture_reads_stdout() {
1589 let out = run_capture(Command::new("sh").args(["-c", "echo hello"]))
1590 .expect("stdout capture should succeed");
1591 assert_eq!(out.trim(), "hello");
1592 }
1593
1594 #[cfg(not(target_os = "windows"))]
1595 #[test]
1596 fn run_capture_falls_back_to_stderr() {
1597 let out = run_capture(Command::new("sh").args(["-c", "echo warn 1>&2"]))
1598 .expect("stderr capture should succeed");
1599 assert_eq!(out.trim(), "warn");
1600 }
1601
1602 #[cfg(not(target_os = "windows"))]
1603 #[test]
1604 fn run_checked_errors_on_non_zero_status() {
1605 let err = run_checked(Command::new("sh").args(["-c", "exit 17"]))
1606 .expect_err("non-zero exit should error");
1607 assert!(err.to_string().contains("Command failed"));
1608 }
1609
1610 #[cfg(not(target_os = "windows"))]
1611 #[test]
1612 fn linux_service_file_has_expected_suffix() {
1613 let file = linux_service_file(&Config::default()).unwrap();
1614 let path = file.to_string_lossy();
1615 assert!(path.ends_with(".config/systemd/user/zeroclaw.service"));
1616 }
1617
1618 #[test]
1619 fn windows_task_name_is_constant() {
1620 assert_eq!(windows_task_name(), "ZeroClaw Daemon");
1621 }
1622
1623 #[cfg(target_os = "windows")]
1624 #[test]
1625 fn run_capture_reads_stdout_windows() {
1626 let out = run_capture(Command::new("cmd").args(["/C", "echo hello"]))
1627 .expect("stdout capture should succeed");
1628 assert_eq!(out.trim(), "hello");
1629 }
1630
1631 #[cfg(target_os = "windows")]
1632 #[test]
1633 fn run_checked_errors_on_non_zero_status_windows() {
1634 let err = run_checked(Command::new("cmd").args(["/C", "exit /b 17"]))
1635 .expect_err("non-zero exit should error");
1636 assert!(err.to_string().contains("Command failed"));
1637 }
1638
1639 #[test]
1640 fn init_system_from_str_parses_valid_values() {
1641 assert_eq!("auto".parse::<InitSystem>().unwrap(), InitSystem::Auto);
1642 assert_eq!("AUTO".parse::<InitSystem>().unwrap(), InitSystem::Auto);
1643 assert_eq!(
1644 "systemd".parse::<InitSystem>().unwrap(),
1645 InitSystem::Systemd
1646 );
1647 assert_eq!(
1648 "SYSTEMD".parse::<InitSystem>().unwrap(),
1649 InitSystem::Systemd
1650 );
1651 assert_eq!("openrc".parse::<InitSystem>().unwrap(), InitSystem::Openrc);
1652 assert_eq!("OPENRC".parse::<InitSystem>().unwrap(), InitSystem::Openrc);
1653 }
1654
1655 #[test]
1656 fn init_system_from_str_rejects_unknown() {
1657 let err = "unknown"
1658 .parse::<InitSystem>()
1659 .expect_err("should reject unknown");
1660 assert!(err.to_string().contains("Unknown init system"));
1661 assert!(err.to_string().contains("Supported: auto, systemd, openrc"));
1662 }
1663
1664 #[test]
1665 fn init_system_default_is_auto() {
1666 assert_eq!(InitSystem::default(), InitSystem::Auto);
1667 }
1668
1669 #[cfg(unix)]
1670 #[test]
1671 fn is_root_matches_system_uid() {
1672 assert_eq!(is_root(), unsafe { libc::getuid() == 0 });
1676 }
1677
1678 #[test]
1679 fn generate_openrc_script_contains_required_directives() {
1680 use std::path::PathBuf;
1681
1682 let exe_path = PathBuf::from("/usr/local/bin/zeroclaw");
1683 let script = generate_openrc_script(&exe_path, Path::new("/etc/zeroclaw"));
1684
1685 assert!(script.starts_with("#!/sbin/openrc-run"));
1686 assert!(script.contains("name=\"zeroclaw\""));
1687 assert!(script.contains("description=\"ZeroClaw daemon\""));
1688 assert!(script.contains("command=\"/usr/local/bin/zeroclaw\""));
1689 assert!(script.contains("command_args=\"--config-dir /etc/zeroclaw daemon\""));
1690 assert!(!script.contains("env ZEROCLAW_CONFIG_DIR"));
1691 assert!(!script.contains("env ZEROCLAW_WORKSPACE"));
1692 assert!(script.contains("command_background=\"yes\""));
1693 assert!(script.contains("command_user=\"zeroclaw:zeroclaw\""));
1694 assert!(script.contains("pidfile=\"/run/${RC_SVCNAME}.pid\""));
1695 assert!(script.contains("umask 027"));
1696 assert!(script.contains("output_log=\"/var/log/zeroclaw/access.log\""));
1697 assert!(script.contains("error_log=\"/var/log/zeroclaw/error.log\""));
1698 assert!(script.contains("depend()"));
1699 assert!(script.contains("need net"));
1700 assert!(script.contains("after firewall"));
1701 }
1702
1703 #[test]
1704 fn generate_openrc_script_sets_home_for_browser() {
1705 use std::path::PathBuf;
1706
1707 let exe_path = PathBuf::from("/usr/local/bin/zeroclaw");
1708 let script = generate_openrc_script(&exe_path, Path::new("/etc/zeroclaw"));
1709
1710 assert!(
1711 script.contains("export HOME=\"/var/lib/zeroclaw\""),
1712 "OpenRC script must set HOME for headless browser support"
1713 );
1714 }
1715
1716 #[test]
1717 fn generate_openrc_script_creates_home_directory() {
1718 use std::path::PathBuf;
1719
1720 let exe_path = PathBuf::from("/usr/local/bin/zeroclaw");
1721 let script = generate_openrc_script(&exe_path, Path::new("/etc/zeroclaw"));
1722
1723 assert!(
1724 script.contains("start_pre()"),
1725 "OpenRC script must have start_pre to create HOME dir"
1726 );
1727 assert!(
1728 script.contains("checkpath --directory --owner zeroclaw:zeroclaw"),
1729 "start_pre must ensure /var/lib/zeroclaw exists with correct ownership"
1730 );
1731 }
1732
1733 #[test]
1734 fn systemd_unit_contains_home_and_pass_environment() {
1735 let unit = "[Unit]\n\
1736 Description=ZeroClaw daemon\n\
1737 After=network.target\n\
1738 \n\
1739 [Service]\n\
1740 Type=simple\n\
1741 ExecStart=/usr/local/bin/zeroclaw daemon\n\
1742 Restart=always\n\
1743 RestartSec=3\n\
1744 # Ensure HOME is set so headless browsers can create profile/cache dirs.\n\
1745 Environment=HOME=%h\n\
1746 # Allow inheriting DISPLAY and XDG_RUNTIME_DIR from the user session\n\
1747 # so graphical/headless browsers can function correctly.\n\
1748 PassEnvironment=DISPLAY XDG_RUNTIME_DIR\n\
1749 \n\
1750 [Install]\n\
1751 WantedBy=default.target\n"
1752 .to_string();
1753
1754 assert!(
1755 unit.contains("Environment=HOME=%h"),
1756 "systemd unit must set HOME for headless browser support"
1757 );
1758 assert!(
1759 unit.contains("PassEnvironment=DISPLAY XDG_RUNTIME_DIR"),
1760 "systemd unit must pass through display/runtime env vars"
1761 );
1762 }
1763
1764 #[test]
1765 fn warn_if_binary_in_home_detects_home_path() {
1766 use std::path::PathBuf;
1767
1768 let home_path = PathBuf::from("/home/user/.cargo/bin/zeroclaw");
1769 assert!(home_path.to_string_lossy().contains("/home/"));
1770 assert!(home_path.to_string_lossy().contains(".cargo/bin"));
1771
1772 let cargo_path = PathBuf::from("/home/user/.cargo/bin/zeroclaw");
1773 assert!(cargo_path.to_string_lossy().contains(".cargo/bin"));
1774
1775 let system_path = PathBuf::from("/usr/local/bin/zeroclaw");
1776 assert!(!system_path.to_string_lossy().contains("/home/"));
1777 assert!(!system_path.to_string_lossy().contains(".cargo/bin"));
1778 }
1779
1780 #[cfg(unix)]
1781 #[test]
1782 fn shell_single_quote_escapes_single_quotes() {
1783 assert_eq!(
1784 shell_single_quote("/tmp/weird'path"),
1785 "'/tmp/weird'\"'\"'path'"
1786 );
1787 }
1788
1789 #[cfg(unix)]
1790 #[test]
1791 fn openrc_writability_probe_prefers_runuser_when_available() {
1792 let (program, args) =
1793 build_openrc_writability_probe_command(Path::new("/etc/zeroclaw"), true);
1794 assert_eq!(program, "runuser");
1795 assert_eq!(
1796 args,
1797 vec![
1798 "-u".to_string(),
1799 "zeroclaw".to_string(),
1800 "--".to_string(),
1801 "sh".to_string(),
1802 "-c".to_string(),
1803 "test -w '/etc/zeroclaw'".to_string()
1804 ]
1805 );
1806 }
1807
1808 #[cfg(unix)]
1809 #[test]
1810 fn openrc_writability_probe_falls_back_to_su() {
1811 let (program, args) =
1812 build_openrc_writability_probe_command(Path::new("/etc/zeroclaw/workspace"), false);
1813 assert_eq!(program, "su");
1814 assert_eq!(
1815 args,
1816 vec![
1817 "-s".to_string(),
1818 "/bin/sh".to_string(),
1819 "-c".to_string(),
1820 "test -w '/etc/zeroclaw/workspace'".to_string(),
1821 "zeroclaw".to_string()
1822 ]
1823 );
1824 }
1825
1826 #[cfg(not(target_os = "windows"))]
1827 #[test]
1828 fn tail_file_errors_on_missing_file() {
1829 let missing = Path::new("/tmp/zeroclaw-test-nonexistent-log-file.log");
1830 let result = tail_file(missing, 10, false);
1831 assert!(result.is_err(), "tail on missing file should fail");
1832 }
1833
1834 #[cfg(not(target_os = "windows"))]
1835 #[test]
1836 fn tail_file_reads_existing_file() {
1837 let dir = tempfile::tempdir().expect("failed to create temp dir");
1838 let log = dir.path().join("test-tail.log");
1839 fs::write(&log, "line1\nline2\nline3\nline4\nline5\n").unwrap();
1840 let result = tail_file(&log, 3, false);
1842 assert!(result.is_ok(), "tail on existing file should succeed");
1843 }
1844
1845 #[test]
1846 fn logs_variant_is_recognized() {
1847 let cmd = crate::ServiceCommands::Logs {
1849 lines: 25,
1850 follow: true,
1851 };
1852 match &cmd {
1853 crate::ServiceCommands::Logs { lines, follow } => {
1854 assert_eq!(*lines, 25);
1855 assert!(*follow);
1856 }
1857 _ => panic!("Expected Logs variant"),
1858 }
1859 }
1860}