@celilo/cli 1.13.0 → 1.14.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 (34) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +6 -2
  3. package/package.json +2 -2
  4. package/src/capabilities/public-web-helpers.test.ts +12 -6
  5. package/src/capabilities/public-web-publish.test.ts +24 -13
  6. package/src/capabilities/validation.test.ts +31 -0
  7. package/src/cli/commands/console-get-chain.test.ts +96 -0
  8. package/src/cli/commands/console.ts +13 -5
  9. package/src/cli/commands/notify-config.test.ts +79 -0
  10. package/src/cli/commands/notify-config.ts +13 -2
  11. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  12. package/src/cli/completion.ts +1 -0
  13. package/src/cli/index.ts +6 -0
  14. package/src/console/closure.test.ts +76 -0
  15. package/src/console/closure.ts +87 -1
  16. package/src/console/projection.test.ts +63 -1
  17. package/src/console/projection.ts +39 -2
  18. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  19. package/src/hooks/capability-loader.ts +67 -10
  20. package/src/manifest/contracts/v1.ts +22 -1
  21. package/src/manifest/validate.ts +25 -4
  22. package/src/module/web-root.ts +35 -0
  23. package/src/policy/module-business-baseline.ts +14 -2
  24. package/src/policy/module-script-scan.test.ts +22 -0
  25. package/src/policy/module-script-scan.ts +32 -0
  26. package/src/services/api-principal-enrolment.test.ts +73 -0
  27. package/src/services/api-principal-enrolment.ts +55 -0
  28. package/src/services/backup-create.ts +33 -7
  29. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  30. package/src/services/fleet-key.test.ts +47 -0
  31. package/src/services/fleet-key.ts +75 -0
  32. package/src/services/restore-from-file.ts +6 -5
  33. package/src/services/system-state-stage.test.ts +165 -0
  34. package/src/services/system-state-stage.ts +196 -0
@@ -59,7 +59,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
59
59
  - **celilo-apt-repo** — Debian apt repository (reprepro + Bun HTTP server) serving the celilo `.deb` at apt.celilo.computer. **provides:** `apt_publish`. **requires:** `public_web`, `dns_registrar`.
60
60
  - **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public) and **`--receive-mode=manual`**, which is load-bearing: signal-cli's default (`on-start`) leaves the daemon permanently receiving, so it drains every reply into an SSE stream nothing is attached to and REFUSES celilo's `receive` call — replies arrive and are unreadable, while `send` and every health check keep passing. Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:** no capabilities — a transport that depended on the proxy, registrar or firewall could not tell you those were broken — and a system in the **`secure-mgmt`** zone: it holds a linked Signal account (the operator's own messaging identity and keys), and its job is to observe every tier while depending on none, which is what the control-plane zone is for. See `openspec/changes/add-alerting/`.
61
61
  - **celilo-website** — public docs site (static Astro) served via Caddy on celilo.computer. **requires:** `public_web`, `dns_registrar`.
62
- - **celilo-web-console** — the read-mostly operator console: the fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. **Not yet deployed** — the manifest, the SPA, the console server and the read verbs exist; the `control_plane_api` enrolment and the e2e suite do not. It holds **no database handle**: every fact it displays arrives over the SSH remote API as a principal whose grants derive from the `COMMANDS` registry's read-only classifier, because an in-process console would hold the whole database including the encrypted secret store and be constrained only by its own code. **⚠️ `secure-mgmt`, and the placement is a security boundary rather than a preference:** celilo reaches into every data-plane zone by trust and no data-plane zone reaches back, so the ONLY client path in is the control-plane VPN. It registers no web route, requests no port forward, takes no `natIp` or ingress address, and gets no DNS record resolvable from a data-plane zone. `apps/celilo/src/console/control-plane-boundary.test.ts` asserts that on every pull request rather than only in e2e, because the way it erodes is a one-line manifest edit adding `private_web` so someone can reach it without bringing up the VPN. It offers no deploy, uninstall, pause, restore or config control: those can raise an interview question, a browser has no responder, and they are ABSENT rather than disabled. **provides:** nothing. **requires:** `idp`, `control_plane_api`. See `openspec/changes/web-ui-console/`.
62
+ - **celilo-web-console** — the read-mostly operator console: the fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. **Not yet deployed** — the manifest, the SPA, the console server, the read verbs and the `control_plane_api` capability exist; the `on_install` that enrols the principal, and the e2e suite, do not. It holds **no database handle**: every fact it displays arrives over the SSH remote API as a principal whose grants derive from the `COMMANDS` registry's read-only classifier, because an in-process console would hold the whole database including the encrypted secret store and be constrained only by its own code. **⚠️ `secure-mgmt`, and the placement is a security boundary rather than a preference:** celilo reaches into every data-plane zone by trust and no data-plane zone reaches back, so the ONLY client path in is the control-plane VPN. It registers no web route, requests no port forward, takes no `natIp` or ingress address, and gets no DNS record resolvable from a data-plane zone. `apps/celilo/src/console/control-plane-boundary.test.ts` asserts that on every pull request rather than only in e2e, because the way it erodes is a one-line manifest edit adding `private_web` so someone can reach it without bringing up the VPN. It offers no deploy, uninstall, pause, restore or config control: those can raise an interview question, a browser has no responder, and they are ABSENT rather than disabled. **provides:** nothing. **requires:** `idp`, `control_plane_api`. See `openspec/changes/web-ui-console/`.
63
63
  - **celilo-canary** — a deliberately minimal nginx serving one static page in **`dmz`**, permanently deployed, whose health check IS the assertion that the deploy path still works. Nothing about it is interesting except that it is *always there*: modules already deployed keep running when the pipeline breaks, so without a canary a regression in IPAM allocation, Terraform provisioning, Ansible convergence or capability wiring stays invisible until the next real deploy — which is exactly when nobody wants to discover it. One deploy exercises all four, plus a live cross-module capability call. **Fleet-only, and the absence is the design**: it registers one route through `private_web` and requires nothing that reaches the perimeter, so there is no public record, no ACME certificate and no port forward. Its `health_check` probes nginx over systemd and its `/healthz` endpoint with **`probeHttp`, from the management server** rather than by SSHing in and running `curl` — the production `ubuntu-22.04-standard` LXC ships no curl, so the old form could not tell a missing binary from a dead service, and reaching the canary at its own address additionally catches a service bound only to loopback. **requires:** `private_web`.
64
64
 
65
65
  ## Git forge & CI pipeline
@@ -290,6 +290,8 @@ SUGGESTS a `backup.schedule`; the operator's override decides; celilo runs it on
290
290
  the resolved cadence and alerts when it stops.
291
291
 
292
292
  - **Creation** — `apps/celilo/src/services/backup-create.ts` — `createModuleBackup` (invokes the module's `on_backup` hook into an encrypted envelope), `createSystemStateBackup` (celilo.db), `findBackupEligibleModules` (returns each module's operator config alongside its manifest, because every caller has to resolve a policy out of the two together), `isBackupDue`. Storage destinations: `backup-storage.ts`. Restore: `backup-restore.ts`.
293
+ - **Staging celilo's own state** — `apps/celilo/src/services/system-state-stage.ts` — `stageSystemState(rootDir)` and `snapshotDatabase(src, dest)`. Before invoking `on_backup` on a `cross_module_read` module, celilo COPIES its own state (`celilo.db` snapshot, `master.key`, the fleet `ssh/`, and `module_src/<id>/` for every module's lean source) into a directory it creates, and passes the path as the contract input `system_state_root`. ⚠️ **This is what lets celilo back ITSELF up without exempting celilo-mgmt from the hook jail** (`openspec/changes/hook-process-boundary`, design D9b): the hook never READ those bytes, it copied them into `backup_dir`, so the framework does the copying and celilo's data directory is in no hook's mount set. Same allow-list as `cross_module_root` — one privilege, one list to audit. ⚠️ **`snapshotDatabase` uses a readonly connection + `serialize()`, never `copyFileSync`**: celilo runs the DB in WAL mode, so the main file is routinely one near-empty page while all the real data sits in `celilo.db-wal` (measured: 4 KB main against 832 KB WAL), and a plain copy produces a snapshot that opens cleanly, contains NOTHING, and is installed by restore. Gate: `services/system-state-stage.test.ts` writes 200 uncheckpointed rows and asserts they survive.
294
+ - **The fleet SSH key (one accessor)** — `apps/celilo/src/services/fleet-key.ts` — `ensureFleetKey()` (idempotent mint, returns the public half) and `getFleetSshDir()`. Surfaced as `celilo system ensure-fleet-key`, which also records `ssh.public_key`. celilo-mgmt's `on_install` used to mint the keypair itself inside celilo's data directory — a WRITE into the one directory the jail exists to keep out of the mount set (design D9b), which staging does not cover because staging covers copies OUT. ⚠️ **Never re-key**: an existing key is reused, because regenerating strands every machine whose `authorized_keys` holds the old public half, and a redeploy calls this every time. `getFleetSshDir()` follows the DB (`dirname(getDbPath())/.ssh`), not `getDataDir()` — the same directory on a deb install and different when `CELILO_DB_PATH` is overridden; both the mint and `restore-from-file.ts`'s laydown read that one helper so they cannot drift apart.
293
295
  - **Cadence (one accessor)** — `apps/celilo/src/services/backup-schedule.ts` — `effectiveBackupSchedule(manifest, override)`. The manifest SUGGESTS; the operator's `backup_schedule` row in `module_configs` decides; absent from both means `daily`, NOT `manual` (opting out takes an explicit `manual`). Resolution happens at READ time — nothing is materialised at install or deploy — so a corrected manifest reaches every install that has not overridden. The one-argument form was DELETED rather than kept as an overload (Rule 3.9): it would let an un-updated reader compile clean while silently ignoring overrides. Both the freshness audit and the backup sweep must read cadence through this one function, or a module can be alerted-on but never backed up.
294
296
  - **The cadence type** — `apps/celilo/src/services/cadence.ts` — `Cadence` (`{minutes}` | `'manual'`), `parseCadence` / `formatCadence` / `cadenceMs`, and `cadenceSchema({floorMinutes})`. One spelling set for every cadence in celilo: a named period (`hourly`/`daily`/`weekly`/`monthly`), a duration (`6h`, `90m`, `3d`), or `manual`. ⚠️ **The floors are DERIVED from the sweep ticks, never written down**: this file owns `BACKUP_SWEEP_PATTERN` / `ALERTING_SWEEP_PATTERN` and computes `BACKUP_CADENCE_FLOOR_MINUTES` / `MONITOR_INTERVAL_FLOOR_MINUTES` from them (the sweeps import their pattern from here), so changing a tick moves what it can serve in the same edit. A cadence finer than its sweep's tick is REFUSED, not coerced — accepting it leaves the operator believing they configured something that can silently never happen.
295
297
  - **Retention (one accessor)** — `apps/celilo/src/services/backup-retention.ts` — `effectiveBackupRetention(manifest, configs)`, `prunesNothing`, `identifyExpiredBackups`, `pruneBackupsForModule`. Two INDEPENDENT dimensions (copies, age), each resolved override → manifest → **unbounded**. ⚠️ **An unset dimension is unbounded, never a default bound.** `backup.retention` is an optional block, so a manifest omitting it prunes nothing at all and its inner `count: 7` / `max_age_days: 30` defaults never apply; if setting one dimension let the other fall back to those, an operator asking to keep 3 copies would silently arm a 30-day deletion on a module that had been keeping everything. Unbounded is `Infinity`, which `identifyExpiredBackups` needs no special case for. Gate: `services/backup-retention.test.ts`. The four sites that used to read `manifest.backup.retention` directly (`cli/commands/backup-sweep.ts`, `backup-create.ts`, `backup-prune.ts` twice) all go through the accessor — that duplication is what let the schedule readers drift.
@@ -346,6 +348,7 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
346
348
  - **Server** — `apps/celilo/src/api/serve.ts` (`apiServeMode`); the `celilo api-serve --principal=<id>` sshd forced-command entry point (dispatched in `apps/celilo/src/cli/index.ts`). Authorizes per principal, runs the command as a protocol-mode child, streams output, audits to stderr.
347
349
  - **Client** — `packages/core/src/remote-client.ts` (`@celilo/core`) — `resolveRemote` (`--remote <dest>` / `CELILO_REMOTE`), `runRemoteClient` (`ssh -T`, renders progress via the local ProgressDisplay, answers interviews via the `@celilo/cli-display` prompts). Refuses to prompt on a non-TTY stdin, replying `unanswerable` rather than submitting a default as if a human had chosen it.
348
350
  - **Access control** — `apps/celilo/src/services/api-access.ts` — `grantPrincipal`, `isAuthorized` (deny-by-default, `command:subcommand` grants), `renderAuthorizedKeys`. Table: `api_principals` (`apps/celilo/src/db/schema.ts`). CLI: `apps/celilo/src/cli/commands/api.ts` (`api grant|list|revoke|authorized-keys|key new`).
351
+ - **Principal enrolment for a module (`control_plane_api`)** — `apps/celilo/src/services/api-principal-enrolment.ts` — `enrolControlPlanePrincipal`, `revokeControlPlanePrincipal`, and `buildControlPlaneApi`, the method table a consuming module's hooks receive. The consumer generates an ed25519 pair on its own system and presents the public half; nothing here accepts a private key. Grants are DERIVED from `readOnlyGrants(COMMANDS)` and are not a parameter, so a caller cannot ask for more, and a write verb is never granted however it is named. **Framework-granted, so no module provides it** — enrolment writes celilo's own `api_principals` row and a module script may import nothing but `@celilo/capabilities`, which rules out celilo-mgmt as much as anyone else (`web-ui-console` D7b). Injected by `capability-loader.ts` ONLY for a module whose stored manifest declares it under `requires`/`optional`, unlike every other capability the loader hands out, and scoped to that module: a caller may not name a neighbour's principal. Contract: `packages/capabilities/src/control-plane-api.ts`.
349
352
  - **Mid-run interview bridge (`kind:daemon` responder)** — `apps/celilo/src/services/remote-responder.ts` — `startRemoteResponder` bridges bus `interview.required.*` ↔ wire.
350
353
  - **Server provisioning** — the `celilo-bootstrap` deb (`packaging/celilo-bootstrap/scripts/postinst`) creates the non-root `celilo-api` landing account + sshd; membership in the `celilo` group + `/etc/sudoers.d/celilo` (`!use_pty`) gives api-serve DB access via the wrapper's sudo-drop.
351
354
  - **Self-upgrade (apt)** — `celilo apt-upgrade` (`apps/celilo/src/cli/commands/apt-upgrade.ts`) upgrades the deb-installed `celilo`/`celilo-bootstrap` packages (`apt-get update` → `--only-upgrade install`) then spawns a fresh `celilo system migrate` (ISS-0100), then `celilo events restart-daemon` so the dispatcher actually runs the code just installed — a failure there fails the whole command and names which steps DID complete, because "upgraded" while the dispatcher serves stale code is the silent state celilo#604 documents. It's the RW target behind the MCP's registry-derived `celilo_apt_upgrade` tool; the celilo user's two apt invocations are scoped-sudo'd by `/etc/sudoers.d/celilo-apt-upgrade`, shipped by `celilo-bootstrap`. **This upgrades celilo ITSELF — not the modules it manages. For those, see Module auto-upgrade below; the two are routinely confused.**
@@ -354,14 +357,15 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
354
357
 
355
358
  ## Web console (read-mostly operator UI)
356
359
 
357
- The fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. Design: `openspec/changes/web-ui-console/`. **Not yet deployed** — the SPA, its server and the console read verbs exist; the module, its `control_plane_api` enrolment and the e2e suite do not.
360
+ The fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. Design: `openspec/changes/web-ui-console/`. **Not yet deployed** — the SPA, its server, the console read verbs, the acknowledgement path and the `control_plane_api` capability exist; the module's `on_install` that CALLS that capability, and the e2e suite, do not. The capability issues only the derived read-only grants and has no parameter that could widen them (task 6.3b settled that deliberately), so acknowledgement renders a denial until an operator grants `alerts:ack` by hand.
358
361
 
359
362
  - **Console read verbs** — `apps/celilo/src/cli/commands/console.ts` — `celilo console status` (the dashboard's single poll: zone order, per-module systems, observed health, backup freshness) and `celilo console get <module-id> [--depth N]` (one module's bounded capability closure). Both classify **read-only** under `readOnlyGrants()`, so the console's principal covers them with no hand-maintained list. The verbs are named from the read-verb vocabulary (`status`, `get`) rather than for prose, because the API authorises at two levels and classifies a leaf by its SUBCOMMAND token — `console roster` would read better and classify as a WRITE.
360
363
  - **Console projection** — `apps/celilo/src/console/projection.ts` — the narrow payload, deliberately WITHOUT `manifestData`: `module list --json` returns 156 KB for 23 modules because it embeds every manifest blob, which is the wrong payload for a poll loop. Distinguishes *not deployed* from *not observed*; a module with no system is carried, not omitted.
361
364
  - **Bounded capability closure** — `apps/celilo/src/console/closure.ts` — `computeClosure()` wraps `planConsumerCleanup()`'s edge rather than forking it, adding distance, optionality and cycle termination. Takes a bindings map (`capability_bindings`, celilo#1072) so the walk follows what a module has ACTUALLY called into; without one it follows declarations, which answers "what could this reach" rather than "what does this stand on".
362
365
  - **Read-only classifier** — `packages/core/src/read-only-classifier.ts` — `READ_VERBS`, `isReadOnlyPath`, `opOf`, `flattenLeaves`, `readOnlyGrants()`. Lives beside `COMMANDS` because it is a property of the registry, and both the MCP server's `RO_GRANTS` and the console's principal derive from it. A new read verb is covered automatically; a new write verb is not.
363
366
  - **SSH transport** — `packages/core/src/ssh-transport.ts` — `childProcessTransport`, `sshArgs`, `sshTransportWith`, and the exit reaper. Shared rather than copied because the reaper exists after 656 orphaned ssh clients threw celilo-mgr into MaxStartups throttling (celilo#921), and the console server has the same restart-freely lifecycle.
364
- - **Console server** — `apps/console-server/src/upstream.ts` — holds **no database handle**; every fact arrives over the remote API as a read-only principal. An interview is a FAILURE (a browser has no responder), and an unavailable read is reported with a reason (`denied` / `unknown-verb` / `unreachable` / `interview` / `malformed`) rather than returned as an empty result. Failures are not cached, so fixing a grant recovers on the next poll.
367
+ - **Console server** — `apps/console-server/src/upstream.ts` — holds **no database handle**; every fact arrives over the remote API as a principal granted the derived read ops plus `alerts:ack`. An interview is a FAILURE (a browser has no responder), and an unavailable read is reported with a reason (`denied` / `unknown-verb` / `unreachable` / `interview` / `malformed`) rather than returned as an empty result. Failures are not cached, so fixing a grant recovers on the next poll. `Upstream.run` is the one non-read: uncached, unparsed (the ack answers with a sentence), and it drops every cached read afterwards.
368
+ - **Console acknowledgement** — `apps/console-server/src/verbs.ts` (`readSession`, `ackAlert`), `apps/celilo/src/cli/commands/notify-config.ts` (`celilo person list --json`) — the console's ONLY write. `celilo alerts ack` falls back to `people[0]` when `--as` is absent, which in a browser would credit every acknowledgement to whoever sorts first, so `ackAlert` takes a required `person` and there is no path through it that omits the flag. The person comes from `readSession`, which resolves the identity provider's subject against `person list --json` (display name, then the `sub` claim, case-insensitive); a subject matching nobody resolves to null and the console draws no control. `ackedAt` is re-read from celilo's row rather than stamped by the console server, which is a different machine. Gates: `apps/console-server/tests/ack.test.ts` records the argv, so "refused but written anyway" is visible.
365
369
  - **Protocol** — `packages/console-protocol/` — tsrpc definitions plus the generated `serviceProto`, shared by server and SPA. The repo's single documented exception to ESM-everywhere: no `type` field, because `tsrpc-cli proto` `require()`s the protocol sources. Gated by a test in an ESM directory that round-trips a call.
366
370
  - **SPA** — `apps/console/src/` — Vite + React 19 + atom.io + tsrpc-browser. Three routes (`dashboard`, `alerts`, `backups`); a module's detail is a panel, not a fourth route. Visual constraints live in `shell.css` and four are asserted against the source by `tests/constraints.test.ts`, including "no control for any operation that can raise an interview". Every read goes through one `Panel` that separates *unavailable* from *empty*.
367
371
  - **Zone topology renderer** — `packages/visualizer/src/gen/zone-topology.ts`, `topology-theme.ts`, `column-assignment.ts`, `render/topology-svg.ts` — bands by zone in trust order, columns assigned so a dependency edge falls as a vertical drop, orthogonal routing checked against every module rather than assumed clear, and a report naming anything it could not place. Tuning playground at `bun run dev` in `packages/visualizer`, route `/topology`; it exports a `TopologyTheme` JSON the console commits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^3.3.0",
61
+ "@celilo/capabilities": "^3.4.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
63
  "@celilo/core": "^0.10.0",
64
64
  "@celilo/event-bus": "^0.6.0",
@@ -89,7 +89,6 @@ function basePublishRequest(
89
89
  ): PublishStaticSiteRequest {
90
90
  return {
91
91
  path: '/lunacycle',
92
- sourceDir: '/tmp/build/dist',
93
92
  ...overrides,
94
93
  };
95
94
  }
@@ -134,17 +133,24 @@ describe('validatePublishStaticSiteRequest', () => {
134
133
  expect(result.valid).toBe(true);
135
134
  });
136
135
 
137
- test('rejects empty sourceDir', () => {
138
- const result = validatePublishStaticSiteRequest(basePublishRequest({ sourceDir: '' }));
136
+ test('rejects a stale sourceDir, and says what to do instead', () => {
137
+ // Replaces 'rejects empty sourceDir'. The field is gone (D10 amendment), so
138
+ // the failure mode inverted: supplying one is now the error, and the
139
+ // message has to carry the directory move because that is not guessable
140
+ // from "unknown key".
141
+ const result = validatePublishStaticSiteRequest({
142
+ path: '/lunacycle',
143
+ sourceDir: '/tmp/site',
144
+ } as unknown as PublishStaticSiteRequest);
139
145
  expect(result.valid).toBe(false);
140
- expect(result.errors).toContain('sourceDir is required');
146
+ expect(result.errors.join(' ')).toContain('site/dist');
141
147
  });
142
148
 
143
149
  test('reports multiple errors at once', () => {
144
150
  const result = validatePublishStaticSiteRequest({
145
151
  path: '',
146
- sourceDir: '',
147
- });
152
+ sourceDir: '/tmp/site',
153
+ } as unknown as PublishStaticSiteRequest);
148
154
  expect(result.valid).toBe(false);
149
155
  expect(result.errors.length).toBeGreaterThanOrEqual(2);
150
156
  });
@@ -106,6 +106,7 @@ describe('publishStaticSite — clientConfig injection', () => {
106
106
  test('writes config.js into sourceDir before upload when clientConfig is provided', async () => {
107
107
  const { ops } = makeRouteOps();
108
108
  const cap = createPublicWeb({
109
+ webRoot: sourceDir,
109
110
  moduleId: 'lunacycle',
110
111
  logger: noopLogger,
111
112
  config: {
@@ -123,7 +124,6 @@ describe('publishStaticSite — clientConfig injection', () => {
123
124
 
124
125
  const result = await cap.publishStaticSite({
125
126
  path: '/lunacycle',
126
- sourceDir,
127
127
  clientConfig: {
128
128
  AUTHENTIK_URL: 'https://auth.example.com/application/o',
129
129
  CLIENT_ID: 'lunacycle-web',
@@ -150,6 +150,7 @@ describe('publishStaticSite — clientConfig injection', () => {
150
150
  test('does not write config.js when clientConfig is omitted', async () => {
151
151
  const { ops } = makeRouteOps();
152
152
  const cap = createPublicWeb({
153
+ webRoot: sourceDir,
153
154
  moduleId: 'lunacycle',
154
155
  logger: noopLogger,
155
156
  config: {
@@ -167,7 +168,6 @@ describe('publishStaticSite — clientConfig injection', () => {
167
168
 
168
169
  await cap.publishStaticSite({
169
170
  path: '/lunacycle',
170
- sourceDir,
171
171
  // no clientConfig
172
172
  });
173
173
 
@@ -177,6 +177,7 @@ describe('publishStaticSite — clientConfig injection', () => {
177
177
  test('registers the route as static and records moduleId/path', async () => {
178
178
  const { ops, routes } = makeRouteOps();
179
179
  const cap = createPublicWeb({
180
+ webRoot: sourceDir,
180
181
  moduleId: 'lunacycle',
181
182
  logger: noopLogger,
182
183
  config: {
@@ -194,7 +195,6 @@ describe('publishStaticSite — clientConfig injection', () => {
194
195
 
195
196
  await cap.publishStaticSite({
196
197
  path: '/lunacycle',
197
- sourceDir,
198
198
  });
199
199
 
200
200
  const stored = routes.find((r) => r.path === '/lunacycle');
@@ -204,9 +204,16 @@ describe('publishStaticSite — clientConfig injection', () => {
204
204
  expect(stored?.slug).toBe('lunacycle');
205
205
  });
206
206
 
207
- test('fails fast when sourceDir does not exist', async () => {
207
+ test('fails fast, and names the path to ship, when the module has no web root', async () => {
208
+ // The publish path's one unrecoverable input. It used to arrive on the
209
+ // request as `sourceDir`; core now resolves it (D10 amendment), so the
210
+ // missing-directory case moves onto the capability's construction.
211
+ //
212
+ // It must throw rather than upload nothing: a zero-file upload succeeds,
213
+ // writes a content hash over an empty release, and serves a blank page.
208
214
  const { ops } = makeRouteOps();
209
215
  const cap = createPublicWeb({
216
+ webRoot: '/nonexistent/path/that/should/not/exist',
210
217
  moduleId: 'lunacycle',
211
218
  logger: noopLogger,
212
219
  config: {
@@ -222,18 +229,19 @@ describe('publishStaticSite — clientConfig injection', () => {
222
229
  dnsManagedDomains: ['www.example.com'],
223
230
  });
224
231
 
225
- await expect(
226
- cap.publishStaticSite({
227
- path: '/lunacycle',
228
- sourceDir: '/nonexistent/path/that/should/not/exist',
229
- clientConfig: { FOO: 'bar' },
230
- }),
231
- ).rejects.toThrow('sourceDir does not exist');
232
+ const attempt = cap.publishStaticSite({
233
+ path: '/lunacycle',
234
+ clientConfig: { FOO: 'bar' },
235
+ });
236
+ await expect(attempt).rejects.toThrow('no built site at');
237
+ // The operator's next move is a directory move, so the error has to name it.
238
+ await expect(attempt).rejects.toThrow('site/dist');
232
239
  });
233
240
 
234
241
  test('fails validation before touching the filesystem', async () => {
235
242
  const { ops } = makeRouteOps();
236
243
  const cap = createPublicWeb({
244
+ webRoot: sourceDir,
237
245
  moduleId: 'lunacycle',
238
246
  logger: noopLogger,
239
247
  config: {
@@ -252,7 +260,6 @@ describe('publishStaticSite — clientConfig injection', () => {
252
260
  await expect(
253
261
  cap.publishStaticSite({
254
262
  path: '', // bad — caught by validator before any side effects
255
- sourceDir,
256
263
  }),
257
264
  ).rejects.toThrow('Invalid publishStaticSite request');
258
265
 
@@ -355,6 +362,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
355
362
  const { ops } = makeRouteOps();
356
363
 
357
364
  const cap = createPublicWeb({
365
+ webRoot: sourceDir,
358
366
  moduleId: 'lunacycle',
359
367
  logger,
360
368
  config: {
@@ -389,6 +397,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
389
397
  const { ops } = makeRouteOps();
390
398
 
391
399
  const cap = createPublicWeb({
400
+ webRoot: sourceDir,
392
401
  moduleId: 'lunacycle',
393
402
  logger,
394
403
  config: {
@@ -424,6 +433,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
424
433
  const { ops } = makeRouteOps();
425
434
 
426
435
  const cap = createPublicWeb({
436
+ webRoot: sourceDir,
427
437
  moduleId: 'lunacycle',
428
438
  logger,
429
439
  config: {
@@ -439,7 +449,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
439
449
  dnsManagedDomains: ['www.example.com'],
440
450
  });
441
451
 
442
- await cap.publishStaticSite({ path: '/lunacycle', sourceDir });
452
+ await cap.publishStaticSite({ path: '/lunacycle' });
443
453
 
444
454
  const messageTexts = messages.map((m) => m.message);
445
455
  // The high-level call itself logs.
@@ -459,6 +469,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
459
469
  const { ops } = makeRouteOps();
460
470
 
461
471
  const cap = createPublicWeb({
472
+ webRoot: sourceDir,
462
473
  moduleId: 'lunacycle',
463
474
  logger,
464
475
  config: {
@@ -228,6 +228,37 @@ describe('Capability Access Validation', () => {
228
228
  expect(result.success).toBe(true);
229
229
  });
230
230
 
231
+ test('a framework-granted capability needs no providing module', async () => {
232
+ // This is the breakage, not a hypothetical. `control_plane_api` has no
233
+ // provider module and never will — celilo grants it (web-ui-console D7b) —
234
+ // so the console's manifest, already merged, hit the refusal below and
235
+ // could not be imported at all. Note the db here is the SAME one that
236
+ // fails the test after this one: the difference is entirely which
237
+ // capability is asked for.
238
+ const manifest: ModuleManifest = {
239
+ celilo_contract: '1.0',
240
+ id: 'celilo-web-console',
241
+ name: 'Celilo Web Console',
242
+ version: '1.0.0',
243
+ description: 'Console',
244
+ requires: {
245
+ capabilities: [{ name: 'control_plane_api', version: '1.0.0' }],
246
+ },
247
+ provides: { capabilities: [] },
248
+ variables: { owns: [], imports: [] },
249
+ };
250
+
251
+ const noProviderDb = {
252
+ prepare: () => ({
253
+ get: () => undefined,
254
+ }),
255
+ } as unknown as Database;
256
+
257
+ const result = await validateCapabilityAccess(manifest, noProviderDb);
258
+
259
+ expect(result.success).toBe(true);
260
+ });
261
+
231
262
  test('should return error when required capability not found', async () => {
232
263
  const manifest: ModuleManifest = {
233
264
  celilo_contract: '1.0',
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The wiring, end to end: does `celilo console get` actually carry the chain?
3
+ *
4
+ * `orderFirewallChain` is unit-tested, `computeClosure` is unit-tested, and
5
+ * `loadClosureInputs` is unit-tested against real rows. None of that says the
6
+ * command joins them up. A dropped argument at this one call site returns a
7
+ * perfectly well-formed answer with an empty `chains` — which reads as a fleet
8
+ * with no delegation, and is the failure mode CLAUDE.md names.
9
+ *
10
+ * So this drives the REAL command against a REAL database and asserts on what
11
+ * it emits.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15
+ import { mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ let testDir: string;
20
+
21
+ beforeEach(() => {
22
+ testDir = mkdtempSync(join(tmpdir(), 'celilo-console-chain-'));
23
+ process.env.CELILO_DB_PATH = join(testDir, 'test.db');
24
+ process.env.CELILO_DATA_DIR = testDir;
25
+ });
26
+
27
+ afterEach(() => {
28
+ rmSync(testDir, { recursive: true, force: true });
29
+ process.env.CELILO_DB_PATH = undefined;
30
+ });
31
+
32
+ /** The live fleet's firewall shape: caddy on iptables, iptables on the router. */
33
+ async function seedFleet() {
34
+ const { getDb } = await import('../../db/client');
35
+ const db = getDb();
36
+ const consumer = JSON.stringify({ requires: { capabilities: [{ name: 'firewall' }] } });
37
+
38
+ db.$client.run(
39
+ `INSERT INTO modules (id, name, version, source_path, manifest_data, state)
40
+ VALUES ('caddy', 'Caddy', '1.0.0', '/p', '${consumer}', 'VERIFIED')`,
41
+ );
42
+ for (const id of ['iptables', 'axon']) {
43
+ db.$client.run(
44
+ `INSERT INTO modules (id, name, version, source_path, manifest_data, state)
45
+ VALUES ('${id}', '${id}', '1.0.0', '/p', '{}', 'VERIFIED')`,
46
+ );
47
+ }
48
+ db.$client.run(
49
+ `INSERT INTO capabilities (module_id, capability_name, version, data)
50
+ VALUES ('iptables', 'firewall', '1.0.0', '{"nat_ip":"192.168.0.253"}')`,
51
+ );
52
+ db.$client.run(
53
+ `INSERT INTO capabilities (module_id, capability_name, version, data)
54
+ VALUES ('axon', 'firewall', '1.0.0', '{"has_external":true}')`,
55
+ );
56
+ }
57
+
58
+ describe('celilo console get, and the delegation chain', () => {
59
+ test('--json carries the chain, in order', async () => {
60
+ await seedFleet();
61
+ const { handleConsoleGet } = await import('./console');
62
+ const result = handleConsoleGet(['caddy'], { json: true });
63
+
64
+ expect(result.success).toBe(true);
65
+ const payload = JSON.parse(result.success ? result.message : '{}');
66
+ expect(payload.chains).toEqual([{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }]);
67
+ });
68
+
69
+ test('the router is in the closure, though no manifest mentions it', async () => {
70
+ // caddy requires `firewall` and nothing else. Walking manifests alone stops
71
+ // at the two registered providers and has no way to know one stands on the
72
+ // other — which is the whole reason the chain is derived.
73
+ await seedFleet();
74
+ const { handleConsoleGet } = await import('./console');
75
+ const payload = JSON.parse(
76
+ (() => {
77
+ const r = handleConsoleGet(['caddy'], { json: true });
78
+ return r.success ? r.message : '{}';
79
+ })(),
80
+ );
81
+
82
+ const axon = payload.nodes.find((n: { moduleId: string }) => n.moduleId === 'axon');
83
+ expect(axon).toBeDefined();
84
+ });
85
+
86
+ test('the human output prints it as a path, not as more rows', async () => {
87
+ // A list of two firewalls is exactly the claim being corrected. The arrow is
88
+ // the information.
89
+ await seedFleet();
90
+ const { handleConsoleGet } = await import('./console');
91
+ const result = handleConsoleGet(['caddy'], {});
92
+
93
+ expect(result.success).toBe(true);
94
+ expect(result.success ? result.message : '').toContain('iptables -> axon');
95
+ });
96
+ });
@@ -73,7 +73,7 @@ export function handleConsoleGet(
73
73
  const depth = parseDepth(flags.depth);
74
74
  if (depth instanceof Error) return { success: false, error: depth.message };
75
75
 
76
- const { manifests, providerStates } = loadClosureInputs(db);
76
+ const { manifests, providerStates, chains } = loadClosureInputs(db);
77
77
 
78
78
  // Real bindings, for every module the walk might reach. The console draws what
79
79
  // the fleet IS doing, not what its manifests permit.
@@ -86,6 +86,7 @@ export function handleConsoleGet(
86
86
  providerStates,
87
87
  depth,
88
88
  bindings,
89
+ chains,
89
90
  });
90
91
 
91
92
  if (hasFlag(flags, 'json')) {
@@ -98,14 +99,21 @@ export function handleConsoleGet(
98
99
  return { success: true, message: `${moduleId} depends on nothing.` };
99
100
  }
100
101
 
102
+ // The chain is printed as a path rather than as more rows, because that is the
103
+ // whole claim: these providers are ordered, and a list of them is not.
104
+ const chainLines = result.chains.map(
105
+ (chain) => `${chain.capability}: ${chain.moduleIds.join(' -> ')}`,
106
+ );
107
+
101
108
  return {
102
109
  success: true,
103
- message: result.nodes
104
- .map(
110
+ message: [
111
+ ...result.nodes.map(
105
112
  (n) =>
106
113
  `hop ${n.hop} ${n.moduleId.padEnd(24)} ${n.optional ? '(optional)' : ' '} via ${n.via.join(', ')}`,
107
- )
108
- .join('\n'),
114
+ ),
115
+ ...(chainLines.length > 0 ? ['', 'delegates upstream:', ...chainLines] : []),
116
+ ].join('\n'),
109
117
  };
110
118
  }
111
119
 
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `celilo person list --json` — the shape the web console maps an identity
3
+ * against.
4
+ *
5
+ * Worth its own test for a reason that is not obvious from the three lines it
6
+ * covers. The console server decides whether an authenticated browser subject
7
+ * corresponds to a real person, and it decides it by reading THIS. Its own
8
+ * tests answer that question against a fake celilo, so they prove the console
9
+ * parses what it was told to expect and say nothing about what celilo emits.
10
+ * This is the end that closes.
11
+ *
12
+ * If the payload ever stops being `[{ name, timezone }]`, the console does not
13
+ * error: it maps nobody, draws no acknowledge control, and reports every
14
+ * operator as unknown to the fleet. That reads as a configuration problem for
15
+ * as long as anyone is willing to believe it.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+ import { mkdtempSync, rmSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { closeDb, getDb } from '../../db/client';
23
+ import { createPerson } from '../../services/alerting/people';
24
+ import { setupTestDatabaseAt } from '../../test-utils/database';
25
+ import { handlePerson } from './notify-config';
26
+
27
+ let dir: string;
28
+ const previousDbPath = process.env.CELILO_DB_PATH;
29
+
30
+ beforeEach(async () => {
31
+ dir = mkdtempSync(join(tmpdir(), 'celilo-person-'));
32
+ const dbPath = join(dir, 'test.db');
33
+ const db = await setupTestDatabaseAt(dbPath);
34
+ createPerson(db, { name: 'peter', timezone: 'America/Los_Angeles' });
35
+ createPerson(db, { name: 'ada', timezone: 'Europe/London' });
36
+ db.$client.close();
37
+ process.env.CELILO_DB_PATH = dbPath;
38
+ });
39
+
40
+ afterEach(() => {
41
+ closeDb();
42
+ if (previousDbPath === undefined) delete process.env.CELILO_DB_PATH;
43
+ else process.env.CELILO_DB_PATH = previousDbPath;
44
+ rmSync(dir, { recursive: true, force: true });
45
+ });
46
+
47
+ /** Run `person list`, insisting it succeeded before reading what it said. */
48
+ async function personList(flags: Record<string, boolean | string>): Promise<string> {
49
+ const result = await handlePerson('list', [], flags);
50
+ if (!result.success) throw new Error(`person list failed: ${result.error}`);
51
+ return result.message;
52
+ }
53
+
54
+ async function listJson(): Promise<{ name: string; timezone: string }[]> {
55
+ return JSON.parse(await personList({ json: true }));
56
+ }
57
+
58
+ describe('celilo person list --json', () => {
59
+ test('names every person, sorted the way listPeople sorts them', async () => {
60
+ expect((await listJson()).map((p) => p.name)).toEqual(['ada', 'peter']);
61
+ });
62
+
63
+ test('carries the timezone, which is the only other thing a person IS', async () => {
64
+ const ada = (await listJson()).find((p) => p.name === 'ada');
65
+ expect(ada?.timezone).toBe('Europe/London');
66
+ });
67
+
68
+ test('an empty fleet is an empty array, not a sentence about it', async () => {
69
+ // The human listing prints "Nobody configured." and a hint. A caller that
70
+ // parsed that would throw, which is a better failure than the one this
71
+ // avoids — but the console polls this, so it gets JSON either way.
72
+ getDb().$client.run('DELETE FROM people');
73
+ expect(await listJson()).toEqual([]);
74
+ });
75
+
76
+ test('without --json it stays the human table, and says how many', async () => {
77
+ expect(await personList({})).toContain('2 person');
78
+ });
79
+ });
@@ -33,6 +33,7 @@ import {
33
33
  listRoutes,
34
34
  } from '../../services/alerting/people';
35
35
  import { parseClockTime } from '../../services/alerting/quiet-hours';
36
+ import { hasFlag } from '../parser';
36
37
  import type { CommandResult } from '../types';
37
38
 
38
39
  const NO_SCHEMAS = defineEvents({});
@@ -109,8 +110,18 @@ function personAdd(args: string[], flags: Record<string, boolean | string>): Com
109
110
  return { success: true, message: `Added ${name} (${timezone}${quietNote})` };
110
111
  }
111
112
 
112
- function personList(): CommandResult {
113
+ function personList(flags: Record<string, boolean | string> = {}): CommandResult {
113
114
  const people = listPeople(getDb());
115
+
116
+ // The web console's subject-to-person mapping reads this. It never invents a
117
+ // person and never lets celilo fall back to `people[0]`, so it has to be able
118
+ // to ASK who exists. `person:list` is already in the read-only grant set, so
119
+ // the flag adds a shape rather than an authority.
120
+ if (hasFlag(flags, 'json')) {
121
+ const payload = people.map((p) => ({ name: p.name, timezone: p.timezone }));
122
+ return { success: true, message: JSON.stringify(payload), rawOutput: true, data: payload };
123
+ }
124
+
114
125
  if (people.length === 0) {
115
126
  console.log('\nNobody configured.\n');
116
127
  console.log(
@@ -158,7 +169,7 @@ export async function handlePerson(
158
169
  switch (subcommand) {
159
170
  case undefined:
160
171
  case 'list':
161
- return personList();
172
+ return personList(flags);
162
173
  case 'add':
163
174
  return personAdd(args, flags);
164
175
  case 'remove':
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `celilo system ensure-fleet-key` — mint celilo's fleet SSH keypair if it
3
+ * is absent, record its public half, and print it.
4
+ *
5
+ * The celilo-side half of "minting the fleet key is not the module's job"
6
+ * (openspec/changes/hook-process-boundary, design D9b). celilo-mgmt's
7
+ * `on_install` used to generate the keypair itself, writing into celilo's
8
+ * data directory from inside a hook — the one directory the hook jail
9
+ * exists to keep out of the mount set. The hook now asks for the key
10
+ * through this command and receives the public half, rather than producing
11
+ * it.
12
+ *
13
+ * Idempotent: an existing key is reused. Re-keying would strand every
14
+ * machine whose authorized_keys holds the old public half.
15
+ */
16
+
17
+ import { getDb } from '../../db/client';
18
+ import { ensureFleetKey, getFleetSshDir } from '../../services/fleet-key';
19
+ import { initializeSystem } from '../../services/system-init';
20
+ import type { CommandResult } from '../types';
21
+
22
+ export async function handleSystemEnsureFleetKey(): Promise<CommandResult> {
23
+ let key: ReturnType<typeof ensureFleetKey>;
24
+ try {
25
+ key = ensureFleetKey();
26
+ } catch (err) {
27
+ return {
28
+ success: false,
29
+ error: `Could not create the fleet SSH key in ${getFleetSshDir()}: ${
30
+ err instanceof Error ? err.message : String(err)
31
+ }`,
32
+ };
33
+ }
34
+
35
+ try {
36
+ initializeSystem(getDb(), { 'ssh.public_key': key.publicKey });
37
+ } catch (err) {
38
+ return {
39
+ success: false,
40
+ error: `Fleet key is on disk but recording ssh.public_key failed: ${
41
+ err instanceof Error ? err.message : String(err)
42
+ }`,
43
+ };
44
+ }
45
+
46
+ // The public key alone, and `rawOutput` to say so on the record: this
47
+ // command exists to be read by celilo-mgmt's on_install as much as by an
48
+ // operator, and the key IS the answer either way (an operator pastes it into
49
+ // a machine's authorized_keys). Wrapping it in prose would make the caller
50
+ // parse for it, which is how a hook ends up depending on a sentence.
51
+ return { success: true, message: key.publicKey, rawOutput: true };
52
+ }
@@ -674,6 +674,7 @@ export async function getCompletions(words: string[], current: number): Promise<
674
674
  'init',
675
675
  'apply-config',
676
676
  'discover-network',
677
+ 'ensure-fleet-key',
677
678
  'config',
678
679
  'secret',
679
680
  'vault-password',