Files
xy/docs/superpowers/plans/2026-07-31-xy-start-on-login.md
T
logaritmiskandClaude Opus 5 7bb80803fe fix(service): strip Disabled before load, verify it, and honour the plist
service-manager 0.11 writes Disabled: true into every plist carrying
KeepAlive, and our RestartPolicy::Always guarantees KeepAlive, so every
agent we installed was born disabled. launchctl load honours the key while
still exiting 0, so install reported success on an agent that would never
start — not then and not at the next login.

service::start now removes the Disabled key itself before loading, via a
pure enable_plist() that rewrites nothing when the key is absent and
preserves every other key, including the EnvironmentVariables PATH
snapshot. That makes start self-healing for plists left disabled by an
earlier build. The crate's own start() is still not used, since without
Disabled it degrades to launchctl start, which fails on an unloaded job.

Because launchctl load exits 0 on failure, start also checks a
post-condition: it asks launchd whether the job now exists and reports a
diagnostic if it does not. stop keeps no such check, since a benign unload
of an already-stopped job also prints a failure while exiting 0.

status now treats the plist on disk as the definition of installed, as the
spec says: a plist that exists but is not loaded reports stopped with its
program, PATH and snapshot date intact instead of collapsing to
not-installed with every field cleared. That is precisely the state the
Disabled bug left users in, so it is the state status most needs to
describe.

status also gains the log path the spec always listed, and the daemon's own
log is renamed xy.log -> daemon.log so a supervised server named xy cannot
share a file, and two rotation counters, with the daemon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-08-01 00:27:34 +02:00

1249 lines
38 KiB
Markdown

# 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 |
| `README.md` | *Modify:* document the five verbs |
No integration test is planned. Driving a real launchd cycle would either
hard-code the live `se.aceofba.xy` label and risk clobbering a working
installation, or require a test-only `--label` flag on the CLI. The load/unload
mechanism is covered instead by the *Manual acceptance* section at the end of
this plan, which is the only check that can actually prove `RunAtLoad` works.
---
### 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<usize>` 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<usize> {
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/daemon.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<Mutex<RotatingLogWriter>>`. No later task depends on this.
`tracing-subscriber` 0.3 implements `MakeWriter` for `Mutex<W> where W: io::Write` (`fmt/writer.rs:808`), so a plain `Mutex<RotatingLogWriter>` satisfies `with_writer` directly. No adapter type is needed.
**Corrected 2026-08-01.** This step originally specified `Arc<Mutex<RotatingLogWriter>>`, on the false premise that `impl MakeWriter for Arc<W>` would cover it. That impl requires `&'a W: io::Write`, and `&Mutex<W>` does not implement `io::Write`. The error was caught during implementation and cost one fix round; the shipped code uses a bare `Mutex`.
- [ ] **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("daemon.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::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<Mutex<RotatingLogWriter>> {
let writer = RotatingLogWriter::open(&log_dir.join("daemon.log"), LOG_FILE_MAX_BYTES, LOG_FILE_KEEP)?;
Ok(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<i32> = 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/daemon.log
./target/debug/xy daemon &
sleep 2
cat ~/.local/state/xy/logs/daemon.log
kill %1
```
Expected: `daemon.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<String>, path_env: String, working_dir: PathBuf }`
- `fn AgentSpec::for_current_exe() -> anyhow::Result<AgentSpec>`
- `fn AgentSpec::install_ctx(&self) -> anyhow::Result<ServiceInstallCtx>`
- `fn AgentSpec::plist_path(&self) -> anyhow::Result<PathBuf>`
- `fn plist_path_for(label: &str) -> anyhow::Result<PathBuf>`
- `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<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")
}
```
- [ ] **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<PathBuf>, path_env: Option<String>, snapshotted: Option<SystemTime>, pid: Option<u32> }`
- `fn status(label: &str, pidfile: &Path) -> anyhow::Result<AgentStatus>`
- `fn start(label: &str) -> anyhow::Result<()>`
- `fn stop(label: &str) -> anyhow::Result<()>`
- `fn read_pid(pidfile: &Path) -> Option<u32>`
- [ ] **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<PathBuf>,
pub path_env: Option<String>,
pub snapshotted: Option<SystemTime>,
pub pid: Option<u32>,
}
pub(crate) fn read_pid(pidfile: &Path) -> Option<u32> {
std::fs::read_to_string(pidfile)
.ok()?
.trim()
.parse::<u32>()
.ok()
}
pub(crate) fn status(label: &str, pidfile: &Path) -> Result<AgentStatus> {
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<PathBuf>, Option<String>) {
let Ok(contents) = std::fs::read_to_string(plist) else {
return (None, None);
};
let program = contents
.split("<key>ProgramArguments</key>")
.nth(1)
.and_then(|rest| rest.split("<string>").nth(1))
.and_then(|rest| rest.split("</string>").next())
.map(PathBuf::from);
let path_env = contents
.split("<key>PATH</key>")
.nth(1)
.and_then(|rest| rest.split("<string>").nth(1))
.and_then(|rest| rest.split("</string>").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 <bare-label>`, but user agents normally need `gui/$UID/<label>`. Confirm the crate's two-pass fallback actually works before building the CLI on it.
```bash
launchctl print "gui/$(id -u)/com.apple.Finder" | head -5
launchctl print com.apple.Finder; echo "exit=$?"
```
Expected: the first prints a `state = running` block. The second is expected to fail; note its exit code and whether stderr names a fully-qualified label.
If the second command exits 64 AND its output mentions a qualified label, the crate's fallback works — proceed. If not, replace the `manager().status(...)` call in `status()` with a direct `launchctl print gui/<uid>/<label>` invocation, parsing `state = running`, and record the deviation in the commit message.
- [ ] **Step 6: Format and lint**
```bash
cargo +nightly fmt
cargo clippy -p xy
```
- [ ] **Step 7: Commit**
```bash
git add crates/xy/src/service.rs
git commit -m "feat(service): agent status plus launchctl load/unload"
```
---
### Task 5: CLI verbs
Wire the five verbs into `clap` and render `AgentStatus` for humans.
**Files:**
- Create: `crates/xy/src/cli/service.rs`
- Modify: `crates/xy/src/cli/mod.rs`
- Modify: `crates/xy/src/main.rs`
- Test: `crates/xy/src/cli/service.rs`
**Interfaces:**
- Consumes: everything `pub(crate)` from `crate::service` (Tasks 3 and 4).
- Produces:
- `pub enum ServiceCmd { Install { force: bool }, Uninstall, Start, Stop, Status }` (in `main.rs`)
- `pub async fn run(paths: Paths, cmd: ServiceCmd) -> anyhow::Result<i32>`
- `fn render_status(status: &AgentStatus) -> String`
- [ ] **Step 1: Write the failing tests**
Create `crates/xy/src/cli/service.rs` containing only the test module:
```rust
#[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:"));
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo nextest run -p xy -E 'test(/cli::service/)'`
Expected: FAIL to compile, with `cannot find function 'render_status' in this scope`.
- [ ] **Step 3: Write the minimal implementation**
Prepend to `crates/xy/src/cli/service.rs`:
```rust
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)
}
```
`toggle` takes the action as a function pointer rather than a pre-computed `Result`, so a missing plist short-circuits before `launchctl` is ever invoked. `ensure_supported()` is checked once at the top of `run`, giving every verb the same clear message on non-macOS rather than a confusing launchctl failure.
- [ ] **Step 4: Declare the module**
In `crates/xy/src/cli/mod.rs`, alongside the existing `mod format;`:
```rust
pub mod service;
```
- [ ] **Step 5: Add the subcommand**
In `crates/xy/src/main.rs`, add to `enum Cmd`:
```rust
/// Manage the launchd start-on-login agent (macOS).
Service {
#[command(subcommand)]
verb: ServiceCmd,
},
```
And add the new enum next to `Cmd`:
```rust
#[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,
}
```
And add the dispatch arm to the `match cli.cmd` block:
```rust
Cmd::Service { verb } => cli::service::run(paths, verb).await,
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `cargo nextest run -p xy -E 'test(/cli::service/)'`
Expected: PASS, 4 tests.
- [ ] **Step 7: Remove the Task 3 allow, if it was added**
If `#![allow(dead_code)]` was added to `service.rs` in Task 3, delete it now and confirm `cargo clippy -p xy` is clean.
- [ ] **Step 8: Check the help output renders**
```bash
cargo run -p xy -- service --help
```
Expected: the five verbs are listed with their doc-comment descriptions.
- [ ] **Step 9: Full suite, format, lint**
```bash
cargo nextest run
cargo +nightly fmt
cargo clippy --workspace
```
- [ ] **Step 10: Commit**
```bash
git add crates/xy/src/cli/service.rs crates/xy/src/cli/mod.rs crates/xy/src/main.rs
git commit -m "feat(cli): xy service install/uninstall/start/stop/status"
```
---
### Task 6: Documentation
Document the five verbs in the README. No integration test: driving a real
launchd cycle would either hard-code the live `se.aceofba.xy` label and risk
clobbering a working installation, or require a test-only `--label` flag on the
CLI. The *Manual acceptance* section below covers the mechanism instead.
**Files:**
- Modify: `README.md`
**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: nothing consumed by later tasks.
- [ ] **Step 1: Document the feature**
Add to `README.md`, after the existing command list:
```markdown
## Start on login (macOS)
xy service install # write the LaunchAgent, load it, start the daemon
xy service uninstall # unload and remove the agent
xy service start # load an installed agent
xy service stop # unload until the next login
xy service status # show agent state
`xy service install` snapshots the current `PATH` into the agent, because a
launchd agent otherwise inherits only `/usr/bin:/bin:/usr/sbin:/sbin` and
supervised servers would not find their toolchains. Re-run with `--force`
after installing a new toolchain to refresh the snapshot.
`xy service stop` lasts until the next login. To disable start-on-login
permanently, use `xy service uninstall`.
The daemon writes to `$XDG_STATE_HOME/xy/logs/daemon.log`. Failures that happen
before the daemon starts logging — a missing binary, a malformed plist — are
visible only to launchd:
launchctl print gui/$UID/se.aceofba.xy
```
- [ ] **Step 2: Full suite, format, lint**
```bash
cargo nextest run
cargo +nightly fmt
cargo clippy --workspace
```
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs(readme): document xy service verbs"
```
---
## Manual acceptance
After Task 6, verify the feature end to end against the real agent:
```bash
cargo install --path crates/xy
xy service install
xy service status # expect: state: running (pid N)
xy list # expect: configured servers, reached via the daemon
tail ~/.local/state/xy/logs/daemon.log
```
Then log out and back in, and confirm `xy service status` still reports `running` with a different pid. That last step is the only real proof that start-on-login works, because it is the only one that exercises `RunAtLoad`.
Finally, confirm `Disabled` is absent from the installed plist — its presence would mean the agent will not start at next login:
```bash
grep -c Disabled ~/Library/LaunchAgents/se.aceofba.xy.plist # expect: 0
```