1use 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
17const DEFAULT_SOCKET_NAME: &str = "agent.sock";
19const PID_FILE_NAME: &str = "agent.pid";
21const ENV_FILE_NAME: &str = "agent.env";
23const 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 {
40 #[arg(long, help = "Custom Unix socket path")]
42 socket: Option<PathBuf>,
43
44 #[arg(long, help = "Run in foreground instead of daemonizing")]
46 foreground: bool,
47
48 #[arg(long, default_value = "30m", help = "Idle timeout before auto-lock")]
50 timeout: String,
51 },
52
53 Stop,
55
56 Status,
58
59 Env {
61 #[arg(long, value_enum, default_value = "bash", help = "Shell format")]
63 shell: ShellFormat,
64 },
65
66 Lock,
68
69 Unlock {
71 #[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 InstallService {
83 #[arg(long, help = "Print service file without installing")]
85 dry_run: bool,
86
87 #[arg(long, help = "Overwrite existing service file")]
89 force: bool,
90
91 #[arg(long, value_enum, help = "Service manager (auto-detect by default)")]
93 manager: Option<ServiceManager>,
94 },
95
96 UninstallService,
98}
99
100#[derive(ValueEnum, Clone, Debug, PartialEq)]
102pub enum ServiceManager {
103 Launchd,
105 Systemd,
107}
108
109#[derive(ValueEnum, Clone, Debug, Default)]
111pub enum ShellFormat {
112 #[default]
113 Bash,
114 Zsh,
115 Fish,
116}
117
118#[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#[allow(dead_code)] pub fn ensure_agent_running(quiet: bool) -> Result<bool> {
138 let socket_path = get_default_socket_path()?;
139
140 if let Some(pid) = read_pid()?
142 && is_process_running(pid)
143 && socket_is_connectable(&socket_path)
144 {
145 return Ok(true); }
147
148 if !quiet {
150 eprintln!("Agent not running, starting...");
151 }
152
153 start_agent(None, false, "30m", quiet)?;
155
156 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); }
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
196fn 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 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 (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
231fn get_auths_dir() -> Result<PathBuf> {
233 auths_core::paths::auths_home().map_err(|e| anyhow!(e))
234}
235
236pub fn get_default_socket_path() -> Result<PathBuf> {
238 Ok(get_auths_dir()?.join(DEFAULT_SOCKET_NAME))
239}
240
241fn get_pid_file_path() -> Result<PathBuf> {
243 Ok(get_auths_dir()?.join(PID_FILE_NAME))
244}
245
246fn get_env_file_path() -> Result<PathBuf> {
248 Ok(get_auths_dir()?.join(ENV_FILE_NAME))
249}
250
251fn get_log_file_path() -> Result<PathBuf> {
253 Ok(get_auths_dir()?.join(LOG_FILE_NAME))
254}
255
256fn 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#[cfg(unix)]
276fn is_process_running(pid: u32) -> bool {
277 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#[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
297fn 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 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 let _ = fs::remove_file(&pid_path);
323 }
324
325 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_agent_foreground(&socket, &pid_path, &env_path, timeout)
337 } else {
338 #[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#[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 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 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 let handle = Arc::new(AgentHandle::with_pid_file_and_timeout(
401 socket.clone(),
402 pid_path.clone(),
403 timeout,
404 ));
405
406 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 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#[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#[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 let exe = std::env::current_exe().context("Failed to get current executable path")?;
452
453 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 unsafe {
474 cmd.pre_exec(|| {
475 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 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
502fn 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 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 #[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 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 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
562fn 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, };
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
609fn output_env(shell: ShellFormat) -> Result<()> {
611 let socket_path = get_default_socket_path()?;
612
613 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 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 let socket_str = socket_path
632 .to_str()
633 .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
634
635 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#[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#[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 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 let passphrase = rpassword::prompt_password(format!("Passphrase for '{}': ", key_alias))
702 .context("Failed to read passphrase")?;
703
704 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 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
724fn detect_service_manager() -> Option<ServiceManager> {
728 #[cfg(target_os = "macos")]
729 {
730 Some(ServiceManager::Launchd)
731 }
732 #[cfg(target_os = "linux")]
733 {
734 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
747fn 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
756fn 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
766fn 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
819fn 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
844fn 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
856fn 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 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 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 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
896fn 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 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 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 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
937fn 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
948fn 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
969fn 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 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 assert_eq!(parse_timeout("0").unwrap(), Duration::ZERO);
1028
1029 assert_eq!(parse_timeout("30s").unwrap(), Duration::from_secs(30));
1031
1032 assert_eq!(parse_timeout("5m").unwrap(), Duration::from_secs(300));
1034 assert_eq!(parse_timeout("30m").unwrap(), Duration::from_secs(1800));
1035
1036 assert_eq!(parse_timeout("1h").unwrap(), Duration::from_secs(3600));
1038 assert_eq!(parse_timeout("2h").unwrap(), Duration::from_secs(7200));
1039
1040 assert_eq!(parse_timeout("30").unwrap(), Duration::from_secs(1800));
1042 }
1043}