1use std::path::{Path, PathBuf};
2
3use roboticus_core::{Result, RoboticusError, home_dir};
4
5const WINDOWS_DAEMON_NAME: &str = "RoboticusAgent";
6
7pub fn launchd_plist(binary_path: &str, config_path: &str, port: u16) -> String {
8 let log_dir = home_dir().join(".roboticus").join("logs");
9 let stdout_log = log_dir.join("roboticus.stdout.log");
10 let stderr_log = log_dir.join("roboticus.stderr.log");
11
12 format!(
13 r#"<?xml version="1.0" encoding="UTF-8"?>
14<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
15<plist version="1.0">
16<dict>
17 <key>Label</key>
18 <string>com.roboticus.agent</string>
19 <key>ProgramArguments</key>
20 <array>
21 <string>{binary_path}</string>
22 <string>serve</string>
23 <string>-c</string>
24 <string>{config_path}</string>
25 <string>-p</string>
26 <string>{port}</string>
27 </array>
28 <key>RunAtLoad</key>
29 <true/>
30 <key>KeepAlive</key>
31 <true/>
32 <key>StandardOutPath</key>
33 <string>{stdout}</string>
34 <key>StandardErrorPath</key>
35 <string>{stderr}</string>
36</dict>
37</plist>"#,
38 binary_path = binary_path,
39 config_path = config_path,
40 port = port,
41 stdout = stdout_log.display(),
42 stderr = stderr_log.display(),
43 )
44}
45
46pub fn systemd_unit(binary_path: &str, config_path: &str, port: u16) -> String {
47 format!(
48 r#"[Unit]
49Description=Roboticus Autonomous Agent Runtime
50After=network.target
51
52[Service]
53Type=simple
54ExecStart={binary_path} serve -c {config_path} -p {port}
55Restart=on-failure
56RestartSec=5
57Environment=RUST_LOG=info
58
59[Install]
60WantedBy=default.target
61"#,
62 binary_path = binary_path,
63 config_path = config_path,
64 port = port
65 )
66}
67
68fn plist_path_for(home: &str) -> PathBuf {
69 PathBuf::from(home).join("Library/LaunchAgents/com.roboticus.agent.plist")
70}
71
72pub fn plist_path() -> PathBuf {
73 plist_path_for(&home_dir().to_string_lossy())
74}
75
76fn systemd_path_for(home: &str) -> PathBuf {
77 PathBuf::from(home).join(".config/systemd/user/roboticus.service")
78}
79
80pub fn systemd_path() -> PathBuf {
81 systemd_path_for(&home_dir().to_string_lossy())
82}
83
84fn windows_service_marker_path() -> PathBuf {
85 home_dir()
86 .join(".roboticus")
87 .join("windows-service-install.txt")
88}
89
90#[derive(Debug, Clone)]
91struct WindowsDaemonInstall {
92 binary: String,
93 config: String,
94 port: u16,
95 pid: Option<u32>,
96}
97
98fn parse_windows_daemon_marker(content: &str) -> Option<WindowsDaemonInstall> {
99 let mut binary = None;
100 let mut config = None;
101 let mut port = None;
102 let mut pid = None;
103
104 for line in content.lines() {
105 if let Some((k, v)) = line.split_once('=') {
106 match k.trim() {
107 "binary" => binary = Some(v.trim().to_string()),
108 "config" => config = Some(v.trim().to_string()),
109 "port" => {
110 port = v.trim().parse::<u16>().ok();
111 }
112 "pid" => {
113 pid = v.trim().parse::<u32>().ok();
114 }
115 _ => {}
116 }
117 }
118 }
119
120 Some(WindowsDaemonInstall {
121 binary: binary?,
122 config: config?,
123 port: port?,
124 pid,
125 })
126}
127
128fn write_windows_daemon_marker(install: &WindowsDaemonInstall) -> Result<()> {
129 let marker = windows_service_marker_path();
130 if let Some(parent) = marker.parent() {
131 std::fs::create_dir_all(parent)?;
132 }
133 let mut content = format!(
134 "name={WINDOWS_DAEMON_NAME}\nmode=user_process\nbinary={}\nconfig={}\nport={}\n",
135 install.binary, install.config, install.port
136 );
137 if let Some(pid) = install.pid {
138 content.push_str(&format!("pid={pid}\n"));
139 }
140 std::fs::write(&marker, content)?;
141 Ok(())
142}
143
144fn read_windows_daemon_marker() -> Result<Option<WindowsDaemonInstall>> {
145 let marker = windows_service_marker_path();
146 if !marker.exists() {
147 return Ok(None);
148 }
149 let content = std::fs::read_to_string(marker)?;
150 Ok(parse_windows_daemon_marker(&content))
151}
152
153fn windows_pid_running(pid: u32) -> Result<bool> {
154 if std::env::consts::OS != "windows" {
155 return Ok(false);
156 }
157 let script = format!(
160 "try {{ $null = Get-Process -Id {pid} -ErrorAction Stop; Write-Output 'RUNNING' }} catch {{ Write-Output 'NOTFOUND' }}"
161 );
162 let out = command_output("powershell", &["-NoProfile", "-Command", &script])?;
163 if !out.status.success() {
164 let pid_filter = format!("PID eq {pid}");
166 let out = command_output("tasklist", &["/FI", &pid_filter, "/FO", "CSV", "/NH"])?;
167 if !out.status.success() {
168 return Ok(false);
169 }
170 let stdout = String::from_utf8_lossy(&out.stdout);
171 return Ok(stdout.contains(&format!("\"{pid}\"")));
172 }
173 let stdout = String::from_utf8_lossy(&out.stdout);
174 Ok(stdout.trim() == "RUNNING")
175}
176
177fn windows_listening_pid(port: u16) -> Result<Option<u32>> {
178 if std::env::consts::OS != "windows" {
179 return Ok(None);
180 }
181
182 let script = format!(
184 "$c = Get-NetTCPConnection -LocalPort {port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) {{ Write-Output $c.OwningProcess }}"
185 );
186 if let Ok(out) = command_output("powershell", &["-NoProfile", "-Command", &script])
187 && out.status.success()
188 {
189 let stdout = String::from_utf8_lossy(&out.stdout);
190 let pid = stdout.trim().parse::<u32>().ok();
191 if pid.is_some() {
192 return Ok(pid);
193 }
194 }
195
196 let out = command_output("netstat", &["-ano"])?;
198 if !out.status.success() {
199 return Ok(None);
200 }
201 let stdout = String::from_utf8_lossy(&out.stdout);
202 let needle = format!(":{port}");
203 for line in stdout.lines() {
204 let lower = line.to_ascii_lowercase();
205 if !lower.contains("listen") || !line.contains(&needle) {
206 continue;
207 }
208 let cols: Vec<&str> = line.split_whitespace().collect();
209 if let Some(last) = cols.last()
210 && let Ok(pid) = last.parse::<u32>()
211 {
212 return Ok(Some(pid));
213 }
214 }
215 Ok(None)
216}
217
218fn spawn_windows_daemon_process(install: &WindowsDaemonInstall) -> Result<u32> {
219 let mut cmd = std::process::Command::new(&install.binary);
220 cmd.args([
221 "serve",
222 "-c",
223 &install.config,
224 "-p",
225 &install.port.to_string(),
226 ])
227 .stdin(std::process::Stdio::null())
228 .stdout(std::process::Stdio::null())
229 .stderr(std::process::Stdio::null());
230 #[cfg(windows)]
231 {
232 use std::os::windows::process::CommandExt;
233 const DETACHED_PROCESS: u32 = 0x00000008;
234 const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
235 cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
236 }
237 let child = cmd
238 .spawn()
239 .map_err(|e| RoboticusError::Config(format!("failed to spawn daemon process: {e}")))?;
240 Ok(child.id())
241}
242
243fn cleanup_legacy_windows_service() {
244 if std::env::consts::OS != "windows" {
245 return;
246 }
247 let is_admin = std::process::Command::new("net")
249 .args(["session"])
250 .stdout(std::process::Stdio::null())
251 .stderr(std::process::Stdio::null())
252 .status()
253 .map(|s| s.success())
254 .unwrap_or(false);
255 if !is_admin {
256 tracing::warn!("legacy Windows service cleanup skipped: Administrator privileges required");
257 return;
258 }
259
260 match legacy_windows_service_exists() {
261 Ok(false) => return,
262 Ok(true) => {}
263 Err(e) => {
264 tracing::warn!(error = %e, "unable to verify legacy Windows service presence");
265 }
266 }
267
268 for attempt in 1..=3 {
269 if let Err(e) = run_sc_best_effort("stop", WINDOWS_DAEMON_NAME) {
270 tracing::debug!(
271 error = %e,
272 attempt,
273 "legacy Windows service stop failed"
274 );
275 }
276 if let Err(e) = run_sc_best_effort("delete", WINDOWS_DAEMON_NAME) {
277 tracing::debug!(
278 error = %e,
279 attempt,
280 "legacy Windows service delete failed"
281 );
282 }
283
284 std::thread::sleep(std::time::Duration::from_millis(600));
285
286 match legacy_windows_service_exists() {
287 Ok(false) => {
288 tracing::info!("legacy Windows service cleanup complete");
289 return;
290 }
291 Ok(true) => {}
292 Err(e) => {
293 tracing::warn!(error = %e, "failed to verify legacy Windows service cleanup");
294 }
295 }
296 }
297
298 tracing::warn!(
299 "legacy Windows service still present after cleanup attempts; remove with `sc.exe delete {WINDOWS_DAEMON_NAME}`"
300 );
301}
302
303fn run_sc_best_effort(action: &str, service: &str) -> Result<()> {
304 let out = command_output("sc.exe", &[action, service])?;
305 if out.status.success() {
306 return Ok(());
307 }
308 let stdout = String::from_utf8_lossy(&out.stdout);
309 let stderr = String::from_utf8_lossy(&out.stderr);
310 let combined = format!("{stdout}\n{stderr}");
311 if sc_output_is_not_found(&combined) || sc_output_is_not_active(&combined) {
312 return Ok(());
313 }
314 let detail = if !stderr.trim().is_empty() {
315 stderr.trim().to_string()
316 } else {
317 stdout.trim().to_string()
318 };
319 Err(RoboticusError::Config(format!(
320 "sc.exe {action} failed (exit {}): {}",
321 out.status.code().unwrap_or(-1),
322 detail
323 )))
324}
325
326fn legacy_windows_service_exists() -> Result<bool> {
327 let out = command_output("sc.exe", &["query", WINDOWS_DAEMON_NAME])?;
328 if out.status.success() {
329 return Ok(true);
330 }
331 let stdout = String::from_utf8_lossy(&out.stdout);
332 let stderr = String::from_utf8_lossy(&out.stderr);
333 let combined = format!("{stdout}\n{stderr}");
334 if sc_output_is_not_found(&combined) {
335 return Ok(false);
336 }
337 Err(RoboticusError::Config(format!(
338 "sc.exe query failed (exit {}): {}",
339 out.status.code().unwrap_or(-1),
340 combined.trim()
341 )))
342}
343
344fn sc_output_is_not_found(output: &str) -> bool {
345 let lowered = output.to_ascii_lowercase();
346 lowered.contains("1060") || lowered.contains("does not exist")
347}
348
349fn sc_output_is_not_active(output: &str) -> bool {
350 let lowered = output.to_ascii_lowercase();
351 lowered.contains("1062") || lowered.contains("has not been started")
352}
353
354fn install_daemon_to(
355 binary_path: &str,
356 config_path: &str,
357 port: u16,
358 home: &str,
359) -> Result<PathBuf> {
360 let os = std::env::consts::OS;
361 let (content, path) = match os {
362 "macos" => (
363 launchd_plist(binary_path, config_path, port),
364 plist_path_for(home),
365 ),
366 "linux" => (
367 systemd_unit(binary_path, config_path, port),
368 systemd_path_for(home),
369 ),
370 "windows" => {
371 cleanup_legacy_windows_service();
372 let marker = windows_service_marker_path();
373 let install = WindowsDaemonInstall {
374 binary: binary_path.to_string(),
375 config: config_path.to_string(),
376 port,
377 pid: None,
378 };
379 write_windows_daemon_marker(&install)?;
380 return Ok(marker);
381 }
382 other => {
383 return Err(RoboticusError::Config(format!(
384 "daemon install not supported on {other}"
385 )));
386 }
387 };
388
389 if let Some(parent) = path.parent() {
390 std::fs::create_dir_all(parent)?;
391 }
392
393 std::fs::write(&path, &content)?;
394 Ok(path)
395}
396
397pub fn install_daemon(binary_path: &str, config_path: &str, port: u16) -> Result<PathBuf> {
398 let home = home_dir();
399 let result = install_daemon_to(binary_path, config_path, port, &home.to_string_lossy())?;
400
401 #[cfg(windows)]
404 {
405 let task_xml = format!(
406 r#"<?xml version="1.0" encoding="UTF-16"?>
407<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
408 <Triggers>
409 <LogonTrigger><Enabled>true</Enabled></LogonTrigger>
410 </Triggers>
411 <Settings>
412 <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
413 <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
414 <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
415 <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
416 <Hidden>false</Hidden>
417 </Settings>
418 <Actions>
419 <Exec>
420 <Command>{binary}</Command>
421 <Arguments>serve -c "{config}" -p {port}</Arguments>
422 </Exec>
423 </Actions>
424</Task>"#,
425 binary = binary_path,
426 config = config_path,
427 port = port,
428 );
429 let task_file = std::env::temp_dir().join("roboticus-task.xml");
430 std::fs::write(&task_file, &task_xml).map_err(|e| {
431 RoboticusError::Config(format!("failed to write task scheduler XML: {e}"))
432 })?;
433 let schtasks_out = std::process::Command::new("schtasks")
434 .args([
435 "/Create",
436 "/TN",
437 "RoboticusAgent",
438 "/XML",
439 &task_file.to_string_lossy(),
440 "/F",
441 ])
442 .output()
443 .map_err(|e| {
444 let _ = std::fs::remove_file(&task_file);
445 RoboticusError::Config(format!("failed to run schtasks: {e}"))
446 })?;
447 let _ = std::fs::remove_file(&task_file);
448 if !schtasks_out.status.success() {
449 let stderr = String::from_utf8_lossy(&schtasks_out.stderr);
450 return Err(RoboticusError::Config(format!(
451 "schtasks /Create failed (exit {}): {}",
452 schtasks_out.status.code().unwrap_or(-1),
453 stderr.trim()
454 )));
455 }
456 }
457
458 Ok(result)
459}
460
461pub fn start_daemon() -> Result<()> {
462 let os = std::env::consts::OS;
463 match os {
464 "macos" => {
465 let output = std::process::Command::new("launchctl")
466 .args(["load", "-w"])
467 .arg(plist_path())
468 .output()
469 .map_err(|e| RoboticusError::Config(format!("failed to run launchctl: {e}")))?;
470
471 let stderr = String::from_utf8_lossy(&output.stderr);
472 if !output.status.success() {
473 return Err(RoboticusError::Config(format!(
474 "launchctl load failed (exit {}): {}",
475 output.status.code().unwrap_or(-1),
476 stderr.trim()
477 )));
478 }
479
480 std::thread::sleep(std::time::Duration::from_secs(1));
481 verify_launchd_running()?;
482 Ok(())
483 }
484 "linux" => {
485 run_cmd("systemctl", &["--user", "daemon-reload"])?;
486 run_cmd(
487 "systemctl",
488 &["--user", "enable", "--now", "roboticus.service"],
489 )
490 }
491 "windows" => {
492 let mut install = read_windows_daemon_marker()?.ok_or_else(|| {
493 RoboticusError::Config("daemon not installed on windows".to_string())
494 })?;
495 if let Some(pid) = install.pid
496 && windows_pid_running(pid)?
497 {
498 return Ok(());
499 }
500 if let Some(pid) = windows_listening_pid(install.port)?
502 && windows_pid_running(pid)?
503 {
504 install.pid = Some(pid);
505 write_windows_daemon_marker(&install)?;
506 return Ok(());
507 }
508 let pid = spawn_windows_daemon_process(&install)?;
509 install.pid = Some(pid);
510 write_windows_daemon_marker(&install)?;
511
512 std::thread::sleep(std::time::Duration::from_secs(1));
515 if !windows_pid_running(pid)? {
516 let detail = if let Some(owner) = windows_listening_pid(install.port)? {
517 format!(
518 "daemon process exited immediately after spawn — port {} is already in use by pid {}",
519 install.port, owner
520 )
521 } else {
522 "daemon process exited immediately after spawn — check config and port availability"
523 .to_string()
524 };
525 return Err(RoboticusError::Config(detail));
526 }
527 Ok(())
528 }
529 other => Err(RoboticusError::Config(format!(
530 "daemon start not supported on {other}"
531 ))),
532 }
533}
534
535pub fn stop_daemon() -> Result<()> {
536 let os = std::env::consts::OS;
537 match os {
538 "macos" => run_cmd("launchctl", &["unload", &plist_path().to_string_lossy()]),
539 "linux" => run_cmd("systemctl", &["--user", "stop", "roboticus.service"]),
540 "windows" => {
541 let mut install = match read_windows_daemon_marker()? {
542 Some(i) => i,
543 None => return Ok(()),
544 };
545 let pid = install.pid.or(windows_listening_pid(install.port)?);
546 let Some(pid) = pid else {
547 return Ok(());
548 };
549 let pid_s = pid.to_string();
550 if windows_pid_running(pid)? {
551 run_cmd("taskkill", &["/PID", &pid_s, "/T", "/F"])?;
552 }
553 install.pid = None;
554 write_windows_daemon_marker(&install)
555 }
556 other => Err(RoboticusError::Config(format!(
557 "daemon stop not supported on {other}"
558 ))),
559 }
560}
561
562pub fn restart_daemon() -> Result<()> {
563 let os = std::env::consts::OS;
564 match os {
565 "macos" => {
566 if let Err(e) = stop_daemon()
567 && !is_benign_stop_error(&e)
568 {
569 return Err(e);
570 }
571 start_daemon()
572 }
573 "linux" => run_cmd("systemctl", &["--user", "restart", "roboticus.service"]),
574 "windows" => {
575 if let Err(e) = stop_daemon()
576 && !is_benign_stop_error(&e)
577 {
578 return Err(e);
579 }
580 start_daemon()
581 }
582 other => Err(RoboticusError::Config(format!(
583 "daemon restart not supported on {other}"
584 ))),
585 }
586}
587
588const LAUNCHD_LABEL: &str = "com.roboticus.agent";
589
590fn run_cmd(program: &str, args: &[&str]) -> Result<()> {
591 let output = std::process::Command::new(program)
592 .args(args)
593 .output()
594 .map_err(|e| RoboticusError::Config(format!("failed to run {program}: {e}")))?;
595
596 if output.status.success() {
597 Ok(())
598 } else {
599 let stdout = String::from_utf8_lossy(&output.stdout);
600 let stderr = String::from_utf8_lossy(&output.stderr);
601 let detail = if !stderr.trim().is_empty() {
602 stderr.trim().to_string()
603 } else {
604 stdout.trim().to_string()
605 };
606 Err(RoboticusError::Config(format!(
607 "{program} failed (exit {}): {}",
608 output.status.code().unwrap_or(-1),
609 detail
610 )))
611 }
612}
613
614fn command_output(program: &str, args: &[&str]) -> Result<std::process::Output> {
615 std::process::Command::new(program)
616 .args(args)
617 .output()
618 .map_err(|e| RoboticusError::Config(format!("failed to run {program}: {e}")))
619}
620
621fn windows_service_exists() -> Result<bool> {
622 if std::env::consts::OS != "windows" {
623 return Ok(false);
624 }
625 let marker = windows_service_marker_path();
626 Ok(marker.exists())
627}
628
629pub fn daemon_status() -> Result<String> {
630 match std::env::consts::OS {
631 "macos" => {
632 if !is_installed() {
633 return Ok("Daemon not installed".into());
634 }
635 match command_output("launchctl", &["list", LAUNCHD_LABEL]) {
636 Ok(out) if out.status.success() => {
637 let stdout = String::from_utf8_lossy(&out.stdout);
638 if stdout.contains("\"PID\"") {
639 Ok("Daemon running (launchd loaded)".into())
640 } else {
641 Ok("Daemon installed but not running".into())
642 }
643 }
644 Ok(_) => Ok("Daemon installed but not running".into()),
645 Err(e) => Err(e),
646 }
647 }
648 "linux" => {
649 if !is_installed() {
650 return Ok("Daemon not installed".into());
651 }
652 let out = command_output("systemctl", &["--user", "is-active", "roboticus.service"])?;
653 if out.status.success() {
654 Ok("Daemon running (systemd active)".into())
655 } else {
656 Ok("Daemon installed but not running".into())
657 }
658 }
659 "windows" => {
660 if !windows_service_exists()? {
661 return Ok("Daemon not installed".into());
662 }
663 let install = read_windows_daemon_marker()?;
664 match install {
665 Some(i) => {
666 if let Some(pid) = i.pid
667 && windows_pid_running(pid)?
668 {
669 return Ok(format!("Daemon running (Windows process pid={pid})"));
670 }
671 Ok("Daemon installed but stopped (Windows user process)".into())
672 }
673 None => Ok("Daemon not installed".into()),
674 }
675 }
676 other => Ok(format!("Daemon status unsupported on {other}")),
677 }
678}
679
680fn verify_launchd_running() -> Result<()> {
681 let output = std::process::Command::new("launchctl")
682 .args(["list", LAUNCHD_LABEL])
683 .output()
684 .map_err(|e| RoboticusError::Config(format!("failed to query launchctl: {e}")))?;
685
686 if !output.status.success() {
687 return Err(RoboticusError::Config(
688 "daemon service is not loaded — check the plist path and binary".into(),
689 ));
690 }
691
692 let stdout = String::from_utf8_lossy(&output.stdout);
693 for line in stdout.lines() {
694 let trimmed = line.trim();
695 if let Some(rest) = trimmed.strip_prefix("\"LastExitStatus\"") {
696 let code = rest
697 .trim_start_matches(|c: char| !c.is_ascii_digit() && c != '-')
698 .trim_end_matches(';')
699 .trim();
700 if code != "0" {
701 let stderr_path = home_dir().join(".roboticus/logs/roboticus.stderr.log");
702 let hint = if stderr_path.exists() {
703 format!(" (see {})", stderr_path.display())
704 } else {
705 String::new()
706 };
707 return Err(RoboticusError::Config(format!(
708 "daemon exited immediately with code {code}{hint}"
709 )));
710 }
711 }
712 }
713
714 for line in stdout.lines() {
715 let trimmed = line.trim();
716 if let Some(rest) = trimmed.strip_prefix("\"PID\"") {
717 let pid = rest
718 .trim_start_matches(|c: char| !c.is_ascii_digit())
719 .trim_end_matches(';')
720 .trim();
721 if !pid.is_empty() {
722 return Ok(());
723 }
724 }
725 }
726
727 Err(RoboticusError::Config(
728 "daemon loaded but no PID found — service may have crashed on startup".into(),
729 ))
730}
731
732fn is_benign_stop_error(e: &RoboticusError) -> bool {
733 let msg = e.to_string().to_ascii_lowercase();
734 msg.contains("1062")
735 || msg.contains("service has not been started")
736 || msg.contains("the service has not been started")
737 || msg.contains("inactive")
738 || msg.contains("not loaded")
739}
740
741fn is_installed_result() -> Result<bool> {
742 let os = std::env::consts::OS;
743 if os == "windows" {
744 return windows_service_exists();
745 }
746 let path = match os {
747 "macos" => plist_path(),
748 "linux" => systemd_path(),
749 _ => return Ok(false),
750 };
751 Ok(path.exists())
752}
753
754pub fn is_installed() -> bool {
755 is_installed_result().unwrap_or(false)
756}
757
758pub fn uninstall_daemon() -> Result<()> {
759 if !is_installed_result()? {
760 return Ok(());
761 }
762 if let Err(e) = stop_daemon()
763 && !is_benign_stop_error(&e)
764 {
765 return Err(e);
766 }
767 if std::env::consts::OS == "windows" {
768 cleanup_legacy_windows_service();
769 let schtasks_del = std::process::Command::new("schtasks")
771 .args(["/Delete", "/TN", "RoboticusAgent", "/F"])
772 .output();
773 if let Ok(out) = schtasks_del
774 && !out.status.success()
775 {
776 let stderr = String::from_utf8_lossy(&out.stderr);
777 if !stderr.to_ascii_lowercase().contains("does not exist")
779 && !stderr.to_ascii_lowercase().contains("cannot find")
780 {
781 return Err(RoboticusError::Config(format!(
782 "schtasks /Delete failed (exit {}): {}",
783 out.status.code().unwrap_or(-1),
784 stderr.trim()
785 )));
786 }
787 }
788 let marker = windows_service_marker_path();
789 if marker.exists()
790 && let Err(e) = std::fs::remove_file(&marker)
791 && e.kind() != std::io::ErrorKind::NotFound
792 {
793 return Err(RoboticusError::Config(format!(
794 "failed to remove windows service marker {}: {e}",
795 marker.display()
796 )));
797 }
798 return Ok(());
799 }
800 let path = match std::env::consts::OS {
801 "macos" => plist_path(),
802 "linux" => systemd_path(),
803 _ => return Ok(()),
804 };
805 std::fs::remove_file(&path)?;
806 Ok(())
807}
808
809pub fn write_pid_file(path: &Path) -> Result<()> {
810 let pid = std::process::id();
811 std::fs::write(path, pid.to_string())?;
812 Ok(())
813}
814
815pub fn read_pid_file(path: &Path) -> Result<Option<u32>> {
816 if !path.exists() {
817 return Ok(None);
818 }
819 let contents = std::fs::read_to_string(path)?;
820 let pid = contents
821 .trim()
822 .parse::<u32>()
823 .map_err(|e| RoboticusError::Config(format!("invalid PID file: {e}")))?;
824 Ok(Some(pid))
825}
826
827pub fn remove_pid_file(path: &Path) -> Result<()> {
828 if path.exists() {
829 std::fs::remove_file(path)?;
830 }
831 Ok(())
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837 use crate::test_support::EnvGuard;
838
839 #[test]
840 fn launchd_plist_format() {
841 let plist = launchd_plist("/usr/local/bin/roboticus", "/etc/roboticus.toml", 18789);
842 assert!(plist.contains("com.roboticus.agent"));
843 assert!(plist.contains("/usr/local/bin/roboticus"));
844 assert!(plist.contains("/etc/roboticus.toml"));
845 assert!(plist.contains("18789"));
846 assert!(plist.contains("KeepAlive"));
847 }
848
849 #[test]
850 fn systemd_unit_format() {
851 let unit = systemd_unit("/usr/local/bin/roboticus", "/etc/roboticus.toml", 18789);
852 assert!(unit.contains("ExecStart="));
853 assert!(unit.contains("/usr/local/bin/roboticus"));
854 assert!(unit.contains("Restart=on-failure"));
855 assert!(unit.contains("[Install]"));
856 }
857
858 #[test]
859 fn daemon_paths_are_derived_from_home() {
860 let home = "/tmp/roboticus-home";
861 assert_eq!(
862 plist_path_for(home),
863 PathBuf::from("/tmp/roboticus-home/Library/LaunchAgents/com.roboticus.agent.plist")
864 );
865 assert_eq!(
866 systemd_path_for(home),
867 PathBuf::from("/tmp/roboticus-home/.config/systemd/user/roboticus.service")
868 );
869 }
870
871 #[test]
872 fn pid_file_roundtrip() {
873 let dir = tempfile::tempdir().unwrap();
874 let pid_path = dir.path().join("test.pid");
875
876 write_pid_file(&pid_path).unwrap();
877 let pid = read_pid_file(&pid_path).unwrap();
878 assert!(pid.is_some());
879 assert_eq!(pid.unwrap(), std::process::id());
880
881 remove_pid_file(&pid_path).unwrap();
882 assert!(!pid_path.exists());
883 }
884
885 #[test]
886 fn read_missing_pid_file() {
887 let result = read_pid_file(Path::new("/nonexistent/pid"));
888 assert!(result.is_ok());
889 assert!(result.unwrap().is_none());
890 }
891
892 #[test]
893 fn remove_missing_pid_file() {
894 let result = remove_pid_file(Path::new("/nonexistent/pid"));
895 assert!(result.is_ok());
896 }
897
898 #[test]
899 fn plist_path_is_under_launch_agents() {
900 let path = plist_path();
901 let path_str = path.to_string_lossy();
902 assert!(path_str.contains("LaunchAgents"));
903 assert!(path_str.ends_with("com.roboticus.agent.plist"));
904 }
905
906 #[test]
907 fn systemd_path_is_under_systemd_user() {
908 let path = systemd_path();
909 let path_str = path.to_string_lossy();
910 assert!(path_str.contains("systemd/user"));
911 assert!(path_str.ends_with("roboticus.service"));
912 }
913
914 #[test]
915 fn read_pid_file_with_invalid_content_returns_error() {
916 let dir = tempfile::tempdir().unwrap();
917 let pid_path = dir.path().join("bad.pid");
918 std::fs::write(&pid_path, "not-a-number").unwrap();
919 assert!(read_pid_file(&pid_path).is_err());
920 }
921
922 #[test]
923 fn read_pid_file_with_whitespace_trims() {
924 let dir = tempfile::tempdir().unwrap();
925 let pid_path = dir.path().join("ws.pid");
926 std::fs::write(&pid_path, " 12345 \n").unwrap();
927 let result = read_pid_file(&pid_path).unwrap();
928 assert_eq!(result, Some(12345));
929 }
930
931 #[test]
932 fn launchd_plist_is_valid_xml() {
933 let plist = launchd_plist("/usr/bin/roboticus", "/etc/roboticus.toml", 9999);
934 assert!(plist.starts_with("<?xml"));
935 assert!(plist.contains("<plist version=\"1.0\">"));
936 assert!(plist.contains("</plist>"));
937 assert!(plist.contains("<string>9999</string>"));
938 assert!(plist.contains("<string>serve</string>"));
939 }
940
941 #[test]
942 fn systemd_unit_has_required_sections() {
943 let unit = systemd_unit("/usr/bin/roboticus", "/etc/roboticus.toml", 8080);
944 assert!(unit.contains("[Unit]"));
945 assert!(unit.contains("[Service]"));
946 assert!(unit.contains("[Install]"));
947 assert!(unit.contains("ExecStart=/usr/bin/roboticus serve -c /etc/roboticus.toml -p 8080"));
948 assert!(unit.contains("Type=simple"));
949 }
950
951 #[test]
952 fn sc_output_not_found_detection() {
953 assert!(sc_output_is_not_found("OpenService FAILED 1060"));
954 assert!(sc_output_is_not_found(
955 "The specified service does not exist as an installed service."
956 ));
957 assert!(!sc_output_is_not_found("SERVICE_NAME: RoboticusAgent"));
958 }
959
960 #[test]
961 fn sc_output_not_active_detection() {
962 assert!(sc_output_is_not_active("ControlService FAILED 1062"));
963 assert!(sc_output_is_not_active("The service has not been started."));
964 assert!(!sc_output_is_not_active("STATE : 4 RUNNING"));
965 }
966
967 #[test]
968 fn install_daemon_creates_file() {
969 if std::env::consts::OS == "windows" {
970 return;
971 }
972 let dir = tempfile::tempdir().unwrap();
973 let home = dir.path().to_str().unwrap();
974 let bin = dir.path().join("roboticus");
975 std::fs::write(&bin, "").unwrap();
976 let cfg = dir.path().join("roboticus.toml");
977 std::fs::write(&cfg, "").unwrap();
978
979 let result = install_daemon_to(bin.to_str().unwrap(), cfg.to_str().unwrap(), 18789, home);
980 assert!(result.is_ok());
981 let path = result.unwrap();
982 assert!(path.exists());
983 }
984
985 #[test]
986 fn write_and_read_pid_roundtrip() {
987 let dir = tempfile::tempdir().unwrap();
988 let pid_path = dir.path().join("test.pid");
989 write_pid_file(&pid_path).unwrap();
990 assert!(pid_path.exists());
991 let pid = read_pid_file(&pid_path).unwrap().unwrap();
992 assert_eq!(pid, std::process::id());
993 remove_pid_file(&pid_path).unwrap();
994 assert!(!pid_path.exists());
995 }
996
997 #[test]
998 fn parse_windows_daemon_marker_basic() {
999 let input = "name=RoboticusAgent\nmode=user_process\nbinary=C:\\x\\roboticus.exe\nconfig=C:\\x\\roboticus.toml\nport=18789\npid=1234\n";
1000 let parsed = parse_windows_daemon_marker(input).unwrap();
1001 assert_eq!(parsed.binary, "C:\\x\\roboticus.exe");
1002 assert_eq!(parsed.config, "C:\\x\\roboticus.toml");
1003 assert_eq!(parsed.port, 18789);
1004 assert_eq!(parsed.pid, Some(1234));
1005 }
1006
1007 #[test]
1008 fn parse_windows_daemon_marker_without_pid() {
1009 let input = "name=RoboticusAgent\nmode=user_process\nbinary=C:\\x\\roboticus.exe\nconfig=C:\\x\\roboticus.toml\nport=18789\n";
1010 let parsed = parse_windows_daemon_marker(input).unwrap();
1011 assert_eq!(parsed.binary, "C:\\x\\roboticus.exe");
1012 assert_eq!(parsed.config, "C:\\x\\roboticus.toml");
1013 assert_eq!(parsed.port, 18789);
1014 assert_eq!(parsed.pid, None);
1015 }
1016
1017 #[test]
1018 fn parse_windows_daemon_marker_rejects_missing_required_fields() {
1019 let missing_binary =
1020 "name=RoboticusAgent\nmode=user_process\nconfig=C:\\x\\roboticus.toml\nport=18789\n";
1021 assert!(parse_windows_daemon_marker(missing_binary).is_none());
1022 let missing_port = "name=RoboticusAgent\nmode=user_process\nbinary=C:\\x\\roboticus.exe\nconfig=C:\\x\\roboticus.toml\n";
1023 assert!(parse_windows_daemon_marker(missing_port).is_none());
1024 }
1025
1026 #[test]
1027 fn write_and_read_windows_daemon_marker_roundtrip() {
1028 let dir = tempfile::tempdir().unwrap();
1029 let home = dir.path().to_string_lossy().into_owned();
1030 let _home = EnvGuard::set("HOME", &home);
1031
1032 let install = WindowsDaemonInstall {
1033 binary: "C:\\roboticus\\roboticus.exe".into(),
1034 config: "C:\\roboticus\\roboticus.toml".into(),
1035 port: 18789,
1036 pid: Some(4242),
1037 };
1038
1039 write_windows_daemon_marker(&install).unwrap();
1040 let loaded = read_windows_daemon_marker()
1041 .unwrap()
1042 .expect("marker exists");
1043 assert_eq!(loaded.binary, install.binary);
1044 assert_eq!(loaded.config, install.config);
1045 assert_eq!(loaded.port, install.port);
1046 assert_eq!(loaded.pid, install.pid);
1047 }
1048
1049 #[test]
1050 fn read_windows_daemon_marker_returns_none_when_missing() {
1051 let dir = tempfile::tempdir().unwrap();
1052 let home = dir.path().to_string_lossy().into_owned();
1053 let _home = EnvGuard::set("HOME", &home);
1054 assert!(read_windows_daemon_marker().unwrap().is_none());
1055 }
1056
1057 #[test]
1058 fn benign_stop_errors_are_classified() {
1059 let err = RoboticusError::Config("service has not been started".into());
1060 assert!(is_benign_stop_error(&err));
1061 let err = RoboticusError::Config("not loaded".into());
1062 assert!(is_benign_stop_error(&err));
1063 let err = RoboticusError::Config("permission denied".into());
1064 assert!(!is_benign_stop_error(&err));
1065 }
1066
1067 #[test]
1068 fn run_cmd_reports_missing_program_with_context() {
1069 let err = run_cmd("definitely-not-a-real-command", &[]).expect_err("missing command");
1070 assert!(
1071 err.to_string()
1072 .contains("failed to run definitely-not-a-real-command")
1073 );
1074 }
1075
1076 #[test]
1077 fn command_output_reports_missing_program_with_context() {
1078 let err =
1079 command_output("definitely-not-a-real-command", &[]).expect_err("missing command");
1080 assert!(
1081 err.to_string()
1082 .contains("failed to run definitely-not-a-real-command")
1083 );
1084 }
1085
1086 #[test]
1087 fn install_daemon_to_writes_expected_platform_content() {
1088 if std::env::consts::OS == "windows" {
1089 return;
1090 }
1091
1092 let dir = tempfile::tempdir().unwrap();
1093 let home = dir.path().to_str().unwrap();
1094 let bin = dir.path().join("roboticus");
1095 let cfg = dir.path().join("roboticus.toml");
1096 std::fs::write(&bin, "").unwrap();
1097 std::fs::write(&cfg, "").unwrap();
1098
1099 let path = install_daemon_to(bin.to_str().unwrap(), cfg.to_str().unwrap(), 18789, home)
1100 .expect("install daemon file");
1101 let contents = std::fs::read_to_string(path).unwrap();
1102 assert!(contents.contains(bin.to_str().unwrap()));
1103 assert!(contents.contains(cfg.to_str().unwrap()));
1104 assert!(contents.contains("18789"));
1105 }
1106
1107 #[test]
1108 fn fresh_home_reports_not_installed() {
1109 let dir = tempfile::tempdir().unwrap();
1110 let home = dir.path().to_string_lossy().into_owned();
1111 let _home = EnvGuard::set("HOME", &home);
1112
1113 assert!(!is_installed());
1114 let status = daemon_status().unwrap();
1115 assert!(status.to_ascii_lowercase().contains("not installed"));
1116 }
1117
1118 #[test]
1119 fn uninstall_daemon_is_noop_when_not_installed() {
1120 let dir = tempfile::tempdir().unwrap();
1121 let home = dir.path().to_string_lossy().into_owned();
1122 let _home = EnvGuard::set("HOME", &home);
1123 uninstall_daemon().unwrap();
1124 }
1125}