1use 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
16const DEFAULT_SOCKET_NAME: &str = "agent.sock";
18const PID_FILE_NAME: &str = "agent.pid";
20const ENV_FILE_NAME: &str = "agent.env";
22const 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 {
39 #[arg(long, help = "Custom Unix socket path")]
41 socket: Option<PathBuf>,
42
43 #[arg(long, help = "Run in foreground instead of daemonizing")]
45 foreground: bool,
46
47 #[arg(long, default_value = "30m", help = "Idle timeout before auto-lock")]
49 timeout: String,
50 },
51
52 Stop,
54
55 Status,
57
58 Env {
60 #[arg(long, value_enum, default_value = "bash", help = "Shell format")]
62 shell: ShellFormat,
63 },
64
65 Lock,
67
68 Unlock {
70 #[arg(long, default_value = "default", help = "Key alias to unlock")]
72 key: String,
73 },
74
75 InstallService {
77 #[arg(long, help = "Print service file without installing")]
79 dry_run: bool,
80
81 #[arg(long, help = "Overwrite existing service file")]
83 force: bool,
84
85 #[arg(long, value_enum, help = "Service manager (auto-detect by default)")]
87 manager: Option<ServiceManager>,
88 },
89
90 UninstallService,
92}
93
94#[derive(ValueEnum, Clone, Debug, PartialEq)]
96pub enum ServiceManager {
97 Launchd,
99 Systemd,
101}
102
103#[derive(ValueEnum, Clone, Debug, Default)]
105pub enum ShellFormat {
106 #[default]
107 Bash,
108 Zsh,
109 Fish,
110}
111
112#[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#[allow(dead_code)] pub fn ensure_agent_running(quiet: bool) -> Result<bool> {
132 let socket_path = get_default_socket_path()?;
133
134 if let Some(pid) = read_pid()?
136 && is_process_running(pid)
137 && socket_path.exists()
138 {
139 return Ok(true); }
141
142 if !quiet {
144 eprintln!("Agent not running, starting...");
145 }
146
147 start_agent(None, false, "30m", quiet)?;
149
150 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); }
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
190fn 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 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 (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
225fn get_auths_dir() -> Result<PathBuf> {
227 auths_core::paths::auths_home().map_err(|e| anyhow!(e))
228}
229
230pub fn get_default_socket_path() -> Result<PathBuf> {
232 Ok(get_auths_dir()?.join(DEFAULT_SOCKET_NAME))
233}
234
235fn get_pid_file_path() -> Result<PathBuf> {
237 Ok(get_auths_dir()?.join(PID_FILE_NAME))
238}
239
240fn get_env_file_path() -> Result<PathBuf> {
242 Ok(get_auths_dir()?.join(ENV_FILE_NAME))
243}
244
245fn get_log_file_path() -> Result<PathBuf> {
247 Ok(get_auths_dir()?.join(LOG_FILE_NAME))
248}
249
250fn 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#[cfg(unix)]
270fn is_process_running(pid: u32) -> bool {
271 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 false
279}
280
281fn 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 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 let _ = fs::remove_file(&pid_path);
307 }
308
309 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_agent_foreground(&socket, &pid_path, &env_path, timeout)
318 } else {
319 #[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#[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 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 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 let handle = Arc::new(AgentHandle::with_pid_file_and_timeout(
382 socket.clone(),
383 pid_path.clone(),
384 timeout,
385 ));
386
387 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 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#[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#[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 let exe = std::env::current_exe().context("Failed to get current executable path")?;
433
434 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 unsafe {
455 cmd.pre_exec(|| {
456 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 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
483fn 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 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 #[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 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 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
543fn 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, };
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
590fn output_env(shell: ShellFormat) -> Result<()> {
592 let socket_path = get_default_socket_path()?;
593
594 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 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 let socket_str = socket_path
613 .to_str()
614 .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
615
616 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#[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#[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 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 let passphrase = rpassword::prompt_password(format!("Passphrase for '{}': ", key_alias))
683 .context("Failed to read passphrase")?;
684
685 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 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
705fn detect_service_manager() -> Option<ServiceManager> {
709 #[cfg(target_os = "macos")]
710 {
711 Some(ServiceManager::Launchd)
712 }
713 #[cfg(target_os = "linux")]
714 {
715 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
728fn 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
737fn 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
747fn 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
800fn 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
825fn 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
837fn 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 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 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 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
877fn 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 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 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 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
918fn 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
929fn 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
950fn 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 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 assert_eq!(parse_timeout("0").unwrap(), Duration::ZERO);
1009
1010 assert_eq!(parse_timeout("30s").unwrap(), Duration::from_secs(30));
1012
1013 assert_eq!(parse_timeout("5m").unwrap(), Duration::from_secs(300));
1015 assert_eq!(parse_timeout("30m").unwrap(), Duration::from_secs(1800));
1016
1017 assert_eq!(parse_timeout("1h").unwrap(), Duration::from_secs(3600));
1019 assert_eq!(parse_timeout("2h").unwrap(), Duration::from_secs(7200));
1020
1021 assert_eq!(parse_timeout("30").unwrap(), Duration::from_secs(1800));
1023 }
1024}