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