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