Skip to main content

nika_cli/
daemon.rs

1//! `nika daemon` subcommand handler.
2//!
3//! Manages the background daemon lifecycle:
4//! - `nika daemon start` — start the daemon (background or foreground)
5//! - `nika daemon stop` — graceful shutdown via SIGTERM
6//! - `nika daemon restart` — stop + start
7//! - `nika daemon status` — show daemon state
8//! - `nika daemon logs` — tail the daemon log file
9
10use clap::Subcommand;
11use colored::Colorize;
12use std::time::Duration;
13
14use nika_daemon::lifecycle::{
15    check_pid_file, cleanup_stale_socket, daemonize, is_process_alive, remove_pid_file,
16    send_sigterm, wait_for_shutdown_signal, write_pid_file,
17};
18use nika_daemon::{
19    daemon_dir, daemon_log_path, daemon_pid_path, daemon_socket_path, DaemonClient, DaemonConfig,
20    DaemonResponse, DaemonServer,
21};
22use nika_engine::error::NikaError;
23
24/// Daemon management actions.
25#[derive(Subcommand)]
26pub enum DaemonAction {
27    /// Start the background daemon
28    Start {
29        /// Run in foreground (don't daemonize)
30        #[arg(long)]
31        foreground: bool,
32    },
33
34    /// Stop the running daemon
35    Stop,
36
37    /// Restart the daemon (stop + start)
38    Restart {
39        /// Run in foreground after restart
40        #[arg(long)]
41        foreground: bool,
42    },
43
44    /// Show daemon status
45    Status,
46
47    /// Install daemon as system service (launchd on macOS, systemd on Linux)
48    Install,
49
50    /// Uninstall daemon system service
51    Uninstall,
52
53    /// Tail daemon log file
54    Logs {
55        /// Follow log output (like tail -f)
56        #[arg(short, long)]
57        follow: bool,
58
59        /// Number of lines to show
60        #[arg(short = 'n', long, default_value = "20")]
61        lines: usize,
62    },
63}
64
65pub async fn handle_daemon_command(action: DaemonAction) -> Result<(), NikaError> {
66    match action {
67        DaemonAction::Start { foreground } => start_daemon(foreground).await,
68        DaemonAction::Stop => stop_daemon().await,
69        DaemonAction::Restart { foreground } => {
70            let _ = stop_daemon().await;
71            tokio::time::sleep(Duration::from_millis(500)).await;
72            start_daemon(foreground).await
73        }
74        DaemonAction::Status => show_status().await,
75        DaemonAction::Install => {
76            nika_daemon::install::install().map_err(daemon_err)?;
77            eprintln!("{} daemon service installed", "✓".green().bold());
78            Ok(())
79        }
80        DaemonAction::Uninstall => {
81            nika_daemon::install::uninstall().map_err(daemon_err)?;
82            eprintln!("{} daemon service removed", "✓".green().bold());
83            Ok(())
84        }
85        DaemonAction::Logs { follow, lines } => show_logs(follow, lines).await,
86    }
87}
88
89async fn start_daemon(foreground: bool) -> Result<(), NikaError> {
90    let socket_path = daemon_socket_path();
91    let pid_path = daemon_pid_path();
92
93    // Check for existing daemon
94    if let Some(pid) = check_pid_file(&pid_path).map_err(daemon_err)? {
95        eprintln!("{} daemon already running (pid {})", "✗".red().bold(), pid);
96        return Ok(());
97    }
98
99    // Clean up stale socket if needed
100    let _ = cleanup_stale_socket(&socket_path, &pid_path);
101
102    // Ensure daemon dir exists
103    std::fs::create_dir_all(daemon_dir()).ok();
104
105    if !foreground {
106        // Self-exec pattern: spawn `nika daemon start --foreground` as detached child.
107        // Tokio's runtime doesn't survive fork(), so we re-exec instead.
108        let log_path = daemon_log_path();
109        daemonize(&log_path).map_err(daemon_err)?;
110
111        // Wait briefly then verify child started
112        tokio::time::sleep(Duration::from_millis(500)).await;
113        let client = DaemonClient::new(&socket_path).with_timeout(Duration::from_secs(3));
114        match client.ping().await {
115            Ok((version, _)) => {
116                eprintln!("{} daemon started (v{version})", "✓".green().bold());
117                eprintln!("  socket: {}", socket_path.display());
118                eprintln!("  logs:   {}", daemon_log_path().display());
119            }
120            Err(_) => {
121                eprintln!(
122                    "{} daemon spawned but not responding yet — check logs",
123                    "⚠".yellow().bold()
124                );
125                eprintln!("  logs: {}", daemon_log_path().display());
126            }
127        }
128        return Ok(());
129    }
130
131    // Foreground mode: run server directly in this process
132    write_pid_file(&pid_path).map_err(daemon_err)?;
133
134    eprintln!(
135        "{} daemon starting in foreground (pid {})",
136        "▸".cyan().bold(),
137        std::process::id()
138    );
139    eprintln!("  socket: {}", socket_path.display());
140    eprintln!("  press Ctrl-C to stop");
141
142    // Create and run server
143    let config = DaemonConfig {
144        socket_path: socket_path.clone(),
145        ..DaemonConfig::default()
146    };
147    let server = DaemonServer::new(config);
148    let shutdown = server.shutdown_handle();
149
150    // Spawn signal handler
151    tokio::spawn(async move {
152        wait_for_shutdown_signal().await;
153        let _ = shutdown.send(true);
154    });
155
156    // Run server (blocks until shutdown)
157    server.run().await.map_err(daemon_err)?;
158
159    // Cleanup
160    remove_pid_file(&pid_path);
161
162    if foreground {
163        eprintln!("{} daemon stopped", "✓".green().bold());
164    }
165
166    Ok(())
167}
168
169async fn stop_daemon() -> Result<(), NikaError> {
170    let pid_path = daemon_pid_path();
171
172    match check_pid_file(&pid_path).map_err(daemon_err)? {
173        Some(pid) => {
174            eprintln!("{} stopping daemon (pid {})", "▸".cyan().bold(), pid);
175
176            // Send SIGTERM
177            send_sigterm(pid).map_err(daemon_err)?;
178
179            // Wait for process to exit (up to 5 seconds)
180            for _ in 0..50 {
181                if !is_process_alive(pid) {
182                    break;
183                }
184                tokio::time::sleep(Duration::from_millis(100)).await;
185            }
186
187            // Cleanup
188            remove_pid_file(&pid_path);
189            let _ = std::fs::remove_file(daemon_socket_path());
190
191            eprintln!("{} daemon stopped", "✓".green().bold());
192            Ok(())
193        }
194        None => {
195            eprintln!("{} daemon is not running", "⚠".yellow().bold());
196            Ok(())
197        }
198    }
199}
200
201async fn show_status() -> Result<(), NikaError> {
202    let socket_path = daemon_socket_path();
203    let pid_path = daemon_pid_path();
204
205    // Check PID file
206    match check_pid_file(&pid_path).map_err(daemon_err)? {
207        None => {
208            println!("{} daemon is not running", "●".red().bold());
209            println!("  socket: {}", socket_path.display());
210            println!("  start:  nika daemon start");
211            return Ok(());
212        }
213        Some(pid) => {
214            // Try to ping
215            let client = DaemonClient::new(&socket_path).with_timeout(Duration::from_secs(2));
216
217            match client.send(nika_daemon::DaemonRequest::Status).await {
218                Ok(DaemonResponse::StatusInfo {
219                    pid,
220                    uptime_secs,
221                    services,
222                }) => {
223                    println!("{} daemon is running", "●".green().bold());
224                    println!("  pid:      {}", pid);
225                    println!("  uptime:   {}", format_uptime(uptime_secs));
226                    println!("  socket:   {}", socket_path.display());
227                    println!("  services: {}", services.join(", "));
228                }
229                Ok(other) => {
230                    println!(
231                        "{} daemon responded unexpectedly: {:?}",
232                        "●".yellow().bold(),
233                        other
234                    );
235                }
236                Err(_) => {
237                    println!(
238                        "{} daemon PID {} exists but not responding",
239                        "●".yellow().bold(),
240                        pid
241                    );
242                    println!("  socket: {}", socket_path.display());
243                    println!("  try:    nika daemon restart");
244                }
245            }
246        }
247    }
248
249    Ok(())
250}
251
252async fn show_logs(follow: bool, lines: usize) -> Result<(), NikaError> {
253    let log_path = daemon_log_path();
254
255    if !log_path.exists() {
256        eprintln!(
257            "{} no daemon log file found at {}",
258            "⚠".yellow().bold(),
259            log_path.display()
260        );
261        return Ok(());
262    }
263
264    if follow {
265        // Use tail -f for simplicity
266        let mut child = tokio::process::Command::new("tail")
267            .args(["-f", "-n", &lines.to_string()])
268            .arg(&log_path)
269            .spawn()
270            .map_err(|e| NikaError::Execution(format!("failed to spawn tail: {e}")))?;
271
272        child
273            .wait()
274            .await
275            .map_err(|e| NikaError::Execution(format!("tail failed: {e}")))?;
276    } else {
277        let content = tokio::fs::read_to_string(&log_path)
278            .await
279            .map_err(|e| NikaError::Execution(format!("failed to read log: {e}")))?;
280
281        let all_lines: Vec<&str> = content.lines().collect();
282        let start = all_lines.len().saturating_sub(lines);
283        for line in &all_lines[start..] {
284            println!("{}", line);
285        }
286    }
287
288    Ok(())
289}
290
291fn format_uptime(secs: u64) -> String {
292    let hours = secs / 3600;
293    let mins = (secs % 3600) / 60;
294    let secs = secs % 60;
295    if hours > 0 {
296        format!("{}h {}m {}s", hours, mins, secs)
297    } else if mins > 0 {
298        format!("{}m {}s", mins, secs)
299    } else {
300        format!("{}s", secs)
301    }
302}
303
304fn daemon_err(e: nika_daemon::DaemonError) -> NikaError {
305    NikaError::Execution(format!("daemon: {e}"))
306}
307
308// ═══════════════════════════════════════════════════════════════════════════
309// TESTS
310// ═══════════════════════════════════════════════════════════════════════════
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn format_uptime_seconds() {
318        assert_eq!(format_uptime(42), "42s");
319    }
320
321    #[test]
322    fn format_uptime_minutes() {
323        assert_eq!(format_uptime(125), "2m 5s");
324    }
325
326    #[test]
327    fn format_uptime_hours() {
328        assert_eq!(format_uptime(3661), "1h 1m 1s");
329    }
330
331    #[test]
332    fn daemon_action_variants_exist() {
333        // Ensure all variants compile
334        let _ = DaemonAction::Start { foreground: false };
335        let _ = DaemonAction::Stop;
336        let _ = DaemonAction::Restart { foreground: false };
337        let _ = DaemonAction::Status;
338        let _ = DaemonAction::Logs {
339            follow: false,
340            lines: 20,
341        };
342    }
343}