feat(cli): xy service install/uninstall/start/stop/status
This commit is contained in:
@@ -8,6 +8,7 @@ use xy_protocol::rpc::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod format;
|
mod format;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
async fn connect(paths: &Paths) -> Result<Client> {
|
async fn connect(paths: &Paths) -> Result<Client> {
|
||||||
match Client::connect(&paths.socket).await {
|
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)]
|
#[arg(short = 'f', long)]
|
||||||
follow: bool,
|
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]
|
#[tokio::main]
|
||||||
@@ -107,6 +129,7 @@ async fn main() -> std::process::ExitCode {
|
|||||||
Cmd::Restart { all, name } => cli::restart(paths, all, name).await,
|
Cmd::Restart { all, name } => cli::restart(paths, all, name).await,
|
||||||
Cmd::Reload => cli::reload(paths).await,
|
Cmd::Reload => cli::reload(paths).await,
|
||||||
Cmd::Logs { name, tail, follow } => cli::logs(paths, name, tail, follow).await,
|
Cmd::Logs { name, tail, follow } => cli::logs(paths, name, tail, follow).await,
|
||||||
|
Cmd::Service { verb } => cli::service::run(paths, verb).await,
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use service_manager::{
|
use service_manager::{
|
||||||
LaunchdServiceManager, RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceManager,
|
LaunchdServiceManager, RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceManager,
|
||||||
|
|||||||
Reference in New Issue
Block a user