diff --git a/Cargo.lock b/Cargo.lock index 862d127..c1381c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index e7d606a..fc03f27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,3 +33,4 @@ humantime-serde = "1" async-trait = "0.1" tempfile = "3" tokio-test = "0.4" +service-manager = "0.11" diff --git a/crates/xy/Cargo.toml b/crates/xy/Cargo.toml index f458b5e..dd72002 100644 --- a/crates/xy/Cargo.toml +++ b/crates/xy/Cargo.toml @@ -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 diff --git a/crates/xy/src/main.rs b/crates/xy/src/main.rs index 9e1ca0d..f95765b 100644 --- a/crates/xy/src/main.rs +++ b/crates/xy/src/main.rs @@ -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")] diff --git a/crates/xy/src/service.rs b/crates/xy/src/service.rs new file mode 100644 index 0000000..e4077c4 --- /dev/null +++ b/crates/xy/src/service.rs @@ -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, + pub path_env: String, + pub working_dir: PathBuf, +} + +impl AgentSpec { + pub fn for_current_exe() -> Result { + 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 { + 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 { + plist_path_for(&self.label) + } +} + +pub(crate) fn plist_path_for(label: &str) -> Result { + 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")); + } +}