feat(service): AgentSpec and launchd install/uninstall

This commit is contained in:
2026-07-31 23:32:46 +02:00
parent ae37060160
commit f267efc967
5 changed files with 480 additions and 7 deletions
Generated
+274 -7
View File
@@ -84,6 +84,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -148,12 +154,73 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "codepage"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4"
dependencies = [
"encoding_rs",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users",
"winapi",
]
[[package]]
name = "either"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]]
name = "encoding-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87b881ab2524b96a5ce932056c7482ba6152e2226fed3936b3e592adeb95ca6d"
dependencies = [
"codepage",
"encoding_rs",
"windows-sys 0.52.0",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -199,6 +266,17 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.2"
@@ -317,6 +395,21 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -419,6 +512,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -477,6 +576,25 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "plist"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64",
"indexmap",
"quick-xml",
"serde",
"time",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -496,6 +614,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -511,6 +638,17 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
@@ -528,6 +666,19 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
@@ -537,7 +688,7 @@ dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
]
@@ -590,6 +741,22 @@ dependencies = [
"zmij",
]
[[package]]
name = "service-manager"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ff6975a4ea07dda326cf122fcc1b5cf6266daad42d1442a666a99fe50f0de9"
dependencies = [
"cfg-if",
"dirs",
"encoding-utils",
"encoding_rs",
"log",
"plist",
"which",
"xml-rs",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -649,19 +816,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"getrandom 0.4.2",
"once_cell",
"rustix",
"rustix 1.1.4",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@@ -684,6 +871,36 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tokio"
version = "1.52.3"
@@ -882,12 +1099,55 @@ dependencies = [
"semver",
]
[[package]]
name = "which"
version = "4.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
dependencies = [
"either",
"home",
"once_cell",
"rustix 0.38.44",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -1073,6 +1333,12 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "xml-rs"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "xy"
version = "0.1.0"
@@ -1084,6 +1350,7 @@ dependencies = [
"nix",
"serde",
"serde_json",
"service-manager",
"tempfile",
"tokio",
"tracing",
@@ -1100,7 +1367,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tracing",
"xy-protocol",
@@ -1116,7 +1383,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -1126,7 +1393,7 @@ dependencies = [
"async-trait",
"nix",
"tempfile",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tokio-test",
"tracing",
+1
View File
@@ -33,3 +33,4 @@ humantime-serde = "1"
async-trait = "0.1"
tempfile = "3"
tokio-test = "0.4"
service-manager = "0.11"
+1
View File
@@ -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
+1
View File
@@ -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")]
+203
View File
@@ -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"));
}
}