@celilo/cli 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +16 -1
  3. package/package.json +4 -4
  4. package/src/cli/commands/hook-run.ts +5 -8
  5. package/src/cli/commands/ipam.ts +93 -0
  6. package/src/cli/commands/machine-add.ts +22 -0
  7. package/src/cli/commands/system-audit.ts +2 -0
  8. package/src/cli/commands/system-doctor.ts +148 -5
  9. package/src/cli/commands/system-update.ts +2 -0
  10. package/src/cli/completion.ts +38 -5
  11. package/src/cli/index.ts +10 -1
  12. package/src/cli/tui/audit-state.ts +2 -0
  13. package/src/db/schema.ts +41 -1
  14. package/src/hooks/artifact-retention.test.ts +136 -0
  15. package/src/hooks/artifact-retention.ts +159 -0
  16. package/src/hooks/executor.test.ts +80 -0
  17. package/src/hooks/executor.ts +68 -23
  18. package/src/hooks/test-fixtures/artifact-writing-hook.ts +25 -0
  19. package/src/hooks/types.ts +20 -2
  20. package/src/ipam/allocator.test.ts +38 -0
  21. package/src/ipam/allocator.ts +63 -1
  22. package/src/ipam/auto-allocator.ts +7 -0
  23. package/src/policy/module-business-baseline.ts +404 -0
  24. package/src/policy/no-module-business-in-core.test.ts +504 -0
  25. package/src/services/alerting/keys.ts +21 -1
  26. package/src/services/alerting/run-monitor.ts +6 -1
  27. package/src/services/aspect-reconcile.test.ts +460 -0
  28. package/src/services/aspect-runner.test.ts +1 -0
  29. package/src/services/aspect-runner.ts +408 -37
  30. package/src/services/audit/browser-pin.test.ts +167 -0
  31. package/src/services/audit/browser-pin.ts +185 -0
  32. package/src/services/audit/index.test.ts +1 -0
  33. package/src/services/audit/index.ts +3 -0
  34. package/src/services/audit/types.ts +1 -0
  35. package/src/services/deploy-ansible-recap.test.ts +76 -0
  36. package/src/services/deploy-ansible.ts +56 -1
  37. package/src/services/health-runner.ts +15 -1
  38. package/src/services/module-deploy.ts +70 -16
  39. package/src/services/update/orchestrator.test.ts +1 -0
  40. package/src/system/browser-provisioning.test.ts +67 -0
  41. package/src/system/prereqs.test.ts +73 -0
  42. package/src/system/prereqs.ts +89 -12
  43. package/src/templates/ingress-ip.test.ts +108 -0
@@ -41,12 +41,12 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
41
41
  - **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
42
42
  - **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
43
43
  - **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **REQUIRES the `control-plane-vpn` network and READS its range** (`requires.networks`; `client_subnet` is a `source: system` derive of `network.control-plane-vpn.subnet`). It does not write that network and has no way to — celilo owns the namespace, and the deploy will not reach any hook until the network is defined, asking for a range if one is missing. So `wg0` is attributable the moment it exists. This replaces a declare-before-you-create ordering inside `on_install`, which could only narrow the window and not close it: a consumer that captured config before the hook started could not see a value the hook wrote, whatever order it wrote it in, and that is exactly what left `wg0` unattributable (celilo#759). `health_check` still asserts the declaration matches what the tunnel serves — what it catches now is divergence, celilo's network having changed since the module resolved its config. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **Adoption is a one-time IMPORT and it ENDS.** The marker is `registered_peers` — celilo's own config key, separate from the operator's `peers` so a machine can never rewrite what an operator typed. While that key has never been written the tunnel is not yet celilo's; the deploy that writes it (even as `[]`) CLAIMS the tunnel, importing whatever `[Peer]` blocks are running, and from then on the render is closed-world (`peers` ∪ `registered_peers`) with a `[Peer]` on the box in neither reported as drift by `on_install` and by `health_check`'s `unknown_peers`. An ABSENT `peers` declaration and a declared-EMPTY one are deliberately different (no `default: []` on either variable): they used to be the same value, so the module read every empty list as "adopt what is running", the last peer could not be revoked, and a hand-added peer rode along on every deploy unreported (celilo#765). On a tunnel celilo has not yet claimed, a declared-empty `peers` is REFUSED rather than obeyed — celilo's own variable-default seeding wrote a real `peers = []` row for every install of the previous version that never set one, so on the installed base an empty list cannot be told from a stored default, and obeying it would `wg syncconf` the admin tunnel down to zero peers. **PROVIDES `control_plane_vpn`** so another module can enrol clients without an operator editing YAML: `registerClient` / `revokeClient` write to `registered_peers` — the same key the adoption claim uses — and `getEndpoint` reads the server's public key LIVE off the host rather than storing a copy that could be republished after the key changed. `client_pool` is the range a consumer may allocate from, a strict subset of the client subnet, and `validate_config` REFUSES any operator-declared peer inside it: the two allocators cannot see each other, so the range is divided rather than negotiated, and that check is the only place a bad division is caught. ⚠️ Granting this capability grants fleet-wide reach — the tunnel's client subnet is a registered trusted source, so every client enrolled through it reaches every managed zone. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
44
- - **wireguard-manager** — self-service enrolment for the control-plane VPN, so adding an administrator's laptop is a web page rather than an operator editing `peers` by hand. A browser generates the WireGuard keypair with WebCrypto and **the private key never leaves it** — celilo never sees it and the app never stores it. The app allocates an address from `client_pool` (the range `wireguard` reserved for a consumer; celilo's IPAM deliberately does not cover VPN clients) and enrols the peer through **`control_plane_vpn`**, so it never edits wireguard's config and cannot reach a peer the operator declared by hand. Its own device list is the app's SQLite, reconciled onto the tunnel by the `reconcile_clients` hook on a `timer.tick.1m` subscription. Two guards make a failed poll harmless and both are structural rather than a check: `planReconcile` accepts only the `read` variant of a discriminated union, so an unreadable poll cannot reach it, and revocation is TOMBSTONE-driven — a device is revoked because the app says it was, never because it is missing from a list — so a short or empty read can only fail to add someone, never cut anyone off. A peer on the tunnel the app does not know and has not tombstoned is reported as an orphan and LEFT ALONE (usually an app restored from an older backup). **REQUIRES `private_web`, never `public_web`**: there is no public record, no ACME certificate and no port forward, and the internal CA's certificate is bundled with each downloaded config so a freshly enrolled device trusts the manager it just connected to. ⚠️ **Everyone enrolled becomes a fleet administrator** — the tunnel's client subnet is a registered trusted source, so the `admin_group` gate on the idp application is the entire access-control story; per-peer narrowing is not possible, because server-side `AllowedIPs` is crypto-key routing rather than access control. Lives in **`app`**, one tier behind `caddy-internal` in `dmz`, and is never reached directly (celilo#879). Enrolment works from INSIDE the fleet only (a device not yet on the VPN cannot reach a manager reachable only over it) — from the LAN through the ingress IP, from a segmented zone at the ingress's dmz address, or over an existing VPN session, and a revocation takes effect at the next reconcile — that interval IS the window in which a revoked device still has reach, which is why the UI shows it pending and why `celilo module run-hook wireguard-manager reconcile_clients` exists. **requires:** `control_plane_vpn` (wireguard), `idp` (authentik), `private_web` (caddy-internal).
44
+ - **wireguard-manager** — self-service enrolment for the control-plane VPN, so adding an administrator's laptop is a web page rather than an operator editing `peers` by hand. A browser generates the WireGuard keypair with WebCrypto and **the private key never leaves it** — celilo never sees it and the app never stores it. The app allocates an address from `client_pool` (the range `wireguard` reserved for a consumer; celilo's IPAM deliberately does not cover VPN clients) and enrols the peer through **`control_plane_vpn`**, so it never edits wireguard's config and cannot reach a peer the operator declared by hand. Its own device list is the app's SQLite, reconciled onto the tunnel by the `reconcile_clients` hook on a `timer.tick.1m` subscription. Two guards make a failed poll harmless and both are structural rather than a check: `planReconcile` accepts only the `read` variant of a discriminated union, so an unreadable poll cannot reach it, and revocation is TOMBSTONE-driven — a device is revoked because the app says it was, never because it is missing from a list — so a short or empty read can only fail to add someone, never cut anyone off. A peer on the tunnel the app does not know and has not tombstoned is reported as an orphan and LEFT ALONE (usually an app restored from an older backup). **REQUIRES `private_web`, never `public_web`**: there is no public record, no ACME certificate and no port forward, and the internal CA's certificate is bundled with each downloaded config so a freshly enrolled device trusts the manager it just connected to. **The app authenticates people ITSELF** — it runs the OIDC authorization-code + PKCE flow from its own page, exchanges the code server-side (the client is confidential), verifies the resulting access JWT against the issuer's JWKS and reads the group claim out of the token it verified. No cookie and no server-side session, because the page is self-contained client-side JS and a session store on the box that hands out fleet access is state worth not having. It previously trusted an `X-Forwarded-User` header from an authenticating ingress that celilo has never had — leaving it both unusable (the header was always absent) and forgeable (anyone reachable could set it); `identity_header` is gone with no fallback. ⚠️ **Everyone enrolled becomes a fleet administrator** — the tunnel's client subnet is a registered trusted source, so membership of `admin_group`, checked against the token's group claim, is the entire access-control story; per-peer narrowing is not possible, because server-side `AllowedIPs` is crypto-key routing rather than access control. Lives in **`app`**, one tier behind `caddy-internal` in `dmz`, and is never reached directly (celilo#879). Enrolment works from INSIDE the fleet only (a device not yet on the VPN cannot reach a manager reachable only over it) — from the LAN through the ingress IP, from a segmented zone at the ingress's dmz address, or over an existing VPN session, and a revocation takes effect at the next reconcile — that interval IS the window in which a revoked device still has reach, which is why the UI shows it pending and why `celilo module run-hook wireguard-manager reconcile_clients` exists. **requires:** `control_plane_vpn` (wireguard), `idp` (authentik), `private_web` (caddy-internal).
45
45
 
46
46
  ## Public edge (ingress / identity)
47
47
 
48
48
  - **caddy** — reverse proxy with automatic HTTPS (HTTP-01 ACME); the standard HTTPS ingress. Emits a 301 redirect block for each served name's `www`/apex companion (kept out of the served-hostname set, so a companion never blocks the ACME wait). Its `health_check`'s DNS item is `internal_dns_resolution` — it digs from the management host behind the split-horizon resolver and is evidence about the in-fleet view only; public reachability is the framework's `public_dns` check. **provides:** `public_web`. **requires:** `dns_registrar`, `firewall`.
49
- - **caddy-internal** — a SECOND Caddy that serves the fleet and nothing else, and is a sibling of **caddy** rather than a mode of it. It never exposes a port on the firewall's EXTERNAL interface, never publishes a public record, and takes its certificates from Caddy's own local CA (`tls internal`) — those absences ARE the capability, and `public_web` cannot express them because it treats an unreachable route as a deploy failure and publishes a public A record to prevent one (design D10). Lives in the **`dmz`** zone, in front of the things it fronts, mirroring the public `caddy` (celilo#879). LAN devices reach it through an IPAM-allocated `internal`-subnet `ingress_ip` that `on_install` passes to `firewall.exposeService({ ingressIp })` — one DNAT on the firewall's INTERNAL side and nothing external, the same mechanism the dmz-resident `dns_internal` resolver has used for `:53` since ISS-0156. Systems already inside `dmz`/`app`/`secure` use its dmz address instead (carried as `zoneRoutableValue` on the internal record), and VPN clients arrive as a registered trusted source. It previously lived in `internal` on the claim that a dmz ingress could not be reached from a LAN without a public port-forward; that conflated a public forward with an internal-side ingress IP, and cost it the ability to serve a browser inside a segmented zone at all. **`getCaCertificate()` is why the contract has a method `public_web` does not** — an internally-issued cert means clients must trust a CA celilo runs, and here that is nearly free because the people who must trust it are the ones who just downloaded a bundle from the service behind it, so the anchor ships with it. **The route table is this module's OWN config (`routes`), never celilo's `web_routes`** (celilo#846): caddy derives its served hostnames from every row of that table, so a private route stored there would be picked up and served PUBLICLY — storage is the privacy boundary, not policy. Route changes reconcile synchronously in the capability call (there is no `routes_changed` event for private routes, and inventing one would only add delay). A consumer cannot register a route on a hostname the ingress is not already configured for: a capability factory gets no capabilities of its own, so it cannot add the internal DNS record a new name would need, and it refuses rather than serving an unresolvable site block. **provides:** `private_web`. **requires:** `dns_internal` — declared under `optional.capabilities` ONLY to route around celilo#854 (the import-time secret gate refuses a consumer over a secret it never reads); `on_install` throws without it.
49
+ - **caddy-internal** — a SECOND Caddy that serves the fleet and nothing else, and is a sibling of **caddy** rather than a mode of it. It never exposes a port on the firewall's EXTERNAL interface, never publishes a public record, and takes its certificates from Caddy's own local CA (`tls internal`) — those absences ARE the capability, and `public_web` cannot express them because it treats an unreachable route as a deploy failure and publishes a public A record to prevent one (design D10). Lives in the **`dmz`** zone, in front of the things it fronts, mirroring the public `caddy` (celilo#879). LAN devices reach it through an IPAM-allocated `internal`-subnet `ingress_ip` that `on_install` passes to `firewall.exposeService({ ingressIp })` — one DNAT on the firewall's INTERNAL side and nothing external, the same mechanism the dmz-resident `dns_internal` resolver has used for `:53` since ISS-0156. Systems already inside `dmz`/`app`/`secure` use its dmz address instead (carried as `zoneRoutableValue` on the internal record), and VPN clients arrive as a registered trusted source. It previously lived in `internal` on the claim that a dmz ingress could not be reached from a LAN without a public port-forward; that conflated a public forward with an internal-side ingress IP, and cost it the ability to serve a browser inside a segmented zone at all. **`getCaCertificate()` is why the contract has a method `public_web` does not** — an internally-issued cert means clients must trust a CA celilo runs, and here that is nearly free because the people who must trust it are the ones who just downloaded a bundle from the service behind it, so the anchor ships with it. **The route table is this module's OWN config (`routes`), never celilo's `web_routes`** (celilo#846): caddy derives its served hostnames from every row of that table, so a private route stored there would be picked up and served PUBLICLY — storage is the privacy boundary, not policy. Route changes reconcile synchronously in the capability call (there is no `routes_changed` event for private routes, and inventing one would only add delay). **Every proxied route strips a client-supplied `X-Forwarded-User`** (`header_up -X-Forwarded-User`): this ingress routes and does not authenticate, so a backend believing that header would believe whatever the client sent — which `wireguard-manager` shipped doing. That module verifies a signed token now, so the strip protects the NEXT backend written against the same assumption. It is defence in depth, NOT an authenticating proxy: adding `forward_auth` to `private_web` is a capability change and deliberately not bundled with it. A consumer cannot register a route on a hostname the ingress is not already configured for: a capability factory gets no capabilities of its own, so it cannot add the internal DNS record a new name would need, and it refuses rather than serving an unresolvable site block. **provides:** `private_web`. **requires:** `dns_internal` — declared under `optional.capabilities` ONLY to route around celilo#854 (the import-time secret gate refuses a consumer over a secret it never reads); `on_install` throws without it.
50
50
  - **generic-cpanel-hosting-provider** — publishes static sites into a subfolder of a domain on a cPanel/SSH web host celilo does **not** govern (the host owns the domain, DNS, TLS and web server). Systemless like **namecheap** — no `requires.system`, no zone, no IPAM; it holds credentials for an external party. One provider serves many accounts, resolved by hostname; onboarding takes the account password once, `ssh-copy-id`s celilo's key, then discards it. **provides:** `external_web`.
51
51
  - **authentik** — Authentik identity provider with OIDC (Docker Compose: server, worker, Postgres, Redis). **provides:** `idp`. **requires:** `public_web`, `dns_registrar`, `firewall`.
52
52
 
@@ -176,7 +176,10 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
176
176
  - **Hook executor / ABI** — `apps/celilo/src/hooks/executor.ts` (`invokeHook`, `executeHookScript`, `checkRequiredCapabilities`), types in `apps/celilo/src/hooks/types.ts` (`HookContext`, `HookDefinition`, `HookName`). Named-hook runner: `apps/celilo/src/hooks/run-named-hook.ts`. Manifest hook config: `apps/celilo/src/hooks/load-hook-config.ts`.
177
177
  - **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
178
178
  - **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
179
- - **Base-module aspects (fan-out across the fleet)** — `apps/celilo/src/services/aspect-runner.ts` — `planAspectFanOut`, `runAspectFanOut`, `maybeRunAspectForTrigger`. Aspect content lives in `modules/<m>/base-module-aspect/` (e.g. knot-unbound-internal, technitium).
179
+ - **Base-module aspects (fan-out across the fleet)** — `apps/celilo/src/services/aspect-runner.ts` — `planAspectFanOut`, `runAspectFanOut`, `maybeRunAspectForTrigger`. Aspect content lives in `modules/<m>/base-module-aspect/` (e.g. knot-unbound-internal, technitium). Two directions, with DELIBERATELY OPPOSITE failure semantics:
180
+ - **Outbound** (`maybeRunAspectForTrigger`, `on_install`): one provider's aspect across the whole fleet, enumerated at that instant. A failure never fails the provider's own deploy — aspects are forward-progress and a partial fleet converges on the next fan-out.
181
+ - **Inbound** (`reconcileAspectsForSystems`): every approved aspect applied to systems that have just come into existence. Called from `module-deploy.ts` between `waitForSSH` and `executeAnsible` (so a module's playbook and `on_install` see a correctly configured host) and from `machine-add.ts`. Eligibility is `applicable_zones` + approval; the aspect's `triggers` list is deliberately NOT consulted, so convergence is not opt-in per manifest. A failure here IS fatal to the deploy that created the system — the aspect is a prerequisite of that host — while a failure on `machine add` is only a warning. PAUSED providers are skipped, which is the escape hatch for a wedged aspect. Nothing is rolled back: rows, guest and IPAM allocation persist and a re-run converges on the same system. Fixes celilo#902, where a system provisioned after its provider deployed silently never received the aspect. See `openspec/changes/aspect-fanout-new-systems/`.
182
+ - **Coverage verification** — `verifyAspectCoverage` + `celilo system doctor --deep [--fix]`. Answers "is any system missing an aspect its zone entitles it to" WITHOUT stored state: the entitled set is `planAspectFanOut` itself, and coverage is measured by evaluating the role against the host in Ansible check mode (`executeAnsible({check:true})` + `parseAnsibleRecap`). Three outcomes, not two — `changed=0,skipped=0` applied, `changed>0` missing, **`skipped>0` unknown**, because check mode SKIPS a task it cannot evaluate and a `command`/`shell` role would otherwise report clean having never run.
180
183
  - **In-flight operation lock** — `apps/celilo/src/services/module-operations.ts` — `startOperation`/`completeOperation`/`failOperation` record deploy/uninstall/backup/restore in `module_operations`; `refuseIfInFlight`/`checkInFlight` are what backup and restore consult. Deploy and uninstall REGISTER but never check: it is a one-way guard protecting backup/restore consistency, not a general mutex (`openspec/specs/management-server-backup/spec.md` "In-flight operation refusal"). A row stops holding the lock once it is GONE, STOPPED/zombie (`isPidRunnable`, `ps -o state=` — `kill(pid,0)` calls a Ctrl-Z'd process alive), or older than `OPERATION_TTL_MS` (2h). The TTL is not redundancy: a pid is a recycled number, and once the pid space wraps an old row names an unrelated healthy process. Operator surface: `celilo module operations [list|clear] [--abandoned] [--all]` (`apps/celilo/src/cli/commands/module-operations.ts`); `list` shows only what holds the lock, abandoned rows are summarised unless `--abandoned`. Abandoned rows are reclaimed hourly by the `celilo-operations-sweep` bus subscriber (`timer.tick.1h` → `celilo module operations clear`, armed by `ensureOperationsSweepSubscriber` from module registration and `celilo system migrate`). `clear` MARKS rows failed rather than deleting them, and that is load-bearing: the `abandoned_operations` audit reads exactly those released rows to notice one module's operation dying over and over.
181
184
  - **Module pause / unpause (control-plane quiescence)** — `apps/celilo/src/services/module-pause.ts` — pure `planPause`/`planUnpause` producing an ordered plan, `executePause`/`executeUnpause` performing it, plus `listPausedModules`/`pausedAmong`/`formatPausedDuration`/`describePausedModule` (the ONE place an age is formatted). CLI: `apps/celilo/src/cli/commands/module-pause.ts` (`celilo module pause|unpause <id> [--cascade] [--stop-infra] [--reason] [--dry-run] [--yes]`). Pausing takes a module out of the CONTROL plane — no dispatched events, no timer hooks, no health checks, alerts suppressed — while leaving the DATA plane running, because capability consumption is deploy-time: every consumer calls `firewall`/`dhcp_server` from `on_install` and nothing calls it while serving. Config, secrets, IPAM/VMID and placement are preserved; `on_uninstall` does NOT run. Quiescence is enforced in two places: pausing drops the module's bus subscriptions (`unregisterModuleSubscriptions`), and `run-named-hook.ts` refuses any non-lifecycle hook for a PAUSED module (`skippedPaused`), which catches the paths that skip the bus — `events resync-subscriptions`, a restore that starts events.db empty, aspect fan-out, public-web republish. `on_install`/`on_uninstall` are exempt by hook NAME (not a caller flag): unpause redeploys through `on_install`, and removing a paused provider needs `on_uninstall`. Unpause always REDEPLOYS (`deployModule`) — that is what rebinds a consumer to a replacement provider and recreates provider-local state from the consumers that own it — and re-registers subscriptions, which a plain deploy does not do. A failed unpause restores `PAUSED` rather than leaving the module live and mis-bound. Cascade order reuses `services/update/dep-graph.ts` unchanged (pause = consumers first, unpause = providers first) and is computed from the GRAPH, never from which modules are currently paused, so a cascade walks THROUGH already-done members and is resumable. See `openspec/changes/module-pause-lifecycle/`.
182
185
  - **Provider-removal guard** — `apps/celilo/src/services/remove-guard.ts` — `findRemovalBlockers`/`describeRemovalRefusal`, called from `apps/celilo/src/cli/commands/module-remove.ts`. A PAUSED module is not a dependent (unpause cannot return it to service without a redeploy, and a redeploy re-resolves capabilities), which is what makes a provider swap possible at all. A dependent is one declaring the capability under `requires` **or** `optional` — the same relation `dep-graph.ts` uses, so the guard and the cascade agree on the set; the guard previously read `requires` alone, which let a removal silently orphan `technitium`'s `optional` `dhcp_server`. Refusals name each blocker AND which declaration makes it one. It deliberately does NOT exempt a dependent because another provider of the same capability exists (celilo#683).
@@ -200,6 +203,18 @@ warning naming every paused module and its age onto EVERY management-API result
200
203
  That last one is the important half: it does not depend on the operator choosing
201
204
  to look, which is how a forgotten pause actually gets found.
202
205
 
206
+ ## Management-host browser runtime
207
+
208
+ - **The browser celilo provisions** — installed by `modules/celilo-mgmt/ansible/roles/celilo-mgmt/tasks/debian.yml` into `/var/lib/celilo/browsers`, behind `install_browser` (a manifest boolean **defaulting to false**, exactly how `install_docker` / `install_terraform` gate third-party-repo tooling: ~170 MB from an external CDN). The pin is `playwright_version` in `modules/celilo-mgmt/manifest.yml` — the fleet's single browser-version declaration. What is UNCONDITIONAL is the *declaration*, so a host that needs a browser and lacks one says so. It provisions `chromium-headless-shell`, **not** full Chromium: both builds on celilo-mgr are headless shells and `chromium.launch()` has been driving one in production the whole time, so the full browser would change what works rather than preserve it. Accepted ceiling: no headed browsing. macOS is an explicit `fail`, not a silent skip.
209
+ - **The seam** — `packages/capabilities/src/browser.ts` — `resolveBrowser()` returns `{ browser, flavor, executablePath, browserVersion, playwrightVersion, revision }` read from the stable symlink `/var/lib/celilo/browsers/current/chrome` plus the descriptor `current/celilo-browser.json` the install writes. Consumers pass `executablePath` to their launcher, which bypasses Playwright's revision-keyed cache resolution — the revision is a property of the CLIENT version (1.55.1 → 1193, 1.60.0 → 1223), so without it a consumer's client bump silently moves the browser out from under it. **A plain exported function, deliberately NOT a capability and NOT a `HookContext` field**: an event-bus subscriber runs as its own subprocess with no hook context at all, and `lunacycle` — the one consumer — launches from both a hook and a subscriber. There is no `CapabilityRegistry` / `KNOWN_CAPABILITY_NAMES` entry and there should not be one.
210
+ - **Two failures, two severities** — `BrowserUnavailableError.reason` is `'not_provisioned' | 'unusable'`, and the discriminant is load-bearing. Installation is opt-in, so *absent* is the NORMAL state of a host; a consumer reports it non-fatally. A module throws on any failing check item and celilo maps any fail → unhealthy → the module stays INSTALLED, so a single severity would take every browser-using module permanently unverified on any host that had not flipped a flag it never knew about. *Unusable* — dangling symlink, not executable, unreadable descriptor — is a regression and fails. **Provisioning status is decided from the BINARY, never from a directory existing**: a build directory present with no executable inside it satisfies every path check and then fails at launch.
211
+ - **`system doctor` rows** — `apps/celilo/src/system/prereqs.ts` — `browser` and `fonts` joined `PREREQUISITES`. `PrerequisiteSpec.command` is the second check kind: an ABSOLUTE path switches presence from `command -v` to exists + executable + reports-a-version. ⚠️ **Never ask Playwright which executable it would use.** `chromium.executablePath()` reports the FULL browser while a headless launch opens the SHELL — measured disagreeing on one machine in one run — so on a shell-only host it validates a path that is not there while the binary that actually runs is fine. Because a not-provisioned browser is deliberately not a check failure, this row is the ONLY place an operator learns the host has none. `fonts` runs `fc-match sans-serif`, which resolves an actual face rather than merely proving fontconfig is installed, and displays the family — that is what decides whether a retained screenshot has legible glyphs.
212
+ - **Per-run artifact directory** — `moduleArtifactDir(modulePath, runKey)` in the same file is the one definition of the module store's artifact layout, consumed by `apps/celilo/src/hooks/executor.ts` when it creates `HookContext.screenshotDir` and reachable from a bus subscriber that has no context. Per-run because a consumer writing fixed filenames — a reasonable thing to do — would otherwise overwrite itself every run and leave retention nothing to retain. The executor discards the directory when the hook wrote nothing, so an idle module accrues none; note it is created only AFTER every early return, because the capability pre-flight returns outside the `try/finally` that reclaims it and an earlier `mkdir` leaked one empty directory per affected run, forever.
213
+ - **Artifacts reach the operator** — the executor collects EVERY file written to the run directory (`collectArtifacts`), carried as `HookResult.artifactPaths` (there is no `screenshotPath`; it was deleted, not deprecated). `apps/celilo/src/services/health-runner.ts` carries them onto `HealthCheckResult` — including the hook-FAILED branch, where they matter most because there are no named checks to explain anything — and `failingKeysFromHealthItems` (`services/alerting/keys.ts`) appends them to each failing item's `details`, which is the field that actually reaches an alert. Collecting them and stopping short of `details` would accomplish nothing. `apps/celilo/src/hooks/types.ts` exports `describeArtifacts`, the one renderer the three error-message sites share.
214
+ - **Retention is by AGE, never by run count** — `apps/celilo/src/hooks/artifact-retention.ts` — 24 h plus a per-module byte ceiling, pruned on write so it needs no scheduler. Count-based pruning is the trap: for a persistent failure the FIRST artifact set carries the original cause and later ones repeat it, so "keep the last 5" discards the useful one within about an hour at a 15-minute cadence. A run is aged by its NEWEST file, not the directory's mtime, so an in-flight run cannot be evicted underneath itself. Artifacts are excluded from module backups (`modules/celilo-mgmt/scripts/on_backup.ts` `EXCLUDE_DIRS`) and preserved across `module update`.
215
+ - **Pin-drift guardrail** — `apps/celilo/src/services/audit/browser-pin.ts` — the `browser_pin` drift category compares each module's BUNDLED `playwright-core` against the provisioned browser, and it compares **revisions, not version strings**: the revision is what has to match a build, and each client states its own in the `browsers.json` inside the package, so nobody maintains a version→revision table that would rot. It WARNS (`drift`) and never blocks — under D2 the consumer passes an explicit `executablePath`, so a mismatch is protocol skew rather than a failure. Silent when no browser is provisioned (the opt-in default) and when no module bundles a client (almost all of them); a check that fired in either case would put a permanent finding on every host in the fleet. Adding a `DriftCategory` does NOT create a monitor — only 3 of the 21 categories have one — so this is a `system audit` finding, not a page (D4).
216
+ - Design and the declined alternatives (no probe capability, no fourth `builtin_check`, no concurrency semaphore, no off-box prober — each with its trigger): `openspec/changes/managed-browser-runtime/`.
217
+
203
218
  ## Generation & templating
204
219
 
205
220
  - **Generator** — `apps/celilo/src/templates/generator.ts` — `generateTemplates` (orchestration), plus Terraform/Ansible file handling.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,9 +58,9 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^2.0.0",
61
+ "@celilo/capabilities": "^2.1.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.8.1",
63
+ "@celilo/core": "^0.9.0",
64
64
  "@celilo/event-bus": "^0.6.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -75,7 +75,7 @@
75
75
  },
76
76
  "devDependencies": {
77
77
  "@biomejs/biome": "^1.9.4",
78
- "@celilo/terraform-fake": "^0.3.0",
78
+ "@celilo/terraform-fake": "^0.3.1",
79
79
  "@types/bun": "^1.1.14",
80
80
  "@types/react": "^19.2.14",
81
81
  "drizzle-kit": "^0.30.0",
@@ -13,6 +13,7 @@ import { getDb } from '../../db/client';
13
13
  import { modules } from '../../db/schema';
14
14
  import { createConsoleLogger, createGaugeLogger } from '../../hooks/logger';
15
15
  import { runNamedHook } from '../../hooks/run-named-hook';
16
+ import { describeArtifacts } from '../../hooks/types';
16
17
  import type { HookName } from '../../hooks/types';
17
18
  import type { ModuleManifest } from '../../manifest/schema';
18
19
  import { getArg, hasFlag, validateRequiredArgs } from '../parser';
@@ -83,10 +84,8 @@ export async function handleHookRun(
83
84
  });
84
85
 
85
86
  if (!result.success) {
86
- let errorMsg = result.error || 'Hook execution failed';
87
- if (result.screenshotPath) {
88
- errorMsg += `\n\nScreenshot saved: ${result.screenshotPath}`;
89
- }
87
+ const errorMsg =
88
+ (result.error || 'Hook execution failed') + describeArtifacts(result.artifactPaths);
90
89
  return { success: false, error: errorMsg };
91
90
  }
92
91
 
@@ -109,10 +108,8 @@ export async function handleHookRun(
109
108
 
110
109
  if (!result.success) {
111
110
  gauge.stop(false);
112
- let errorMsg = result.error || 'Hook execution failed';
113
- if (result.screenshotPath) {
114
- errorMsg += `\n\nScreenshot saved: ${result.screenshotPath}`;
115
- }
111
+ const errorMsg =
112
+ (result.error || 'Hook execution failed') + describeArtifacts(result.artifactPaths);
116
113
  return { success: false, error: errorMsg };
117
114
  }
118
115
 
@@ -12,6 +12,7 @@ import {
12
12
  reserveVMID,
13
13
  unreserveIP,
14
14
  unreserveVMID,
15
+ updateReservationReason,
15
16
  } from '../../ipam/allocator';
16
17
  import { getArg, getFlag, validateRequiredArgs } from '../parser';
17
18
  import type { CommandResult } from '../types';
@@ -409,6 +410,98 @@ export async function handleIpamIpUnreserve(
409
410
  }
410
411
  }
411
412
 
413
+ /**
414
+ * Handle IPAM IP edit command (change an exclusion's reason in place)
415
+ *
416
+ * Usage: celilo ipam ip edit <ip> --reason <reason> [--zone <zone>]
417
+ *
418
+ * Editing beats include-then-exclude: that dance drops the row for a moment
419
+ * and can race an allocation into the address it was holding. It also matters
420
+ * because reservations celilo writes itself carry generated reasons that can be
421
+ * identical between a live row and a dead one (celilo#892) — an operator needs
422
+ * a way to annotate which is which.
423
+ *
424
+ * @param args - Command arguments
425
+ * @param flags - Command flags
426
+ * @returns Command result
427
+ */
428
+ export async function handleIpamIpEdit(
429
+ args: string[],
430
+ flags: Record<string, string | boolean>,
431
+ ): Promise<CommandResult> {
432
+ const error = validateRequiredArgs(args, 1);
433
+ if (error) {
434
+ return {
435
+ success: false,
436
+ error: `${error}\n\nUsage: celilo ipam ip edit <ip> --reason <reason> [--zone <zone>]`,
437
+ };
438
+ }
439
+
440
+ const ipArg = getArg(args, 0);
441
+ if (!ipArg) {
442
+ return {
443
+ success: false,
444
+ error: 'IP is required',
445
+ };
446
+ }
447
+
448
+ const reason = getFlag(flags, 'reason');
449
+ if (!reason) {
450
+ return {
451
+ success: false,
452
+ error: 'Reason is required. Use --reason "description"',
453
+ };
454
+ }
455
+
456
+ const db = getDb();
457
+
458
+ try {
459
+ // A range is addressed by its first IP, the same way `include` does it.
460
+ const ipStart = ipArg.includes('-') ? ipArg.split('-')[0].trim() : ipArg;
461
+
462
+ const explicitZone = getFlag(flags, 'zone');
463
+ const zone = explicitZone || (await inferZoneFromIP(ipStart, db));
464
+
465
+ if (!zone) {
466
+ return {
467
+ success: false,
468
+ error: `Cannot determine zone for IP ${ipStart}. Use --zone to specify manually.`,
469
+ };
470
+ }
471
+
472
+ if (
473
+ zone !== 'dmz' &&
474
+ zone !== 'app' &&
475
+ zone !== 'secure' &&
476
+ zone !== 'secure-mgmt' &&
477
+ zone !== 'internal'
478
+ ) {
479
+ return {
480
+ success: false,
481
+ error: `Invalid zone: ${zone}. Must be internal, dmz, app, or secure`,
482
+ };
483
+ }
484
+
485
+ const updated = await updateReservationReason(ipStart, zone, reason, db);
486
+ if (!updated) {
487
+ return {
488
+ success: false,
489
+ error: `No IP exclusion for ${ipStart} in zone ${zone}. List them with: celilo ipam ip list-exclusions`,
490
+ };
491
+ }
492
+
493
+ return {
494
+ success: true,
495
+ message: `Updated exclusion for IP ${ipStart} in zone ${zone}: ${reason}`,
496
+ };
497
+ } catch (err) {
498
+ return {
499
+ success: false,
500
+ error: err instanceof Error ? err.message : String(err),
501
+ };
502
+ }
503
+ }
504
+
412
505
  /**
413
506
  * Handle IPAM IP list-exclusions command
414
507
  *
@@ -301,6 +301,28 @@ export async function handleMachineAdd(
301
301
  earmarkedModule: earmark || null,
302
302
  });
303
303
 
304
+ // A machine joining a zone is a new system by the same definition a
305
+ // provisioned container is, so the fleet's approved aspects apply to it
306
+ // too (celilo#902, design D5). Without this, a machine added after an
307
+ // aspect's provider deployed carries the identical defect: its
308
+ // /etc/resolv.conf still names whatever it booted with.
309
+ //
310
+ // A failure here is a WARNING, not fatal — deliberately unlike the deploy
311
+ // path, where the same failure aborts. The machine is already in the pool
312
+ // and nothing is proceeding on a false premise; refusing to add it would
313
+ // be worse than adding it unconverged and saying so.
314
+ const { reconcileAspectsForSystems } = await import('../../services/aspect-runner');
315
+ const reconcile = await reconcileAspectsForSystems({
316
+ systems: [{ hostname: detectedInfo.hostname, zone }],
317
+ db: getDb(),
318
+ });
319
+ for (const failure of reconcile.failures) {
320
+ console.log(
321
+ `⚠ Fleet aspect '${failure.role}' from '${failure.providerModuleId}' failed on ${detectedInfo.hostname}: ${failure.error ?? 'unknown error'}\n` +
322
+ ` The machine was added. Run \`celilo module deploy ${failure.providerModuleId}\` to converge it.`,
323
+ );
324
+ }
325
+
304
326
  const earmarkNote = earmark ? `\n Earmarked for: ${earmark}` : '';
305
327
  const roleNote = role === 'router' ? ` (router - ${interfaces.length} interfaces)` : '';
306
328
  celiloOutro(
@@ -40,6 +40,7 @@ import { runAudit } from '../../services/audit';
40
40
  import type { DriftFinding, SystemAuditReport } from '../../services/audit';
41
41
  import { loadAbandonedOperations } from '../../services/audit/abandoned-operations';
42
42
  import { loadBackupAuditInfo } from '../../services/audit/backup-source';
43
+ import { collectBrowserPinDeps } from '../../services/audit/browser-pin';
43
44
  import {
44
45
  type LatestCliVersionFetcher,
45
46
  fetchLatestCliVersion,
@@ -358,6 +359,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
358
359
  manifest: m.manifestData as ModuleManifest,
359
360
  })),
360
361
  },
362
+ browserPin: collectBrowserPinDeps(deployedModules),
361
363
  terraformPlan: {
362
364
  modules: terraformModules,
363
365
  run: realTerraformPlan,
@@ -33,10 +33,12 @@ import { spawnSync } from 'node:child_process';
33
33
  import { existsSync, readFileSync, statSync } from 'node:fs';
34
34
  import { createRequire } from 'node:module';
35
35
  import { dirname, join, resolve } from 'node:path';
36
+ import { resolveBrowser } from '@celilo/capabilities';
36
37
  import { defineEvents, openBus } from '@celilo/event-bus';
37
38
  import cliPkg from '../../../package.json' with { type: 'json' };
38
39
  import { getDbPath, getEventBusPath } from '../../config/paths';
39
40
  import { getDb } from '../../db/client';
41
+ import type { ModuleManifest } from '../../manifest/schema';
40
42
  import {
41
43
  type FleetFinding,
42
44
  type FleetFindingStatus,
@@ -279,6 +281,7 @@ function applyFix(drifted: DriftedDep[], cliRoot: string): string[] {
279
281
  function renderPrereqSection(): { lines: string[]; failingCount: number } {
280
282
  const checks = checkAllPrerequisites();
281
283
  const lines: string[] = [];
284
+ const browserFlavor = provisionedBrowserFlavor();
282
285
 
283
286
  lines.push('System prerequisites');
284
287
  // Right-pad column for alignment. Take the longest tool name +1
@@ -288,7 +291,16 @@ function renderPrereqSection(): { lines: string[]; failingCount: number } {
288
291
  for (const c of checks) {
289
292
  if (c.present && c.meetsMinimum) {
290
293
  const version = c.version ? c.version : `${ANSI.dim}(version unknown)${ANSI.reset}`;
291
- lines.push(` ${ANSI.green}✔${ANSI.reset} ${c.name.padEnd(nameCol)} ${version}`);
294
+ // Flavour rides on the browser row because the ceiling it names is
295
+ // real — a headless-only build cannot run headed — and an operator
296
+ // reading doctor is the other party who benefits from that being
297
+ // legible rather than discovered by a launch failure. It cannot come
298
+ // from the check itself: a headless shell reports
299
+ // "Google Chrome for Testing <v>", the same string a full build
300
+ // prints, so only the descriptor knows which one is installed.
301
+ const flavor =
302
+ c.name === 'browser' && browserFlavor ? ` ${ANSI.dim}(${browserFlavor})${ANSI.reset}` : '';
303
+ lines.push(` ${ANSI.green}✔${ANSI.reset} ${c.name.padEnd(nameCol)} ${version}${flavor}`);
292
304
  } else if (c.present && !c.meetsMinimum) {
293
305
  // Present but below minimum (or version-parse failed with a minimum).
294
306
  const detail = c.version ? `${c.version} — below minimum required` : 'version unreadable';
@@ -306,6 +318,24 @@ function renderPrereqSection(): { lines: string[]; failingCount: number } {
306
318
  return { lines, failingCount: failingPrerequisites(checks).length };
307
319
  }
308
320
 
321
+ /**
322
+ * Which flavour of browser is provisioned, or null when none is (or the
323
+ * descriptor cannot be read). Never throws — a doctor row is not worth
324
+ * failing the whole report over.
325
+ *
326
+ * This reads the DESCRIPTOR while the prerequisite check RUNS the binary.
327
+ * Those answer different questions and both are wanted: executing it is
328
+ * what distinguishes a real install from a build directory with no binary
329
+ * in it, and only the descriptor records what was installed.
330
+ */
331
+ function provisionedBrowserFlavor(): string | null {
332
+ try {
333
+ return resolveBrowser().flavor;
334
+ } catch {
335
+ return null;
336
+ }
337
+ }
338
+
309
339
  /**
310
340
  * mtime (ms) of the installed dispatcher code (`@celilo/event-bus`
311
341
  * package.json). The fleet dispatcher check compares this against the
@@ -341,6 +371,107 @@ function renderFleetFinding(f: FleetFinding): string[] {
341
371
  * cleanly on a fresh dev box with no DB unless `--fleet` forces it. `--fix`
342
372
  * runs only the auto-fixable checks (today: subscribers resync).
343
373
  */
374
+ /**
375
+ * Aspect coverage, measured against every entitled system (celilo#902 design D7).
376
+ *
377
+ * Behind `--deep` and never in a default pass, because it SSHes to every
378
+ * system an approved aspect covers. That gating is also what keeps it honest:
379
+ * there is no "we have no data yet" state to explain away — the check either
380
+ * ran and measured, or it did not run.
381
+ *
382
+ * `--fix` converges only the hosts measured as MISSING. An `unknown` host is
383
+ * never converged: celilo does not know what state it is in, so there is
384
+ * nothing to converge toward, and re-running the role anyway would be inventing
385
+ * the verdict this check exists to avoid.
386
+ */
387
+ async function renderAspectCoverage(opts: { fix: boolean }): Promise<{
388
+ lines: string[];
389
+ failCount: number;
390
+ warnCount: number;
391
+ }> {
392
+ const { verifyAspectCoverage } = await import('../../services/aspect-runner');
393
+ const { getDb } = await import('../../db/client');
394
+
395
+ const lines: string[] = ['Aspect coverage'];
396
+ const findings = await verifyAspectCoverage({ db: getDb() });
397
+ if (findings.length === 0) {
398
+ lines.push(` ${ANSI.dim}no approved base-module aspects to verify${ANSI.reset}`);
399
+ return { lines, failCount: 0, warnCount: 0 };
400
+ }
401
+
402
+ const missing = findings.filter((f) => f.state === 'missing');
403
+ const unknown = findings.filter((f) => f.state === 'unknown' || f.state === 'unreachable');
404
+ const applied = findings.filter((f) => f.state === 'applied');
405
+
406
+ for (const f of applied) {
407
+ lines.push(` ${ANSI.green}✓${ANSI.reset} ${f.hostname} has ${f.providerModuleId}/${f.role}`);
408
+ }
409
+ for (const f of missing) {
410
+ lines.push(
411
+ ` ${ANSI.red}✗${ANSI.reset} ${f.hostname} is MISSING ${f.providerModuleId}/${f.role}`,
412
+ );
413
+ if (f.detail) lines.push(` ${ANSI.dim}${f.detail}${ANSI.reset}`);
414
+ }
415
+ for (const f of unknown) {
416
+ lines.push(
417
+ ` ${ANSI.yellow}?${ANSI.reset} ${f.hostname} — ${f.providerModuleId}/${f.role} NOT MEASURED`,
418
+ );
419
+ if (f.detail) lines.push(` ${ANSI.dim}${f.detail}${ANSI.reset}`);
420
+ }
421
+
422
+ if (opts.fix && missing.length > 0) {
423
+ // Converged per PROVIDER with `onlyHostnames`, not through
424
+ // `reconcileAspectsForSystems`. Entitlement was already established by
425
+ // `planAspectFanOut` when the finding was measured, and consent was already
426
+ // confirmed 'approved' — re-deriving either from the zone would only add a
427
+ // second path that can disagree with the first.
428
+ const { runAspectFanOut } = await import('../../services/aspect-runner');
429
+ const { modules } = await import('../../db/schema');
430
+ const { eq } = await import('drizzle-orm');
431
+ const db = getDb();
432
+
433
+ const byProvider = new Map<string, string[]>();
434
+ for (const f of missing) {
435
+ byProvider.set(f.providerModuleId, [
436
+ ...(byProvider.get(f.providerModuleId) ?? []),
437
+ f.hostname,
438
+ ]);
439
+ }
440
+
441
+ lines.push('');
442
+ lines.push(` Converging ${missing.length} host/aspect pair(s) measured as missing:`);
443
+ for (const [providerModuleId, hostnames] of byProvider) {
444
+ const row = db.select().from(modules).where(eq(modules.id, providerModuleId)).get();
445
+ const aspect = (row?.manifestData as ModuleManifest | null)?.base_module_aspect;
446
+ if (!row || !aspect) continue;
447
+ const result = await runAspectFanOut({
448
+ moduleId: providerModuleId,
449
+ aspect,
450
+ moduleSourcePath: row.sourcePath,
451
+ options: { trigger: 'on_new_system_in_zone', onlyHostnames: hostnames },
452
+ db,
453
+ });
454
+ const mark = result.success ? `${ANSI.green}✓${ANSI.reset}` : `${ANSI.red}✗${ANSI.reset}`;
455
+ lines.push(
456
+ ` ${mark} ${providerModuleId} → ${hostnames.join(', ')}${result.success ? '' : `: ${result.error ?? 'unknown error'}`}`,
457
+ );
458
+ }
459
+ // Re-MEASURED on the next --deep run rather than asserted from this run's
460
+ // exit status. "The command exited 0" is the verdict, not the contract.
461
+ lines.push(
462
+ ` ${ANSI.dim}Re-run \`celilo system doctor --deep\` to confirm against the hosts.${ANSI.reset}`,
463
+ );
464
+ return { lines, failCount: 0, warnCount: unknown.length };
465
+ }
466
+
467
+ if (missing.length > 0) {
468
+ lines.push(
469
+ ` ${ANSI.dim}Run \`celilo system doctor --deep --fix\` to apply the missing aspects.${ANSI.reset}`,
470
+ );
471
+ }
472
+ return { lines, failCount: missing.length, warnCount: unknown.length };
473
+ }
474
+
344
475
  async function renderFleetSection(opts: { forced: boolean; fix: boolean }): Promise<{
345
476
  lines: string[];
346
477
  failCount: number;
@@ -522,6 +653,18 @@ export async function handleSystemDoctor(
522
653
  lines.push('');
523
654
  }
524
655
 
656
+ // Aspect coverage. `--deep` only: it reaches out to every entitled system
657
+ // over SSH, which a default doctor pass must not do.
658
+ let aspectFailCount = 0;
659
+ let aspectWarnCount = 0;
660
+ if (flags.deep === true) {
661
+ const coverage = await renderAspectCoverage({ fix });
662
+ lines.push(...coverage.lines);
663
+ lines.push('');
664
+ aspectFailCount = coverage.failCount;
665
+ aspectWarnCount = coverage.warnCount;
666
+ }
667
+
525
668
  // Unified summary: anything that fails the run, with a per-cause count.
526
669
  const problems: string[] = [];
527
670
  if (prereqResult.failingCount > 0) {
@@ -530,6 +673,7 @@ export async function handleSystemDoctor(
530
673
  if (driftCount > 0) problems.push(`${driftCount} package(s) behind workspace`);
531
674
  if (unresolvedCount > 0) problems.push(`${unresolvedCount} unresolved`);
532
675
  if (fleet.failCount > 0) problems.push(`${fleet.failCount} fleet check(s) failing`);
676
+ if (aspectFailCount > 0) problems.push(`${aspectFailCount} system(s) missing a fleet aspect`);
533
677
 
534
678
  if (problems.length > 0) {
535
679
  return {
@@ -540,10 +684,9 @@ export async function handleSystemDoctor(
540
684
  }
541
685
 
542
686
  // Fleet warnings don't fail the run, but they shouldn't read as a clean bill.
543
- if (fleet.warnCount > 0) {
544
- lines.push(
545
- `${ANSI.yellow}OK with warnings${ANSI.reset} — ${fleet.warnCount} fleet warning(s); see above`,
546
- );
687
+ const warnTotal = fleet.warnCount + aspectWarnCount;
688
+ if (warnTotal > 0) {
689
+ lines.push(`${ANSI.yellow}OK with warnings${ANSI.reset} — ${warnTotal} warning(s); see above`);
547
690
  } else {
548
691
  lines.push(`${ANSI.green}OK${ANSI.reset} — no issues detected`);
549
692
  }
@@ -27,6 +27,7 @@ import type { ModuleManifest } from '../../manifest/schema';
27
27
  import { RegistryClient } from '../../registry/client';
28
28
  import { runAudit } from '../../services/audit';
29
29
  import { loadAbandonedOperations } from '../../services/audit/abandoned-operations';
30
+ import { collectBrowserPinDeps } from '../../services/audit/browser-pin';
30
31
  import { fetchLatestCliVersion } from '../../services/audit/cli-version';
31
32
  import { unusedPublicDnsProbe } from '../../services/audit/public-dns';
32
33
  import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
@@ -536,6 +537,7 @@ export async function handleSystemUpdate(
536
537
  manifest: m.manifestData as ModuleManifest,
537
538
  })),
538
539
  },
540
+ browserPin: collectBrowserPinDeps(upgradableModules),
539
541
  terraformPlan: {
540
542
  modules: upgradableModules.map((m) => ({
541
543
  id: m.id,
@@ -262,9 +262,9 @@ export async function getCompletions(words: string[], current: number): Promise<
262
262
  return filterSuggestions(frameworkKeys, args[4] || '');
263
263
  }
264
264
 
265
- // Module secret subcommands (celilo module secret set/list)
265
+ // Module secret subcommands (celilo module secret set/get/list)
266
266
  if (command === 'module' && args[1] === 'secret' && currentIndex === 2) {
267
- const subcommands = ['set', 'list'];
267
+ const subcommands = ['set', 'get', 'list'];
268
268
  return filterSuggestions(subcommands, args[2] || '');
269
269
  }
270
270
 
@@ -272,7 +272,7 @@ export async function getCompletions(words: string[], current: number): Promise<
272
272
  if (
273
273
  command === 'module' &&
274
274
  args[1] === 'secret' &&
275
- (args[2] === 'set' || args[2] === 'list') &&
275
+ (args[2] === 'set' || args[2] === 'get' || args[2] === 'list') &&
276
276
  currentIndex === 3
277
277
  ) {
278
278
  const db = getDb();
@@ -281,8 +281,13 @@ export async function getCompletions(words: string[], current: number): Promise<
281
281
  return filterSuggestions(moduleIds, args[3] || '');
282
282
  }
283
283
 
284
- // Module secret set <module-id> - complete with secret names
285
- if (command === 'module' && args[1] === 'secret' && args[2] === 'set' && currentIndex === 4) {
284
+ // Module secret set/get <module-id> - complete with secret names
285
+ if (
286
+ command === 'module' &&
287
+ args[1] === 'secret' &&
288
+ (args[2] === 'set' || args[2] === 'get') &&
289
+ currentIndex === 4
290
+ ) {
286
291
  const db = getDb();
287
292
  const module = db
288
293
  .select()
@@ -609,6 +614,34 @@ export async function getCompletions(words: string[], current: number): Promise<
609
614
  return filterSuggestions(subcommands, args[1] || '');
610
615
  }
611
616
 
617
+ // IPAM VMID actions
618
+ if (command === 'ipam' && args[1] === 'vmid' && currentIndex === 2) {
619
+ const actions = ['reserve', 'unreserve', 'list-reservations'];
620
+ return filterSuggestions(actions, args[2] || '');
621
+ }
622
+
623
+ // IPAM IP actions
624
+ if (command === 'ipam' && args[1] === 'ip' && currentIndex === 2) {
625
+ const actions = ['exclude', 'include', 'edit', 'list-exclusions'];
626
+ return filterSuggestions(actions, args[2] || '');
627
+ }
628
+
629
+ // The address argument to `ipam ip edit` / `ipam ip include` — both act on an
630
+ // exclusion that already exists, and nobody remembers which address it was.
631
+ if (
632
+ command === 'ipam' &&
633
+ args[1] === 'ip' &&
634
+ (args[2] === 'edit' || args[2] === 'include') &&
635
+ currentIndex === 3
636
+ ) {
637
+ const { listReservations } = await import('../ipam/allocator');
638
+ const reservations = await listReservations(getDb());
639
+ return filterSuggestions(
640
+ reservations.map((r: { ipStart: string }) => r.ipStart),
641
+ args[3] || '',
642
+ );
643
+ }
644
+
612
645
  // Completion subcommands
613
646
  if (command === 'completion' && currentIndex === 1) {
614
647
  const subcommands = ['bash', 'zsh'];