@celilo/cli 4.0.0 → 5.0.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 (119) hide show
  1. package/AGENTS.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0034_private_routes.sql +41 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +5 -4
  6. package/src/api/sessions.test.ts +1 -1
  7. package/src/capabilities/registration.test.ts +11 -11
  8. package/src/capabilities/secrets.test.ts +18 -18
  9. package/src/capabilities/validation.test.ts +24 -24
  10. package/src/cli/cli.test.ts +3 -3
  11. package/src/cli/command-tree-parser.test.ts +18 -0
  12. package/src/cli/command-tree-parser.ts +12 -0
  13. package/src/cli/commands/api.ts +67 -14
  14. package/src/cli/commands/events.ts +5 -2
  15. package/src/cli/commands/machine-reclassify.ts +79 -0
  16. package/src/cli/commands/module-changeset.test.ts +1 -1
  17. package/src/cli/commands/module-generate.test.ts +2 -2
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -2
  19. package/src/cli/commands/module-update.test.ts +14 -14
  20. package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
  21. package/src/cli/commands/module-upgrade.test.ts +3 -3
  22. package/src/cli/commands/module-upgrade.ts +12 -0
  23. package/src/cli/commands/module-version.test.ts +1 -1
  24. package/src/cli/commands/system-audit.ts +22 -0
  25. package/src/cli/commands/system-reboot.ts +126 -0
  26. package/src/cli/commands/system-update.ts +5 -0
  27. package/src/cli/completion.ts +3 -2
  28. package/src/cli/index.ts +41 -2
  29. package/src/cli/restore-migration-failure.test.ts +1 -1
  30. package/src/cli/stdout-pipe-flush.test.ts +35 -0
  31. package/src/cli/tui/audit-state.ts +4 -0
  32. package/src/db/private-routes.test.ts +159 -0
  33. package/src/db/schema.ts +49 -0
  34. package/src/hooks/broker.ts +31 -0
  35. package/src/hooks/capability-loader.ts +185 -12
  36. package/src/hooks/executor.test.ts +13 -13
  37. package/src/hooks/executor.ts +20 -2
  38. package/src/hooks/hook-state-dir.test.ts +2 -2
  39. package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
  40. package/src/hooks/test-fixtures/store-backed.ts +5 -3
  41. package/src/hooks/timeout-names-provider.test.ts +87 -0
  42. package/src/manifest/contracts/index.ts +28 -7
  43. package/src/manifest/contracts/v2.ts +32 -0
  44. package/src/manifest/icon-schema.test.ts +1 -1
  45. package/src/manifest/schema.ts +13 -2
  46. package/src/manifest/template-validator.test.ts +1 -1
  47. package/src/manifest/validate-privileged.test.ts +1 -1
  48. package/src/manifest/validate.test.ts +68 -29
  49. package/src/manifest/validate.ts +14 -1
  50. package/src/module/import.test.ts +7 -7
  51. package/src/module/packaging/build.test.ts +1 -1
  52. package/src/packaging/nfpm-depends.test.ts +42 -0
  53. package/src/policy/capability-shape-baseline.ts +7 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +5 -5
  55. package/src/policy/module-business-baseline.ts +33 -4
  56. package/src/services/api-access.test.ts +44 -0
  57. package/src/services/aspect-approvals.test.ts +2 -2
  58. package/src/services/aspect-reconcile.test.ts +2 -2
  59. package/src/services/aspect-runner.test.ts +4 -4
  60. package/src/services/audit/backups.test.ts +1 -1
  61. package/src/services/audit/capability-abi.test.ts +1 -1
  62. package/src/services/audit/index.test.ts +7 -0
  63. package/src/services/audit/index.ts +15 -0
  64. package/src/services/audit/machine-interface-zones.test.ts +30 -0
  65. package/src/services/audit/machine-interface-zones.ts +43 -0
  66. package/src/services/audit/module-configs.test.ts +1 -1
  67. package/src/services/audit/reboot-pending.test.ts +63 -0
  68. package/src/services/audit/reboot-pending.ts +85 -0
  69. package/src/services/audit/recurrence-gate.test.ts +21 -0
  70. package/src/services/audit/server-bun-pin.test.ts +44 -0
  71. package/src/services/audit/server-bun-pin.ts +95 -0
  72. package/src/services/audit/types.ts +2 -0
  73. package/src/services/build-bus/hook-dispatch-executor.test.ts +3 -3
  74. package/src/services/bus-interview-park.test.ts +1 -1
  75. package/src/services/bus-secret-flow.test.ts +1 -1
  76. package/src/services/capability-compat.test.ts +22 -0
  77. package/src/services/capability-compat.ts +15 -2
  78. package/src/services/capability-table-rows.test.ts +20 -2
  79. package/src/services/celilo-events.test.ts +119 -6
  80. package/src/services/celilo-events.ts +156 -16
  81. package/src/services/celilo-mgmt-hooks.test.ts +6 -3
  82. package/src/services/cross-module-read.test.ts +2 -2
  83. package/src/services/deploy-preflight.ts +12 -3
  84. package/src/services/deploy-validation.test.ts +86 -7
  85. package/src/services/deploy-validation.ts +19 -0
  86. package/src/services/dns-provider-backfill.test.ts +2 -2
  87. package/src/services/dns-registrations.test.ts +1 -1
  88. package/src/services/fleet-checks.test.ts +2 -2
  89. package/src/services/fleet-checks.ts +40 -10
  90. package/src/services/health-runner.test.ts +1 -1
  91. package/src/services/infrastructure-variable-resolver.test.ts +11 -11
  92. package/src/services/instance-ops.test.ts +2 -2
  93. package/src/services/machine-interface-zones.test.ts +73 -0
  94. package/src/services/machine-interface-zones.ts +101 -0
  95. package/src/services/module-deploy.dns-repoint.test.ts +1 -1
  96. package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
  97. package/src/services/module-deploy.ts +31 -10
  98. package/src/services/module-pause-quiescence.test.ts +1 -1
  99. package/src/services/module-pause.test.ts +1 -1
  100. package/src/services/module-subscriptions.test.ts +2 -2
  101. package/src/services/module-types-generator.test.ts +1 -1
  102. package/src/services/module-validator/contract-version.test.ts +3 -3
  103. package/src/services/network-ensure.test.ts +1 -1
  104. package/src/services/provider-arrival.test.ts +62 -46
  105. package/src/services/provider-arrival.ts +41 -10
  106. package/src/services/proxmox-reconcile.test.ts +2 -2
  107. package/src/services/remove-guard.test.ts +1 -1
  108. package/src/services/system-reboot.test.ts +231 -0
  109. package/src/services/system-reboot.ts +357 -0
  110. package/src/services/update/dep-graph.test.ts +1 -1
  111. package/src/services/update/orchestrator.test.ts +165 -1
  112. package/src/services/update/orchestrator.ts +43 -12
  113. package/src/services/update/types.ts +10 -1
  114. package/src/templates/generator.test.ts +15 -4
  115. package/src/templates/generator.ts +12 -4
  116. package/src/variables/capability-self-ref.test.ts +2 -2
  117. package/src/variables/computed/computed-integration.test.ts +1 -1
  118. package/src/variables/context.ts +14 -1
  119. package/src/variables/declarative-derivation.test.ts +19 -19
package/AGENTS.md CHANGED
@@ -30,7 +30,7 @@ web** — they are the source of truth; the hosted copies at
30
30
  An **app** is a module that provides no capability (it serves content), so set
31
31
  `version_source: { kind: changeset }`.
32
32
 
33
- 1. **`manifest.yml`** — `id` (kebab-case), `celilo_contract: "1.0"`, `version`,
33
+ 1. **`manifest.yml`** — `id` (kebab-case), `celilo_contract: "2.0"`, `version`,
34
34
  the capabilities you `requires:` (e.g. `public_web`, `idp`), `variables`,
35
35
  `hooks`, and `version_source`. Keep the schema directive on line 1 so your
36
36
  editor validates:
@@ -60,7 +60,7 @@ celilo module deploy <id> / update / health # deploy + verify on the fleet
60
60
 
61
61
  ## Conventions / rules
62
62
 
63
- - Module `id` is **kebab-case**; pin `celilo_contract: "1.0"`.
63
+ - Module `id` is **kebab-case**; pin `celilo_contract: "2.0"`.
64
64
  - **`version_source`** picks how `version` is sourced: `changeset` (apps/content —
65
65
  authored via changesets, ordered by `+N`), `pin` (wraps upstream software —
66
66
  `version` = the installed upstream version, resolver-checked), `recipe`
@@ -212,6 +212,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
212
212
  - **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/`.
213
213
  - **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.
214
214
  - **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.
215
+ - **Management-server reboot** — `apps/celilo/src/services/system-reboot.ts`, surfaced as `celilo system reboot [--allow-reboot] [--verify]` (`apps/celilo/src/cli/commands/system-reboot.ts`) and reachable over MCP like any other registry leaf. Rebooting THIS box takes down the dispatcher, the MCP server and the remote API, so `requestReboot` refuses while `checkInFlight` reports a module operation running and names it, and consent follows the flag-or-interview rule (`--allow-reboot` wins, otherwise an event-bus question — never stdin). **Verification is a second call, by construction**: the process asking for the reboot dies in it, so no single invocation can both reboot the host and report that it came back. `requestReboot` records `state/reboot-requested.json` carrying the boot id at request time; `verifyReturn` (`--verify`) passes only once that boot id has CHANGED and the dispatcher heartbeat and installed CLI both answer, and names which of the three is missing when it has not. It refuses up front when the boot id is unreadable, because a reboot whose return could never be verified is the out-of-band `sudo reboot` this replaces. `judgeRebootPending` compares the running kernel against the newest installed one and reads Debian's `/var/run/reboot-required`; either tell is enough, neither readable is `unmeasured` rather than clean. Both facts surface as the `reboot_pending` audit category (`apps/celilo/src/services/audit/reboot-pending.ts`): a pending reboot is `drift`, an UNVERIFIED one is `blocked` — that is a control plane that may not have come back. celilo#1378.
215
216
  - **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/specs/module-pause/spec.md`.
216
217
  - **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).
217
218
  - **Consumer-removal cleanup (every provider is told)** — `apps/celilo/src/services/consumer-cleanup.ts` — pure `planConsumerCleanup` + `loadConsumerCleanupPlan` + `runConsumerCleanup`, called from `apps/celilo/src/cli/commands/module-remove.ts` after `on_uninstall` and before `terraform destroy`. A capability is two-sided: the consumer asks, the provider mints something in ITS world (a caddy site block, a DNAT rule, an OIDC client at authentik, a registered CI runner), and removal only ever touched one side — the FK cascade dropped celilo's row, so the provider's next converge had no way to learn the thing existed. This dispatches the `on_consumer_removed` hook to every provider of every capability the departing module declared under `requires` OR `optional` (the same relation `remove-guard.ts` counts as a dependency edge), **once per provider** rather than once per capability, sorted by provider id. The hook receives one input, `consumer`, and NOTHING else: a provider that cannot answer "what do I hold for this module" without being told has a different defect — the consumer's id was never recorded at mint time. It replaces `services/web-route-cleanup.ts`, which did the same job for exactly one capability, by name, from core. Semantics that are easy to get backwards: **a failed withdrawal never blocks the removal** — the consumer goes and the failing PROVIDER is marked `ERROR` with the departing consumer named in `error_message` (surfaced as a `blocked` finding by `services/audit/undeployed-modules.ts`), because the hook is a full converge and after a failure the provider's state is unknown rather than "one thing missed". **Dispatch continues past a failure**, so one broken provider cannot leave the others holding state. A PAUSED provider is skipped with a warning naming what it keeps (`run-named-hook.ts` refuses non-lifecycle hooks on a paused module, and `on_consumer_removed` must NOT join `LIFECYCLE_HOOKS`), and a never-deployed one is skipped silently. Providers implementing it: caddy, caddy-internal, iptables, greenwave, axon, authentik, forgejo, generic-cpanel-hosting-provider. See `openspec/changes/consumer-removal-cleanup/`.
@@ -0,0 +1,41 @@
1
+ -- The internal ingress's declared routes, in core, in their own table.
2
+ --
3
+ -- `caddy-internal` kept the routes its consumers registered in a `routes`
4
+ -- module-config key: the same rows as `web_routes`, stored as one module's
5
+ -- business rather than as declared state core owns. That key is why the two
6
+ -- ingresses could not share plumbing — the private provider had nothing core
7
+ -- could render, converge or audit.
8
+ --
9
+ -- Same columns as `web_routes`, separate table, and the separation is the
10
+ -- point. The PUBLIC provider derives the hostnames it serves from every row of
11
+ -- `web_routes`, so a private route stored there would be picked up and served
12
+ -- publicly, with a public certificate, by the ingress caddy-internal exists to
13
+ -- keep it away from (celilo#846). One table with a flag would make that leak a
14
+ -- missing `WHERE` clause away; two tables make it impossible.
15
+ --
16
+ -- No data migration (design D9 of providers-converge-declared-state). The
17
+ -- table starts empty. When caddy-internal deploys, provider arrival
18
+ -- re-registers its consumers and their hooks write their routes into it inside
19
+ -- that same deploy — observed 2026-09-17, "Re-registered 1 consumer(s) with
20
+ -- 'caddy-internal': wireguard-manager". A one-time backfill would be code that
21
+ -- runs once and is then wrong to keep.
22
+ CREATE TABLE `private_routes` (
23
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
24
+ `slug` text NOT NULL,
25
+ `module_id` text NOT NULL,
26
+ `type` text NOT NULL,
27
+ `path` text NOT NULL,
28
+ `hostname` text NOT NULL,
29
+ `target_host` text,
30
+ `target_port` integer,
31
+ `websocket` integer DEFAULT false NOT NULL,
32
+ `content_hash` text,
33
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
34
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL,
35
+ FOREIGN KEY (`module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
36
+ );
37
+ --> statement-breakpoint
38
+ -- Scoped to hostname as `web_routes` is, and scoped to THIS table: the same
39
+ -- hostname and path may exist on both ingresses, which is a legitimate
40
+ -- split-horizon arrangement and not a collision.
41
+ CREATE UNIQUE INDEX `private_routes_hostname_path_idx` ON `private_routes` (`hostname`,`path`);
@@ -239,6 +239,13 @@
239
239
  "when": 1789082481000,
240
240
  "tag": "0033_build_bus_hook_runs",
241
241
  "breakpoints": true
242
+ },
243
+ {
244
+ "idx": 34,
245
+ "version": "6",
246
+ "when": 1789947740964,
247
+ "tag": "0034_private_routes",
248
+ "breakpoints": true
242
249
  }
243
250
  ]
244
- }
251
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "4.0.0",
3
+ "version": "5.0.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,6 +35,7 @@
35
35
  "dev": "bun run src/cli/index.ts",
36
36
  "test": "bun test --timeout 60000 src/",
37
37
  "test:integration": "bun test --timeout 60000 test-integration/",
38
+ "test:integration:pr": "bun test --timeout 60000 test-integration/alerting test-integration/ansible test-integration/api test-integration/cli test-integration/completion test-integration/deploy test-integration/golden test-integration/machine test-integration/module test-integration/publish test-integration/secrets test-integration/service test-integration/sizing test-integration/system test-integration/values test-integration/module-integrity-baseline.test.ts",
38
39
  "test:integration-slow": "bun test --timeout 300000 test-integration/",
39
40
  "test:completion": "bun test --timeout 60000 test-integration/completion/",
40
41
  "test:all": "bun test --timeout 60000 src/ test-integration/",
@@ -58,10 +59,10 @@
58
59
  "dependencies": {
59
60
  "@aws-sdk/client-s3": "^3.1109.0",
60
61
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^5.1.0",
62
+ "@celilo/capabilities": "^6.0.0",
62
63
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.13.0",
64
- "@celilo/event-bus": "^0.6.0",
64
+ "@celilo/core": "^0.14.0",
65
+ "@celilo/event-bus": "^0.7.0",
65
66
  "ajv": "^8.18.0",
66
67
  "drizzle-orm": "^0.36.4",
67
68
  "ink": "^7.0.1",
@@ -79,7 +79,7 @@ beforeEach(() => {
79
79
  name: 'iptables',
80
80
  sourcePath: join(dir, 'installed'),
81
81
  version: '1.0.2+9',
82
- manifestData: { celilo_contract: '1.0', id: 'iptables', name: 'iptables', version: '1.0.2' },
82
+ manifestData: { celilo_contract: '2.0', id: 'iptables', name: 'iptables', version: '1.0.2' },
83
83
  })
84
84
  .run();
85
85
 
@@ -17,7 +17,7 @@ describe('Capability Registration', () => {
17
17
  };
18
18
 
19
19
  const manifest: ModuleManifest = {
20
- celilo_contract: '1.0',
20
+ celilo_contract: '2.0',
21
21
  id: 'test-module',
22
22
  name: 'Test Module',
23
23
  version: '1.0.0',
@@ -50,7 +50,7 @@ describe('Capability Registration', () => {
50
50
  };
51
51
 
52
52
  const manifest: ModuleManifest = {
53
- celilo_contract: '1.0',
53
+ celilo_contract: '2.0',
54
54
  id: 'test-module',
55
55
  name: 'Test Module',
56
56
  version: '1.0.0',
@@ -89,7 +89,7 @@ describe('Capability Registration', () => {
89
89
  };
90
90
 
91
91
  const manifest: ModuleManifest = {
92
- celilo_contract: '1.0',
92
+ celilo_contract: '2.0',
93
93
  id: 'dns-external',
94
94
  name: 'DNS External',
95
95
  version: '1.0.0',
@@ -126,7 +126,7 @@ describe('Capability Registration', () => {
126
126
  };
127
127
 
128
128
  const manifest: ModuleManifest = {
129
- celilo_contract: '1.0',
129
+ celilo_contract: '2.0',
130
130
  id: 'test-module',
131
131
  name: 'Test Module',
132
132
  version: '1.0.0',
@@ -155,7 +155,7 @@ describe('Capability Registration', () => {
155
155
  };
156
156
 
157
157
  const manifest: ModuleManifest = {
158
- celilo_contract: '1.0',
158
+ celilo_contract: '2.0',
159
159
  id: 'test-module',
160
160
  name: 'Test Module',
161
161
  version: '1.0.0',
@@ -185,7 +185,7 @@ describe('Capability Registration', () => {
185
185
  };
186
186
 
187
187
  const manifest: ModuleManifest = {
188
- celilo_contract: '1.0',
188
+ celilo_contract: '2.0',
189
189
  id: 'test-module',
190
190
  name: 'Test Module',
191
191
  version: '1.0.0',
@@ -213,7 +213,7 @@ describe('Capability Registration', () => {
213
213
  };
214
214
 
215
215
  const manifest: ModuleManifest = {
216
- celilo_contract: '1.0',
216
+ celilo_contract: '2.0',
217
217
  id: 'test-module',
218
218
  name: 'Test Module',
219
219
  version: '1.0.0',
@@ -244,7 +244,7 @@ describe('Capability Registration', () => {
244
244
  };
245
245
 
246
246
  const manifest: ModuleManifest = {
247
- celilo_contract: '1.0',
247
+ celilo_contract: '2.0',
248
248
  id: 'test-module',
249
249
  name: 'Test Module',
250
250
  version: '1.0.0',
@@ -280,7 +280,7 @@ describe('Capability Registration', () => {
280
280
  };
281
281
 
282
282
  const manifest: ModuleManifest = {
283
- celilo_contract: '1.0',
283
+ celilo_contract: '2.0',
284
284
  id: 'test-module',
285
285
  name: 'Test Module',
286
286
  version: '1.0.0',
@@ -312,7 +312,7 @@ describe('Capability Registration', () => {
312
312
  };
313
313
 
314
314
  const manifest: ModuleManifest = {
315
- celilo_contract: '1.0',
315
+ celilo_contract: '2.0',
316
316
  id: 'test-module',
317
317
  name: 'Test Module',
318
318
  version: '1.0.0',
@@ -343,7 +343,7 @@ describe('Capability Registration', () => {
343
343
  };
344
344
 
345
345
  const manifest: ModuleManifest = {
346
- celilo_contract: '1.0',
346
+ celilo_contract: '2.0',
347
347
  id: 'test-module',
348
348
  name: 'Test Module',
349
349
  version: '1.0.0',
@@ -11,7 +11,7 @@ describe('Capability Secret Access', () => {
11
11
  describe('isCapabilityFieldSecret', () => {
12
12
  test('should return true when field is marked as secret', () => {
13
13
  const providerManifest: ModuleManifest = {
14
- celilo_contract: '1.0',
14
+ celilo_contract: '2.0',
15
15
  id: 'dns-external',
16
16
  name: 'DNS External',
17
17
  version: '1.0.0',
@@ -56,7 +56,7 @@ describe('Capability Secret Access', () => {
56
56
 
57
57
  test('should return true for any secret in the list', () => {
58
58
  const providerManifest: ModuleManifest = {
59
- celilo_contract: '1.0',
59
+ celilo_contract: '2.0',
60
60
  id: 'provider',
61
61
  name: 'Provider',
62
62
  version: '1.0.0',
@@ -98,7 +98,7 @@ describe('Capability Secret Access', () => {
98
98
 
99
99
  test('should return false when field is not in secrets list', () => {
100
100
  const providerManifest: ModuleManifest = {
101
- celilo_contract: '1.0',
101
+ celilo_contract: '2.0',
102
102
  id: 'dns-external',
103
103
  name: 'DNS External',
104
104
  version: '1.0.0',
@@ -137,7 +137,7 @@ describe('Capability Secret Access', () => {
137
137
 
138
138
  test('should return false when capability has no secrets', () => {
139
139
  const providerManifest: ModuleManifest = {
140
- celilo_contract: '1.0',
140
+ celilo_contract: '2.0',
141
141
  id: 'provider',
142
142
  name: 'Provider',
143
143
  version: '1.0.0',
@@ -183,7 +183,7 @@ describe('Capability Secret Access', () => {
183
183
 
184
184
  test('should match exact secret name', () => {
185
185
  const providerManifest: ModuleManifest = {
186
- celilo_contract: '1.0',
186
+ celilo_contract: '2.0',
187
187
  id: 'provider',
188
188
  name: 'Provider',
189
189
  version: '1.0.0',
@@ -227,7 +227,7 @@ describe('Capability Secret Access', () => {
227
227
  describe('checkCapabilitySecretAccess', () => {
228
228
  test('should return true when consumer matches allowlist', () => {
229
229
  const providerManifest: ModuleManifest = {
230
- celilo_contract: '1.0',
230
+ celilo_contract: '2.0',
231
231
  id: 'dns-external',
232
232
  name: 'DNS External',
233
233
  version: '1.0.0',
@@ -253,7 +253,7 @@ describe('Capability Secret Access', () => {
253
253
  };
254
254
 
255
255
  const consumerManifest: ModuleManifest = {
256
- celilo_contract: '1.0',
256
+ celilo_contract: '2.0',
257
257
  id: 'caddy',
258
258
  name: 'Caddy',
259
259
  version: '1.0.0',
@@ -298,7 +298,7 @@ describe('Capability Secret Access', () => {
298
298
 
299
299
  test('should return false when consumer does not match allowlist', () => {
300
300
  const providerManifest: ModuleManifest = {
301
- celilo_contract: '1.0',
301
+ celilo_contract: '2.0',
302
302
  id: 'dns-external',
303
303
  name: 'DNS External',
304
304
  version: '1.0.0',
@@ -324,7 +324,7 @@ describe('Capability Secret Access', () => {
324
324
  };
325
325
 
326
326
  const consumerManifest: ModuleManifest = {
327
- celilo_contract: '1.0',
327
+ celilo_contract: '2.0',
328
328
  id: 'some-app',
329
329
  name: 'Some App',
330
330
  version: '1.0.0',
@@ -367,7 +367,7 @@ describe('Capability Secret Access', () => {
367
367
 
368
368
  test('should return true when secret has no readable_by restriction', () => {
369
369
  const providerManifest: ModuleManifest = {
370
- celilo_contract: '1.0',
370
+ celilo_contract: '2.0',
371
371
  id: 'provider',
372
372
  name: 'Provider',
373
373
  version: '1.0.0',
@@ -393,7 +393,7 @@ describe('Capability Secret Access', () => {
393
393
  };
394
394
 
395
395
  const consumerManifest: ModuleManifest = {
396
- celilo_contract: '1.0',
396
+ celilo_contract: '2.0',
397
397
  id: 'consumer',
398
398
  name: 'Consumer',
399
399
  version: '1.0.0',
@@ -429,7 +429,7 @@ describe('Capability Secret Access', () => {
429
429
 
430
430
  test('should return true when readable_by is empty array', () => {
431
431
  const providerManifest: ModuleManifest = {
432
- celilo_contract: '1.0',
432
+ celilo_contract: '2.0',
433
433
  id: 'provider',
434
434
  name: 'Provider',
435
435
  version: '1.0.0',
@@ -455,7 +455,7 @@ describe('Capability Secret Access', () => {
455
455
  };
456
456
 
457
457
  const consumerManifest: ModuleManifest = {
458
- celilo_contract: '1.0',
458
+ celilo_contract: '2.0',
459
459
  id: 'consumer',
460
460
  name: 'Consumer',
461
461
  version: '1.0.0',
@@ -507,7 +507,7 @@ describe('Capability Secret Access', () => {
507
507
 
508
508
  test('should return false when consumer not found', () => {
509
509
  const providerManifest: ModuleManifest = {
510
- celilo_contract: '1.0',
510
+ celilo_contract: '2.0',
511
511
  id: 'provider',
512
512
  name: 'Provider',
513
513
  version: '1.0.0',
@@ -558,7 +558,7 @@ describe('Capability Secret Access', () => {
558
558
 
559
559
  test('should return false when secret not found in capability', () => {
560
560
  const providerManifest: ModuleManifest = {
561
- celilo_contract: '1.0',
561
+ celilo_contract: '2.0',
562
562
  id: 'provider',
563
563
  name: 'Provider',
564
564
  version: '1.0.0',
@@ -583,7 +583,7 @@ describe('Capability Secret Access', () => {
583
583
  };
584
584
 
585
585
  const consumerManifest: ModuleManifest = {
586
- celilo_contract: '1.0',
586
+ celilo_contract: '2.0',
587
587
  id: 'consumer',
588
588
  name: 'Consumer',
589
589
  version: '1.0.0',
@@ -623,7 +623,7 @@ describe('Capability Secret Access', () => {
623
623
 
624
624
  test('should return false when capability has no secrets', () => {
625
625
  const providerManifest: ModuleManifest = {
626
- celilo_contract: '1.0',
626
+ celilo_contract: '2.0',
627
627
  id: 'provider',
628
628
  name: 'Provider',
629
629
  version: '1.0.0',
@@ -643,7 +643,7 @@ describe('Capability Secret Access', () => {
643
643
  };
644
644
 
645
645
  const consumerManifest: ModuleManifest = {
646
- celilo_contract: '1.0',
646
+ celilo_contract: '2.0',
647
647
  id: 'consumer',
648
648
  name: 'Consumer',
649
649
  version: '1.0.0',
@@ -95,7 +95,7 @@ describe('Capability Access Validation', () => {
95
95
  describe('getProviderManifest', () => {
96
96
  test('should return manifest when capability exists', () => {
97
97
  const mockManifest: ModuleManifest = {
98
- celilo_contract: '1.0',
98
+ celilo_contract: '2.0',
99
99
  id: 'dns-external',
100
100
  name: 'DNS External',
101
101
  version: '1.0.0',
@@ -153,7 +153,7 @@ describe('Capability Access Validation', () => {
153
153
 
154
154
  test('should parse JSON manifest correctly', () => {
155
155
  const mockManifest: ModuleManifest = {
156
- celilo_contract: '1.0',
156
+ celilo_contract: '2.0',
157
157
  id: 'test-module',
158
158
  name: 'Test Module',
159
159
  version: '1.0.0',
@@ -190,7 +190,7 @@ describe('Capability Access Validation', () => {
190
190
  describe('validateCapabilityAccess', () => {
191
191
  test('should return success when module does not require capabilities', async () => {
192
192
  const manifest: ModuleManifest = {
193
- celilo_contract: '1.0',
193
+ celilo_contract: '2.0',
194
194
  id: 'test-module',
195
195
  name: 'Test Module',
196
196
  version: '1.0.0',
@@ -209,7 +209,7 @@ describe('Capability Access Validation', () => {
209
209
 
210
210
  test('should return success when module requires capabilities array is empty', async () => {
211
211
  const manifest: ModuleManifest = {
212
- celilo_contract: '1.0',
212
+ celilo_contract: '2.0',
213
213
  id: 'test-module',
214
214
  name: 'Test Module',
215
215
  version: '1.0.0',
@@ -236,7 +236,7 @@ describe('Capability Access Validation', () => {
236
236
  // fails the test after this one: the difference is entirely which
237
237
  // capability is asked for.
238
238
  const manifest: ModuleManifest = {
239
- celilo_contract: '1.0',
239
+ celilo_contract: '2.0',
240
240
  id: 'celilo-web-console',
241
241
  name: 'Celilo Web Console',
242
242
  version: '1.0.0',
@@ -261,7 +261,7 @@ describe('Capability Access Validation', () => {
261
261
 
262
262
  test('should return error when required capability not found', async () => {
263
263
  const manifest: ModuleManifest = {
264
- celilo_contract: '1.0',
264
+ celilo_contract: '2.0',
265
265
  id: 'caddy',
266
266
  name: 'Caddy',
267
267
  version: '1.0.0',
@@ -292,7 +292,7 @@ describe('Capability Access Validation', () => {
292
292
  // not demand a providing module for it — otherwise celilo-mgmt
293
293
  // can't be imported (regression: openspec/specs/progressive-zone-disclosure/spec.md).
294
294
  const manifest: ModuleManifest = {
295
- celilo_contract: '1.0',
295
+ celilo_contract: '2.0',
296
296
  id: 'celilo-mgmt',
297
297
  name: 'Celilo Management Server',
298
298
  version: '1.0.0',
@@ -318,7 +318,7 @@ describe('Capability Access Validation', () => {
318
318
 
319
319
  test('should return success when capability has no secrets', async () => {
320
320
  const manifest: ModuleManifest = {
321
- celilo_contract: '1.0',
321
+ celilo_contract: '2.0',
322
322
  id: 'consumer',
323
323
  name: 'Consumer',
324
324
  version: '1.0.0',
@@ -331,7 +331,7 @@ describe('Capability Access Validation', () => {
331
331
  };
332
332
 
333
333
  const providerManifest: ModuleManifest = {
334
- celilo_contract: '1.0',
334
+ celilo_contract: '2.0',
335
335
  id: 'provider',
336
336
  name: 'Provider',
337
337
  version: '1.0.0',
@@ -364,7 +364,7 @@ describe('Capability Access Validation', () => {
364
364
 
365
365
  test('should return success when secret has no readable_by restriction', async () => {
366
366
  const manifest: ModuleManifest = {
367
- celilo_contract: '1.0',
367
+ celilo_contract: '2.0',
368
368
  id: 'consumer',
369
369
  name: 'Consumer',
370
370
  version: '1.0.0',
@@ -377,7 +377,7 @@ describe('Capability Access Validation', () => {
377
377
  };
378
378
 
379
379
  const providerManifest: ModuleManifest = {
380
- celilo_contract: '1.0',
380
+ celilo_contract: '2.0',
381
381
  id: 'dns-external',
382
382
  name: 'DNS External',
383
383
  version: '1.0.0',
@@ -418,7 +418,7 @@ describe('Capability Access Validation', () => {
418
418
 
419
419
  test('should return success when consumer matches allowlist', async () => {
420
420
  const manifest: ModuleManifest = {
421
- celilo_contract: '1.0',
421
+ celilo_contract: '2.0',
422
422
  id: 'caddy',
423
423
  name: 'Caddy',
424
424
  version: '1.0.0',
@@ -439,7 +439,7 @@ describe('Capability Access Validation', () => {
439
439
  };
440
440
 
441
441
  const providerManifest: ModuleManifest = {
442
- celilo_contract: '1.0',
442
+ celilo_contract: '2.0',
443
443
  id: 'dns-external',
444
444
  name: 'DNS External',
445
445
  version: '1.0.0',
@@ -480,7 +480,7 @@ describe('Capability Access Validation', () => {
480
480
 
481
481
  test('should return error when consumer does not match allowlist', async () => {
482
482
  const manifest: ModuleManifest = {
483
- celilo_contract: '1.0',
483
+ celilo_contract: '2.0',
484
484
  id: 'some-app',
485
485
  name: 'Some App',
486
486
  version: '1.0.0',
@@ -501,7 +501,7 @@ describe('Capability Access Validation', () => {
501
501
  };
502
502
 
503
503
  const providerManifest: ModuleManifest = {
504
- celilo_contract: '1.0',
504
+ celilo_contract: '2.0',
505
505
  id: 'dns-external',
506
506
  name: 'DNS External',
507
507
  version: '1.0.0',
@@ -546,7 +546,7 @@ describe('Capability Access Validation', () => {
546
546
 
547
547
  test('should return error when consumer provides no capabilities', async () => {
548
548
  const manifest: ModuleManifest = {
549
- celilo_contract: '1.0',
549
+ celilo_contract: '2.0',
550
550
  id: 'consumer',
551
551
  name: 'Consumer',
552
552
  version: '1.0.0',
@@ -559,7 +559,7 @@ describe('Capability Access Validation', () => {
559
559
  };
560
560
 
561
561
  const providerManifest: ModuleManifest = {
562
- celilo_contract: '1.0',
562
+ celilo_contract: '2.0',
563
563
  id: 'dns-external',
564
564
  name: 'DNS External',
565
565
  version: '1.0.0',
@@ -601,7 +601,7 @@ describe('Capability Access Validation', () => {
601
601
 
602
602
  test('should validate all required capabilities', async () => {
603
603
  const manifest: ModuleManifest = {
604
- celilo_contract: '1.0',
604
+ celilo_contract: '2.0',
605
605
  id: 'complex-app',
606
606
  name: 'Complex App',
607
607
  version: '1.0.0',
@@ -697,7 +697,7 @@ describe('Capability Access Validation', () => {
697
697
  // dependency graph that six core services read.
698
698
  describe('celilo#854 — secret access is gated on reference, not on declaration', () => {
699
699
  const knotManifest: ModuleManifest = {
700
- celilo_contract: '1.0',
700
+ celilo_contract: '2.0',
701
701
  id: 'knot-unbound-internal',
702
702
  name: 'Knot + Unbound',
703
703
  version: '1.0.0',
@@ -730,7 +730,7 @@ describe('Capability Access Validation', () => {
730
730
 
731
731
  test('a consumer that requires the capability but never names the secret imports', async () => {
732
732
  const manifest: ModuleManifest = {
733
- celilo_contract: '1.0',
733
+ celilo_contract: '2.0',
734
734
  id: 'caddy-internal',
735
735
  name: 'Caddy (fleet-only ingress)',
736
736
  version: '1.0.0',
@@ -752,7 +752,7 @@ describe('Capability Access Validation', () => {
752
752
 
753
753
  test('a consumer that DOES name the restricted secret is still refused', async () => {
754
754
  const manifest: ModuleManifest = {
755
- celilo_contract: '1.0',
755
+ celilo_contract: '2.0',
756
756
  id: 'nosy-app',
757
757
  name: 'Nosy App',
758
758
  version: '1.0.0',
@@ -817,7 +817,7 @@ describe('Capability Access Validation', () => {
817
817
  test('naming one secret enforces that secret allow-list and no other', async () => {
818
818
  function consumer(deriveFrom: string): ModuleManifest {
819
819
  return {
820
- celilo_contract: '1.0',
820
+ celilo_contract: '2.0',
821
821
  id: 'caddy-internal',
822
822
  name: 'Caddy (fleet-only ingress)',
823
823
  version: '1.0.0',
@@ -871,7 +871,7 @@ describe('Capability Access Validation', () => {
871
871
  */
872
872
  test('a secret named only in a template is refused at import', async () => {
873
873
  const manifest: ModuleManifest = {
874
- celilo_contract: '1.0',
874
+ celilo_contract: '2.0',
875
875
  id: 'nosy-app',
876
876
  name: 'Nosy App',
877
877
  version: '1.0.0',
@@ -890,7 +890,7 @@ describe('Capability Access Validation', () => {
890
890
 
891
891
  test('a non-secret template reference is still not a secret reference', async () => {
892
892
  const manifest: ModuleManifest = {
893
- celilo_contract: '1.0',
893
+ celilo_contract: '2.0',
894
894
  id: 'caddy-internal',
895
895
  name: 'Caddy (fleet-only ingress)',
896
896
  version: '1.0.0',
@@ -100,7 +100,7 @@ describe('CLI Integration', () => {
100
100
  await writeFile(
101
101
  join(TEST_MODULE_DIR, 'manifest.yml'),
102
102
  `
103
- celilo_contract: "1.0"
103
+ celilo_contract: "2.0"
104
104
  id: test-module
105
105
  name: Test Module
106
106
  version: 1.0.0
@@ -255,7 +255,7 @@ description: Test module for CLI
255
255
  const db = getDb();
256
256
  // Insert test module with variable declarations
257
257
  const manifestData = JSON.stringify({
258
- celilo_contract: '1.0',
258
+ celilo_contract: '2.0',
259
259
  id: 'test-module',
260
260
  name: 'Test Module',
261
261
  version: '1.0.0',
@@ -440,7 +440,7 @@ description: Test module for CLI
440
440
  const db = getDb();
441
441
  // Insert test module with secrets manifest
442
442
  const manifestData = JSON.stringify({
443
- celilo_contract: '1.0',
443
+ celilo_contract: '2.0',
444
444
  id: 'test-module',
445
445
  name: 'Test Module',
446
446
  version: '1.0.0',
@@ -162,6 +162,24 @@ Commands:
162
162
  expect(commands.filter((c: string) => c === 'module')).toHaveLength(1);
163
163
  });
164
164
 
165
+ test('treats wrapped description lines as continuations, not subcommands', () => {
166
+ // celilo#1409: a wrapped help description's continuation lines matched the
167
+ // subcommand regex, so prose words were reported as missing registry entries.
168
+ const helpText = `
169
+ Commands:
170
+ reboot Reboot the system if an operation
171
+ dies, and it
172
+ came back
173
+ status Show system status
174
+ `;
175
+
176
+ // biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
177
+ const parseMethod = (parser as any).parseHelpText.bind(parser);
178
+ const commands = parseMethod(helpText);
179
+
180
+ expect(commands).toEqual(['reboot', 'status']);
181
+ });
182
+
165
183
  test('handles hyphenated command names', () => {
166
184
  const helpText = `
167
185
  Commands: