acp/commands/
daemon.rs

1//! @acp:module "Daemon Command"
2//! @acp:summary "Manage the ACP daemon (acpd)"
3//! @acp:domain cli
4//! @acp:layer handler
5//!
6//! Implements `acp daemon` command for daemon lifecycle management.
7
8use std::io::{BufRead, BufReader};
9use std::net::TcpListener;
10use std::path::PathBuf;
11use std::process::Command;
12
13use anyhow::Result;
14use console::style;
15
16/// Daemon subcommands
17#[derive(Debug, Clone)]
18pub enum DaemonSubcommand {
19    /// Start the daemon
20    Start {
21        /// Run in foreground mode
22        foreground: bool,
23        /// HTTP server port
24        port: u16,
25    },
26    /// Stop the daemon
27    Stop,
28    /// Check daemon status
29    Status,
30    /// View daemon logs
31    Logs {
32        /// Number of lines to show
33        lines: usize,
34        /// Follow log output
35        follow: bool,
36    },
37}
38
39/// Execute daemon subcommands
40pub fn execute_daemon(cmd: DaemonSubcommand) -> Result<()> {
41    let acp_dir = PathBuf::from(".acp");
42    let pid_file = acp_dir.join("daemon.pid");
43    let log_file = acp_dir.join("daemon.log");
44
45    match cmd {
46        DaemonSubcommand::Start { foreground, port } => {
47            // Check if already running
48            if let Some(pid) = read_pid_file(&pid_file) {
49                if is_process_running(pid) {
50                    println!(
51                        "{} Daemon already running with PID {}",
52                        style("!").yellow(),
53                        pid
54                    );
55                    return Ok(());
56                }
57                // Stale PID file
58                let _ = std::fs::remove_file(&pid_file);
59            }
60
61            // Check if port is already in use
62            if is_port_in_use(port) {
63                eprintln!("{} Port {} is already in use", style("✗").red(), port);
64                eprintln!("  Another process may be using this port.");
65                eprintln!("  Try a different port with: acp daemon start --port <PORT>");
66                return Err(anyhow::anyhow!("Port {} is already in use", port));
67            }
68
69            // Ensure .acp directory exists
70            if !acp_dir.exists() {
71                std::fs::create_dir_all(&acp_dir)?;
72            }
73
74            // Find the acpd binary
75            let acpd_path = find_acpd_binary()?;
76
77            if foreground {
78                // Run in foreground - exec the daemon
79                println!(
80                    "{} Starting daemon in foreground mode...",
81                    style("→").cyan()
82                );
83                let status = Command::new(&acpd_path)
84                    .arg("--port")
85                    .arg(port.to_string())
86                    .arg("run")
87                    .status()?;
88
89                if !status.success() {
90                    eprintln!("{} Daemon exited with error", style("✗").red());
91                    std::process::exit(1);
92                }
93            } else {
94                // Start in background
95                let log = std::fs::File::create(&log_file)?;
96                let log_err = log.try_clone()?;
97
98                let child = Command::new(&acpd_path)
99                    .arg("--port")
100                    .arg(port.to_string())
101                    .arg("run")
102                    .stdout(log)
103                    .stderr(log_err)
104                    .spawn()?;
105
106                let pid = child.id();
107                // Store pid:port for status command to use
108                std::fs::write(&pid_file, format!("{}:{}", pid, port))?;
109
110                println!(
111                    "{} Daemon started with PID {} (port {})",
112                    style("✓").green(),
113                    pid,
114                    port
115                );
116                println!("  Log file: {}", log_file.display());
117                println!("  API: http://127.0.0.1:{}/health", port);
118            }
119        }
120
121        DaemonSubcommand::Stop => match read_pid_file(&pid_file) {
122            Some(pid) => {
123                if is_process_running(pid) {
124                    // Send SIGTERM
125                    #[cfg(unix)]
126                    {
127                        let _ = Command::new("kill")
128                            .arg("-TERM")
129                            .arg(pid.to_string())
130                            .status();
131                    }
132
133                    #[cfg(not(unix))]
134                    {
135                        eprintln!(
136                            "{} Stopping daemon not supported on this platform",
137                            style("✗").red()
138                        );
139                    }
140
141                    println!(
142                        "{} Sent stop signal to daemon (PID {})",
143                        style("✓").green(),
144                        pid
145                    );
146                } else {
147                    println!(
148                        "{} Daemon not running (stale PID file)",
149                        style("!").yellow()
150                    );
151                }
152                let _ = std::fs::remove_file(&pid_file);
153            }
154            None => {
155                println!("{} No daemon running", style("•").dim());
156            }
157        },
158
159        DaemonSubcommand::Status => match read_pid_file(&pid_file) {
160            Some(pid) => {
161                if is_process_running(pid) {
162                    // Read port from PID file, default to 9222 for backwards compat
163                    let port = read_port_from_pid_file(&pid_file).unwrap_or(9222);
164                    println!(
165                        "{} Daemon is running (PID {}, port {})",
166                        style("✓").green(),
167                        pid,
168                        port
169                    );
170
171                    // Try to check health endpoint
172                    if let Ok(health) = check_daemon_health(port) {
173                        println!("  Health: {}", health);
174                    }
175                } else {
176                    println!(
177                        "{} Daemon not running (stale PID file)",
178                        style("!").yellow()
179                    );
180                    let _ = std::fs::remove_file(&pid_file);
181                }
182            }
183            None => {
184                println!("{} Daemon not running", style("•").dim());
185            }
186        },
187
188        DaemonSubcommand::Logs { lines, follow } => {
189            if !log_file.exists() {
190                println!(
191                    "{} No log file found at {}",
192                    style("!").yellow(),
193                    log_file.display()
194                );
195                return Ok(());
196            }
197
198            if follow {
199                // Use tail -f
200                let mut child = Command::new("tail")
201                    .arg("-f")
202                    .arg("-n")
203                    .arg(lines.to_string())
204                    .arg(&log_file)
205                    .spawn()?;
206
207                child.wait()?;
208            } else {
209                // Read last N lines
210                let file = std::fs::File::open(&log_file)?;
211                let reader = BufReader::new(file);
212                let all_lines: Vec<String> = reader.lines().map_while(Result::ok).collect();
213                let start = if all_lines.len() > lines {
214                    all_lines.len() - lines
215                } else {
216                    0
217                };
218
219                for line in &all_lines[start..] {
220                    println!("{}", line);
221                }
222            }
223        }
224    }
225
226    Ok(())
227}
228
229/// Read PID file content. Format: "pid" or "pid:port"
230fn read_pid_file(path: &PathBuf) -> Option<u32> {
231    std::fs::read_to_string(path).ok().and_then(|s| {
232        let content = s.trim();
233        // Support format "pid" or "pid:port"
234        content
235            .split(':')
236            .next()
237            .and_then(|pid_str| pid_str.parse().ok())
238    })
239}
240
241/// Read port from PID file if stored. Format: "pid:port"
242fn read_port_from_pid_file(path: &PathBuf) -> Option<u16> {
243    std::fs::read_to_string(path).ok().and_then(|s| {
244        let parts: Vec<&str> = s.trim().split(':').collect();
245        if parts.len() >= 2 {
246            parts[1].parse().ok()
247        } else {
248            None
249        }
250    })
251}
252
253fn is_process_running(pid: u32) -> bool {
254    #[cfg(unix)]
255    {
256        Command::new("kill")
257            .arg("-0")
258            .arg(pid.to_string())
259            .status()
260            .map(|s| s.success())
261            .unwrap_or(false)
262    }
263
264    #[cfg(not(unix))]
265    {
266        true // Assume running on non-Unix
267    }
268}
269
270fn is_port_in_use(port: u16) -> bool {
271    TcpListener::bind(("127.0.0.1", port)).is_err()
272}
273
274fn find_acpd_binary() -> Result<PathBuf> {
275    // First check if acpd is in PATH
276    if let Ok(output) = Command::new("which").arg("acpd").output() {
277        if output.status.success() {
278            let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
279            if !path.is_empty() {
280                return Ok(PathBuf::from(path));
281            }
282        }
283    }
284
285    // Check common locations relative to current binary
286    let current_exe = std::env::current_exe()?;
287    if let Some(bin_dir) = current_exe.parent() {
288        let acpd_path = bin_dir.join("acpd");
289        if acpd_path.exists() {
290            return Ok(acpd_path);
291        }
292    }
293
294    // Check target/debug and target/release
295    for dir in &["target/debug/acpd", "target/release/acpd"] {
296        let path = PathBuf::from(dir);
297        if path.exists() {
298            return Ok(path);
299        }
300    }
301
302    Err(anyhow::anyhow!(
303        "Could not find acpd binary. Make sure it's installed or built.\n\
304         Try: cargo build -p acpd"
305    ))
306}
307
308fn check_daemon_health(port: u16) -> std::result::Result<String, Box<dyn std::error::Error>> {
309    let output = Command::new("curl")
310        .arg("-s")
311        .arg("-m")
312        .arg("2") // 2 second timeout
313        .arg(format!("http://127.0.0.1:{}/health", port))
314        .output()?;
315
316    if output.status.success() {
317        Ok(String::from_utf8_lossy(&output.stdout).to_string())
318    } else {
319        Err("Failed to connect".into())
320    }
321}