feat(cli): xy service install/uninstall/start/stop/status
This commit is contained in:
@@ -8,6 +8,7 @@ use xy_protocol::rpc::{
|
||||
};
|
||||
|
||||
mod format;
|
||||
pub mod service;
|
||||
|
||||
async fn connect(paths: &Paths) -> Result<Client> {
|
||||
match Client::connect(&paths.socket).await {
|
||||
|
||||
@@ -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<String> {
|
||||
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<i32> {
|
||||
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<i32> {
|
||||
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<i32> {
|
||||
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<i32> {
|
||||
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<i32> {
|
||||
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:"));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use service_manager::{
|
||||
LaunchdServiceManager, RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceManager,
|
||||
|
||||
Reference in New Issue
Block a user