@celilo/cli 2.2.0 → 2.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 (180) hide show
  1. package/CELILO_CORE_MODULES.md +1 -0
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/schemas/system_config.json +5 -0
  9. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  10. package/src/ansible/inventory.test.ts +2 -1
  11. package/src/api/sessions.test.ts +5 -4
  12. package/src/api-clients/proxmox.ts +10 -7
  13. package/src/cli/backup-rename.test.ts +5 -4
  14. package/src/cli/cli.test.ts +3 -2
  15. package/src/cli/commands/console-get-chain.test.ts +2 -1
  16. package/src/cli/commands/events.test.ts +2 -2
  17. package/src/cli/commands/firewall-interface-list.test.ts +4 -3
  18. package/src/cli/commands/machine-list.test.ts +57 -0
  19. package/src/cli/commands/machine-list.ts +35 -4
  20. package/src/cli/commands/module-config.test.ts +2 -1
  21. package/src/cli/commands/module-deploy.ts +8 -2
  22. package/src/cli/commands/module-generate.test.ts +53 -0
  23. package/src/cli/commands/module-generate.ts +31 -26
  24. package/src/cli/commands/module-health.ts +1 -0
  25. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  26. package/src/cli/commands/module-import-registry.test.ts +3 -2
  27. package/src/cli/commands/module-jail.test.ts +242 -0
  28. package/src/cli/commands/module-jail.ts +227 -0
  29. package/src/cli/commands/module-list-jail.test.ts +136 -0
  30. package/src/cli/commands/module-list.ts +30 -3
  31. package/src/cli/commands/module-operations.test.ts +2 -1
  32. package/src/cli/commands/module-publish.test.ts +11 -18
  33. package/src/cli/commands/module-update.test.ts +28 -12
  34. package/src/cli/commands/module-update.ts +4 -1
  35. package/src/cli/commands/module-upgrade.test.ts +130 -1
  36. package/src/cli/commands/module-upgrade.ts +77 -3
  37. package/src/cli/commands/module-verify.test.ts +3 -4
  38. package/src/cli/commands/module-verify.ts +0 -1
  39. package/src/cli/commands/notify-config.test.ts +5 -3
  40. package/src/cli/commands/publish/execute.ts +4 -1
  41. package/src/cli/commands/publish/index.ts +11 -1
  42. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  43. package/src/cli/commands/publish/module-registry.ts +23 -4
  44. package/src/cli/commands/publish/plan.ts +1 -1
  45. package/src/cli/commands/publish/types.ts +7 -0
  46. package/src/cli/commands/registry-owner.test.ts +3 -2
  47. package/src/cli/commands/registry-token.test.ts +3 -2
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  49. package/src/cli/commands/system-audit.ts +20 -6
  50. package/src/cli/commands/system-config.test.ts +148 -0
  51. package/src/cli/commands/system-config.ts +26 -1
  52. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  53. package/src/cli/commands/system-doctor.test.ts +143 -6
  54. package/src/cli/commands/system-doctor.ts +142 -25
  55. package/src/cli/commands/system-init-deprecation.test.ts +7 -4
  56. package/src/cli/commands/system-migrate.test.ts +2 -1
  57. package/src/cli/commands/system-update.ts +5 -0
  58. package/src/cli/completion.ts +8 -2
  59. package/src/cli/flag-surface-gate.test.ts +279 -0
  60. package/src/cli/index.ts +70 -3
  61. package/src/cli/parser.test.ts +37 -1
  62. package/src/cli/restore-command.test.ts +5 -4
  63. package/src/cli/restore-migration-failure.test.ts +4 -3
  64. package/src/cli/tui/audit-state.ts +2 -0
  65. package/src/config/paths.test.ts +22 -22
  66. package/src/db/client.test.ts +46 -1
  67. package/src/db/client.ts +26 -0
  68. package/src/db/migrate.test.ts +2 -1
  69. package/src/db/migrate.ts +16 -16
  70. package/src/db/schema.ts +63 -0
  71. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  72. package/src/hooks/capability-loader.test.ts +78 -0
  73. package/src/hooks/capability-loader.ts +45 -2
  74. package/src/hooks/executor.test.ts +161 -1
  75. package/src/hooks/executor.ts +87 -21
  76. package/src/hooks/hook-jail-unreachability.test.ts +17 -18
  77. package/src/hooks/hook-store.test.ts +6 -0
  78. package/src/hooks/hook-trespass.test.ts +26 -9
  79. package/src/hooks/jail.test.ts +152 -5
  80. package/src/hooks/jail.ts +93 -4
  81. package/src/manifest/contracts/v1.ts +13 -0
  82. package/src/module/packaging/audit.ts +9 -26
  83. package/src/module/packaging/build-paths.test.ts +127 -0
  84. package/src/module/packaging/build-paths.ts +175 -0
  85. package/src/module/packaging/build.test.ts +71 -1
  86. package/src/module/packaging/build.ts +60 -0
  87. package/src/module/packaging/extract.ts +1 -5
  88. package/src/policy/module-script-scan.ts +51 -107
  89. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +14 -15
  91. package/src/registry/client.test.ts +2 -2
  92. package/src/secrets/storage.test.ts +1 -1
  93. package/src/services/alerting/ack.test.ts +2 -1
  94. package/src/services/alerting/cadence-migration.test.ts +3 -2
  95. package/src/services/alerting/coverage-source.test.ts +2 -1
  96. package/src/services/alerting/deferral.test.ts +2 -1
  97. package/src/services/alerting/delivery-loop.test.ts +2 -1
  98. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  99. package/src/services/alerting/inbound-poller.test.ts +2 -1
  100. package/src/services/alerting/inbound.test.ts +2 -1
  101. package/src/services/alerting/keys.test.ts +4 -0
  102. package/src/services/alerting/keys.ts +12 -2
  103. package/src/services/alerting/notification-responder.test.ts +2 -1
  104. package/src/services/alerting/run-monitor.test.ts +2 -1
  105. package/src/services/alerting/store.test.ts +2 -1
  106. package/src/services/alerting/sweep-runner.test.ts +2 -1
  107. package/src/services/alerting/tokens.test.ts +2 -1
  108. package/src/services/aspect-approvals.test.ts +2 -1
  109. package/src/services/aspect-reconcile.test.ts +4 -3
  110. package/src/services/aspect-runner.test.ts +2 -1
  111. package/src/services/audit/health.test.ts +97 -2
  112. package/src/services/audit/health.ts +64 -2
  113. package/src/services/audit/index.test.ts +22 -0
  114. package/src/services/audit/index.ts +8 -2
  115. package/src/services/audit/jail-exemptions.test.ts +42 -0
  116. package/src/services/audit/jail-exemptions.ts +44 -0
  117. package/src/services/audit/module-integrity.test.ts +23 -22
  118. package/src/services/audit/module-integrity.ts +7 -16
  119. package/src/services/audit/types.ts +2 -1
  120. package/src/services/backup-age-agreement.test.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +3 -2
  123. package/src/services/backup-in-flight-refusal.test.ts +3 -2
  124. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
  125. package/src/services/build-bus/hook-dispatcher.ts +49 -1
  126. package/src/services/bus-ensure-flow.test.ts +3 -2
  127. package/src/services/bus-interview-park.test.ts +4 -3
  128. package/src/services/bus-interview.test.ts +2 -2
  129. package/src/services/bus-secret-flow.test.ts +3 -2
  130. package/src/services/capability-table-rows.test.ts +2 -1
  131. package/src/services/celilo-events.test.ts +1 -1
  132. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  133. package/src/services/consumer-cleanup.test.ts +3 -2
  134. package/src/services/container-service.test.ts +3 -2
  135. package/src/services/control-plane-bootstrap.test.ts +2 -1
  136. package/src/services/cross-module-read.test.ts +4 -3
  137. package/src/services/deploy-preflight.ts +7 -0
  138. package/src/services/deploy-validation.test.ts +3 -2
  139. package/src/services/deployed-systems.ts +1 -1
  140. package/src/services/dns-internal-records.test.ts +3 -2
  141. package/src/services/dns-provider-backfill.test.ts +2 -1
  142. package/src/services/dns-registrations.test.ts +2 -1
  143. package/src/services/ensure-interview.test.ts +3 -2
  144. package/src/services/fleet-checks.test.ts +126 -6
  145. package/src/services/fleet-checks.ts +176 -3
  146. package/src/services/fleet-key.test.ts +3 -2
  147. package/src/services/health-runner.test.ts +87 -2
  148. package/src/services/health-runner.ts +40 -11
  149. package/src/services/infrastructure-selector.test.ts +3 -2
  150. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  151. package/src/services/jail-exemptions.test.ts +125 -0
  152. package/src/services/jail-exemptions.ts +81 -0
  153. package/src/services/machine-pool.test.ts +3 -2
  154. package/src/services/module-config.test.ts +2 -1
  155. package/src/services/module-deploy.dns-repoint.test.ts +2 -1
  156. package/src/services/module-deploy.ts +48 -24
  157. package/src/services/module-operations.test.ts +2 -1
  158. package/src/services/module-subscriptions.test.ts +4 -3
  159. package/src/services/network-discovery.test.ts +64 -1
  160. package/src/services/network-discovery.ts +30 -5
  161. package/src/services/port-forwards.test.ts +2 -1
  162. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  163. package/src/services/proxmox-reconcile.test.ts +2 -1
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +7 -6
  166. package/src/services/restore-preflight.test.ts +3 -2
  167. package/src/services/secret-schema-loader.test.ts +2 -1
  168. package/src/services/ssh-key-manager.test.ts +4 -3
  169. package/src/services/static-content-converge.test.ts +5 -4
  170. package/src/services/static-content-converge.ts +33 -10
  171. package/src/services/system-state-stage.test.ts +4 -3
  172. package/src/services/trusted-sources.test.ts +3 -2
  173. package/src/services/update/orchestrator.test.ts +2 -0
  174. package/src/services/zone-detector.test.ts +2 -1
  175. package/src/test-utils/bus-responder.ts +6 -4
  176. package/src/test-utils/db-path.ts +25 -0
  177. package/src/test-utils/integration.ts +7 -0
  178. package/src/test-utils/module-fixtures.ts +5 -6
  179. package/src/module/packaging/generated-plane.test.ts +0 -79
  180. package/src/module/packaging/generated-plane.ts +0 -134
@@ -71,6 +71,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
71
71
 
72
72
  ## Applications
73
73
 
74
+ - **burner** — fleet-only ISO library and on-demand optical-disc writer. An unprivileged controller LXC owns a deliberately disposable content-addressed image store, SQLite job queue, HTTP Basic-protected dashboard, and the only inbound surface; small host workers poll it over private HTTPS and alone receive `cdrom` access to each configured `/dev/sr0`. Jobs accept only blank media with sufficient capacity, stage by SHA-256, burn through argv-only `xorriso`, read exactly the source image length back for SHA-256 verification, and never automatically retry after the laser may have started. Images are uploaded/deleted in the UI and are not covered by a Celilo backup hook; a future NAS can replace the controller store without changing the digest-addressed worker protocol. **requires:** `private_web`.
74
75
  - **homebridge** — HomeKit bridge for smart-home devices (VeSync, Leviton, Lutron, TP-Link, Tuya). No capabilities (leaf app).
75
76
 
76
77
  ## E2E fixtures & probes (not production apps)
@@ -203,7 +203,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
203
203
  - **Hook process boundary (a hook is a program celilo RUNS)** — `apps/celilo/src/hooks/hook-protocol.ts` (the NDJSON frame union, `HOOK_PROTOCOL_VERSION`, `serializeError`/`deserializeError`), `apps/celilo/src/hooks/broker.ts` (`startBroker`, `capabilityShape`), `apps/celilo/src/hooks/hook-runner-entry.ts` + `apps/celilo/src/hooks/hook-runner.ts` (the spawned shim — the ONLY thing that `import()`s module code; the entry exists to install the advisory lint before the shim's import graph can ESM-load `node:fs`, see `unjailed-lint.ts`). `executeHookScript` spawns `bun hook-runner-entry.ts` over a Unix socket instead of importing; the nine `invokeHook` call sites and `defineHook` are unchanged. **The broker does not know what a capability is**: it sends a shape descriptor built by the same own-string-key walk `wrapWithLogging` does (functions → `methods`, everything else → `data`, which is where `stampProvider`'s `providerModuleId` lives), and the shim rebuilds forwarding proxies from it — so an optional method a provider did not implement is absent rather than present-and-throwing, and `if (cap.registerTrustedSource)` keeps answering correctly. A socket rather than stdout because module scripts spawn subprocesses and a grandchild writing to fd 1 would corrupt the frame stream. Two consequences worth knowing: the child's environment is an **allow-list** (`hookChildEnv` — how to run: `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`; whom to trust: `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `SSL_CERT_DIR`; the proxy variables; the `CELILO_HOOK_*` channels; `CELILO_DEBUG` — `FORWARDED_ENV` in executor.ts is the source of truth), so a hook reading any other operator variable now gets `undefined`; and a timeout is a real SIGTERM-then-SIGKILL with the broker refusing further capability calls, replacing a `Promise.race` that cancelled nothing and let a "timed out" hook go on writing DNS and firewall state (celilo#1003). Capability PROVIDER factories still load in-process — they ARE the broker's implementation. Stages 1 and 2 of `openspec/changes/hook-process-boundary`; the filesystem is claimed by the jail below, and SSH reachability by the remote-ops broker next.
204
204
  - **Unjailed advisory lint (task 4.7, NOT a security boundary)** — `apps/celilo/src/hooks/unjailed-lint.ts`. Where there is no jail backend the executor passes the run's derived mount set to the shim in the environment (`CELILO_HOOK_MOUNT_SET`), and the shim wraps the path-taking `node:fs` / `node:fs/promises` functions so an access outside the set (or a write to a read-only row) warns through the hook's own logger: "on a jailed host this would fail", and that it is advisory. It is the mount-set derivation's SECOND consumer, so it cannot drift from what the jail enforces. It observes JS-level `node:fs` calls only — module code bypasses it trivially — and it must never be described as a boundary, in code or output.
205
205
  - **Remote-ops broker (reachability scoped by the credential, stage 3 / D12)** — `apps/celilo/src/hooks/remote-broker.ts` (`startRemoteBroker`, `RemoteAccessPolicy`) answering a SECOND socket beside the capability one, with the policy in `apps/celilo/src/services/remote-access.ts` (`remoteAccessPolicy`) and the asking half inside `@celilo/capabilities`' own remote primitives (`packages/capabilities/src/remote.ts`, "The hook remote-ops bridge"). The jail binds no `~/.ssh`, so a hand-built `ssh` cannot authenticate; the primitives detect `CELILO_HOOK_REMOTE_SOCKET`, send each operation as a STRUCTURED request (never a shell string — the broker rebuilds the ssh line itself), and the broker checks the target against `ownedSystemModuleIds` + `getModuleSystems` before running anything, refusing with the module, the target, and the capability route named. Requests carrying the module's OWN credential (an `identityFile` crossing as content and materialised per call, or `installAuthorizedKey`'s password — the cPanel case) are scoped by that credential instead; an explicit non-root user likewise, because the fleet key's authority is root on fleet systems. Stream primitives' LOCAL paths are confined to the run's granted roots (stateDir, screenshots, generated/, declared path inputs), or `streamBackup` would be a write-as-celilo oracle. Attribution is by the module that PERFORMS the operation: a hook's request to the hook's module (the nine `invokeHook` sites build the policy), a provider's transport to the provider's module — providers run in-process and do not cross this socket, and `public_web`'s hand-built upload was replaced by the Ansible static-content converge (capability-owned-tables stage 4, celilo#1014). Residual, recorded rather than papered over: a hook can still `fetch()` any HTTP endpoint directly; only `probeHttp` consults the target check.
206
- - **Hook jail (a hook sees the paths it was given, and nothing else)** — `apps/celilo/src/hooks/mount-set.ts` (`deriveMountSet`, `toBwrapArgs`, `forbiddenPaths` — PURE, computes a filesystem view and touches nothing) and `apps/celilo/src/hooks/jail.ts` (`detectJailBackend`, `planJailedSpawn`, `realpathRequest`, `runtimeModulePathsFor`, `recordJailMode` — the half that touches the machine). `executeHookScript` spawns the shim under `bwrap` with the module's tree read-only, `state/` + `generated/` + this run's `screenshots/<run>` read-write on top of it, each contract-declared path input at its declared access, the broker's socket directory, the runtime and the `node_modules` the shim resolves through, and `/tmp` a fresh tmpfs FIRST so it cannot erase the socket or a staged input. `~/.ssh` is deliberately NOT bound (stage 3, D12): withholding the credential is what makes the remote-ops broker's target check a boundary. **The acceptance criterion is absence, not a check**: the module store and the data directory are simply not bound, so `master.key` and a sibling module give `ENOENT`. The set is DERIVED — a module cannot ask for more — and `bwrap` itself is never in it, because the AppArmor profile grants `userns` to `/usr/bin/bwrap` for anyone on the box (design D9). Backend detection RUNS bubblewrap rather than looking for it (four different denials all leave the binary in place), and the resulting mode is written to `hook-jail-mode.json` beside celilo's other per-machine state rather than logged, so a host that stops jailing is readable. `CELILO_HOOK_JAIL` = `auto` (default) / `required` / `off`. macOS has a `sandbox-exec` backend in the union, but `auto` defers on it (ce-29z: sandbox-exec DENIES undeclared writes where bubblewrap masks them, and D14's declared-path mechanism is not built), so the hook runs unjailed with the mode recorded; `required` bypasses the deferral for an operator who opts in. An `unjailed` record carries `lastJailed` (the jailed record it replaced on the same host), which is what the `hook_jail` self-monitor reads (`services/alerting/hook-jail.ts`, created unsuppressible by `celilo monitor add hook_jail`) to alert on a host that used to jail and has stopped. `celilo system doctor`'s "Hook execution" section (`renderHookExecutionSection`) reports the live mode and, when unjailed, why.
206
+ - **Hook jail (a hook sees the paths it was given, and nothing else)** — `apps/celilo/src/hooks/mount-set.ts` (`deriveMountSet`, `toBwrapArgs`, `forbiddenPaths` — PURE, computes a filesystem view and touches nothing) and `apps/celilo/src/hooks/jail.ts` (`detectJailBackend`, `planJailedSpawn`, `realpathRequest`, `runtimeModulePathsFor`, `recordJailMode` — the half that touches the machine). `executeHookScript` spawns the shim under `bwrap` with the module's tree read-only, `state/` + `generated/` + this run's `screenshots/<run>` read-write on top of it, each contract-declared path input at its declared access, the broker's socket directory, the runtime and the `node_modules` the shim resolves through, and `/tmp` a fresh tmpfs FIRST so it cannot erase the socket or a staged input. `~/.ssh` is deliberately NOT bound (stage 3, D12): withholding the credential is what makes the remote-ops broker's target check a boundary. **The acceptance criterion is absence, not a check**: the module store and the data directory are simply not bound, so `master.key` and a sibling module give `ENOENT`. The set is DERIVED — a module cannot ask for more — and `bwrap` itself is never in it, because the AppArmor profile grants `userns` to `/usr/bin/bwrap` for anyone on the box (design D9). Backend detection RUNS bubblewrap rather than looking for it (four different denials all leave the binary in place), and the resulting mode is written to `hook-jail-mode.json` beside celilo's other per-machine state rather than logged, so a host that stops jailing is readable. The policy is `auto` / `required` / `off`, resolved in a fixed precedence: the `CELILO_HOOK_JAIL` environment variable, then the stored `hooks.jail_policy` system config key (`celilo system config set hooks.jail_policy <value>`, which asks an interview question before writing `off` unless `--force`), then the default, which is **`off`** (ce-rez7). Jailing begins because an operator set a policy, never because an upgrade installed a backend — celilo-mgr's 2026-09-07 move to 1:2.2.1 installed bubblewrap and loaded the AppArmor profile and hooks kept running unjailed, which is the ruling working. `resolveJailPolicy` returns the source alongside the policy and `system doctor` renders it, so an effective value is always locatable. macOS has a `sandbox-exec` backend in the union, but `auto` defers on it (ce-29z: sandbox-exec DENIES undeclared writes where bubblewrap masks them, and D14's declared-path mechanism is not built), so the hook runs unjailed with the mode recorded; `required` bypasses the deferral for an operator who opts in. An `unjailed` record carries `lastJailed` (the jailed record it replaced on the same host), which is what the `hook_jail` self-monitor reads (`services/alerting/hook-jail.ts`, created unsuppressible by `celilo monitor add hook_jail`) to alert on a host that used to jail and has stopped. `celilo system doctor`'s "Hook execution" section (`renderHookExecutionSection`) reports the live mode and, when unjailed, why.
207
207
  - **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
208
208
  - **Control-plane bootstrap (celilo initialises its own box)** — `apps/celilo/src/services/control-plane-bootstrap.ts` (`bootstrapControlPlane`) with `apps/celilo/src/services/dns-discovery.ts` (`discoverDns`) beside `network-discovery.ts`. Called from `module-deploy.ts` at the point `on_install` runs, for `CONTROL_PLANE_MODULE_ID` (exported from `services/deployed-systems.ts`, replacing three private copies of the string). Reads the box's upstream resolvers, mints the fleet key via `ensureFleetKey`, writes `dns.*` + `ssh.public_key` in ONE `initializeSystem` call, records the network via `discoverAndRecordNetwork`, then polls `checkDispatcher` and FAILS the deploy if no dispatcher answers. **It is not a hook, and that is the point** (celilo#1225): it was `modules/celilo-mgmt/scripts/on_install.ts`, which reached all of this by spawning the `celilo` CLI — impossible inside the jail, whose mount set binds no `/usr/bin`, no `/usr/local/bin` and no shell, so the deploy ran Ansible clean and then died in its own install hook on every host with a jail backend. celilo-mgmt is never deployed to a remote box, so the host being configured is always the host celilo runs on and the hook was a process boundary with celilo on both sides. Self-registering the management host into its own pool did NOT move: celilo places a module only on a machine already in the pool. The dispatcher read is celilo's four-part `checkDispatcher`, not the "is a process up?" probe the hook used.
209
209
  - **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
package/README.md CHANGED
@@ -1536,7 +1536,7 @@ When debugging issues, check:
1536
1536
  ### Getting Help
1537
1537
 
1538
1538
  **Documentation**:
1539
- - [CLAUDE.md](../../CLAUDE.md) - Engineering standards
1539
+ - [AGENTS.md](../../AGENTS.md) - Engineering standards
1540
1540
  - [TESTING_STRATEGY.md](../../reference/TESTING_STRATEGY.md) - Testing approach
1541
1541
  - [IDENTIFIER_NAMING_CONVENTIONS.md](../../reference/IDENTIFIER_NAMING_CONVENTIONS.md) - Naming rules
1542
1542
  - [TEMPLATE_VARIABLE_SYNTAX.md](../../reference/TEMPLATE_VARIABLE_SYNTAX.md) - Variable syntax
@@ -0,0 +1,28 @@
1
+ -- Per-module hook jail policy (openspec/changes/per-module-jail-policy, task 1.1).
2
+ --
3
+ -- The operator's row in the four-step precedence, ranked between the
4
+ -- `CELILO_HOOK_JAIL` environment variable and the system-wide
5
+ -- `hooks.jail_policy` key. One row per module; an ABSENT row means "follow
6
+ -- the system", so there is no default column to get wrong and no way to
7
+ -- distinguish "not set" from "set to the same value as the system".
8
+ --
9
+ -- `module_id` is both the primary key and the FK (same shape as
10
+ -- `module_instances`): a policy cannot exist without the module row it
11
+ -- names, and dropping the module row drops this. The row must never
12
+ -- outlive the module it governs — an exemption for a module that no longer
13
+ -- exists is a finding that names a ghost.
14
+ --
15
+ -- No CHECK constraint on `policy` on purpose. The set-time validation lives
16
+ -- in the `module jail` verb, and the read-time validation lives in
17
+ -- `resolveJailPolicy`, which throws rather than coercing (peba's ruling on
18
+ -- ce-8832, applied to the system key). A restored or hand-edited junk value
19
+ -- fails loudly on the next hook invocation or doctor run; a CHECK would
20
+ -- trade that loud failure at two sites for a silent one at insert time.
21
+ -- The drizzle type keeps the union honest inside the codebase.
22
+
23
+ CREATE TABLE `module_jail_policies` (
24
+ `module_id` text PRIMARY KEY NOT NULL,
25
+ `policy` text NOT NULL,
26
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL,
27
+ FOREIGN KEY (`module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
28
+ );
@@ -0,0 +1,37 @@
1
+ -- Durable outcome record for build-bus `on_upstream_publish` hook runs.
2
+ --
3
+ -- The hook dispatcher (services/build-bus/hook-dispatcher.ts) is the only
4
+ -- component that runs a module's self-update script, and until now its
5
+ -- outcomes lived nowhere but the daemon's console output: a failed
6
+ -- self-update left no row any surface could read, so `system doctor`
7
+ -- reported the event dispatcher healthy while the CLI on the box aged in
8
+ -- place (celilo#1304). Every `on_upstream_publish` result lands here, one
9
+ -- row per (event, module, hook) execution.
10
+ --
11
+ -- Not event-bus `deliveries`: those record the SQLite bus dispatcher's own
12
+ -- subscriber handlers. The build-bus receiver emits `build-bus.publish`
13
+ -- onto that bus for the record, but hooks run from the receiver's
14
+ -- in-process dispatcher, which no delivery row was ever written for.
15
+ --
16
+ -- `exit_code` is NULLABLE on purpose: a spawn failure (bash missing, EACCES)
17
+ -- never produces an exit code, and recording 0/1 there would conflate "the
18
+ -- script ran and said no" with "the script never ran".
19
+
20
+ CREATE TABLE `build_bus_hook_runs` (
21
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
22
+ `event_id` text NOT NULL,
23
+ `package_name` text NOT NULL,
24
+ `package_version` text NOT NULL,
25
+ `tag` text NOT NULL,
26
+ `module_id` text NOT NULL,
27
+ `hook_name` text NOT NULL,
28
+ `script_path` text NOT NULL,
29
+ `exit_code` integer,
30
+ `timed_out` integer DEFAULT 0 NOT NULL,
31
+ `duration_ms` integer NOT NULL,
32
+ `stdout_tail` text,
33
+ `stderr_tail` text,
34
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL
35
+ );
36
+ --> statement-breakpoint
37
+ CREATE INDEX `build_bus_hook_runs_created_at_idx` ON `build_bus_hook_runs` (`created_at`);
@@ -225,6 +225,20 @@
225
225
  "when": 1788768000000,
226
226
  "tag": "0031_module_config_source",
227
227
  "breakpoints": true
228
+ },
229
+ {
230
+ "idx": 32,
231
+ "version": "6",
232
+ "when": 1788982143000,
233
+ "tag": "0032_module_jail_policies",
234
+ "breakpoints": true
235
+ },
236
+ {
237
+ "idx": 33,
238
+ "version": "6",
239
+ "when": 1789082481000,
240
+ "tag": "0033_build_bus_hook_runs",
241
+ "breakpoints": true
228
242
  }
229
243
  ]
230
- }
244
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "2.2.0",
3
+ "version": "2.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": "^4.2.0",
61
+ "@celilo/capabilities": "^4.3.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.11.0",
63
+ "@celilo/core": "^0.12.0",
64
64
  "@celilo/event-bus": "^0.6.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -118,6 +118,11 @@
118
118
  "type": "string",
119
119
  "description": "SSH public key for container access"
120
120
  },
121
+ "hooks.jail_policy": {
122
+ "type": "string",
123
+ "pattern": "^(auto|off|required)$",
124
+ "description": "Hook jail policy: 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off' (hooks run unjailed). Precedence: the CELILO_HOOK_JAIL environment variable wins over this stored value. Run 'celilo system doctor' to see the effective policy and which source it came from."
125
+ },
121
126
  "public_dns.resolver": {
122
127
  "type": "string",
123
128
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
@@ -25,7 +25,7 @@ describe('Container Services and Machine Pool CLI Integration', () => {
25
25
  });
26
26
 
27
27
  afterEach(async () => {
28
- process.env.CELILO_MASTER_KEY_PATH = undefined;
28
+ delete process.env.CELILO_MASTER_KEY_PATH;
29
29
  await ctx.cleanup();
30
30
  });
31
31
 
@@ -4,6 +4,7 @@ import { rm } from 'node:fs/promises';
4
4
  import { type DbClient, createDbClient } from '../db/client';
5
5
  import { systemConfig } from '../db/schema';
6
6
  import { upsertModuleConfig } from '../services/module-config';
7
+ import { resetTestDbPath } from '../test-utils/db-path';
7
8
  import {
8
9
  buildHostVars,
9
10
  buildSystemVars,
@@ -286,7 +287,7 @@ describe('Database integration', () => {
286
287
  afterEach(async () => {
287
288
  db.$client.close();
288
289
  // Clean up environment variable
289
- process.env.CELILO_DB_PATH = undefined;
290
+ resetTestDbPath();
290
291
  if (existsSync(TEST_DB_PATH)) {
291
292
  await rm(TEST_DB_PATH);
292
293
  }
@@ -32,6 +32,7 @@ import { handleModuleUpdate } from '../cli/commands/module-update';
32
32
  import { getDb } from '../db/client';
33
33
  import { modules } from '../db/schema';
34
34
  import { RESPONDER_PROBE_EVENT } from '../services/responder-probe';
35
+ import { resetTestDbPath } from '../test-utils/db-path';
35
36
  import { SessionWriter, listSessions, reapExpiredSessions } from './sessions';
36
37
 
37
38
  const NO_SCHEMAS = defineEvents({});
@@ -99,10 +100,10 @@ beforeEach(() => {
99
100
  afterEach(() => {
100
101
  server.stop(true);
101
102
  rmSync(dir, { recursive: true, force: true });
102
- process.env.CELILO_DB_PATH = undefined;
103
- process.env.CELILO_DATA_DIR = undefined;
104
- process.env.CELILO_ORIGINAL_CWD = undefined;
105
- process.env.EVENT_BUS_DB = undefined;
103
+ resetTestDbPath();
104
+ delete process.env.CELILO_DATA_DIR;
105
+ delete process.env.CELILO_ORIGINAL_CWD;
106
+ delete process.env.EVENT_BUS_DB;
106
107
  });
107
108
 
108
109
  test('an expired session is reaped and the command reports abandoned, not declined', async () => {
@@ -38,7 +38,8 @@ async function makeProxmoxRequest<T>(
38
38
  credentials: ProxmoxCredentials,
39
39
  path: string,
40
40
  ): Promise<ProxmoxResult<T>> {
41
- return new Promise((resolve) => {
41
+ let timeout: ReturnType<typeof setTimeout> | undefined;
42
+ return new Promise<ProxmoxResult<T>>((resolve) => {
42
43
  try {
43
44
  const { api_url, api_token_id, api_token_secret } = credentials;
44
45
  const authHeader = `PVEAPIToken=${api_token_id}=${api_token_secret}`;
@@ -114,17 +115,17 @@ async function makeProxmoxRequest<T>(
114
115
  });
115
116
  });
116
117
 
117
- // Fail fast instead of hanging on an unreachable host (no implicit timeout
118
- // on https.request). Callers treat a failed result as "couldn't reach
119
- // Proxmox" and fall back accordingly.
120
- req.setTimeout(15_000, () => {
121
- req.destroy();
118
+ // Bound the entire request, including connection establishment. On Bun
119
+ // 1.4, req.setTimeout does not bound a connection to an unreachable host.
120
+ // Callers need a failed result so they can fall back instead of hanging.
121
+ timeout = setTimeout(() => {
122
122
  resolve({
123
123
  success: false,
124
124
  message: 'Request timed out',
125
125
  details: { timeoutMs: 15_000 },
126
126
  });
127
- });
127
+ req.destroy();
128
+ }, 15_000);
128
129
 
129
130
  req.end();
130
131
  } catch (error) {
@@ -134,6 +135,8 @@ async function makeProxmoxRequest<T>(
134
135
  details: { error: String(error) },
135
136
  });
136
137
  }
138
+ }).finally(() => {
139
+ if (timeout !== undefined) clearTimeout(timeout);
137
140
  });
138
141
  }
139
142
 
@@ -16,6 +16,7 @@ import { tmpdir } from 'node:os';
16
16
  import { join } from 'node:path';
17
17
  import { closeDb } from '../db/client';
18
18
  import { runMigrations } from '../db/migrate';
19
+ import { resetTestDbPath } from '../test-utils/db-path';
19
20
  import { runCli } from './index';
20
21
 
21
22
  describe('celilo module backup (renamed surface)', () => {
@@ -27,7 +28,7 @@ describe('celilo module backup (renamed surface)', () => {
27
28
  dir = mkdtempSync(join(tmpdir(), 'celilo-rename-test-'));
28
29
  process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
29
30
  process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key');
30
- process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
31
+ delete process.env.CELILO_SUPPRESS_DEPRECATION;
31
32
  await runMigrations(process.env.CELILO_DB_PATH);
32
33
 
33
34
  warnings = [];
@@ -48,9 +49,9 @@ describe('celilo module backup (renamed surface)', () => {
48
49
  warnSpy = null;
49
50
  }
50
51
  closeDb();
51
- process.env.CELILO_DB_PATH = undefined;
52
- process.env.CELILO_MASTER_KEY_PATH = undefined;
53
- process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
52
+ resetTestDbPath();
53
+ delete process.env.CELILO_MASTER_KEY_PATH;
54
+ delete process.env.CELILO_SUPPRESS_DEPRECATION;
54
55
  try {
55
56
  rmSync(dir, { recursive: true, force: true });
56
57
  } catch {
@@ -8,6 +8,7 @@ import { moduleConfigs, modules, secrets } from '../db/schema';
8
8
  import { encryptSecret } from '../secrets/encryption';
9
9
  import { generateMasterKey, writeMasterKey } from '../secrets/master-key';
10
10
  import { upsertModuleConfig } from '../services/module-config';
11
+ import { resetTestDbPath } from '../test-utils/db-path';
11
12
  import { runCli } from './index';
12
13
 
13
14
  const TEST_DB_PATH = `./test-cli-${Date.now()}-${Math.random()}.db`;
@@ -146,8 +147,8 @@ description: Test module for CLI
146
147
  }
147
148
 
148
149
  // Clear environment variables
149
- process.env.CELILO_MASTER_KEY_PATH = undefined;
150
- process.env.CELILO_DB_PATH = undefined;
150
+ delete process.env.CELILO_MASTER_KEY_PATH;
151
+ resetTestDbPath();
151
152
  });
152
153
 
153
154
  describe('help', () => {
@@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15
15
  import { mkdtempSync, rmSync } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { join } from 'node:path';
18
+ import { resetTestDbPath } from '../../test-utils/db-path';
18
19
 
19
20
  let testDir: string;
20
21
 
@@ -26,7 +27,7 @@ beforeEach(() => {
26
27
 
27
28
  afterEach(() => {
28
29
  rmSync(testDir, { recursive: true, force: true });
29
- process.env.CELILO_DB_PATH = undefined;
30
+ resetTestDbPath();
30
31
  });
31
32
 
32
33
  /** The live fleet's firewall shape: caddy on iptables, iptables on the router. */
@@ -28,7 +28,7 @@ describe('celilo events command handlers', () => {
28
28
  process.env.EVENT_BUS_DB = dbPath;
29
29
  });
30
30
  afterEach(() => {
31
- process.env.EVENT_BUS_DB = undefined;
31
+ delete process.env.EVENT_BUS_DB;
32
32
  try {
33
33
  rmSync(dir, { recursive: true, force: true });
34
34
  } catch {
@@ -284,7 +284,7 @@ describe('celilo events list-failed', () => {
284
284
  bus.close();
285
285
  });
286
286
  afterEach(() => {
287
- process.env.EVENT_BUS_DB = undefined;
287
+ delete process.env.EVENT_BUS_DB;
288
288
  try {
289
289
  rmSync(dir, { recursive: true, force: true });
290
290
  } catch {
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
12
12
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
13
13
  import { tmpdir } from 'node:os';
14
14
  import { join } from 'node:path';
15
+ import { resetTestDbPath } from '../../test-utils/db-path';
15
16
 
16
17
  // The command reaches boxes ONLY through these two modules, and both are
17
18
  // mocked below so the tests can drive the live-read and fallback paths without
@@ -67,9 +68,9 @@ beforeEach(() => {
67
68
  afterEach(() => {
68
69
  console.log = originalLog;
69
70
  rmSync(testDir, { recursive: true, force: true });
70
- process.env.CELILO_DB_PATH = undefined;
71
- process.env.CELILO_DATA_DIR = undefined;
72
- process.env.CELILO_MASTER_KEY_PATH = undefined;
71
+ resetTestDbPath();
72
+ delete process.env.CELILO_DATA_DIR;
73
+ delete process.env.CELILO_MASTER_KEY_PATH;
73
74
  });
74
75
 
75
76
  describe('celilo firewall interface list', () => {
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { Machine } from '../../types/infrastructure';
3
+ import { machineListJson } from './machine-list';
4
+
5
+ const AT = Date.UTC(2026, 8, 8, 22, 59, 59);
6
+
7
+ function machine(partial: Partial<Machine> = {}): Machine {
8
+ return {
9
+ id: '3f6c2a44-1f0e-4a5b-9c8d-7e6f5a4b3c2d',
10
+ hostname: 'forgejo',
11
+ zone: 'dmz',
12
+ ipAddress: '10.0.20.5',
13
+ sshUser: 'peba',
14
+ // A real row always carries one; the JSON must not.
15
+ sshKeyEncrypted: '{"v":1,"sealed":"secret"}',
16
+ hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 128, arch: 'arm64' },
17
+ role: 'host',
18
+ interfaces: [{ name: 'eth0', ipAddress: '10.0.20.5', zone: 'dmz' }],
19
+ earmarkedModule: null,
20
+ apiOnly: false,
21
+ createdAt: new Date(AT),
22
+ updatedAt: new Date(AT),
23
+ ...partial,
24
+ };
25
+ }
26
+
27
+ function parse(machines: Machine[]): unknown[] {
28
+ const result = machineListJson(machines);
29
+ if (!result.success) throw new Error(result.error);
30
+ return JSON.parse(result.message ?? 'null') as unknown[];
31
+ }
32
+
33
+ describe('machineListJson', () => {
34
+ test('output parses as a machine-readable JSON array', () => {
35
+ const rows = parse([machine()]);
36
+ expect(Array.isArray(rows)).toBe(true);
37
+ });
38
+
39
+ test('the encrypted SSH key envelope is not in the payload', () => {
40
+ // Machine-readable output gets piped into scripts and stored in
41
+ // artifacts. A secret envelope in every listing is worse than a
42
+ // missing field in a terminal table.
43
+ const [row] = parse([machine()]) as Array<Record<string, unknown>>;
44
+ expect(row).not.toHaveProperty('sshKeyEncrypted');
45
+ expect(row.sshUser).toBe('peba');
46
+ });
47
+
48
+ test('timestamps survive as exact instants, not strings', () => {
49
+ const [row] = parse([machine()]) as Array<Record<string, unknown>>;
50
+ expect(row.createdAt).toBe(AT);
51
+ expect(row.updatedAt).toBe(AT);
52
+ });
53
+
54
+ test('an empty pool is a valid answer, not an error', () => {
55
+ expect(parse([])).toEqual([]);
56
+ });
57
+ });
@@ -9,28 +9,59 @@ import {
9
9
  getModulesOnMachine,
10
10
  listMachines,
11
11
  } from '../../services/machine-pool';
12
+ import type { Machine } from '../../types/infrastructure';
12
13
  import { celiloIntro } from '../prompts';
13
14
  import type { CommandResult } from '../types';
14
15
 
16
+ /**
17
+ * Machine-readable rows for `machine list --json`. One row per machine,
18
+ * with the same fields the human table renders. The encrypted SSH key
19
+ * envelope is deliberately absent: machine-readable output travels into
20
+ * scripts, logs and artifacts, where a secret is one more copy to leak.
21
+ */
22
+ export function machineListJson(machines: Machine[]): CommandResult {
23
+ const payload = machines.map((machine) => ({
24
+ id: machine.id,
25
+ hostname: machine.hostname,
26
+ zone: machine.zone,
27
+ ipAddress: machine.ipAddress,
28
+ sshUser: machine.sshUser,
29
+ role: machine.role,
30
+ hardware: machine.hardware,
31
+ interfaces: machine.interfaces,
32
+ earmarkedModule: machine.earmarkedModule,
33
+ apiOnly: machine.apiOnly,
34
+ createdAt: machine.createdAt.getTime(),
35
+ updatedAt: machine.updatedAt.getTime(),
36
+ }));
37
+
38
+ // `rawOutput` keeps the payload out of the decorating renderer, which
39
+ // would wrap it and stop it parsing (celilo#698).
40
+ return { success: true, message: JSON.stringify(payload, null, 2), rawOutput: true };
41
+ }
42
+
15
43
  /**
16
44
  * Handle machine list command
17
45
  *
18
46
  * @param args - Command arguments (unused)
19
- * @param flags - Command flags (--zone filter)
47
+ * @param flags - Command flags (--zone filter, --json for machine-readable output)
20
48
  */
21
49
  export async function handleMachineList(
22
50
  _args: string[],
23
51
  flags: Record<string, boolean | string> = {},
24
52
  ): Promise<CommandResult> {
25
53
  try {
26
- celiloIntro('Machine Pool');
27
-
28
- // Apply zone filter if provided
29
54
  const filters: MachineFilters = {};
30
55
  if (flags.zone && typeof flags.zone === 'string') {
31
56
  filters.zone = flags.zone as NetworkZone;
32
57
  }
33
58
 
59
+ if (flags.json) {
60
+ return machineListJson(await listMachines(filters));
61
+ }
62
+
63
+ celiloIntro('Machine Pool');
64
+
34
65
  const machines = await listMachines(filters);
35
66
 
36
67
  if (machines.length === 0) {
@@ -12,6 +12,7 @@ import { join } from 'node:path';
12
12
  import { type DbClient, getDb } from '../../db/client';
13
13
  import { moduleConfigs, modules } from '../../db/schema';
14
14
  import { resolveDeployPosture } from '../../services/deploy-posture';
15
+ import { resetTestDbPath } from '../../test-utils/db-path';
15
16
  import {
16
17
  FRAMEWORK_CONFIG_KEYS,
17
18
  handleModuleConfigGet,
@@ -51,7 +52,7 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
51
52
 
52
53
  afterEach(() => {
53
54
  rmSync(tempDir, { recursive: true, force: true });
54
- process.env.CELILO_DB_PATH = undefined;
55
+ resetTestDbPath();
55
56
  });
56
57
 
57
58
  test('rejects an infrastructure-managed key (no silent accept-then-override)', async () => {
@@ -14,7 +14,7 @@ import type { CommandResult } from '../types';
14
14
  * Handle module deploy command
15
15
  *
16
16
  * Usage:
17
- * celilo module deploy <module-id> [--debug] [--preflight] [--verbose]
17
+ * celilo module deploy <module-id> [--debug] [--preflight] [--verbose] [--keep]
18
18
  *
19
19
  * Note: there is no `--no-interactive` flag. The deploy interview
20
20
  * runs through the bus event system; automation answers via a
@@ -59,8 +59,14 @@ export async function handleModuleDeploy(
59
59
  const debug = hasFlag(flags, 'debug');
60
60
  const verbose = hasFlag(flags, 'verbose');
61
61
  const stopAfterInterview = hasFlag(flags, 'stop-after-interview');
62
+ const keepGeneratedProject = hasFlag(flags, 'keep');
62
63
 
63
- const result = await deployModule(moduleId, db, { debug, verbose, stopAfterInterview });
64
+ const result = await deployModule(moduleId, db, {
65
+ debug,
66
+ verbose,
67
+ stopAfterInterview,
68
+ keepGeneratedProject,
69
+ });
64
70
 
65
71
  if (!result.success) {
66
72
  return {
@@ -0,0 +1,53 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { ModuleManifest } from '../../manifest/schema';
6
+ import { missingBuildOutputs } from './module-generate';
7
+
8
+ const DECLARED = join('ansible', 'roles', 'fixture', 'files', 'binary');
9
+
10
+ function manifestWithArtifacts(artifacts: string[]): ModuleManifest {
11
+ return {
12
+ celilo_contract: '1.0',
13
+ id: 'fixture',
14
+ name: 'Fixture',
15
+ version: '1.0.0',
16
+ description: 'fixture',
17
+ build: { command: 'true', artifacts },
18
+ } as ModuleManifest;
19
+ }
20
+
21
+ describe('missingBuildOutputs', () => {
22
+ let payload: string;
23
+
24
+ beforeEach(() => {
25
+ payload = mkdtempSync(join(tmpdir(), 'celilo-missing-outputs-'));
26
+ });
27
+
28
+ afterEach(() => {
29
+ rmSync(payload, { recursive: true, force: true });
30
+ });
31
+
32
+ test('names the declared output the payload is missing', () => {
33
+ expect(missingBuildOutputs(payload, manifestWithArtifacts([DECLARED]))).toEqual([DECLARED]);
34
+ });
35
+
36
+ test('reports nothing when the payload carries its declared outputs', () => {
37
+ mkdirSync(join(payload, 'ansible', 'roles', 'fixture', 'files'), { recursive: true });
38
+ writeFileSync(join(payload, DECLARED), 'built-bytes');
39
+ expect(missingBuildOutputs(payload, manifestWithArtifacts([DECLARED]))).toEqual([]);
40
+ });
41
+
42
+ test('reports nothing for a module with no build block', () => {
43
+ expect(
44
+ missingBuildOutputs(payload, {
45
+ celilo_contract: '1.0',
46
+ id: 'fixture',
47
+ name: 'Fixture',
48
+ version: '1.0.0',
49
+ description: 'fixture',
50
+ } as ModuleManifest),
51
+ ).toEqual([]);
52
+ });
53
+ });