Skip to main content

auths_cli/commands/agent/
service.rs

1//! Platform-specific service installation (launchd on macOS, systemd on Linux).
2
3use anyhow::{Context, Result, anyhow};
4use clap::ValueEnum;
5use std::fs;
6use std::io::IsTerminal;
7use std::path::{Path, PathBuf};
8
9use super::{get_default_socket_path, get_log_file_path};
10
11/// Gate overwriting an already-installed service unit.
12///
13/// `--force` is explicit consent and proceeds. Otherwise this confirms interactively (a service unit
14/// controls what runs as the agent, so replacing it is a privileged change) and refuses when there is no
15/// terminal to confirm on.
16///
17/// Args:
18/// * `path`: The service unit that already exists.
19/// * `force`: Whether `--force` was passed.
20///
21/// Usage:
22/// ```ignore
23/// if unit_path.exists() { confirm_overwrite(&unit_path, force)?; }
24/// ```
25fn confirm_overwrite(path: &Path, force: bool) -> Result<()> {
26    if force {
27        return Ok(());
28    }
29    if !std::io::stdin().is_terminal() {
30        return Err(anyhow!(
31            "Service already installed at {}. Use --force to overwrite.",
32            path.display()
33        ));
34    }
35    let confirmed = dialoguer::Confirm::new()
36        .with_prompt(format!(
37            "A service is already installed at {}. Overwrite it? This replaces what runs as your agent.",
38            path.display()
39        ))
40        .default(false)
41        .interact()
42        .context("Failed to read overwrite confirmation")?;
43    if !confirmed {
44        return Err(anyhow!("Aborted: the existing service was left unchanged."));
45    }
46    Ok(())
47}
48
49/// Service manager type for platform-specific service installation.
50#[derive(ValueEnum, Clone, Debug, PartialEq)]
51pub enum ServiceManager {
52    /// macOS launchd
53    Launchd,
54    /// Linux systemd (user mode)
55    Systemd,
56}
57
58/// Detect the available service manager on the current platform.
59///
60/// Usage:
61/// ```ignore
62/// let manager = detect_service_manager()
63///     .ok_or_else(|| anyhow!("No supported service manager found"))?;
64/// ```
65pub fn detect_service_manager() -> Option<ServiceManager> {
66    #[cfg(target_os = "macos")]
67    {
68        Some(ServiceManager::Launchd)
69    }
70    #[cfg(target_os = "linux")]
71    {
72        if std::path::Path::new("/run/systemd/system").exists() {
73            Some(ServiceManager::Systemd)
74        } else {
75            None
76        }
77    }
78    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
79    {
80        None
81    }
82}
83
84fn get_launchd_plist_path() -> Result<PathBuf> {
85    let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
86    Ok(home
87        .join("Library")
88        .join("LaunchAgents")
89        .join("com.auths.agent.plist"))
90}
91
92fn get_systemd_unit_path() -> Result<PathBuf> {
93    let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
94    Ok(home
95        .join(".config")
96        .join("systemd")
97        .join("user")
98        .join("auths-agent.service"))
99}
100
101fn generate_launchd_plist() -> Result<String> {
102    let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
103    let exe_str = exe_path
104        .to_str()
105        .ok_or_else(|| anyhow!("Executable path is not valid UTF-8"))?;
106
107    let socket_path = get_default_socket_path()?;
108    let socket_str = socket_path
109        .to_str()
110        .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
111
112    let log_path = get_log_file_path()?;
113    let log_str = log_path
114        .to_str()
115        .ok_or_else(|| anyhow!("Log path is not valid UTF-8"))?;
116
117    Ok(format!(
118        r#"<?xml version="1.0" encoding="UTF-8"?>
119<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
120<plist version="1.0">
121<dict>
122    <key>Label</key>
123    <string>com.auths.agent</string>
124    <key>ProgramArguments</key>
125    <array>
126        <string>{exe}</string>
127        <string>agent</string>
128        <string>start</string>
129        <string>--foreground</string>
130    </array>
131    <key>RunAtLoad</key>
132    <true/>
133    <key>KeepAlive</key>
134    <true/>
135    <key>StandardOutPath</key>
136    <string>{log}</string>
137    <key>StandardErrorPath</key>
138    <string>{log}</string>
139    <key>EnvironmentVariables</key>
140    <dict>
141        <key>SSH_AUTH_SOCK</key>
142        <string>{socket}</string>
143    </dict>
144</dict>
145</plist>
146"#,
147        exe = exe_str,
148        log = log_str,
149        socket = socket_str
150    ))
151}
152
153fn generate_systemd_unit() -> Result<String> {
154    let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
155    let exe_str = exe_path
156        .to_str()
157        .ok_or_else(|| anyhow!("Executable path is not valid UTF-8"))?;
158
159    Ok(format!(
160        r#"[Unit]
161Description=Auths SSH Agent
162Documentation=https://github.com/auths-rs/auths
163
164[Service]
165Type=simple
166ExecStart={exe} agent start --foreground
167Restart=on-failure
168RestartSec=5
169
170[Install]
171WantedBy=default.target
172"#,
173        exe = exe_str
174    ))
175}
176
177/// Install the agent as a system service.
178///
179/// Args:
180/// * `dry_run`: If true, print the service file without installing.
181/// * `force`: If true, overwrite an existing service file.
182/// * `manager`: Service manager to use, or auto-detect if `None`.
183///
184/// Usage:
185/// ```ignore
186/// install_service(false, false, None)?;
187/// ```
188pub fn install_service(dry_run: bool, force: bool, manager: Option<ServiceManager>) -> Result<()> {
189    let manager = manager
190        .or_else(detect_service_manager)
191        .ok_or_else(|| anyhow!("No supported service manager found on this platform"))?;
192
193    match manager {
194        ServiceManager::Launchd => install_launchd_service(dry_run, force),
195        ServiceManager::Systemd => install_systemd_service(dry_run, force),
196    }
197}
198
199fn install_launchd_service(dry_run: bool, force: bool) -> Result<()> {
200    let plist_content = generate_launchd_plist()?;
201    let plist_path = get_launchd_plist_path()?;
202
203    if dry_run {
204        eprintln!("Would install to: {}", plist_path.display());
205        eprintln!();
206        println!("{}", plist_content);
207        return Ok(());
208    }
209
210    if plist_path.exists() {
211        confirm_overwrite(&plist_path, force)?;
212    }
213
214    if let Some(parent) = plist_path.parent() {
215        fs::create_dir_all(parent)
216            .with_context(|| format!("Failed to create directory: {:?}", parent))?;
217    }
218
219    fs::write(&plist_path, &plist_content)
220        .with_context(|| format!("Failed to write plist: {:?}", plist_path))?;
221
222    eprintln!("Installed launchd service: {}", plist_path.display());
223    eprintln!();
224    eprintln!("To start the service now:");
225    eprintln!("  launchctl load {}", plist_path.display());
226    eprintln!();
227    eprintln!("The agent will start automatically on login.");
228
229    Ok(())
230}
231
232fn install_systemd_service(dry_run: bool, force: bool) -> Result<()> {
233    let unit_content = generate_systemd_unit()?;
234    let unit_path = get_systemd_unit_path()?;
235
236    if dry_run {
237        eprintln!("Would install to: {}", unit_path.display());
238        eprintln!();
239        println!("{}", unit_content);
240        return Ok(());
241    }
242
243    if unit_path.exists() {
244        confirm_overwrite(&unit_path, force)?;
245    }
246
247    if let Some(parent) = unit_path.parent() {
248        fs::create_dir_all(parent)
249            .with_context(|| format!("Failed to create directory: {:?}", parent))?;
250    }
251
252    fs::write(&unit_path, &unit_content)
253        .with_context(|| format!("Failed to write unit file: {:?}", unit_path))?;
254
255    eprintln!("Installed systemd service: {}", unit_path.display());
256    eprintln!();
257    eprintln!("To enable and start the service:");
258    eprintln!("  systemctl --user daemon-reload");
259    eprintln!("  systemctl --user enable --now auths-agent");
260    eprintln!();
261    eprintln!("The agent will start automatically on login.");
262
263    Ok(())
264}
265
266/// Uninstall the agent system service.
267///
268/// Usage:
269/// ```ignore
270/// uninstall_service()?;
271/// ```
272pub fn uninstall_service() -> Result<()> {
273    let manager = detect_service_manager()
274        .ok_or_else(|| anyhow!("No supported service manager found on this platform"))?;
275
276    match manager {
277        ServiceManager::Launchd => uninstall_launchd_service(),
278        ServiceManager::Systemd => uninstall_systemd_service(),
279    }
280}
281
282fn uninstall_launchd_service() -> Result<()> {
283    let plist_path = get_launchd_plist_path()?;
284
285    if !plist_path.exists() {
286        return Err(anyhow!("Service not installed at {}", plist_path.display()));
287    }
288
289    eprintln!("Unloading launchd service...");
290    let _ = std::process::Command::new("launchctl")
291        .arg("unload")
292        .arg(&plist_path)
293        .status();
294
295    fs::remove_file(&plist_path)
296        .with_context(|| format!("Failed to remove plist: {:?}", plist_path))?;
297
298    eprintln!("Uninstalled launchd service: {}", plist_path.display());
299    Ok(())
300}
301
302fn uninstall_systemd_service() -> Result<()> {
303    let unit_path = get_systemd_unit_path()?;
304
305    if !unit_path.exists() {
306        return Err(anyhow!("Service not installed at {}", unit_path.display()));
307    }
308
309    eprintln!("Stopping and disabling systemd service...");
310    let _ = std::process::Command::new("systemctl")
311        .args(["--user", "disable", "--now", "auths-agent"])
312        .status();
313
314    fs::remove_file(&unit_path)
315        .with_context(|| format!("Failed to remove unit file: {:?}", unit_path))?;
316
317    let _ = std::process::Command::new("systemctl")
318        .args(["--user", "daemon-reload"])
319        .status();
320
321    eprintln!("Uninstalled systemd service: {}", unit_path.display());
322    Ok(())
323}