aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authordiogo464 <[email protected]>2025-06-19 08:52:20 +0100
committerdiogo464 <[email protected]>2025-06-19 08:52:20 +0100
commit39b3d9bfd499e131fd8a9bd1bf0021b62ec18c53 (patch)
tree9975c7d92f28ed19edc370c7e11473f56334629c /src
Initial implementation of demon CLI tool
Implement complete daemon process management CLI with the following features: - demon run: spawn background processes with stdout/stderr redirection - demon stop: graceful process termination with SIGTERM/SIGKILL timeout - demon tail: real-time file watching and log tailing - demon cat: display log file contents - demon list: show all managed processes with status - demon status: detailed process information - demon clean: remove orphaned files from dead processes Technical implementation: - Uses clap for CLI with enum-based subcommands - Structured logging with tracing crate - File watching with notify crate for efficient tailing - Process management with proper signal handling - Creates .pid, .stdout, .stderr files in working directory - Comprehensive error handling and edge case coverage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/main.rs706
1 files changed, 706 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..e0545e6
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,706 @@
1use clap::{Parser, Subcommand, Args};
2use std::fs::File;
3use std::io::{Write, Read, Seek, SeekFrom};
4use std::process::{Command, Stdio};
5use std::thread;
6use std::time::Duration;
7use std::path::Path;
8use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
9use std::sync::mpsc::channel;
10use glob::glob;
11
12#[derive(Parser)]
13#[command(name = "demon")]
14#[command(about = "A daemon process management CLI", long_about = None)]
15#[command(version)]
16#[command(propagate_version = true)]
17struct Cli {
18 #[command(subcommand)]
19 command: Commands,
20}
21
22#[derive(Subcommand)]
23enum Commands {
24 /// Spawn a background process and redirect stdout/stderr to files
25 Run(RunArgs),
26
27 /// Stop a running daemon process
28 Stop(StopArgs),
29
30 /// Tail daemon logs in real-time
31 Tail(TailArgs),
32
33 /// Display daemon log contents
34 Cat(CatArgs),
35
36 /// List all running daemon processes
37 List,
38
39 /// Check status of a daemon process
40 Status(StatusArgs),
41
42 /// Clean up orphaned pid and log files
43 Clean,
44}
45
46#[derive(Args)]
47struct RunArgs {
48 /// Process identifier
49 #[arg(long)]
50 id: String,
51
52 /// Command and arguments to execute
53 command: Vec<String>,
54}
55
56#[derive(Args)]
57struct StopArgs {
58 /// Process identifier
59 #[arg(long)]
60 id: String,
61
62 /// Timeout in seconds before sending SIGKILL after SIGTERM
63 #[arg(long, default_value = "10")]
64 timeout: u64,
65}
66
67#[derive(Args)]
68struct TailArgs {
69 /// Process identifier
70 #[arg(long)]
71 id: String,
72
73 /// Only tail stdout
74 #[arg(long)]
75 stdout: bool,
76
77 /// Only tail stderr
78 #[arg(long)]
79 stderr: bool,
80}
81
82#[derive(Args)]
83struct CatArgs {
84 /// Process identifier
85 #[arg(long)]
86 id: String,
87
88 /// Only show stdout
89 #[arg(long)]
90 stdout: bool,
91
92 /// Only show stderr
93 #[arg(long)]
94 stderr: bool,
95}
96
97#[derive(Args)]
98struct StatusArgs {
99 /// Process identifier
100 #[arg(long)]
101 id: String,
102}
103
104fn main() {
105 tracing_subscriber::fmt()
106 .with_writer(std::io::stderr)
107 .init();
108
109 let cli = Cli::parse();
110
111 if let Err(e) = run_command(cli.command) {
112 tracing::error!("Error: {}", e);
113 std::process::exit(1);
114 }
115}
116
117fn run_command(command: Commands) -> Result<(), Box<dyn std::error::Error>> {
118 match command {
119 Commands::Run(args) => {
120 if args.command.is_empty() {
121 return Err("Command cannot be empty".into());
122 }
123 run_daemon(&args.id, &args.command)
124 }
125 Commands::Stop(args) => {
126 stop_daemon(&args.id, args.timeout)
127 }
128 Commands::Tail(args) => {
129 let show_stdout = !args.stderr || args.stdout;
130 let show_stderr = !args.stdout || args.stderr;
131 tail_logs(&args.id, show_stdout, show_stderr)
132 }
133 Commands::Cat(args) => {
134 let show_stdout = !args.stderr || args.stdout;
135 let show_stderr = !args.stdout || args.stderr;
136 cat_logs(&args.id, show_stdout, show_stderr)
137 }
138 Commands::List => {
139 list_daemons()
140 }
141 Commands::Status(args) => {
142 status_daemon(&args.id)
143 }
144 Commands::Clean => {
145 clean_orphaned_files()
146 }
147 }
148}
149
150fn run_daemon(id: &str, command: &[String]) -> Result<(), Box<dyn std::error::Error>> {
151 let pid_file = format!("{}.pid", id);
152 let stdout_file = format!("{}.stdout", id);
153 let stderr_file = format!("{}.stderr", id);
154
155 // Check if process is already running
156 if is_process_running(&pid_file)? {
157 return Err(format!("Process '{}' is already running", id).into());
158 }
159
160 tracing::info!("Starting daemon '{}' with command: {:?}", id, command);
161
162 // Truncate/create output files
163 File::create(&stdout_file)?;
164 File::create(&stderr_file)?;
165
166 // Open files for redirection
167 let stdout_redirect = File::create(&stdout_file)?;
168 let stderr_redirect = File::create(&stderr_file)?;
169
170 // Spawn the process
171 let program = &command[0];
172 let args = if command.len() > 1 { &command[1..] } else { &[] };
173
174 let child = Command::new(program)
175 .args(args)
176 .stdout(Stdio::from(stdout_redirect))
177 .stderr(Stdio::from(stderr_redirect))
178 .stdin(Stdio::null())
179 .spawn()
180 .map_err(|e| format!("Failed to start process '{}': {}", program, e))?;
181
182 // Write PID to file
183 let mut pid_file_handle = File::create(&pid_file)?;
184 writeln!(pid_file_handle, "{}", child.id())?;
185
186 // Don't wait for the child - let it run detached
187 std::mem::forget(child);
188
189 println!("Started daemon '{}' with PID written to {}", id, pid_file);
190
191 Ok(())
192}
193
194fn is_process_running(pid_file: &str) -> Result<bool, Box<dyn std::error::Error>> {
195 // Try to read the PID file
196 let mut file = match File::open(pid_file) {
197 Ok(f) => f,
198 Err(_) => return Ok(false), // No PID file means no running process
199 };
200
201 let mut contents = String::new();
202 file.read_to_string(&mut contents)?;
203
204 let pid: u32 = match contents.trim().parse() {
205 Ok(p) => p,
206 Err(_) => return Ok(false), // Invalid PID file
207 };
208
209 // Check if process is still running using kill -0
210 let output = Command::new("kill")
211 .args(&["-0", &pid.to_string()])
212 .output()?;
213
214 Ok(output.status.success())
215}
216
217fn stop_daemon(id: &str, timeout: u64) -> Result<(), Box<dyn std::error::Error>> {
218 let pid_file = format!("{}.pid", id);
219
220 // Check if PID file exists
221 let mut file = match File::open(&pid_file) {
222 Ok(f) => f,
223 Err(_) => {
224 println!("Process '{}' is not running (no PID file found)", id);
225 return Ok(());
226 }
227 };
228
229 // Read PID
230 let mut contents = String::new();
231 file.read_to_string(&mut contents)?;
232
233 let pid: u32 = match contents.trim().parse() {
234 Ok(p) => p,
235 Err(_) => {
236 println!("Process '{}': invalid PID file, removing it", id);
237 std::fs::remove_file(&pid_file)?;
238 return Ok(());
239 }
240 };
241
242 tracing::info!("Stopping daemon '{}' (PID: {}) with timeout {}s", id, pid, timeout);
243
244 // Check if process is running
245 if !is_process_running_by_pid(pid) {
246 println!("Process '{}' (PID: {}) is not running, cleaning up PID file", id, pid);
247 std::fs::remove_file(&pid_file)?;
248 return Ok(());
249 }
250
251 // Send SIGTERM
252 tracing::info!("Sending SIGTERM to PID {}", pid);
253 let output = Command::new("kill")
254 .args(&["-TERM", &pid.to_string()])
255 .output()?;
256
257 if !output.status.success() {
258 return Err(format!("Failed to send SIGTERM to PID {}", pid).into());
259 }
260
261 // Wait for the process to terminate
262 for i in 0..timeout {
263 if !is_process_running_by_pid(pid) {
264 println!("Process '{}' (PID: {}) terminated gracefully", id, pid);
265 std::fs::remove_file(&pid_file)?;
266 return Ok(());
267 }
268
269 if i == 0 {
270 tracing::info!("Waiting for process to terminate gracefully...");
271 }
272
273 thread::sleep(Duration::from_secs(1));
274 }
275
276 // Process didn't terminate, send SIGKILL
277 tracing::warn!("Process {} didn't terminate after {}s, sending SIGKILL", pid, timeout);
278 let output = Command::new("kill")
279 .args(&["-KILL", &pid.to_string()])
280 .output()?;
281
282 if !output.status.success() {
283 return Err(format!("Failed to send SIGKILL to PID {}", pid).into());
284 }
285
286 // Wait a bit more for SIGKILL to take effect
287 thread::sleep(Duration::from_secs(1));
288
289 if is_process_running_by_pid(pid) {
290 return Err(format!("Process {} is still running after SIGKILL", pid).into());
291 }
292
293 println!("Process '{}' (PID: {}) terminated forcefully", id, pid);
294 std::fs::remove_file(&pid_file)?;
295
296 Ok(())
297}
298
299fn is_process_running_by_pid(pid: u32) -> bool {
300 let output = Command::new("kill")
301 .args(&["-0", &pid.to_string()])
302 .output();
303
304 match output {
305 Ok(output) => output.status.success(),
306 Err(_) => false,
307 }
308}
309
310fn cat_logs(id: &str, show_stdout: bool, show_stderr: bool) -> Result<(), Box<dyn std::error::Error>> {
311 let stdout_file = format!("{}.stdout", id);
312 let stderr_file = format!("{}.stderr", id);
313
314 let mut files_found = false;
315
316 if show_stdout {
317 if let Ok(contents) = std::fs::read_to_string(&stdout_file) {
318 if !contents.is_empty() {
319 files_found = true;
320 if show_stderr {
321 println!("==> {} <==", stdout_file);
322 }
323 print!("{}", contents);
324 }
325 } else {
326 tracing::warn!("Could not read {}", stdout_file);
327 }
328 }
329
330 if show_stderr {
331 if let Ok(contents) = std::fs::read_to_string(&stderr_file) {
332 if !contents.is_empty() {
333 files_found = true;
334 if show_stdout {
335 println!("==> {} <==", stderr_file);
336 }
337 print!("{}", contents);
338 }
339 } else {
340 tracing::warn!("Could not read {}", stderr_file);
341 }
342 }
343
344 if !files_found {
345 println!("No log files found for daemon '{}'", id);
346 }
347
348 Ok(())
349}
350
351fn tail_logs(id: &str, show_stdout: bool, show_stderr: bool) -> Result<(), Box<dyn std::error::Error>> {
352 let stdout_file = format!("{}.stdout", id);
353 let stderr_file = format!("{}.stderr", id);
354
355 // First, display existing content and set up initial positions
356 let mut file_positions: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
357
358 if show_stdout && Path::new(&stdout_file).exists() {
359 let mut file = File::open(&stdout_file)?;
360 let initial_content = read_file_content(&mut file)?;
361 if !initial_content.is_empty() {
362 if show_stderr {
363 println!("==> {} <==", stdout_file);
364 }
365 print!("{}", initial_content);
366 }
367 let position = file.seek(SeekFrom::Current(0))?;
368 file_positions.insert(stdout_file.clone(), position);
369 }
370
371 if show_stderr && Path::new(&stderr_file).exists() {
372 let mut file = File::open(&stderr_file)?;
373 let initial_content = read_file_content(&mut file)?;
374 if !initial_content.is_empty() {
375 if show_stdout && file_positions.len() > 0 {
376 println!("\n==> {} <==", stderr_file);
377 } else if show_stdout {
378 println!("==> {} <==", stderr_file);
379 }
380 print!("{}", initial_content);
381 }
382 let position = file.seek(SeekFrom::Current(0))?;
383 file_positions.insert(stderr_file.clone(), position);
384 }
385
386 if file_positions.is_empty() {
387 println!("No log files found for daemon '{}'. Watching for new files...", id);
388 }
389
390 tracing::info!("Watching for changes to log files... Press Ctrl+C to stop.");
391
392 // Set up file watcher
393 let (tx, rx) = channel();
394 let mut watcher = RecommendedWatcher::new(tx, Config::default())?;
395
396 // Watch the current directory for new files and changes
397 watcher.watch(Path::new("."), RecursiveMode::NonRecursive)?;
398
399 // Handle Ctrl+C gracefully
400 let running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
401 let r = running.clone();
402
403 ctrlc::set_handler(move || {
404 r.store(false, std::sync::atomic::Ordering::SeqCst);
405 })?;
406
407 while running.load(std::sync::atomic::Ordering::SeqCst) {
408 match rx.recv_timeout(Duration::from_millis(100)) {
409 Ok(res) => {
410 match res {
411 Ok(Event {
412 kind: EventKind::Modify(_),
413 paths,
414 ..
415 }) => {
416 for path in paths {
417 let path_str = path.to_string_lossy().to_string();
418
419 if (show_stdout && path_str == stdout_file) ||
420 (show_stderr && path_str == stderr_file) {
421
422 if let Err(e) = handle_file_change(&path_str, &mut file_positions, show_stdout && show_stderr) {
423 tracing::error!("Error handling file change: {}", e);
424 }
425 }
426 }
427 }
428 Ok(Event {
429 kind: EventKind::Create(_),
430 paths,
431 ..
432 }) => {
433 // Handle file creation
434 for path in paths {
435 let path_str = path.to_string_lossy().to_string();
436
437 if (show_stdout && path_str == stdout_file) ||
438 (show_stderr && path_str == stderr_file) {
439
440 tracing::info!("New file detected: {}", path_str);
441 file_positions.insert(path_str.clone(), 0);
442
443 if let Err(e) = handle_file_change(&path_str, &mut file_positions, show_stdout && show_stderr) {
444 tracing::error!("Error handling new file: {}", e);
445 }
446 }
447 }
448 }
449 Ok(_) => {} // Ignore other events
450 Err(e) => tracing::error!("Watch error: {:?}", e),
451 }
452 }
453 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
454 // Timeout is normal, just continue
455 }
456 Err(e) => {
457 tracing::error!("Receive error: {}", e);
458 break;
459 }
460 }
461 }
462
463 println!("\nTailing stopped.");
464 Ok(())
465}
466
467fn read_file_content(file: &mut File) -> Result<String, Box<dyn std::error::Error>> {
468 let mut content = String::new();
469 file.read_to_string(&mut content)?;
470 Ok(content)
471}
472
473fn handle_file_change(
474 file_path: &str,
475 positions: &mut std::collections::HashMap<String, u64>,
476 show_headers: bool
477) -> Result<(), Box<dyn std::error::Error>> {
478 let mut file = File::open(file_path)?;
479 let current_pos = positions.get(file_path).copied().unwrap_or(0);
480
481 // Seek to the last read position
482 file.seek(SeekFrom::Start(current_pos))?;
483
484 // Read new content
485 let mut new_content = String::new();
486 file.read_to_string(&mut new_content)?;
487
488 if !new_content.is_empty() {
489 if show_headers {
490 println!("==> {} <==", file_path);
491 }
492 print!("{}", new_content);
493 std::io::Write::flush(&mut std::io::stdout())?;
494
495 // Update position
496 let new_pos = file.seek(SeekFrom::Current(0))?;
497 positions.insert(file_path.to_string(), new_pos);
498 }
499
500 Ok(())
501}
502
503fn list_daemons() -> Result<(), Box<dyn std::error::Error>> {
504 println!("{:<20} {:<8} {:<10} {}", "ID", "PID", "STATUS", "COMMAND");
505 println!("{}", "-".repeat(50));
506
507 let mut found_any = false;
508
509 // Find all .pid files in current directory
510 for entry in glob("*.pid")? {
511 match entry {
512 Ok(path) => {
513 found_any = true;
514 let path_str = path.to_string_lossy();
515
516 // Extract ID from filename (remove .pid extension)
517 let id = path_str.strip_suffix(".pid").unwrap_or(&path_str);
518
519 // Read PID from file
520 match std::fs::read_to_string(&path) {
521 Ok(contents) => {
522 let pid_str = contents.trim();
523 match pid_str.parse::<u32>() {
524 Ok(pid) => {
525 let status = if is_process_running_by_pid(pid) {
526 "RUNNING"
527 } else {
528 "DEAD"
529 };
530
531 // Try to read command from a hypothetical command file
532 // For now, we'll just show "N/A" since we don't store the command
533 let command = "N/A";
534
535 println!("{:<20} {:<8} {:<10} {}", id, pid, status, command);
536 }
537 Err(_) => {
538 println!("{:<20} {:<8} {:<10} {}", id, "INVALID", "ERROR", "Invalid PID file");
539 }
540 }
541 }
542 Err(e) => {
543 println!("{:<20} {:<8} {:<10} {}", id, "ERROR", "ERROR", format!("Cannot read: {}", e));
544 }
545 }
546 }
547 Err(e) => {
548 tracing::warn!("Error reading glob entry: {}", e);
549 }
550 }
551 }
552
553 if !found_any {
554 println!("No daemon processes found.");
555 }
556
557 Ok(())
558}
559
560fn status_daemon(id: &str) -> Result<(), Box<dyn std::error::Error>> {
561 let pid_file = format!("{}.pid", id);
562 let stdout_file = format!("{}.stdout", id);
563 let stderr_file = format!("{}.stderr", id);
564
565 println!("Daemon: {}", id);
566 println!("PID file: {}", pid_file);
567
568 // Check if PID file exists
569 if !Path::new(&pid_file).exists() {
570 println!("Status: NOT FOUND (no PID file)");
571 return Ok(());
572 }
573
574 // Read PID from file
575 match std::fs::read_to_string(&pid_file) {
576 Ok(contents) => {
577 let pid_str = contents.trim();
578 match pid_str.parse::<u32>() {
579 Ok(pid) => {
580 println!("PID: {}", pid);
581
582 if is_process_running_by_pid(pid) {
583 println!("Status: RUNNING");
584
585 // Show file information
586 if Path::new(&stdout_file).exists() {
587 let metadata = std::fs::metadata(&stdout_file)?;
588 println!("Stdout file: {} ({} bytes)", stdout_file, metadata.len());
589 } else {
590 println!("Stdout file: {} (not found)", stdout_file);
591 }
592
593 if Path::new(&stderr_file).exists() {
594 let metadata = std::fs::metadata(&stderr_file)?;
595 println!("Stderr file: {} ({} bytes)", stderr_file, metadata.len());
596 } else {
597 println!("Stderr file: {} (not found)", stderr_file);
598 }
599 } else {
600 println!("Status: DEAD (process not running)");
601 println!("Note: Use 'demon clean' to remove orphaned files");
602 }
603 }
604 Err(_) => {
605 println!("Status: ERROR (invalid PID in file)");
606 }
607 }
608 }
609 Err(e) => {
610 println!("Status: ERROR (cannot read PID file: {})", e);
611 }
612 }
613
614 Ok(())
615}
616
617fn clean_orphaned_files() -> Result<(), Box<dyn std::error::Error>> {
618 tracing::info!("Scanning for orphaned daemon files...");
619
620 let mut cleaned_count = 0;
621
622 // Find all .pid files in current directory
623 for entry in glob("*.pid")? {
624 match entry {
625 Ok(path) => {
626 let path_str = path.to_string_lossy();
627 let id = path_str.strip_suffix(".pid").unwrap_or(&path_str);
628
629 // Read PID from file
630 match std::fs::read_to_string(&path) {
631 Ok(contents) => {
632 let pid_str = contents.trim();
633 match pid_str.parse::<u32>() {
634 Ok(pid) => {
635 // Check if process is still running
636 if !is_process_running_by_pid(pid) {
637 println!("Cleaning up orphaned files for '{}' (PID: {})", id, pid);
638
639 // Remove PID file
640 if let Err(e) = std::fs::remove_file(&path) {
641 tracing::warn!("Failed to remove {}: {}", path_str, e);
642 } else {
643 tracing::info!("Removed {}", path_str);
644 }
645
646 // Remove stdout file if it exists
647 let stdout_file = format!("{}.stdout", id);
648 if Path::new(&stdout_file).exists() {
649 if let Err(e) = std::fs::remove_file(&stdout_file) {
650 tracing::warn!("Failed to remove {}: {}", stdout_file, e);
651 } else {
652 tracing::info!("Removed {}", stdout_file);
653 }
654 }
655
656 // Remove stderr file if it exists
657 let stderr_file = format!("{}.stderr", id);
658 if Path::new(&stderr_file).exists() {
659 if let Err(e) = std::fs::remove_file(&stderr_file) {
660 tracing::warn!("Failed to remove {}: {}", stderr_file, e);
661 } else {
662 tracing::info!("Removed {}", stderr_file);
663 }
664 }
665
666 cleaned_count += 1;
667 } else {
668 tracing::info!("Skipping '{}' (PID: {}) - process is still running", id, pid);
669 }
670 }
671 Err(_) => {
672 println!("Cleaning up invalid PID file: {}", path_str);
673 if let Err(e) = std::fs::remove_file(&path) {
674 tracing::warn!("Failed to remove invalid PID file {}: {}", path_str, e);
675 } else {
676 tracing::info!("Removed invalid PID file {}", path_str);
677 cleaned_count += 1;
678 }
679 }
680 }
681 }
682 Err(_) => {
683 println!("Cleaning up unreadable PID file: {}", path_str);
684 if let Err(e) = std::fs::remove_file(&path) {
685 tracing::warn!("Failed to remove unreadable PID file {}: {}", path_str, e);
686 } else {
687 tracing::info!("Removed unreadable PID file {}", path_str);
688 cleaned_count += 1;
689 }
690 }
691 }
692 }
693 Err(e) => {
694 tracing::warn!("Error reading glob entry: {}", e);
695 }
696 }
697 }
698
699 if cleaned_count == 0 {
700 println!("No orphaned files found.");
701 } else {
702 println!("Cleaned up {} orphaned daemon(s).", cleaned_count);
703 }
704
705 Ok(())
706}