diff --git a/crates/xy/src/cli/mod.rs b/crates/xy/src/cli/mod.rs index 057a6fa..a8bfdf1 100644 --- a/crates/xy/src/cli/mod.rs +++ b/crates/xy/src/cli/mod.rs @@ -8,6 +8,7 @@ use xy_protocol::rpc::{ }; mod format; +pub mod service; async fn connect(paths: &Paths) -> Result { match Client::connect(&paths.socket).await { diff --git a/crates/xy/src/cli/service.rs b/crates/xy/src/cli/service.rs new file mode 100644 index 0000000..e6aae48 --- /dev/null +++ b/crates/xy/src/cli/service.rs @@ -0,0 +1,199 @@ +use crate::paths::Paths; +use crate::service::{self, AgentSpec, AgentState, AgentStatus, DEFAULT_LABEL}; +use anyhow::Result; + +pub(crate) fn render_status(status: &AgentStatus) -> String { + let mut out = String::new(); + + out.push_str(&format!(" agent: {} (user)\n", status.label)); + out.push_str(&format!(" plist: {}\n", status.plist.display())); + + let state = match (&status.state, status.pid) { + (AgentState::Running, Some(pid)) => format!("running (pid {pid})"), + (AgentState::Running, None) => "running".to_string(), + (AgentState::Stopped, _) => "stopped".to_string(), + (AgentState::NotInstalled, _) => "not installed".to_string(), + }; + + out.push_str(&format!(" state: {state}\n")); + + if let Some(program) = &status.program { + out.push_str(&format!(" program: {}\n", program.display())); + } + + if let Some(path_env) = &status.path_env { + match status.snapshotted.and_then(snapshot_date) { + Some(date) => out.push_str(&format!(" path: {path_env} (snapshotted {date})\n")), + None => out.push_str(&format!(" path: {path_env}\n")), + } + } + + out +} + +fn snapshot_date(at: std::time::SystemTime) -> Option { + let stamp = humantime::format_rfc3339_seconds(at).to_string(); + + stamp.split('T').next().map(str::to_string) +} + +pub async fn run(paths: Paths, cmd: crate::ServiceCmd) -> Result { + if let Err(err) = service::ensure_supported() { + eprintln!("xy: {err}"); + + return Ok(1); + } + + match cmd { + crate::ServiceCmd::Install { force } => install(force), + crate::ServiceCmd::Uninstall => uninstall(), + crate::ServiceCmd::Start => toggle(service::start, "loaded"), + crate::ServiceCmd::Stop => toggle(service::stop, "unloaded"), + crate::ServiceCmd::Status => status(&paths), + } +} + +fn install(force: bool) -> Result { + let spec = AgentSpec::for_current_exe()?; + + let plist = spec.plist_path()?; + + if plist.exists() { + if !force { + eprintln!("xy: agent already installed at {}", plist.display()); + eprintln!("xy: pass --force to replace it"); + + return Ok(1); + } + + service::uninstall(&spec.label)?; + } + + if service::is_build_tree_path(&spec.program) { + eprintln!( + "xy: warning: pointing the agent at a build-tree binary\n {}\n it will disappear on `cargo clean`", + spec.program.display() + ); + } + + service::install(&spec)?; + service::start(&spec.label)?; + + println!("wrote {}", plist.display()); + println!("loaded {}", spec.label); + + Ok(0) +} + +fn uninstall() -> Result { + let plist = service::plist_path_for(DEFAULT_LABEL)?; + + if !plist.exists() { + println!("not installed"); + + return Ok(0); + } + + service::uninstall(DEFAULT_LABEL)?; + + println!("removed {}", plist.display()); + + Ok(0) +} + +fn toggle(action: fn(&str) -> Result<()>, verb: &str) -> Result { + let plist = service::plist_path_for(DEFAULT_LABEL)?; + + if !plist.exists() { + eprintln!("xy: agent is not installed"); + + return Ok(1); + } + + match action(DEFAULT_LABEL) { + Ok(()) => { + println!("{verb} {DEFAULT_LABEL}"); + + Ok(0) + } + Err(err) => { + eprintln!("xy: {err:#}"); + + Ok(1) + } + } +} + +fn status(paths: &Paths) -> Result { + let status = service::status(DEFAULT_LABEL, &paths.pidfile)?; + + print!("{}", render_status(&status)); + + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn running_status() -> AgentStatus { + AgentStatus { + label: "se.aceofba.xy".to_string(), + plist: PathBuf::from("/Users/me/Library/LaunchAgents/se.aceofba.xy.plist"), + state: AgentState::Running, + program: Some(PathBuf::from("/Users/me/.cargo/bin/xy")), + path_env: Some("/opt/homebrew/bin:/usr/bin".to_string()), + snapshotted: None, + pid: Some(4821), + } + } + + #[test] + fn render_status_shows_running_state_with_pid() { + let out = render_status(&running_status()); + + assert!(out.contains("agent: se.aceofba.xy (user)")); + assert!(out.contains("state: running (pid 4821)")); + assert!(out.contains("program: /Users/me/.cargo/bin/xy")); + assert!(out.contains("path: /opt/homebrew/bin:/usr/bin")); + } + + #[test] + fn render_status_omits_pid_when_stopped() { + let mut status = running_status(); + status.state = AgentState::Stopped; + status.pid = None; + + let out = render_status(&status); + + assert!(out.contains("state: stopped")); + assert!(!out.contains("pid")); + } + + #[test] + fn render_status_annotates_path_with_snapshot_date() { + let mut status = running_status(); + status.snapshotted = + Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_753_920_000)); + + let out = render_status(&status); + + assert!(out.contains("path: /opt/homebrew/bin:/usr/bin (snapshotted 2025-07-31)")); + } + + #[test] + fn render_status_reports_not_installed_without_program_lines() { + let mut status = running_status(); + status.state = AgentState::NotInstalled; + status.program = None; + status.path_env = None; + status.pid = None; + + let out = render_status(&status); + + assert!(out.contains("state: not installed")); + assert!(!out.contains("program:")); + assert!(!out.contains("path:")); + } +} diff --git a/crates/xy/src/main.rs b/crates/xy/src/main.rs index f95765b..ade518d 100644 --- a/crates/xy/src/main.rs +++ b/crates/xy/src/main.rs @@ -53,6 +53,28 @@ enum Cmd { #[arg(short = 'f', long)] follow: bool, }, + /// Manage the launchd start-on-login agent (macOS). + Service { + #[command(subcommand)] + verb: ServiceCmd, + }, +} + +#[derive(Debug, Subcommand)] +pub enum ServiceCmd { + /// Install the login agent and load it. + Install { + #[arg(long)] + force: bool, + }, + /// Remove the login agent. + Uninstall, + /// Load the installed agent. + Start, + /// Unload the agent until the next login. + Stop, + /// Show the agent's state. + Status, } #[tokio::main] @@ -107,6 +129,7 @@ async fn main() -> std::process::ExitCode { Cmd::Restart { all, name } => cli::restart(paths, all, name).await, Cmd::Reload => cli::reload(paths).await, Cmd::Logs { name, tail, follow } => cli::logs(paths, name, tail, follow).await, + Cmd::Service { verb } => cli::service::run(paths, verb).await, }; match result { diff --git a/crates/xy/src/service.rs b/crates/xy/src/service.rs index 67238c8..937b570 100644 --- a/crates/xy/src/service.rs +++ b/crates/xy/src/service.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use anyhow::{Context, Result}; use service_manager::{ LaunchdServiceManager, RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceManager,