From dff798a9bee9ab413dc3d40937132e6cdc64afb0 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 31 Jul 2026 22:21:01 +0200 Subject: [PATCH] docs(plan): implementation plan for start-on-login Six TDD tasks: io::Write for RotatingLogWriter, the daemon log file and main.rs reorder, AgentSpec plus launchd install/uninstall, status and launchctl load/unload, the CLI verbs, and the ignored end-to-end test plus README. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp --- .../plans/2026-07-31-xy-start-on-login.md | 1302 +++++++++++++++++ 1 file changed, 1302 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-31-xy-start-on-login.md diff --git a/docs/superpowers/plans/2026-07-31-xy-start-on-login.md b/docs/superpowers/plans/2026-07-31-xy-start-on-login.md new file mode 100644 index 0000000..52e14f6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-xy-start-on-login.md @@ -0,0 +1,1302 @@ +# Start on Login (macOS) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `xy` a `service` subcommand that installs a user-level launchd agent so the daemon starts at login and restarts on crash. + +**Architecture:** A new `service.rs` in the `xy` crate wraps the `service-manager` crate's `LaunchdServiceManager::user()` and returns plain data; a new `cli/service.rs` renders that data and maps it to exit codes. Separately, the daemon gains its own rotating log file so login-time failures are diagnosable. Nothing crosses the IPC boundary — `xy service *` talks to launchd directly, which is what makes it work when the daemon is dead. + +**Tech Stack:** Rust 2024, `service-manager` 0.11, `clap` derive, `tracing-subscriber`, `etcetera`, existing `xy_supervisor::logs::RotatingLogWriter`. + +**Spec:** `docs/superpowers/specs/2026-07-31-xy-start-on-login-design.md` + +## Global Constraints + +- Format with `cargo +nightly fmt` before every commit. Never `cargo fmt`. +- Fix all `cargo clippy` warnings before moving to the next task. +- Run tests with `cargo nextest run`, not `cargo test`. +- TDD: write the failing test, run it, confirm it fails for the expected reason, then write the minimal implementation. +- No `mod.rs` for new modules — `foo.rs` module root plus a `foo/` directory for submodules. +- All `#[cfg(test)] mod tests` blocks go at the END of the source file. +- Naming: short but descriptive. `err` not `e`, `result` not `r`. Single chars only for loop indices (`i`, `j`, `k`) and counts (`n`). +- Blank line after every statement by default; multi-line `let` bindings ALWAYS get a blank line after. Adjacent asserts stay grouped with no blanks between them. +- No comments unless the *why* is non-obvious. +- `pub(crate)` for internals; `pub` only at a crate's API boundary. +- The agent label is `se.aceofba.xy`. The test label is `se.aceofba.xy-test`. +- Exit codes: `0` success, `1` operational error. Codes `2` and `3` are not used by `xy service`. + +## File Structure + +| File | Responsibility | +|---|---| +| `Cargo.toml` (workspace) | Declare `service-manager = "0.11"` in `[workspace.dependencies]` | +| `crates/xy/Cargo.toml` | Consume `service-manager` | +| `crates/xy-supervisor/src/logs.rs` | *Modify:* add `impl std::io::Write for RotatingLogWriter` | +| `crates/xy/src/logging.rs` | *New:* build the daemon's tee'd subscriber writer | +| `crates/xy/src/service.rs` | *New:* `AgentSpec`, `AgentStatus`, launchd install/uninstall/start/stop/status. Returns data, never prints | +| `crates/xy/src/cli/service.rs` | *New:* verb dispatch, human output, exit codes | +| `crates/xy/src/cli/mod.rs` | *Modify:* declare `mod service;` and re-export | +| `crates/xy/src/main.rs` | *Modify:* `Service` subcommand arm; reorder path resolution before logger init | +| `crates/xy/src/daemon/mod.rs` | *Modify:* drop the now-duplicated `ensure_dirs()` call | +| `crates/xy/tests/service.rs` | *New:* `#[ignore]` end-to-end launchd cycle | +| `README.md` | *Modify:* document the five verbs | + +--- + +### Task 1: `io::Write` for `RotatingLogWriter` + +`RotatingLogWriter` already tracks bytes written and rotates, but only exposes `write_line(tag, line)`, which prefixes a tag the daemon's own log must not have. An `io::Write` impl makes the type usable as a `tracing` writer. + +**Files:** +- Modify: `crates/xy-supervisor/src/logs.rs` +- Test: `crates/xy-supervisor/src/logs.rs` (existing `#[cfg(test)] mod tests` at end of file) + +**Interfaces:** +- Consumes: nothing. +- Produces: `impl std::io::Write for RotatingLogWriter`, with `write(&mut self, buf: &[u8]) -> io::Result` and `flush(&mut self) -> io::Result<()>`. Task 2 depends on this. + +- [ ] **Step 1: Write the failing tests** + +Append to the `#[cfg(test)] mod tests` block at the end of `crates/xy-supervisor/src/logs.rs`: + +```rust +#[test] +fn write_trait_appends_bytes() { + use std::io::Write; + + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join("daemon.log"); + + let mut writer = RotatingLogWriter::open(&base, 1024, 3).unwrap(); + + writer.write_all(b"hello\n").unwrap(); + writer.flush().unwrap(); + + let contents = std::fs::read_to_string(&base).unwrap(); + assert_eq!(contents, "hello\n"); +} + +#[test] +fn write_trait_rotates_at_threshold() { + use std::io::Write; + + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join("daemon.log"); + + let mut writer = RotatingLogWriter::open(&base, 8, 3).unwrap(); + + writer.write_all(b"0123456789").unwrap(); + writer.write_all(b"after\n").unwrap(); + writer.flush().unwrap(); + + let rotated = tmp.path().join("daemon.log.1"); + assert!(rotated.exists()); + assert_eq!(std::fs::read_to_string(&rotated).unwrap(), "0123456789"); + assert_eq!(std::fs::read_to_string(&base).unwrap(), "after\n"); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo nextest run -p xy-supervisor -E 'test(/write_trait/)'` + +Expected: FAIL to compile, with `no method named 'write_all' found for struct 'RotatingLogWriter'`. + +- [ ] **Step 3: Write the minimal implementation** + +Add to `crates/xy-supervisor/src/logs.rs`, after the existing `impl RotatingLogWriter` block. Note `Write` is already imported at the top of the file (`use std::io::Write;`). + +```rust +impl std::io::Write for RotatingLogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.file.write_all(buf)?; + + self.written += buf.len() as u64; + + if self.written >= self.max_bytes { + self.rotate()?; + } + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.file.flush() + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo nextest run -p xy-supervisor -E 'test(/write_trait/)'` + +Expected: PASS, 2 tests. + +- [ ] **Step 5: Verify nothing else broke, format, lint** + +```bash +cargo nextest run -p xy-supervisor +cargo +nightly fmt +cargo clippy -p xy-supervisor +``` + +Expected: all tests pass, no clippy warnings. + +- [ ] **Step 6: Commit** + +```bash +git add crates/xy-supervisor/src/logs.rs +git commit -m "feat(logs): impl io::Write for RotatingLogWriter" +``` + +--- + +### Task 2: Daemon log file + +The daemon currently logs only to stderr, which launchd discards. Give it `log_dir/xy.log` using the same rotation as per-server logs, and reorder `main.rs` so paths resolve before the logger is built. + +**Files:** +- Create: `crates/xy/src/logging.rs` +- Modify: `crates/xy/src/main.rs` +- Modify: `crates/xy/src/daemon/mod.rs` (remove the duplicated `ensure_dirs()` call) +- Test: `crates/xy/src/logging.rs` + +**Interfaces:** +- Consumes: `impl io::Write for RotatingLogWriter` from Task 1. +- Produces: `pub(crate) fn daemon_writer(log_dir: &Path) -> std::io::Result>>`. No later task depends on this. + +`tracing-subscriber` 0.3 already implements `MakeWriter` for `Mutex where W: io::Write` and for `Arc`, so `Arc>` satisfies `with_writer` directly. No adapter type is needed. + +- [ ] **Step 1: Write the failing test** + +Create `crates/xy/src/logging.rs` containing only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn daemon_writer_creates_and_appends_to_xy_log() { + use std::io::Write; + + let tmp = tempfile::tempdir().unwrap(); + + let writer = daemon_writer(tmp.path()).unwrap(); + + writer.lock().unwrap().write_all(b"line\n").unwrap(); + + let contents = std::fs::read_to_string(tmp.path().join("xy.log")).unwrap(); + assert_eq!(contents, "line\n"); + } +} +``` + +Declare the module in `crates/xy/src/main.rs` alongside the existing `mod` declarations: + +```rust +mod logging; +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo nextest run -p xy -E 'test(/daemon_writer/)'` + +Expected: FAIL to compile, with `cannot find function 'daemon_writer' in this scope`. + +- [ ] **Step 3: Write the minimal implementation** + +Prepend to `crates/xy/src/logging.rs`, above the test module: + +```rust +use std::path::Path; +use std::sync::{Arc, Mutex}; +use xy_supervisor::logs::RotatingLogWriter; + +const LOG_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024; +const LOG_FILE_KEEP: usize = 5; + +pub(crate) fn daemon_writer(log_dir: &Path) -> std::io::Result>> { + let writer = RotatingLogWriter::open(&log_dir.join("xy.log"), LOG_FILE_MAX_BYTES, LOG_FILE_KEEP)?; + + Ok(Arc::new(Mutex::new(writer))) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo nextest run -p xy -E 'test(/daemon_writer/)'` + +Expected: PASS. + +- [ ] **Step 5: Reorder `main.rs` so paths resolve before the logger** + +Replace the top of `main` in `crates/xy/src/main.rs`. The current body initialises `tracing_subscriber` first and resolves paths second; this inverts that order and tees the daemon's output to a file. + +```rust +#[tokio::main] +async fn main() -> std::process::ExitCode { + let cli = Cli::parse(); + + let paths = match paths::Paths::resolve() { + Ok(p) => p, + Err(err) => { + eprintln!("xy: failed to resolve XDG paths: {err}"); + return std::process::ExitCode::from(3); + } + }; + + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + + if matches!(cli.cmd, Cmd::Daemon) { + use tracing_subscriber::fmt::writer::MakeWriterExt; + + if let Err(err) = paths.ensure_dirs() { + eprintln!("xy: failed to create state dirs: {err}"); + return std::process::ExitCode::from(3); + } + + let file = match logging::daemon_writer(&paths.log_dir) { + Ok(w) => w, + Err(err) => { + eprintln!("xy: failed to open daemon log: {err}"); + return std::process::ExitCode::from(3); + } + }; + + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_ansi(false) + .with_writer(std::io::stderr.and(file)) + .init(); + } else { + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .init(); + } + + // ... existing `let result: anyhow::Result = match cli.cmd { ... }` unchanged +} +``` + +`with_ansi(false)` is set on the daemon path because the tee writes the same bytes to both sinks, and escape codes in a log file are noise. + +- [ ] **Step 6: Remove the now-duplicated `ensure_dirs()` call** + +In `crates/xy/src/daemon/mod.rs`, delete the first line of `run`: + +```rust +paths.ensure_dirs().context("create state dirs")?; +``` + +`main` now does this before the logger is built, which is the only ordering that lets the logger open a file inside `log_dir`. + +- [ ] **Step 7: Verify the daemon actually writes its log** + +```bash +cargo build -p xy +rm -f ~/.local/state/xy/logs/xy.log +./target/debug/xy daemon & +sleep 2 +cat ~/.local/state/xy/logs/xy.log +kill %1 +``` + +Expected: `xy.log` exists and contains a `daemon listening` line. + +- [ ] **Step 8: Run the full suite, format, lint** + +```bash +cargo nextest run +cargo +nightly fmt +cargo clippy --workspace +``` + +Expected: all tests pass, no clippy warnings. + +- [ ] **Step 9: Commit** + +```bash +git add crates/xy/src/logging.rs crates/xy/src/main.rs crates/xy/src/daemon/mod.rs +git commit -m "feat(daemon): write rotating xy.log alongside stderr" +``` + +--- + +### Task 3: `AgentSpec` and install/uninstall + +The OS-facing core. `AgentSpec` is the single source of truth for what gets installed; it converts into `service-manager`'s `ServiceInstallCtx`. + +**Files:** +- Modify: `Cargo.toml` (workspace) +- Modify: `crates/xy/Cargo.toml` +- Create: `crates/xy/src/service.rs` +- Modify: `crates/xy/src/main.rs` (declare `mod service;`) +- Test: `crates/xy/src/service.rs` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces, all `pub(crate)`: + - `const DEFAULT_LABEL: &str = "se.aceofba.xy"` + - `struct AgentSpec { label: String, program: PathBuf, args: Vec, path_env: String, working_dir: PathBuf }` + - `fn AgentSpec::for_current_exe() -> anyhow::Result` + - `fn AgentSpec::install_ctx(&self) -> anyhow::Result` + - `fn AgentSpec::plist_path(&self) -> anyhow::Result` + - `fn plist_path_for(label: &str) -> anyhow::Result` + - `fn is_build_tree_path(path: &Path) -> bool` + - `fn ensure_supported() -> anyhow::Result<()>` + - `fn install(spec: &AgentSpec) -> anyhow::Result<()>` + - `fn uninstall(label: &str) -> anyhow::Result<()>` + +Task 4 adds status/start/stop to this same file; Task 5 consumes all of it. + +- [ ] **Step 1: Add the dependency** + +```bash +cargo add --package xy service-manager@0.11 +``` + +Then move the version to the workspace, matching the existing convention. In the root `Cargo.toml` under `[workspace.dependencies]` add: + +```toml +service-manager = "0.11" +``` + +And in `crates/xy/Cargo.toml` under `[dependencies]` set: + +```toml +service-manager = { workspace = true } +``` + +- [ ] **Step 2: Write the failing tests** + +Create `crates/xy/src/service.rs` containing only the test module: + +```rust +#[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")); + } +} +``` + +The last case in `installed_paths_are_not_build_tree_paths` is deliberate: a naive `to_string_lossy().contains("target/")` would match `/opt/targeted/bin/xy`. The implementation must compare whole path components. + +Declare the module in `crates/xy/src/main.rs`: + +```rust +mod service; +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cargo nextest run -p xy -E 'test(/service::/)'` + +Expected: FAIL to compile, with `cannot find struct 'AgentSpec' in this scope`. + +- [ ] **Step 4: Write the minimal implementation** + +Prepend to `crates/xy/src/service.rs`, above the test module: + +```rust +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") +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo nextest run -p xy -E 'test(/service::/)'` + +Expected: PASS, 7 tests (six platform-independent, plus `macos_is_supported`). + +- [ ] **Step 6: Format and lint** + +```bash +cargo +nightly fmt +cargo clippy -p xy +``` + +Expected: no warnings. `dead_code` warnings for `install`/`uninstall` are expected until Task 5 wires them; if clippy flags them, add `#![allow(dead_code)]` at the top of `service.rs` and REMOVE it in Task 5. + +- [ ] **Step 7: Commit** + +```bash +git add Cargo.toml Cargo.lock crates/xy/Cargo.toml crates/xy/src/service.rs crates/xy/src/main.rs +git commit -m "feat(service): AgentSpec and launchd install/uninstall" +``` + +--- + +### Task 4: Status, start, and stop + +`service-manager`'s `stop()` runs `launchctl stop`, which a `KeepAlive: true` agent survives, so start/stop are implemented directly against `launchctl` on the plist path. Status comes from the crate. + +**Files:** +- Modify: `crates/xy/src/service.rs` +- Test: `crates/xy/src/service.rs` + +**Interfaces:** +- Consumes: `AgentSpec`, `plist_path_for`, `manager` from Task 3. +- Produces, all `pub(crate)`: + - `enum AgentState { NotInstalled, Stopped, Running }` + - `struct AgentStatus { label: String, plist: PathBuf, state: AgentState, program: Option, path_env: Option, snapshotted: Option, pid: Option }` + - `fn status(label: &str, pidfile: &Path) -> anyhow::Result` + - `fn start(label: &str) -> anyhow::Result<()>` + - `fn stop(label: &str) -> anyhow::Result<()>` + - `fn read_pid(pidfile: &Path) -> Option` + +- [ ] **Step 1: Write the failing tests** + +Add to the `#[cfg(test)] mod tests` block in `crates/xy/src/service.rs`: + +```rust +#[test] +fn status_reports_not_installed_when_plist_is_absent() { + let tmp = tempfile::tempdir().unwrap(); + + let status = status("se.aceofba.xy-absent", &tmp.path().join("xy.pid")).unwrap(); + + assert!(matches!(status.state, AgentState::NotInstalled)); + assert!(status.program.is_none()); + assert!(status.pid.is_none()); +} + +#[test] +fn read_pid_parses_a_pidfile() { + let tmp = tempfile::tempdir().unwrap(); + let pidfile = tmp.path().join("xy.pid"); + + std::fs::write(&pidfile, "4821\n").unwrap(); + + assert_eq!(read_pid(&pidfile), Some(4821)); +} + +#[test] +fn read_pid_returns_none_for_missing_or_garbage() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("nope.pid"); + let garbage = tmp.path().join("garbage.pid"); + + std::fs::write(&garbage, "not-a-pid").unwrap(); + + assert_eq!(read_pid(&missing), None); + assert_eq!(read_pid(&garbage), None); +} +``` + +`status_reports_not_installed_when_plist_is_absent` relies on the plist file being the authority, so it does not shell out to `launchctl` and is safe in CI. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo nextest run -p xy -E 'test(/service::/)'` + +Expected: FAIL to compile, with `cannot find function 'status' in this scope`. + +- [ ] **Step 3: Write the minimal implementation** + +Add to `crates/xy/src/service.rs`, above the test module. Extend the existing `use service_manager::{...}` line to also import `ServiceStatus` and `ServiceStatusCtx`. + +```rust +use std::time::SystemTime; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum AgentState { + NotInstalled, + Stopped, + Running, +} + +pub(crate) struct AgentStatus { + pub label: String, + pub plist: PathBuf, + pub state: AgentState, + pub program: Option, + pub path_env: Option, + pub snapshotted: Option, + pub pid: Option, +} + +pub(crate) fn read_pid(pidfile: &Path) -> Option { + std::fs::read_to_string(pidfile) + .ok()? + .trim() + .parse::() + .ok() +} + +pub(crate) fn status(label: &str, pidfile: &Path) -> Result { + let plist = plist_path_for(label)?; + + if !plist.exists() { + return Ok(AgentStatus { + label: label.to_string(), + plist, + state: AgentState::NotInstalled, + program: None, + path_env: None, + snapshotted: None, + pid: None, + }); + } + + let snapshotted = std::fs::metadata(&plist).and_then(|meta| meta.modified()).ok(); + + let parsed: ServiceLabel = label.parse().context("parse service label")?; + + let state = match manager().status(ServiceStatusCtx { label: parsed })? { + ServiceStatus::Running => AgentState::Running, + ServiceStatus::Stopped(_) => AgentState::Stopped, + ServiceStatus::NotInstalled => AgentState::NotInstalled, + }; + + let pid = if state == AgentState::Running { + read_pid(pidfile) + } else { + None + }; + + let (program, path_env) = read_plist_fields(&plist); + + Ok(AgentStatus { + label: label.to_string(), + plist, + state, + program, + path_env, + snapshotted, + pid, + }) +} + +fn read_plist_fields(plist: &Path) -> (Option, Option) { + let Ok(contents) = std::fs::read_to_string(plist) else { + return (None, None); + }; + + let program = contents + .split("ProgramArguments") + .nth(1) + .and_then(|rest| rest.split("").nth(1)) + .and_then(|rest| rest.split("").next()) + .map(PathBuf::from); + + let path_env = contents + .split("PATH") + .nth(1) + .and_then(|rest| rest.split("").nth(1)) + .and_then(|rest| rest.split("").next()) + .map(str::to_string); + + (program, path_env) +} + +fn launchctl(verb: &str, plist: &Path) -> Result<()> { + let output = std::process::Command::new("launchctl") + .arg(verb) + .arg(plist) + .output() + .with_context(|| format!("run launchctl {verb}"))?; + + if !output.status.success() { + anyhow::bail!( + "launchctl {verb} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + Ok(()) +} + +pub(crate) fn start(label: &str) -> Result<()> { + launchctl("load", &plist_path_for(label)?) +} + +pub(crate) fn stop(label: &str) -> Result<()> { + launchctl("unload", &plist_path_for(label)?) +} +``` + +`read_plist_fields` does string slicing rather than pulling in the `plist` crate as a direct dependency; it reads two well-known keys from a file this program wrote, and returns `None` rather than failing when the shape is unexpected. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo nextest run -p xy -E 'test(/service::/)'` + +Expected: PASS, 9 tests. + +- [ ] **Step 5: Verify the `launchctl print` risk empirically** + +The spec flags this: `service-manager`'s `status()` calls `launchctl print `, but user agents normally need `gui/$UID/