Six TDD tasks: io::Write for RotatingLogWriter, the daemon log file and main.rs reorder, AgentSpec plus launchd install/uninstall, status and launchctl load/unload, the CLI verbs, and the ignored end-to-end test plus README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
1303 lines
40 KiB
Markdown
1303 lines
40 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 |
|
|
| `crates/xy/tests/service.rs` | *New:* `#[ignore]` end-to-end launchd cycle |
|
|
| `README.md` | *Modify:* document the five verbs |
|
|
|
|
---
|
|
|
|
### Task 1: `io::Write` for `RotatingLogWriter`
|
|
|
|
`RotatingLogWriter` already tracks bytes written and rotates, but only exposes `write_line(tag, line)`, which prefixes a tag the daemon's own log must not have. An `io::Write` impl makes the type usable as a `tracing` writer.
|
|
|
|
**Files:**
|
|
- Modify: `crates/xy-supervisor/src/logs.rs`
|
|
- Test: `crates/xy-supervisor/src/logs.rs` (existing `#[cfg(test)] mod tests` at end of file)
|
|
|
|
**Interfaces:**
|
|
- Consumes: nothing.
|
|
- Produces: `impl std::io::Write for RotatingLogWriter`, with `write(&mut self, buf: &[u8]) -> io::Result<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<Arc<Mutex<RotatingLogWriter>>>`. No later task depends on this.
|
|
|
|
`tracing-subscriber` 0.3 already implements `MakeWriter` for `Mutex<W> where W: io::Write` and for `Arc<W>`, so `Arc<Mutex<RotatingLogWriter>>` satisfies `with_writer` directly. No adapter type is needed.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `crates/xy/src/logging.rs` containing only the test module:
|
|
|
|
```rust
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn daemon_writer_creates_and_appends_to_xy_log() {
|
|
use std::io::Write;
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
let writer = daemon_writer(tmp.path()).unwrap();
|
|
|
|
writer.lock().unwrap().write_all(b"line\n").unwrap();
|
|
|
|
let contents = std::fs::read_to_string(tmp.path().join("xy.log")).unwrap();
|
|
assert_eq!(contents, "line\n");
|
|
}
|
|
}
|
|
```
|
|
|
|
Declare the module in `crates/xy/src/main.rs` alongside the existing `mod` declarations:
|
|
|
|
```rust
|
|
mod logging;
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `cargo nextest run -p xy -E 'test(/daemon_writer/)'`
|
|
|
|
Expected: FAIL to compile, with `cannot find function 'daemon_writer' in this scope`.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Prepend to `crates/xy/src/logging.rs`, above the test module:
|
|
|
|
```rust
|
|
use std::path::Path;
|
|
use std::sync::{Arc, Mutex};
|
|
use xy_supervisor::logs::RotatingLogWriter;
|
|
|
|
const LOG_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
|
const LOG_FILE_KEEP: usize = 5;
|
|
|
|
pub(crate) fn daemon_writer(log_dir: &Path) -> std::io::Result<Arc<Mutex<RotatingLogWriter>>> {
|
|
let writer = RotatingLogWriter::open(&log_dir.join("xy.log"), LOG_FILE_MAX_BYTES, LOG_FILE_KEEP)?;
|
|
|
|
Ok(Arc::new(Mutex::new(writer)))
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `cargo nextest run -p xy -E 'test(/daemon_writer/)'`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Reorder `main.rs` so paths resolve before the logger**
|
|
|
|
Replace the top of `main` in `crates/xy/src/main.rs`. The current body initialises `tracing_subscriber` first and resolves paths second; this inverts that order and tees the daemon's output to a file.
|
|
|
|
```rust
|
|
#[tokio::main]
|
|
async fn main() -> std::process::ExitCode {
|
|
let cli = Cli::parse();
|
|
|
|
let paths = match paths::Paths::resolve() {
|
|
Ok(p) => p,
|
|
Err(err) => {
|
|
eprintln!("xy: failed to resolve XDG paths: {err}");
|
|
return std::process::ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
|
|
|
if matches!(cli.cmd, Cmd::Daemon) {
|
|
use tracing_subscriber::fmt::writer::MakeWriterExt;
|
|
|
|
if let Err(err) = paths.ensure_dirs() {
|
|
eprintln!("xy: failed to create state dirs: {err}");
|
|
return std::process::ExitCode::from(3);
|
|
}
|
|
|
|
let file = match logging::daemon_writer(&paths.log_dir) {
|
|
Ok(w) => w,
|
|
Err(err) => {
|
|
eprintln!("xy: failed to open daemon log: {err}");
|
|
return std::process::ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(filter)
|
|
.with_ansi(false)
|
|
.with_writer(std::io::stderr.and(file))
|
|
.init();
|
|
} else {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(filter)
|
|
.with_writer(std::io::stderr)
|
|
.init();
|
|
}
|
|
|
|
// ... existing `let result: anyhow::Result<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: End-to-end test and documentation
|
|
|
|
An ignored integration test that exercises a real launchd cycle under a distinct label, plus README coverage.
|
|
|
|
**Files:**
|
|
- Create: `crates/xy/tests/service.rs`
|
|
- Modify: `README.md`
|
|
|
|
**Interfaces:**
|
|
- Consumes: the `xy` binary built by cargo. The test shells out rather than linking, because `service.rs` is `pub(crate)`.
|
|
- Produces: nothing consumed by later tasks.
|
|
|
|
- [ ] **Step 1: Write the ignored end-to-end test**
|
|
|
|
Create `crates/xy/tests/service.rs`:
|
|
|
|
```rust
|
|
use std::process::Command;
|
|
|
|
fn launchctl(verb: &str, arg: &str) -> bool {
|
|
Command::new("launchctl")
|
|
.arg(verb)
|
|
.arg(arg)
|
|
.output()
|
|
.map(|out| out.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "installs a real launchd agent; run manually with --ignored"]
|
|
fn install_start_stop_uninstall_cycle() {
|
|
let home = std::env::var("HOME").unwrap();
|
|
let plist = format!("{home}/Library/LaunchAgents/se.aceofba.xy-test.plist");
|
|
|
|
assert!(
|
|
!std::path::Path::new(&plist).exists(),
|
|
"test agent already installed; remove {plist} first"
|
|
);
|
|
|
|
let contents = format!(
|
|
r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0"><dict>
|
|
<key>Label</key><string>se.aceofba.xy-test</string>
|
|
<key>ProgramArguments</key><array><string>/bin/sleep</string><string>3600</string></array>
|
|
<key>RunAtLoad</key><true/>
|
|
</dict></plist>
|
|
"#
|
|
);
|
|
|
|
std::fs::write(&plist, contents).unwrap();
|
|
|
|
assert!(launchctl("load", &plist), "load failed");
|
|
assert!(launchctl("unload", &plist), "unload failed");
|
|
|
|
std::fs::remove_file(&plist).unwrap();
|
|
|
|
assert!(!std::path::Path::new(&plist).exists());
|
|
}
|
|
```
|
|
|
|
This deliberately drives `launchctl` against a harmless `/bin/sleep` agent under the `se.aceofba.xy-test` label rather than invoking `xy service install`, which would hard-code the real `se.aceofba.xy` label and could disturb a working installation. It verifies the load/unload mechanism Task 4 relies on.
|
|
|
|
- [ ] **Step 2: Confirm it is skipped by default**
|
|
|
|
Run: `cargo nextest run -p xy`
|
|
|
|
Expected: the test is listed as skipped, not run.
|
|
|
|
- [ ] **Step 3: Run it explicitly and confirm it passes**
|
|
|
|
Run: `cargo nextest run -p xy --run-ignored all -E 'test(/install_start_stop_uninstall_cycle/)'`
|
|
|
|
Expected: PASS. Then confirm no residue: `ls ~/Library/LaunchAgents/ | grep xy-test` returns nothing.
|
|
|
|
- [ ] **Step 4: 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 5: Full suite, format, lint**
|
|
|
|
```bash
|
|
cargo nextest run
|
|
cargo +nightly fmt
|
|
cargo clippy --workspace
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add crates/xy/tests/service.rs README.md
|
|
git commit -m "test(service): ignored launchd cycle test; document xy service"
|
|
```
|
|
|
|
---
|
|
|
|
## 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
|
|
```
|