feat(service): AgentSpec and launchd install/uninstall
This commit is contained in:
@@ -30,6 +30,7 @@ anyhow.workspace = true
|
||||
etcetera.workspace = true
|
||||
nix.workspace = true
|
||||
humantime.workspace = true
|
||||
service-manager = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -5,6 +5,7 @@ mod daemon;
|
||||
mod logging;
|
||||
mod paths;
|
||||
mod pidfile;
|
||||
mod service;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "xy", version, about = "HTTP MCP server supervisor")]
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use service_manager::{
|
||||
LaunchdServiceManager, RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceManager,
|
||||
ServiceUninstallCtx,
|
||||
};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub(crate) const DEFAULT_LABEL: &str = "se.aceofba.xy";
|
||||
|
||||
pub(crate) struct AgentSpec {
|
||||
pub label: String,
|
||||
pub program: PathBuf,
|
||||
pub args: Vec<String>,
|
||||
pub path_env: String,
|
||||
pub working_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AgentSpec {
|
||||
pub fn for_current_exe() -> Result<Self> {
|
||||
let program = std::env::current_exe()
|
||||
.context("resolve current executable")?
|
||||
.canonicalize()
|
||||
.context("canonicalize current executable")?;
|
||||
|
||||
let path_env = std::env::var("PATH").context("read PATH")?;
|
||||
|
||||
let working_dir = etcetera::home_dir().context("locate home directory")?;
|
||||
|
||||
Ok(Self {
|
||||
label: DEFAULT_LABEL.to_string(),
|
||||
program,
|
||||
args: vec!["daemon".to_string()],
|
||||
path_env,
|
||||
working_dir,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn install_ctx(&self) -> Result<ServiceInstallCtx> {
|
||||
let label: ServiceLabel = self.label.parse().context("parse service label")?;
|
||||
|
||||
Ok(ServiceInstallCtx {
|
||||
label,
|
||||
program: self.program.clone(),
|
||||
args: self.args.iter().map(std::ffi::OsString::from).collect(),
|
||||
contents: None,
|
||||
username: None,
|
||||
working_directory: Some(self.working_dir.clone()),
|
||||
environment: Some(vec![("PATH".to_string(), self.path_env.clone())]),
|
||||
autostart: true,
|
||||
restart_policy: RestartPolicy::Always { delay_secs: None },
|
||||
})
|
||||
}
|
||||
|
||||
pub fn plist_path(&self) -> Result<PathBuf> {
|
||||
plist_path_for(&self.label)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn plist_path_for(label: &str) -> Result<PathBuf> {
|
||||
let home = etcetera::home_dir().context("locate home directory")?;
|
||||
|
||||
Ok(home
|
||||
.join("Library")
|
||||
.join("LaunchAgents")
|
||||
.join(format!("{label}.plist")))
|
||||
}
|
||||
|
||||
pub(crate) fn is_build_tree_path(path: &Path) -> bool {
|
||||
let mut components = path.components().peekable();
|
||||
|
||||
while let Some(component) = components.next() {
|
||||
if component != Component::Normal("target".as_ref()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
components.peek(),
|
||||
Some(Component::Normal(next))
|
||||
if *next == std::ffi::OsStr::new("debug") || *next == std::ffi::OsStr::new("release")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn ensure_supported() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn ensure_supported() -> Result<()> {
|
||||
anyhow::bail!("start-on-login is macOS-only for now")
|
||||
}
|
||||
|
||||
fn manager() -> LaunchdServiceManager {
|
||||
LaunchdServiceManager::user()
|
||||
}
|
||||
|
||||
pub(crate) fn install(spec: &AgentSpec) -> Result<()> {
|
||||
manager()
|
||||
.install(spec.install_ctx()?)
|
||||
.context("install launchd agent")
|
||||
}
|
||||
|
||||
pub(crate) fn uninstall(label: &str) -> Result<()> {
|
||||
let label: ServiceLabel = label.parse().context("parse service label")?;
|
||||
|
||||
manager()
|
||||
.uninstall(ServiceUninstallCtx { label })
|
||||
.context("uninstall launchd agent")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_spec() -> AgentSpec {
|
||||
AgentSpec {
|
||||
label: "se.aceofba.xy-test".to_string(),
|
||||
program: PathBuf::from("/usr/local/bin/xy"),
|
||||
args: vec!["daemon".to_string()],
|
||||
path_env: "/usr/local/bin:/usr/bin".to_string(),
|
||||
working_dir: PathBuf::from("/Users/someone"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_ctx_maps_every_field() {
|
||||
let ctx = sample_spec().install_ctx().unwrap();
|
||||
|
||||
assert_eq!(ctx.label.to_qualified_name(), "se.aceofba.xy-test");
|
||||
assert_eq!(ctx.program, PathBuf::from("/usr/local/bin/xy"));
|
||||
assert_eq!(ctx.args, vec![std::ffi::OsString::from("daemon")]);
|
||||
assert_eq!(ctx.working_directory, Some(PathBuf::from("/Users/someone")));
|
||||
assert!(ctx.autostart);
|
||||
assert_eq!(
|
||||
ctx.environment,
|
||||
Some(vec![(
|
||||
"PATH".to_string(),
|
||||
"/usr/local/bin:/usr/bin".to_string()
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_ctx_uses_always_restart_without_delay() {
|
||||
let ctx = sample_spec().install_ctx().unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
ctx.restart_policy,
|
||||
RestartPolicy::Always { delay_secs: None }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_ctx_supplies_no_raw_contents() {
|
||||
let ctx = sample_spec().install_ctx().unwrap();
|
||||
|
||||
assert!(ctx.contents.is_none());
|
||||
assert!(ctx.username.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tree_paths_are_detected() {
|
||||
assert!(is_build_tree_path(Path::new("/home/me/xy/target/debug/xy")));
|
||||
assert!(is_build_tree_path(Path::new(
|
||||
"/home/me/xy/target/release/xy"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_paths_are_not_build_tree_paths() {
|
||||
assert!(!is_build_tree_path(Path::new("/Users/me/.cargo/bin/xy")));
|
||||
assert!(!is_build_tree_path(Path::new("/usr/local/bin/xy")));
|
||||
assert!(!is_build_tree_path(Path::new("/opt/targeted/bin/xy")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plist_path_sits_in_user_launch_agents() {
|
||||
let path = sample_spec().plist_path().unwrap();
|
||||
|
||||
assert!(path.ends_with("Library/LaunchAgents/se.aceofba.xy-test.plist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_is_supported() {
|
||||
assert!(ensure_supported().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn other_platforms_are_rejected() {
|
||||
let err = ensure_supported().unwrap_err().to_string();
|
||||
|
||||
assert!(err.contains("macOS-only"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user