1use std::path::{Path, PathBuf};
2
3use roboticus_core::{RoboticusError, Result, 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
838 #[test]
839 fn launchd_plist_format() {
840 let plist = launchd_plist("/usr/local/bin/roboticus", "/etc/roboticus.toml", 18789);
841 assert!(plist.contains("com.roboticus.agent"));
842 assert!(plist.contains("/usr/local/bin/roboticus"));
843 assert!(plist.contains("/etc/roboticus.toml"));
844 assert!(plist.contains("18789"));
845 assert!(plist.contains("KeepAlive"));
846 }
847
848 #[test]
849 fn systemd_unit_format() {
850 let unit = systemd_unit("/usr/local/bin/roboticus", "/etc/roboticus.toml", 18789);
851 assert!(unit.contains("ExecStart="));
852 assert!(unit.contains("/usr/local/bin/roboticus"));
853 assert!(unit.contains("Restart=on-failure"));
854 assert!(unit.contains("[Install]"));
855 }
856
857 #[test]
858 fn pid_file_roundtrip() {
859 let dir = tempfile::tempdir().unwrap();
860 let pid_path = dir.path().join("test.pid");
861
862 write_pid_file(&pid_path).unwrap();
863 let pid = read_pid_file(&pid_path).unwrap();
864 assert!(pid.is_some());
865 assert_eq!(pid.unwrap(), std::process::id());
866
867 remove_pid_file(&pid_path).unwrap();
868 assert!(!pid_path.exists());
869 }
870
871 #[test]
872 fn read_missing_pid_file() {
873 let result = read_pid_file(Path::new("/nonexistent/pid"));
874 assert!(result.is_ok());
875 assert!(result.unwrap().is_none());
876 }
877
878 #[test]
879 fn remove_missing_pid_file() {
880 let result = remove_pid_file(Path::new("/nonexistent/pid"));
881 assert!(result.is_ok());
882 }
883
884 #[test]
885 fn plist_path_is_under_launch_agents() {
886 let path = plist_path();
887 let path_str = path.to_string_lossy();
888 assert!(path_str.contains("LaunchAgents"));
889 assert!(path_str.ends_with("com.roboticus.agent.plist"));
890 }
891
892 #[test]
893 fn systemd_path_is_under_systemd_user() {
894 let path = systemd_path();
895 let path_str = path.to_string_lossy();
896 assert!(path_str.contains("systemd/user"));
897 assert!(path_str.ends_with("roboticus.service"));
898 }
899
900 #[test]
901 fn read_pid_file_with_invalid_content_returns_error() {
902 let dir = tempfile::tempdir().unwrap();
903 let pid_path = dir.path().join("bad.pid");
904 std::fs::write(&pid_path, "not-a-number").unwrap();
905 assert!(read_pid_file(&pid_path).is_err());
906 }
907
908 #[test]
909 fn read_pid_file_with_whitespace_trims() {
910 let dir = tempfile::tempdir().unwrap();
911 let pid_path = dir.path().join("ws.pid");
912 std::fs::write(&pid_path, " 12345 \n").unwrap();
913 let result = read_pid_file(&pid_path).unwrap();
914 assert_eq!(result, Some(12345));
915 }
916
917 #[test]
918 fn launchd_plist_is_valid_xml() {
919 let plist = launchd_plist("/usr/bin/roboticus", "/etc/roboticus.toml", 9999);
920 assert!(plist.starts_with("<?xml"));
921 assert!(plist.contains("<plist version=\"1.0\">"));
922 assert!(plist.contains("</plist>"));
923 assert!(plist.contains("<string>9999</string>"));
924 assert!(plist.contains("<string>serve</string>"));
925 }
926
927 #[test]
928 fn systemd_unit_has_required_sections() {
929 let unit = systemd_unit("/usr/bin/roboticus", "/etc/roboticus.toml", 8080);
930 assert!(unit.contains("[Unit]"));
931 assert!(unit.contains("[Service]"));
932 assert!(unit.contains("[Install]"));
933 assert!(unit.contains("ExecStart=/usr/bin/roboticus serve -c /etc/roboticus.toml -p 8080"));
934 assert!(unit.contains("Type=simple"));
935 }
936
937 #[test]
938 fn sc_output_not_found_detection() {
939 assert!(sc_output_is_not_found("OpenService FAILED 1060"));
940 assert!(sc_output_is_not_found(
941 "The specified service does not exist as an installed service."
942 ));
943 assert!(!sc_output_is_not_found("SERVICE_NAME: RoboticusAgent"));
944 }
945
946 #[test]
947 fn sc_output_not_active_detection() {
948 assert!(sc_output_is_not_active("ControlService FAILED 1062"));
949 assert!(sc_output_is_not_active("The service has not been started."));
950 assert!(!sc_output_is_not_active("STATE : 4 RUNNING"));
951 }
952
953 #[test]
954 fn install_daemon_creates_file() {
955 if std::env::consts::OS == "windows" {
956 return;
957 }
958 let dir = tempfile::tempdir().unwrap();
959 let home = dir.path().to_str().unwrap();
960 let bin = dir.path().join("roboticus");
961 std::fs::write(&bin, "").unwrap();
962 let cfg = dir.path().join("roboticus.toml");
963 std::fs::write(&cfg, "").unwrap();
964
965 let result = install_daemon_to(bin.to_str().unwrap(), cfg.to_str().unwrap(), 18789, home);
966 assert!(result.is_ok());
967 let path = result.unwrap();
968 assert!(path.exists());
969 }
970
971 #[test]
972 fn write_and_read_pid_roundtrip() {
973 let dir = tempfile::tempdir().unwrap();
974 let pid_path = dir.path().join("test.pid");
975 write_pid_file(&pid_path).unwrap();
976 assert!(pid_path.exists());
977 let pid = read_pid_file(&pid_path).unwrap().unwrap();
978 assert_eq!(pid, std::process::id());
979 remove_pid_file(&pid_path).unwrap();
980 assert!(!pid_path.exists());
981 }
982
983 #[test]
984 fn parse_windows_daemon_marker_basic() {
985 let input = "name=RoboticusAgent\nmode=user_process\nbinary=C:\\x\\roboticus.exe\nconfig=C:\\x\\roboticus.toml\nport=18789\npid=1234\n";
986 let parsed = parse_windows_daemon_marker(input).unwrap();
987 assert_eq!(parsed.binary, "C:\\x\\roboticus.exe");
988 assert_eq!(parsed.config, "C:\\x\\roboticus.toml");
989 assert_eq!(parsed.port, 18789);
990 assert_eq!(parsed.pid, Some(1234));
991 }
992
993 #[test]
994 fn parse_windows_daemon_marker_without_pid() {
995 let input = "name=RoboticusAgent\nmode=user_process\nbinary=C:\\x\\roboticus.exe\nconfig=C:\\x\\roboticus.toml\nport=18789\n";
996 let parsed = parse_windows_daemon_marker(input).unwrap();
997 assert_eq!(parsed.binary, "C:\\x\\roboticus.exe");
998 assert_eq!(parsed.config, "C:\\x\\roboticus.toml");
999 assert_eq!(parsed.port, 18789);
1000 assert_eq!(parsed.pid, None);
1001 }
1002
1003 #[test]
1004 fn parse_windows_daemon_marker_rejects_missing_required_fields() {
1005 let missing_binary =
1006 "name=RoboticusAgent\nmode=user_process\nconfig=C:\\x\\roboticus.toml\nport=18789\n";
1007 assert!(parse_windows_daemon_marker(missing_binary).is_none());
1008 let missing_port = "name=RoboticusAgent\nmode=user_process\nbinary=C:\\x\\roboticus.exe\nconfig=C:\\x\\roboticus.toml\n";
1009 assert!(parse_windows_daemon_marker(missing_port).is_none());
1010 }
1011
1012 #[test]
1013 fn benign_stop_errors_are_classified() {
1014 let err = RoboticusError::Config("service has not been started".into());
1015 assert!(is_benign_stop_error(&err));
1016 let err = RoboticusError::Config("not loaded".into());
1017 assert!(is_benign_stop_error(&err));
1018 let err = RoboticusError::Config("permission denied".into());
1019 assert!(!is_benign_stop_error(&err));
1020 }
1021}