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
1249 lines
38 KiB
Markdown
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
|
|
```
|