Files
xy/docs/superpowers/plans/2026-07-31-xy-start-on-login.md
T
logaritmiskandClaude Opus 5 fd842289e3 docs: correct the false Arc<Mutex> MakeWriter claim
Both the spec and the plan asserted that Arc<Mutex<RotatingLogWriter>>
satisfies tracing-subscriber's MakeWriter via impl MakeWriter for Arc<W>.
That impl requires &'a W: io::Write, and &Mutex<W> does not implement
io::Write. The error reached the implementer and cost a fix round before
being caught; the shipped code uses a bare Mutex.

Corrections are marked inline so the mistake stays visible rather than
being silently erased.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-08-01 00:13:54 +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/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<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("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::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("xy.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/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<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/xy.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/xy.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
```