Skip to main content

auths_cli/commands/
agent.rs

1//! SSH agent daemon commands (start, stop, status).
2
3use anyhow::{Context, Result, anyhow};
4use clap::{Parser, Subcommand, ValueEnum};
5use serde::Serialize;
6use std::fs;
7use std::path::PathBuf;
8
9use crate::core::fs::{create_restricted_dir, write_sensitive_file};
10use crate::ux::format::{JsonResponse, is_json_mode};
11
12#[cfg(unix)]
13use nix::sys::signal::{self, Signal};
14#[cfg(unix)]
15use nix::unistd::Pid;
16
17/// Default socket filename within ~/.auths
18const DEFAULT_SOCKET_NAME: &str = "agent.sock";
19/// PID file name
20const PID_FILE_NAME: &str = "agent.pid";
21/// Environment file for SSH_AUTH_SOCK
22const ENV_FILE_NAME: &str = "agent.env";
23/// Log file for daemon output
24const LOG_FILE_NAME: &str = "agent.log";
25
26#[derive(Parser, Debug, Clone)]
27#[command(
28    name = "agent",
29    about = "SSH agent daemon management (start, stop, status)."
30)]
31pub struct AgentCommand {
32    #[command(subcommand)]
33    pub command: AgentSubcommand,
34}
35
36#[derive(Subcommand, Debug, Clone)]
37pub enum AgentSubcommand {
38    /// Start the SSH agent daemon
39    Start {
40        /// Custom socket path (default: ~/.auths/agent.sock)
41        #[arg(long, help = "Custom Unix socket path")]
42        socket: Option<PathBuf>,
43
44        /// Run in foreground (don't daemonize)
45        #[arg(long, help = "Run in foreground instead of daemonizing")]
46        foreground: bool,
47
48        /// Idle timeout (e.g., "30m", "1h", "0" for never)
49        #[arg(long, default_value = "30m", help = "Idle timeout before auto-lock")]
50        timeout: String,
51    },
52
53    /// Stop the SSH agent daemon
54    Stop,
55
56    /// Show agent status
57    Status,
58
59    /// Output shell environment for SSH_AUTH_SOCK (use with eval)
60    Env {
61        /// Shell format for output
62        #[arg(long, value_enum, default_value = "bash", help = "Shell format")]
63        shell: ShellFormat,
64    },
65
66    /// Lock the agent (clear keys from memory)
67    Lock,
68
69    /// Unlock the agent (re-load keys)
70    Unlock {
71        /// Key alias to unlock
72        #[arg(
73            long = "agent-key-alias",
74            visible_alias = "key",
75            default_value = "default",
76            help = "Key alias to unlock"
77        )]
78        agent_key_alias: String,
79    },
80
81    /// Install as a system service (launchd on macOS, systemd on Linux)
82    InstallService {
83        /// Don't install, just print the service file
84        #[arg(long, help = "Print service file without installing")]
85        dry_run: bool,
86
87        /// Force overwrite if service already exists
88        #[arg(long, help = "Overwrite existing service file")]
89        force: bool,
90
91        /// Service manager to use (auto-detect if not specified)
92        #[arg(long, value_enum, help = "Service manager (auto-detect by default)")]
93        manager: Option<ServiceManager>,
94    },
95
96    /// Uninstall the system service
97    UninstallService,
98}
99
100/// Service manager type for platform-specific service installation
101#[derive(ValueEnum, Clone, Debug, PartialEq)]
102pub enum ServiceManager {
103    /// macOS launchd
104    Launchd,
105    /// Linux systemd (user mode)
106    Systemd,
107}
108
109/// Shell format for environment output
110#[derive(ValueEnum, Clone, Debug, Default)]
111pub enum ShellFormat {
112    #[default]
113    Bash,
114    Zsh,
115    Fish,
116}
117
118/// Status information about the agent
119#[derive(Serialize, Debug)]
120pub struct AgentStatus {
121    pub running: bool,
122    pub pid: Option<u32>,
123    pub socket_path: Option<String>,
124    pub socket_exists: bool,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub uptime_secs: Option<u64>,
127}
128
129/// Ensures the SSH agent is running, starting it if necessary.
130///
131/// Returns `Ok(true)` if the agent was already running, `Ok(false)` if it was started.
132/// Returns an error if the agent could not be started.
133///
134/// This function can be called from commands that need the agent to be running
135/// before performing signing operations, enabling auto-start functionality.
136#[allow(dead_code)] // Used by bin/sign.rs (cross-target usage not tracked by lint)
137pub fn ensure_agent_running(quiet: bool) -> Result<bool> {
138    let socket_path = get_default_socket_path()?;
139
140    // Check if already running via connection attempt (avoids TOCTOU on socket path)
141    if let Some(pid) = read_pid()?
142        && is_process_running(pid)
143        && socket_is_connectable(&socket_path)
144    {
145        return Ok(true); // Already running
146    }
147
148    // Start agent
149    if !quiet {
150        eprintln!("Agent not running, starting...");
151    }
152
153    // Use default timeout of 30m
154    start_agent(None, false, "30m", quiet)?;
155
156    // Poll for socket connectivity with 2s timeout
157    let timeout = std::time::Duration::from_secs(2);
158    let start = std::time::Instant::now();
159    while start.elapsed() < timeout {
160        if let Some(pid) = read_pid()?
161            && is_process_running(pid)
162            && socket_is_connectable(&socket_path)
163        {
164            if !quiet {
165                eprintln!("Agent started (PID {})", pid);
166            }
167            return Ok(false); // Just started
168        }
169        std::thread::sleep(std::time::Duration::from_millis(100));
170    }
171
172    Err(anyhow!("Failed to start agent within 2 seconds"))
173}
174
175pub fn handle_agent(cmd: AgentCommand) -> Result<()> {
176    match cmd.command {
177        AgentSubcommand::Start {
178            socket,
179            foreground,
180            timeout,
181        } => start_agent(socket, foreground, &timeout, false),
182        AgentSubcommand::Stop => stop_agent(),
183        AgentSubcommand::Status => show_status(),
184        AgentSubcommand::Env { shell } => output_env(shell),
185        AgentSubcommand::Lock => lock_agent(),
186        AgentSubcommand::Unlock { agent_key_alias } => unlock_agent(&agent_key_alias),
187        AgentSubcommand::InstallService {
188            dry_run,
189            force,
190            manager,
191        } => install_service(dry_run, force, manager),
192        AgentSubcommand::UninstallService => uninstall_service(),
193    }
194}
195
196/// Parse a timeout string like "30m", "1h", "0", "5s"
197fn parse_timeout(s: &str) -> Result<std::time::Duration> {
198    use std::time::Duration;
199
200    let s = s.trim();
201    if s == "0" {
202        return Ok(Duration::ZERO);
203    }
204
205    // Try to parse as number + suffix using strip_suffix
206    let (num_str, suffix) = if let Some(stripped) = s.strip_suffix('s') {
207        (stripped, "s")
208    } else if let Some(stripped) = s.strip_suffix('m') {
209        (stripped, "m")
210    } else if let Some(stripped) = s.strip_suffix('h') {
211        (stripped, "h")
212    } else {
213        // Assume minutes if no suffix
214        (s, "m")
215    };
216
217    let num: u64 = num_str
218        .parse()
219        .with_context(|| format!("Invalid timeout number: {}", num_str))?;
220
221    let secs = match suffix {
222        "s" => num,
223        "m" => num * 60,
224        "h" => num * 3600,
225        _ => return Err(anyhow!("Invalid timeout suffix: {}", suffix)),
226    };
227
228    Ok(Duration::from_secs(secs))
229}
230
231/// Get the auths directory path (~/.auths), respecting AUTHS_HOME.
232fn get_auths_dir() -> Result<PathBuf> {
233    auths_core::paths::auths_home().map_err(|e| anyhow!(e))
234}
235
236/// Get the default socket path
237pub fn get_default_socket_path() -> Result<PathBuf> {
238    Ok(get_auths_dir()?.join(DEFAULT_SOCKET_NAME))
239}
240
241/// Get the PID file path
242fn get_pid_file_path() -> Result<PathBuf> {
243    Ok(get_auths_dir()?.join(PID_FILE_NAME))
244}
245
246/// Get the environment file path
247fn get_env_file_path() -> Result<PathBuf> {
248    Ok(get_auths_dir()?.join(ENV_FILE_NAME))
249}
250
251/// Get the log file path
252fn get_log_file_path() -> Result<PathBuf> {
253    Ok(get_auths_dir()?.join(LOG_FILE_NAME))
254}
255
256/// Read PID from file
257fn read_pid() -> Result<Option<u32>> {
258    let pid_path = get_pid_file_path()?;
259    if !pid_path.exists() {
260        return Ok(None);
261    }
262
263    let content = fs::read_to_string(&pid_path)
264        .with_context(|| format!("Failed to read PID file: {:?}", pid_path))?;
265
266    let pid: u32 = content
267        .trim()
268        .parse()
269        .with_context(|| format!("Invalid PID in file: {}", content.trim()))?;
270
271    Ok(Some(pid))
272}
273
274/// Check if a process with the given PID is running
275#[cfg(unix)]
276fn is_process_running(pid: u32) -> bool {
277    // Try to send signal 0 (doesn't actually send anything, just checks if process exists)
278    signal::kill(Pid::from_raw(pid as i32), None).is_ok()
279}
280
281#[cfg(not(unix))]
282fn is_process_running(_pid: u32) -> bool {
283    false
284}
285
286/// Test if the agent socket is connectable (avoids TOCTOU vs exists() check).
287#[cfg(unix)]
288fn socket_is_connectable(path: &std::path::Path) -> bool {
289    std::os::unix::net::UnixStream::connect(path).is_ok()
290}
291
292#[cfg(not(unix))]
293fn socket_is_connectable(_path: &std::path::Path) -> bool {
294    false
295}
296
297/// Start the agent
298fn start_agent(
299    socket_path: Option<PathBuf>,
300    foreground: bool,
301    timeout_str: &str,
302    quiet: bool,
303) -> Result<()> {
304    let auths_dir = get_auths_dir()?;
305    create_restricted_dir(&auths_dir)
306        .with_context(|| format!("Failed to create auths directory: {:?}", auths_dir))?;
307
308    let socket = socket_path.unwrap_or_else(|| get_default_socket_path().unwrap());
309    let pid_path = get_pid_file_path()?;
310    let env_path = get_env_file_path()?;
311    let timeout = parse_timeout(timeout_str)?;
312
313    // Check if already running
314    if let Some(pid) = read_pid()? {
315        if is_process_running(pid) {
316            return Err(anyhow!(
317                "Agent already running (PID {}). Use 'auths agent stop' first.",
318                pid
319            ));
320        }
321        // Stale PID file, clean it up
322        let _ = fs::remove_file(&pid_path);
323    }
324
325    // Remove stale socket atomically (no TOCTOU exists-then-remove)
326    match fs::remove_file(&socket) {
327        Ok(()) => {}
328        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
329        Err(e) => {
330            return Err(anyhow!("Failed to remove stale socket {:?}: {}", socket, e));
331        }
332    }
333
334    if foreground {
335        // Run in foreground
336        run_agent_foreground(&socket, &pid_path, &env_path, timeout)
337    } else {
338        // Daemonize
339        #[cfg(unix)]
340        {
341            daemonize_agent(
342                &socket,
343                &pid_path,
344                &env_path,
345                &get_log_file_path()?,
346                timeout_str,
347                quiet,
348            )
349        }
350        #[cfg(not(unix))]
351        {
352            Err(anyhow!(
353                "Daemonization not supported on this platform. Use --foreground."
354            ))
355        }
356    }
357}
358
359/// Run the agent in the foreground (Unix only)
360#[cfg(unix)]
361fn run_agent_foreground(
362    socket: &PathBuf,
363    pid_path: &PathBuf,
364    env_path: &PathBuf,
365    timeout: std::time::Duration,
366) -> Result<()> {
367    use auths_core::AgentHandle;
368    use std::sync::Arc;
369
370    // Write PID file
371    let pid = std::process::id();
372    write_sensitive_file(pid_path, pid.to_string())
373        .with_context(|| format!("Failed to write PID file: {:?}", pid_path))?;
374
375    // Write environment file
376    let socket_str = socket
377        .to_str()
378        .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
379    let env_content = format!("export SSH_AUTH_SOCK=\"{}\"\n", socket_str);
380    write_sensitive_file(env_path, &env_content)
381        .with_context(|| format!("Failed to write env file: {:?}", env_path))?;
382
383    eprintln!("Starting SSH agent (foreground)...");
384    eprintln!("Socket: {}", socket_str);
385    eprintln!("PID: {}", pid);
386    if timeout.is_zero() {
387        eprintln!("Idle timeout: disabled");
388    } else {
389        eprintln!("Idle timeout: {:?}", timeout);
390    }
391    eprintln!();
392    eprintln!("To use this agent in your shell:");
393    eprintln!("  eval $(cat {})", env_path.display());
394    eprintln!("  # or");
395    eprintln!("  export SSH_AUTH_SOCK=\"{}\"", socket_str);
396    eprintln!();
397    eprintln!("Press Ctrl+C to stop.");
398
399    // Create agent handle with timeout
400    let handle = Arc::new(AgentHandle::with_pid_file_and_timeout(
401        socket.clone(),
402        pid_path.clone(),
403        timeout,
404    ));
405
406    // Run the listener
407    let rt = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
408    let result = rt.block_on(async {
409        auths_core::api::start_agent_listener_with_handle(handle.clone()).await
410    });
411
412    // Cleanup on exit
413    let _ = fs::remove_file(pid_path);
414    let _ = fs::remove_file(env_path);
415    let _ = fs::remove_file(socket);
416
417    result.map_err(|e| anyhow!("Agent error: {}", e))
418}
419
420/// Run the agent in the foreground (non-Unix stub)
421#[cfg(not(unix))]
422fn run_agent_foreground(
423    _socket: &PathBuf,
424    _pid_path: &PathBuf,
425    _env_path: &PathBuf,
426    _timeout: std::time::Duration,
427) -> Result<()> {
428    Err(anyhow!(
429        "SSH agent is not supported on this platform (requires Unix domain sockets)"
430    ))
431}
432
433/// Daemonize the agent process (Unix only)
434#[cfg(unix)]
435fn daemonize_agent(
436    socket: &std::path::Path,
437    _pid_path: &std::path::Path,
438    env_path: &std::path::Path,
439    log_path: &std::path::Path,
440    timeout_str: &str,
441    quiet: bool,
442) -> Result<()> {
443    use std::os::unix::process::CommandExt;
444    use std::process::Command;
445
446    let socket_str = socket
447        .to_str()
448        .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
449
450    // Get the path to the current executable
451    let exe = std::env::current_exe().context("Failed to get current executable path")?;
452
453    // Fork by re-executing ourselves with --foreground
454    // The child will detach and become the daemon
455    let log_file = fs::File::create(log_path)
456        .with_context(|| format!("Failed to create log file: {:?}", log_path))?;
457    let log_file_err = log_file
458        .try_clone()
459        .context("Failed to clone log file handle")?;
460
461    let mut cmd = Command::new(&exe);
462    cmd.arg("agent")
463        .arg("start")
464        .arg("--foreground")
465        .arg("--socket")
466        .arg(socket_str)
467        .arg("--timeout")
468        .arg(timeout_str)
469        .stdout(log_file)
470        .stderr(log_file_err);
471
472    // Use process_group(0) to create a new process group (detach from terminal)
473    unsafe {
474        cmd.pre_exec(|| {
475            // Create new session (detach from controlling terminal)
476            nix::unistd::setsid().map_err(std::io::Error::other)?;
477            Ok(())
478        });
479    }
480
481    let child = cmd.spawn().context("Failed to spawn daemon process")?;
482
483    if !quiet {
484        eprintln!("Agent daemon started (PID {})", child.id());
485        eprintln!("Socket: {}", socket_str);
486        eprintln!("Log file: {}", log_path.display());
487        eprintln!();
488        eprintln!("To use this agent:");
489        eprintln!("  eval $(auths agent env)");
490        eprintln!("  # or");
491        eprintln!("  export SSH_AUTH_SOCK=\"{}\"", socket_str);
492    }
493
494    // Write environment file for the parent to report
495    let env_content = format!("export SSH_AUTH_SOCK=\"{}\"\n", socket_str);
496    write_sensitive_file(env_path, &env_content)
497        .with_context(|| format!("Failed to write env file: {:?}", env_path))?;
498
499    Ok(())
500}
501
502/// Stop the agent
503fn stop_agent() -> Result<()> {
504    let pid_path = get_pid_file_path()?;
505    let socket_path = get_default_socket_path()?;
506    let env_path = get_env_file_path()?;
507
508    let pid = read_pid()?
509        .ok_or_else(|| anyhow!("Agent not running (no PID file found at {:?})", pid_path))?;
510
511    if !is_process_running(pid) {
512        // Process not running, clean up stale files
513        eprintln!("Agent process {} not found. Cleaning up stale files.", pid);
514        let _ = fs::remove_file(&pid_path);
515        let _ = fs::remove_file(&socket_path);
516        let _ = fs::remove_file(&env_path);
517        return Ok(());
518    }
519
520    eprintln!("Stopping agent (PID {})...", pid);
521
522    // Send SIGTERM
523    #[cfg(unix)]
524    {
525        signal::kill(Pid::from_raw(pid as i32), Signal::SIGTERM)
526            .with_context(|| format!("Failed to send SIGTERM to PID {}", pid))?;
527    }
528
529    #[cfg(not(unix))]
530    {
531        return Err(anyhow!("Stopping agent not supported on this platform"));
532    }
533
534    #[cfg(unix)]
535    {
536        // Wait for process to terminate (with timeout)
537        let start = std::time::Instant::now();
538        let timeout = std::time::Duration::from_secs(5);
539
540        while start.elapsed() < timeout {
541            if !is_process_running(pid) {
542                break;
543            }
544            std::thread::sleep(std::time::Duration::from_millis(100));
545        }
546
547        if is_process_running(pid) {
548            eprintln!("Process did not terminate gracefully, sending SIGKILL...");
549            let _ = signal::kill(Pid::from_raw(pid as i32), Signal::SIGKILL);
550        }
551
552        // Clean up files
553        let _ = fs::remove_file(&pid_path);
554        let _ = fs::remove_file(&socket_path);
555        let _ = fs::remove_file(&env_path);
556
557        eprintln!("Agent stopped.");
558        Ok(())
559    }
560}
561
562/// Show agent status
563fn show_status() -> Result<()> {
564    let pid_path = get_pid_file_path()?;
565    let socket_path = get_default_socket_path()?;
566
567    let pid = read_pid()?;
568    let running = pid.map(is_process_running).unwrap_or(false);
569    let socket_exists = socket_path.exists();
570
571    let status = AgentStatus {
572        running,
573        pid: if running { pid } else { None },
574        socket_path: if socket_exists {
575            Some(socket_path.to_string_lossy().to_string())
576        } else {
577            None
578        },
579        socket_exists,
580        uptime_secs: None, // Would need to track start time to implement
581    };
582
583    if is_json_mode() {
584        JsonResponse::success("agent status", status).print()?;
585    } else if running {
586        eprintln!("Agent Status: RUNNING");
587        if let Some(p) = status.pid {
588            eprintln!("  PID: {}", p);
589        }
590        if let Some(ref sock) = status.socket_path {
591            eprintln!("  Socket: {}", sock);
592        }
593        eprintln!();
594        eprintln!("To use this agent:");
595        eprintln!("  eval $(auths agent env)");
596    } else {
597        eprintln!("Agent Status: STOPPED");
598        if pid.is_some() && !running {
599            eprintln!("  (Stale PID file found at {:?})", pid_path);
600        }
601        eprintln!();
602        eprintln!("To start the agent:");
603        eprintln!("  auths agent start");
604    }
605
606    Ok(())
607}
608
609/// Output environment variables for shell integration
610fn output_env(shell: ShellFormat) -> Result<()> {
611    let socket_path = get_default_socket_path()?;
612
613    // Check if agent is running
614    let pid = read_pid()?;
615    let running = pid.map(is_process_running).unwrap_or(false);
616
617    if !running {
618        eprintln!("Error: Agent is not running.");
619        eprintln!("Start the agent with: auths agent start");
620        std::process::exit(1);
621    }
622
623    // Check if socket exists
624    if !socket_path.exists() {
625        eprintln!("Error: Socket file not found at {:?}", socket_path);
626        eprintln!("The agent may have crashed. Try: auths agent start");
627        std::process::exit(1);
628    }
629
630    // Get socket path as string
631    let socket_str = socket_path
632        .to_str()
633        .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
634
635    // Output in appropriate shell format
636    match shell {
637        ShellFormat::Bash | ShellFormat::Zsh => {
638            println!("export SSH_AUTH_SOCK=\"{}\"", socket_str);
639        }
640        ShellFormat::Fish => {
641            println!("set -x SSH_AUTH_SOCK \"{}\"", socket_str);
642        }
643    }
644
645    Ok(())
646}
647
648/// Lock the agent (clear keys from memory).
649/// IMPORTANT: Uses auths_core::agent::remove_all_identities which relies on Unix
650/// domain sockets. Do NOT remove this #[cfg(unix)] — it will break Windows CI.
651#[cfg(unix)]
652fn lock_agent() -> Result<()> {
653    let pid = read_pid()?;
654    let running = pid.map(is_process_running).unwrap_or(false);
655
656    if !running {
657        return Err(anyhow!("Agent is not running"));
658    }
659
660    let socket_path = get_default_socket_path()?;
661    auths_core::agent::remove_all_identities(&socket_path)
662        .map_err(|e| anyhow!("Failed to lock agent: {}", e))?;
663
664    eprintln!("Agent locked — all keys removed from memory.");
665    eprintln!("Use `auths agent unlock <key-alias>` to reload a key.");
666
667    Ok(())
668}
669
670#[cfg(not(unix))]
671fn lock_agent() -> Result<()> {
672    Err(anyhow!(
673        "Agent lock is not supported on this platform (requires Unix domain sockets)"
674    ))
675}
676
677/// Unlock the agent (re-load a key into memory).
678/// IMPORTANT: Uses auths_core::agent::add_identity which relies on Unix domain
679/// sockets. Do NOT remove this #[cfg(unix)] — it will break Windows CI.
680#[cfg(unix)]
681fn unlock_agent(key_alias: &str) -> Result<()> {
682    let pid = read_pid()?;
683    let running = pid.map(is_process_running).unwrap_or(false);
684
685    if !running {
686        return Err(anyhow!("Agent is not running"));
687    }
688
689    let socket_path = get_default_socket_path()?;
690
691    // Load encrypted key from platform keychain
692    let keychain = auths_core::storage::keychain::get_platform_keychain()
693        .map_err(|e| anyhow!("Failed to get platform keychain: {}", e))?;
694    let (_identity_did, _role, encrypted_data) = keychain
695        .load_key(&auths_core::storage::keychain::KeyAlias::new_unchecked(
696            key_alias,
697        ))
698        .map_err(|e| anyhow!("Failed to load key '{}': {}", key_alias, e))?;
699
700    // Prompt for passphrase
701    let passphrase = rpassword::prompt_password(format!("Passphrase for '{}': ", key_alias))
702        .context("Failed to read passphrase")?;
703
704    // Decrypt the key
705    let key_bytes = auths_core::crypto::signer::decrypt_keypair(&encrypted_data, &passphrase)
706        .map_err(|e| anyhow!("Failed to decrypt key '{}': {}", key_alias, e))?;
707
708    // Add to agent
709    auths_core::agent::add_identity(&socket_path, &key_bytes)
710        .map_err(|e| anyhow!("Failed to add key to agent: {}", e))?;
711
712    eprintln!("Agent unlocked — key '{}' loaded.", key_alias);
713
714    Ok(())
715}
716
717#[cfg(not(unix))]
718fn unlock_agent(_key_alias: &str) -> Result<()> {
719    Err(anyhow!(
720        "Agent unlock is not supported on this platform (requires Unix domain sockets)"
721    ))
722}
723
724// --- Service Installation ---
725
726/// Detect the available service manager on the current platform
727fn detect_service_manager() -> Option<ServiceManager> {
728    #[cfg(target_os = "macos")]
729    {
730        Some(ServiceManager::Launchd)
731    }
732    #[cfg(target_os = "linux")]
733    {
734        // Check if systemd is running
735        if std::path::Path::new("/run/systemd/system").exists() {
736            Some(ServiceManager::Systemd)
737        } else {
738            None
739        }
740    }
741    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
742    {
743        None
744    }
745}
746
747/// Get the launchd plist path
748fn get_launchd_plist_path() -> Result<PathBuf> {
749    let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
750    Ok(home
751        .join("Library")
752        .join("LaunchAgents")
753        .join("com.auths.agent.plist"))
754}
755
756/// Get the systemd unit file path
757fn get_systemd_unit_path() -> Result<PathBuf> {
758    let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
759    Ok(home
760        .join(".config")
761        .join("systemd")
762        .join("user")
763        .join("auths-agent.service"))
764}
765
766/// Generate launchd plist content
767fn generate_launchd_plist() -> Result<String> {
768    let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
769    let exe_str = exe_path
770        .to_str()
771        .ok_or_else(|| anyhow!("Executable path is not valid UTF-8"))?;
772
773    let socket_path = get_default_socket_path()?;
774    let socket_str = socket_path
775        .to_str()
776        .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
777
778    let log_path = get_log_file_path()?;
779    let log_str = log_path
780        .to_str()
781        .ok_or_else(|| anyhow!("Log path is not valid UTF-8"))?;
782
783    Ok(format!(
784        r#"<?xml version="1.0" encoding="UTF-8"?>
785<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
786<plist version="1.0">
787<dict>
788    <key>Label</key>
789    <string>com.auths.agent</string>
790    <key>ProgramArguments</key>
791    <array>
792        <string>{exe}</string>
793        <string>agent</string>
794        <string>start</string>
795        <string>--foreground</string>
796    </array>
797    <key>RunAtLoad</key>
798    <true/>
799    <key>KeepAlive</key>
800    <true/>
801    <key>StandardOutPath</key>
802    <string>{log}</string>
803    <key>StandardErrorPath</key>
804    <string>{log}</string>
805    <key>EnvironmentVariables</key>
806    <dict>
807        <key>SSH_AUTH_SOCK</key>
808        <string>{socket}</string>
809    </dict>
810</dict>
811</plist>
812"#,
813        exe = exe_str,
814        log = log_str,
815        socket = socket_str
816    ))
817}
818
819/// Generate systemd unit file content
820fn generate_systemd_unit() -> Result<String> {
821    let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
822    let exe_str = exe_path
823        .to_str()
824        .ok_or_else(|| anyhow!("Executable path is not valid UTF-8"))?;
825
826    Ok(format!(
827        r#"[Unit]
828Description=Auths SSH Agent
829Documentation=https://github.com/auths-rs/auths
830
831[Service]
832Type=simple
833ExecStart={exe} agent start --foreground
834Restart=on-failure
835RestartSec=5
836
837[Install]
838WantedBy=default.target
839"#,
840        exe = exe_str
841    ))
842}
843
844/// Install the service
845fn install_service(dry_run: bool, force: bool, manager: Option<ServiceManager>) -> Result<()> {
846    let manager = manager
847        .or_else(detect_service_manager)
848        .ok_or_else(|| anyhow!("No supported service manager found on this platform"))?;
849
850    match manager {
851        ServiceManager::Launchd => install_launchd_service(dry_run, force),
852        ServiceManager::Systemd => install_systemd_service(dry_run, force),
853    }
854}
855
856/// Install launchd service (macOS)
857fn install_launchd_service(dry_run: bool, force: bool) -> Result<()> {
858    let plist_content = generate_launchd_plist()?;
859    let plist_path = get_launchd_plist_path()?;
860
861    if dry_run {
862        eprintln!("Would install to: {}", plist_path.display());
863        eprintln!();
864        println!("{}", plist_content);
865        return Ok(());
866    }
867
868    // Check if already exists
869    if plist_path.exists() && !force {
870        return Err(anyhow!(
871            "Service already installed at {}. Use --force to overwrite.",
872            plist_path.display()
873        ));
874    }
875
876    // Create parent directory
877    if let Some(parent) = plist_path.parent() {
878        fs::create_dir_all(parent)
879            .with_context(|| format!("Failed to create directory: {:?}", parent))?;
880    }
881
882    // Write plist file
883    fs::write(&plist_path, &plist_content)
884        .with_context(|| format!("Failed to write plist: {:?}", plist_path))?;
885
886    eprintln!("Installed launchd service: {}", plist_path.display());
887    eprintln!();
888    eprintln!("To start the service now:");
889    eprintln!("  launchctl load {}", plist_path.display());
890    eprintln!();
891    eprintln!("The agent will start automatically on login.");
892
893    Ok(())
894}
895
896/// Install systemd service (Linux)
897fn install_systemd_service(dry_run: bool, force: bool) -> Result<()> {
898    let unit_content = generate_systemd_unit()?;
899    let unit_path = get_systemd_unit_path()?;
900
901    if dry_run {
902        eprintln!("Would install to: {}", unit_path.display());
903        eprintln!();
904        println!("{}", unit_content);
905        return Ok(());
906    }
907
908    // Check if already exists
909    if unit_path.exists() && !force {
910        return Err(anyhow!(
911            "Service already installed at {}. Use --force to overwrite.",
912            unit_path.display()
913        ));
914    }
915
916    // Create parent directory
917    if let Some(parent) = unit_path.parent() {
918        fs::create_dir_all(parent)
919            .with_context(|| format!("Failed to create directory: {:?}", parent))?;
920    }
921
922    // Write unit file
923    fs::write(&unit_path, &unit_content)
924        .with_context(|| format!("Failed to write unit file: {:?}", unit_path))?;
925
926    eprintln!("Installed systemd service: {}", unit_path.display());
927    eprintln!();
928    eprintln!("To enable and start the service:");
929    eprintln!("  systemctl --user daemon-reload");
930    eprintln!("  systemctl --user enable --now auths-agent");
931    eprintln!();
932    eprintln!("The agent will start automatically on login.");
933
934    Ok(())
935}
936
937/// Uninstall the service
938fn uninstall_service() -> Result<()> {
939    let manager = detect_service_manager()
940        .ok_or_else(|| anyhow!("No supported service manager found on this platform"))?;
941
942    match manager {
943        ServiceManager::Launchd => uninstall_launchd_service(),
944        ServiceManager::Systemd => uninstall_systemd_service(),
945    }
946}
947
948/// Uninstall launchd service (macOS)
949fn uninstall_launchd_service() -> Result<()> {
950    let plist_path = get_launchd_plist_path()?;
951
952    if !plist_path.exists() {
953        return Err(anyhow!("Service not installed at {}", plist_path.display()));
954    }
955
956    eprintln!("Unloading launchd service...");
957    let _ = std::process::Command::new("launchctl")
958        .arg("unload")
959        .arg(&plist_path)
960        .status();
961
962    fs::remove_file(&plist_path)
963        .with_context(|| format!("Failed to remove plist: {:?}", plist_path))?;
964
965    eprintln!("Uninstalled launchd service: {}", plist_path.display());
966    Ok(())
967}
968
969/// Uninstall systemd service (Linux)
970fn uninstall_systemd_service() -> Result<()> {
971    let unit_path = get_systemd_unit_path()?;
972
973    if !unit_path.exists() {
974        return Err(anyhow!("Service not installed at {}", unit_path.display()));
975    }
976
977    eprintln!("Stopping and disabling systemd service...");
978    let _ = std::process::Command::new("systemctl")
979        .args(["--user", "disable", "--now", "auths-agent"])
980        .status();
981
982    fs::remove_file(&unit_path)
983        .with_context(|| format!("Failed to remove unit file: {:?}", unit_path))?;
984
985    let _ = std::process::Command::new("systemctl")
986        .args(["--user", "daemon-reload"])
987        .status();
988
989    eprintln!("Uninstalled systemd service: {}", unit_path.display());
990    Ok(())
991}
992
993impl crate::commands::executable::ExecutableCommand for AgentCommand {
994    fn execute(&self, _ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
995        handle_agent(self.clone())
996    }
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002
1003    #[test]
1004    fn test_get_auths_dir() {
1005        let dir = get_auths_dir().unwrap();
1006        assert!(dir.ends_with(".auths"));
1007    }
1008
1009    #[test]
1010    fn test_get_default_socket_path() {
1011        let path = get_default_socket_path().unwrap();
1012        assert!(path.ends_with("agent.sock"));
1013    }
1014
1015    #[test]
1016    fn test_shell_format_default() {
1017        // Default should be Bash
1018        let format: ShellFormat = Default::default();
1019        assert!(matches!(format, ShellFormat::Bash));
1020    }
1021
1022    #[test]
1023    fn test_parse_timeout() {
1024        use std::time::Duration;
1025
1026        // Zero timeout
1027        assert_eq!(parse_timeout("0").unwrap(), Duration::ZERO);
1028
1029        // Seconds
1030        assert_eq!(parse_timeout("30s").unwrap(), Duration::from_secs(30));
1031
1032        // Minutes
1033        assert_eq!(parse_timeout("5m").unwrap(), Duration::from_secs(300));
1034        assert_eq!(parse_timeout("30m").unwrap(), Duration::from_secs(1800));
1035
1036        // Hours
1037        assert_eq!(parse_timeout("1h").unwrap(), Duration::from_secs(3600));
1038        assert_eq!(parse_timeout("2h").unwrap(), Duration::from_secs(7200));
1039
1040        // No suffix defaults to minutes
1041        assert_eq!(parse_timeout("30").unwrap(), Duration::from_secs(1800));
1042    }
1043}