Skip to content

Commit 3bf14b6

Browse files
feat(cli): pearlite reconcile subcommand + vm-10 scenario (#67)
* docs(todo): refresh post-#65 reconcile + bootstrap status TODO.md was 8 days stale. Marks the M4 W1 reconcile read-side (Engine::reconcile, #65) as done; resolves the M3 W1 runuser / per-user nix.conf line as the bootstrap stack (#55-#58) plus ADR-0013 (Home Manager owns per-user nix.conf); updates the M3 W2 vm-09 prose to reflect that vm-09-nix-bootstrap.sh shipped in M4 W1 (#59) rather than remaining deferred. Bumps Last updated to 2026-05-04. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): pearlite reconcile subcommand wires Engine::reconcile Read-only CLI surface for the M4 W1 reconcile read-side that landed in #65. Adds Command::Reconcile (no flags), dispatch_reconcile, reconcile_outcome_view, and reconcile_error_payload mapping every pearlite_engine::ReconcileError variant to a typed error code: RECONCILE_PROBE_FAILED — probe adapter failure RECONCILE_EMPTY_HOSTNAME — /etc/hostname is blank RECONCILE_INVALID_HOSTNAME — `/`, `\`, or NUL in hostname RECONCILE_ALREADY_EXISTS — refuses to clobber operator review RECONCILE_IO_FAILED — mkdir or atomic-write failure All five are class=preflight, exit_code=2 — reconcile is read-only with respect to state.toml; the only system-side effect is the atomic write of <config_dir>/hosts/<hostname>.imported.ncl, and a failed write leaves the operator's config repo untouched (tempfile is dropped before rename). Three dispatch tests cover the happy path (writes the imported.ncl to disk and returns hostname + path in the envelope), the already-exists guard (pre-seeded file is preserved verbatim), and the metadata.command label. Out of scope for this chunk: - `--commit` and `--adopt-all` flags (need Engine::reconcile_commit) - vm-10-reconcile-fresh-install.sh Refs: PRD §11, Plan §7.5 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(cli): rustfmt reconcile dispatch tests Two mechanical wrappings flagged by `cargo fmt --all --check`: - assert! is_file() chain breaks across lines - let preserved = ... fits on a single line at 100 cols No behavior change. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): extract dispatch_plan_or_status to fit too_many_lines Adding `Command::Reconcile` pushed `dispatch()` to 101 lines (clippy limit is 100). Pulled the inline Plan/Status arm into `dispatch_plan_or_status`, mirroring the existing extraction pattern for Apply, Bootstrap, Reconcile, etc. Behavior is identical; read-only test run via cargo test -p pearlite-cli passes 42 tests under WSL Arch. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(vm): vm-10 reconcile-fresh-install scenario Verifies the read-side reconcile pipeline end-to-end against the `pearlite reconcile` CLI shipped earlier in this branch. Phase A (happy path): - exit 0 - envelope: command=pearlite reconcile, hostname non-empty, imported_path resolves to <sandbox>/repo/hosts/<hostname>.imported.ncl - on-disk file contains the Nickel record markers emit_host produces (meta = {, kernel = {, packages = {, services = {) Phase B (clobber refusal): - re-running with the same --config-dir exits 2 with RECONCILE_ALREADY_EXISTS, class=preflight - the original .imported.ncl is byte-identical to the Phase A capture (cmp -s) Whitelisted alongside vm-01 in scripts/ci/run-vm-tests.sh -- vm-10 is read-only with respect to system state (only mutation is a single Nickel file inside a tempdir), so it runs unconditionally without PEARLITE_VM_TEST=1. Verified locally via WSL Arch: bash tests/vm/vm-10-reconcile-fresh-install.sh -> PASS Refs: PRD §11, Plan §7.5 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(todo): mark vm-10 reconcile-fresh-install done Shipped earlier in this branch (commit 3d22c36) and verified locally via WSL Arch. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(agents): document pearlite reconcile flow + error codes Adds a Reconcile-flow section enumerating the five RECONCILE_* error codes and the read-only / interactive split, so future agents can discover the surface without re-deriving it from dispatch.rs. Notes the users / config empty-array placeholders as intentional per PRD §11, references vm-10 for end-to-end coverage, and points at the M4 W1 remainder (reconcile --commit). Bumps Last updated to 2026-05-04. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 010e7d6 commit 3bf14b6

6 files changed

Lines changed: 446 additions & 39 deletions

File tree

AGENTS.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,36 @@ The five-way taxonomy is fixed. Never invent a sixth.
108108
109109
Get this wrong and Pearlite removes packages the user wants to keep.
110110
111+
## Reconcile flow (PRD §11, M4 W1)
112+
113+
`pearlite reconcile` (read-only) probes the live system and writes a fresh
114+
`<config_dir>/hosts/<hostname>.imported.ncl` as a **review draft** for operator
115+
hand-curation. The emitted Nickel record carries `meta`, `kernel`, `packages`,
116+
and `services` blocks from probe data; `users` and `config` are deliberately
117+
emitted as empty arrays per PRD §11 (no `/etc/passwd` enumeration; no clobbering
118+
of operator config-repo paths). The imported file is a draft, not a validated
119+
declaration — the operator hand-curates it and renames it to `<hostname>.ncl`
120+
for the next `pearlite plan`.
121+
122+
The interactive counterpart `pearlite reconcile --commit` (M4 W1 remainder)
123+
commits the import to `state.toml` with a drift-threshold safety check. Until
124+
it lands, reconcile only writes the review draft.
125+
126+
Error codes (all class 1 preflight, exit 2 — reconcile never mutates
127+
`state.toml` and a failed atomic write leaves the operator config repo
128+
untouched):
129+
130+
| `error.code` | Triggers |
131+
|---|---|
132+
| `RECONCILE_PROBE_FAILED` | adapter failure during probe |
133+
| `RECONCILE_EMPTY_HOSTNAME` | blank `/etc/hostname` |
134+
| `RECONCILE_INVALID_HOSTNAME` | `/`, `\`, or NUL in hostname |
135+
| `RECONCILE_ALREADY_EXISTS` | refuses to clobber an existing imported.ncl |
136+
| `RECONCILE_IO_FAILED` | mkdir or atomic-write failure |
137+
138+
VM-tier coverage: [`tests/vm/vm-10-reconcile-fresh-install.sh`](tests/vm/vm-10-reconcile-fresh-install.sh)
139+
exercises the read-side end-to-end (happy path + clobber refusal).
140+
111141
## Always
112142
113143
- Run `cargo clippy --workspace --all-targets -- -D warnings` before claiming a
@@ -152,4 +182,4 @@ Do not propose names outside this convention.
152182
153183
---
154184
155-
*Last updated: 2026-04-27.*
185+
*Last updated: 2026-05-04.*

TODO.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,24 +86,24 @@
8686
### Week 1 — Adapter and bootstrap
8787
- [] `pearlite-userenv` with `HomeManagerBackend` (PR #44)
8888
- [] `DeterminateNixInstaller` (hash-pinned curl|sh) + `MockInstaller` (PR #45)
89-
- [~] `runuser` drop wrapper; per-user `nix.conf` handling — runuser wrap shipped via `LiveHmBackend`; per-user `nix.conf` provisioning deferred to M4 alongside the bootstrap-config story
89+
- [] `runuser` drop wrapper; per-user `nix.conf` handling — runuser wrap shipped via `LiveHmBackend`; bootstrap-config story landed in M4 W1 (PRs #55#58); per-user `nix.conf` resolved as Home Manager's responsibility per ADR-0013
9090

9191
### Week 2 — Engine integration
9292
- [] Wire phase 7 into `pearlite-engine::apply` (PRs #46 / #47 / #50)
9393
- [] Extend `state.toml` schema with `[[managed.user_env]]` (PR #50; schema already shipped in M1)
9494
- [] `pearlite-diff` surfaces HM config-hash drift (PRs #48 / #49)
95-
- [] VM scenarios: `vm-07` user-env apply, `vm-08` user-env drift (PR #51); `vm-09` Nix bootstrap deferred until `LiveNixInstaller` has a wired apply-time caller (M4 W1)
95+
- [] VM scenarios: `vm-07` user-env apply, `vm-08` user-env drift (PR #51); `vm-09` Nix bootstrap shipped in M4 W1 (PR #59)
9696
- [~] Tag `m3-exit` (signed tag from this commit)
9797

9898
---
9999

100100
## M4 — Reconcile and Codify (2 weeks)
101101

102102
### Week 1 — Reconcile flow
103-
- [ ] `pearlite-engine::reconcile()` (read-only) → writes `hosts/<host>.imported.ncl`
103+
- [] `pearlite-engine::reconcile()` (read-only) → writes `hosts/<host>.imported.ncl` (PR #65)
104104
- [ ] `pearlite-engine::reconcile_commit()` — interactive prompts, drift threshold safety, state.toml write
105105
- [ ] `pearlite reconcile`, `reconcile --commit`, `--adopt-all`
106-
- [ ] VM scenario `vm-10-reconcile-fresh-install.sh`
106+
- [] VM scenario `vm-10-reconcile-fresh-install.sh` (PR #67)
107107

108108
### Week 2 — Codify and the sidecar
109109
- [ ] Codified sidecar emitter — deterministic, byte-identical re-emission
@@ -187,4 +187,4 @@ Defer until ADR is written; each blocks a specific milestone exit.
187187

188188
---
189189

190-
*Last updated: 2026-04-27. Mirrors `Pearlite-Plan-v1.0.docx`.*
190+
*Last updated: 2026-05-04. Mirrors `Pearlite-Plan-v1.0.docx`.*

crates/pearlite-cli/src/args.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,20 @@ pub enum Command {
162162
#[arg(long, default_value = "/etc/nix/nix.conf")]
163163
nix_conf: PathBuf,
164164
},
165+
/// Probe the live system and write a fresh
166+
/// `<config_dir>/hosts/<hostname>.imported.ncl` for operator review.
167+
///
168+
/// Read-only with respect to `state.toml`: probes pacman, cargo,
169+
/// systemd, and the kernel via `Engine::reconcile`, renders the
170+
/// result through `pearlite_nickel::emit_host`, and lands the text
171+
/// at the imported path. Refuses to clobber an existing
172+
/// `.imported.ncl` — `RECONCILE_ALREADY_EXISTS` surfaces and the
173+
/// operator removes or renames it before retrying.
174+
///
175+
/// The interactive `--commit` and `--adopt-all` flags that mutate
176+
/// `state.toml` ride along with `Engine::reconcile_commit` in a
177+
/// follow-up PR (M4 W1 remainder).
178+
Reconcile,
165179
}
166180

167181
/// Sub-actions for [`Command::Gen`].

crates/pearlite-cli/src/dispatch.rs

Lines changed: 220 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -70,32 +70,7 @@ pub fn dispatch(args: &Args, ctx: &RunContext) -> Envelope {
7070

7171
match &args.command {
7272
Command::Plan { host_file } | Command::Status { host_file } => {
73-
let host_path = host_file
74-
.clone()
75-
.unwrap_or_else(|| default_host_file(&args.config_dir, &ctx.fallback_host));
76-
let state = match read_state_or_empty(&ctx.state_path, &ctx.fallback_host) {
77-
Ok(s) => s,
78-
Err(payload) => {
79-
return Envelope::failure(metadata_at(None), payload);
80-
}
81-
};
82-
match ctx.engine.plan(&host_path, &state, false) {
83-
Ok(plan) => match serde_json::to_value(&plan) {
84-
Ok(v) => Envelope::success(metadata_at(Some(plan.host)), v),
85-
Err(e) => Envelope::failure(
86-
metadata_at(None),
87-
ErrorPayload {
88-
code: "RENDER_FAILED".to_owned(),
89-
class: "internal".to_owned(),
90-
exit_code: 1,
91-
message: format!("could not serialize plan: {e}"),
92-
hint: "report this as a Pearlite bug".to_owned(),
93-
details: serde_json::Value::Null,
94-
},
95-
),
96-
},
97-
Err(e) => Envelope::failure(metadata_at(None), engine_error_payload(&e)),
98-
}
73+
dispatch_plan_or_status(args, ctx, host_file.as_ref(), &metadata_at)
9974
}
10075
Command::Apply {
10176
host_file,
@@ -138,6 +113,7 @@ pub fn dispatch(args: &Args, ctx: &RunContext) -> Envelope {
138113
nix_conf,
139114
&metadata_at,
140115
),
116+
Command::Reconcile => dispatch_reconcile(args, ctx, &metadata_at),
141117
Command::Schema { bare } => {
142118
if *bare {
143119
Envelope::success(metadata_at(None), bare_schema())
@@ -159,6 +135,48 @@ pub fn dispatch(args: &Args, ctx: &RunContext) -> Envelope {
159135
}
160136
}
161137

138+
/// Dispatch arm for `pearlite plan` and `pearlite status`.
139+
///
140+
/// Both subcommands share the same read-only flow: load the state file
141+
/// (tolerating its absence — a fresh host has no `state.toml` yet),
142+
/// run [`Engine::plan`], and either serialize the plan or surface a
143+
/// typed error payload. Pulled out of [`dispatch`] so the top-level
144+
/// match stays under clippy's `too_many_lines` limit; logic is
145+
/// otherwise identical to the previous inline form.
146+
fn dispatch_plan_or_status(
147+
args: &Args,
148+
ctx: &RunContext,
149+
host_file: Option<&PathBuf>,
150+
metadata_at: &dyn Fn(Option<String>) -> Metadata,
151+
) -> Envelope {
152+
let host_path = host_file
153+
.cloned()
154+
.unwrap_or_else(|| default_host_file(&args.config_dir, &ctx.fallback_host));
155+
let state = match read_state_or_empty(&ctx.state_path, &ctx.fallback_host) {
156+
Ok(s) => s,
157+
Err(payload) => {
158+
return Envelope::failure(metadata_at(None), payload);
159+
}
160+
};
161+
match ctx.engine.plan(&host_path, &state, false) {
162+
Ok(plan) => match serde_json::to_value(&plan) {
163+
Ok(v) => Envelope::success(metadata_at(Some(plan.host)), v),
164+
Err(e) => Envelope::failure(
165+
metadata_at(None),
166+
ErrorPayload {
167+
code: "RENDER_FAILED".to_owned(),
168+
class: "internal".to_owned(),
169+
exit_code: 1,
170+
message: format!("could not serialize plan: {e}"),
171+
hint: "report this as a Pearlite bug".to_owned(),
172+
details: serde_json::Value::Null,
173+
},
174+
),
175+
},
176+
Err(e) => Envelope::failure(metadata_at(None), engine_error_payload(&e)),
177+
}
178+
}
179+
162180
/// Dispatch arm for `pearlite apply`.
163181
///
164182
/// Plan → apply → render. Pulled out of [`dispatch`] so the top-level
@@ -409,6 +427,98 @@ fn bootstrap_error_payload(err: &pearlite_engine::BootstrapError) -> ErrorPayloa
409427
}
410428
}
411429

430+
/// Dispatch arm for `pearlite reconcile` (read-only).
431+
///
432+
/// Probes the live system and writes
433+
/// `<config_dir>/hosts/<hostname>.imported.ncl` via
434+
/// [`Engine::reconcile`]. Does not touch `state.toml` — the operator
435+
/// reviews the import, hand-curates it, and renames it to
436+
/// `<hostname>.ncl` for `pearlite plan` to pick up. The interactive
437+
/// `--commit` and `--adopt-all` flags (which DO mutate state) ride
438+
/// along with `Engine::reconcile_commit` in a follow-up PR.
439+
fn dispatch_reconcile(
440+
args: &Args,
441+
ctx: &RunContext,
442+
metadata_at: &dyn Fn(Option<String>) -> Metadata,
443+
) -> Envelope {
444+
match ctx.engine.reconcile(&args.config_dir) {
445+
Ok(outcome) => Envelope::success(
446+
metadata_at(Some(outcome.hostname.clone())),
447+
reconcile_outcome_view(&outcome),
448+
),
449+
Err(e) => Envelope::failure(metadata_at(None), reconcile_error_payload(&e)),
450+
}
451+
}
452+
453+
/// Render-friendly view of [`pearlite_engine::ReconcileOutcome`].
454+
fn reconcile_outcome_view(outcome: &pearlite_engine::ReconcileOutcome) -> serde_json::Value {
455+
serde_json::json!({
456+
"imported_path": outcome.path.to_string_lossy(),
457+
"hostname": outcome.hostname,
458+
})
459+
}
460+
461+
/// Map `ReconcileError` to a typed [`ErrorPayload`].
462+
///
463+
/// Reconcile is class 1 (preflight) throughout: the only system-side
464+
/// effect is the atomic write of the `.imported.ncl` file, and a
465+
/// failure of that write leaves the operator's config repo untouched
466+
/// (the temp file is dropped). `state.toml` is never read or written
467+
/// on this path, so no recoverable/incoherent classes apply.
468+
fn reconcile_error_payload(err: &pearlite_engine::ReconcileError) -> ErrorPayload {
469+
use pearlite_engine::ReconcileError as R;
470+
match err {
471+
R::Probe(e) => ErrorPayload {
472+
code: "RECONCILE_PROBE_FAILED".to_owned(),
473+
class: "preflight".to_owned(),
474+
exit_code: 2,
475+
message: format!("probing live system failed: {e}"),
476+
hint: "run `pearlite plan` first to surface the underlying probe error".to_owned(),
477+
details: serde_json::Value::Null,
478+
},
479+
R::EmptyHostname => ErrorPayload {
480+
code: "RECONCILE_EMPTY_HOSTNAME".to_owned(),
481+
class: "preflight".to_owned(),
482+
exit_code: 2,
483+
message: "probe returned an empty hostname".to_owned(),
484+
hint: "set /etc/hostname to a non-empty value, then re-run `pearlite reconcile`"
485+
.to_owned(),
486+
details: serde_json::Value::Null,
487+
},
488+
R::InvalidHostname { hostname } => ErrorPayload {
489+
code: "RECONCILE_INVALID_HOSTNAME".to_owned(),
490+
class: "preflight".to_owned(),
491+
exit_code: 2,
492+
message: format!("hostname {hostname:?} is not a valid filename component"),
493+
hint: "set /etc/hostname to an RFC-1123-compliant value (no `/`, `\\`, or NUL)"
494+
.to_owned(),
495+
details: serde_json::json!({ "hostname": hostname }),
496+
},
497+
R::AlreadyExists { path } => ErrorPayload {
498+
code: "RECONCILE_ALREADY_EXISTS".to_owned(),
499+
class: "preflight".to_owned(),
500+
exit_code: 2,
501+
message: format!("{} already exists", path.display()),
502+
hint: format!(
503+
"rm {} or rename it before re-running `pearlite reconcile`",
504+
path.display()
505+
),
506+
details: serde_json::json!({ "path": path.to_string_lossy() }),
507+
},
508+
R::Io { path, source } => ErrorPayload {
509+
code: "RECONCILE_IO_FAILED".to_owned(),
510+
class: "preflight".to_owned(),
511+
exit_code: 2,
512+
message: format!("I/O error at {}: {source}", path.display()),
513+
hint: format!(
514+
"ensure pearlite can write to {}; re-run as the user who owns the config repo",
515+
path.display()
516+
),
517+
details: serde_json::json!({ "path": path.to_string_lossy() }),
518+
},
519+
}
520+
}
521+
412522
/// Default plans directory: `<state_file dir>/plans`.
413523
///
414524
/// Mirrors [`default_failures_dir`]: on a production install with
@@ -693,6 +803,7 @@ fn label_for(command: &Command) -> String {
693803
},
694804
Command::Schema { .. } => "pearlite schema".to_owned(),
695805
Command::Bootstrap { .. } => "pearlite bootstrap".to_owned(),
806+
Command::Reconcile => "pearlite reconcile".to_owned(),
696807
}
697808
}
698809

@@ -2325,4 +2436,87 @@ expected_sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abc
23252436
env.error
23262437
);
23272438
}
2439+
2440+
fn args_for_reconcile(config_dir: PathBuf, state_file: PathBuf) -> Args {
2441+
Args {
2442+
format: OutputFormat::Json,
2443+
config_dir,
2444+
state_file,
2445+
command: Command::Reconcile,
2446+
}
2447+
}
2448+
2449+
#[test]
2450+
fn reconcile_dispatches_through_engine_and_writes_imported_ncl() {
2451+
let dir = TempDir::new().expect("tempdir");
2452+
let config_dir = dir.path().join("repo");
2453+
let state_path = dir.path().join("state.toml");
2454+
// ctx_with seeds a host file path into MockNickel, but reconcile
2455+
// never consults the evaluator (see reconcile.rs:174). Any path
2456+
// is fine here.
2457+
let host = config_dir.join("hosts").join("forge.ncl");
2458+
2459+
let ctx = ctx_with(host, MINIMAL_HOST, state_path.clone());
2460+
let args = args_for_reconcile(config_dir.clone(), state_path);
2461+
let env = dispatch(&args, &ctx);
2462+
2463+
assert!(env.error.is_none(), "got error {:?}", env.error);
2464+
let data = env.data.expect("data populated");
2465+
assert_eq!(
2466+
data.get("hostname").and_then(serde_json::Value::as_str),
2467+
Some("forge")
2468+
);
2469+
let path_str = data
2470+
.get("imported_path")
2471+
.and_then(serde_json::Value::as_str)
2472+
.expect("imported_path");
2473+
assert!(
2474+
path_str.ends_with("forge.imported.ncl"),
2475+
"imported_path was {path_str}"
2476+
);
2477+
assert!(
2478+
config_dir
2479+
.join("hosts")
2480+
.join("forge.imported.ncl")
2481+
.is_file(),
2482+
"imported.ncl was not written to disk"
2483+
);
2484+
}
2485+
2486+
#[test]
2487+
fn reconcile_surfaces_already_exists_when_file_present() {
2488+
let dir = TempDir::new().expect("tempdir");
2489+
let config_dir = dir.path().join("repo");
2490+
let hosts = config_dir.join("hosts");
2491+
std::fs::create_dir_all(&hosts).expect("mkdir");
2492+
std::fs::write(hosts.join("forge.imported.ncl"), b"prior").expect("seed");
2493+
2494+
let state_path = dir.path().join("state.toml");
2495+
let host = config_dir.join("hosts").join("forge.ncl");
2496+
2497+
let ctx = ctx_with(host, MINIMAL_HOST, state_path.clone());
2498+
let args = args_for_reconcile(config_dir.clone(), state_path);
2499+
let env = dispatch(&args, &ctx);
2500+
2501+
let err = env.error.expect("must error");
2502+
assert_eq!(err.code, "RECONCILE_ALREADY_EXISTS");
2503+
assert_eq!(err.class, "preflight");
2504+
assert_eq!(err.exit_code, 2);
2505+
// Pre-seeded file must be untouched.
2506+
let preserved = std::fs::read_to_string(hosts.join("forge.imported.ncl")).expect("read");
2507+
assert_eq!(preserved, "prior");
2508+
}
2509+
2510+
#[test]
2511+
fn reconcile_metadata_command_label() {
2512+
let dir = TempDir::new().expect("tempdir");
2513+
let config_dir = dir.path().join("repo");
2514+
let state_path = dir.path().join("state.toml");
2515+
let host = config_dir.join("hosts").join("forge.ncl");
2516+
2517+
let ctx = ctx_with(host, MINIMAL_HOST, state_path.clone());
2518+
let args = args_for_reconcile(config_dir, state_path);
2519+
let env = dispatch(&args, &ctx);
2520+
assert_eq!(env.metadata.command, "pearlite reconcile");
2521+
}
23282522
}

0 commit comments

Comments
 (0)