@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
package/src/cli/index.ts CHANGED
@@ -80,6 +80,7 @@ import { handleModuleDeploy } from './commands/module-deploy';
80
80
  import { handleModuleGenerate } from './commands/module-generate';
81
81
  import { handleModuleHealth } from './commands/module-health';
82
82
  import { handleModuleImport } from './commands/module-import';
83
+ import { handleModuleJail } from './commands/module-jail';
83
84
  import { handleModuleJournal } from './commands/module-journal';
84
85
  import { handleModuleList } from './commands/module-list';
85
86
  import { handleModuleLogs } from './commands/module-logs';
@@ -315,6 +316,7 @@ Subcommands:
315
316
  resync-subscriptions Rebuild subscribers from deployed modules' manifests (after a restore/migration)
316
317
  list-pending [--subscriber] List pending deliveries
317
318
  list-failed [--subscriber] List failed/abandoned deliveries with a true total
319
+ list-unanswered List interview questions nobody has answered yet
318
320
  drain [--concurrency N] Process pending deliveries once and return
319
321
  run [--poll-ms N] Run the long-running dispatcher (foreground)
320
322
  emit <type> [<payload>] Emit an event (operator/test path)
@@ -588,6 +590,32 @@ Subcommands:
588
590
  --auto-generate-secrets Auto-generate all secrets without prompting
589
591
 
590
592
  list List all installed modules
593
+ Options:
594
+ --json Output the module roster as stable JSON
595
+
596
+ status <id> Show detailed module status
597
+
598
+ where <id> Show which host(s) serve a module (host discovery)
599
+ Options:
600
+ --json Output host discovery as stable JSON
601
+
602
+ logs <id> Show deploy logs for a module
603
+ Options:
604
+ --tail <n> Show last N lines
605
+ --last Show only the most recent deploy run
606
+
607
+ journal <id> Tail a deployed module's runtime daemon journal on its host (no SSH)
608
+ Options:
609
+ --unit <glob> systemd unit or glob (default: <module-id>*)
610
+ --lines <n> Lines to return (default 100)
611
+ --since <window> Time window, e.g. '30 min ago'
612
+ --grep <text> Only lines containing this fixed string
613
+ --json Machine-readable JSON output
614
+
615
+ operations list|clear Show or release in-flight module operations (the backup/restore lock)
616
+ Options:
617
+ --all With clear: also release operations that still look alive
618
+ --abandoned With list: also show abandoned rows (hidden by default)
591
619
 
592
620
  remove <id> Remove a module and all its data
593
621
 
@@ -605,8 +633,12 @@ Subcommands:
605
633
  --dry-run Print the ordered plan and change nothing
606
634
  --yes Skip the cascade confirmation
607
635
 
608
- verify <id> Verify module integrity (signature + checksums)
609
- (legacy alias: 'audit')
636
+ verify <id> Verify module integrity (signature + checksums) (legacy alias: 'audit')
637
+ Options:
638
+ --deep Deep verification (re-check all files)
639
+ --json Machine-readable JSON output
640
+
641
+ validate <id> Pre-flight check: validate module is ready for deployment
610
642
 
611
643
  config set <id> <key> <value> Set module configuration value
612
644
  config get <id> [key] Get module configuration value(s)
@@ -616,6 +648,11 @@ Subcommands:
616
648
  secret get <id> <key> Get a decrypted module secret value
617
649
  secret list <id> List module secrets
618
650
 
651
+ jail <id> [policy] Get or set one module's hook jail policy (auto|off|required)
652
+ Options:
653
+ --clear Remove the module's policy; follow the system's hooks.jail_policy again
654
+ --force Skip the confirmation when the new policy is weaker than the system's
655
+
619
656
  show-config <id> Show all config including auto-derived values
620
657
  show-zone <id> Show module's network zone and config
621
658
 
@@ -631,6 +668,7 @@ Subcommands:
631
668
  --preflight Run pre-flight validation only (no deployment)
632
669
  --verbose Keep all sub-events visible (no collapse-on-success)
633
670
  --stop-after-interview Exit after config+secrets interview (no infra/hooks)
671
+ --keep Retain the generated project after a successful deploy (it is deleted by design)
634
672
 
635
673
  health [module-id] Run health checks (transitions to VERIFIED on success)
636
674
  Options:
@@ -646,6 +684,11 @@ Subcommands:
646
684
  Options:
647
685
  --registry <url> Use a custom registry (sweep mode only)
648
686
 
687
+ upgrade <id> Upgrade a module to its latest registry version (update, posture-gated backup, deploy, verify)
688
+ Options:
689
+ --poll Run the CD poll: upgrade every auto_upgrade module with a newer version
690
+ --registry <url> Registry URL (defaults to configured registry)
691
+
649
692
  Registry:
650
693
  search [query] Search the registry for modules
651
694
  Options:
@@ -661,6 +704,19 @@ Registry:
661
704
  --allow-dirty Permit publishing from a dirty git tree
662
705
  --allow-stale Skip the manifest-vs-src stale-check. Use sparingly.
663
706
 
707
+ changeset <module-dir> Author a version changeset for a module (.changeset/<name>.md)
708
+ Options:
709
+ --bump <type> Version bump type: major, minor or patch (required)
710
+ --message <text> Changeset body text
711
+ --name <stem> Changeset filename stem (default: auto-generated slug)
712
+
713
+ backup <module-id> Create a backup of a module's data (invokes its on_backup hook)
714
+ Options:
715
+ --force Ignore schedule, back up now
716
+ --now Ignore schedule, back up now (alias for --force)
717
+ --storage <id> Storage destination ID
718
+ --no-interactive Non-interactive mode (for cron)
719
+
664
720
  check [path] Check a module for drift against the framework (default: .)
665
721
  Options:
666
722
  --no-build Skip the TypeScript build check
@@ -835,6 +891,12 @@ Subcommands:
835
891
  --storage <id> Use specific storage destination
836
892
  --yes Skip confirmation prompt
837
893
 
894
+ pull Download another box's backup artifact from a storage destination
895
+ Options:
896
+ --storage <name> Storage destination name
897
+ --module <id> Module ID whose backup to pull (default: system state)
898
+ --output <path> Local path to write the artifact
899
+
838
900
  Description:
839
901
  Backups are encrypted with the master key and uploaded to a configured
840
902
  storage destination. System state backups capture the Celilo database.
@@ -1055,6 +1117,7 @@ Usage:
1055
1117
  Resources:
1056
1118
  vmid Manage VMID reservations and allocations
1057
1119
  ip Manage IP address reservations
1120
+ show Show comprehensive IPAM summary
1058
1121
  list-allocations List all IPAM allocations (VMIDs and IPs)
1059
1122
 
1060
1123
  VMID Commands:
@@ -1139,6 +1202,8 @@ Subcommands:
1139
1202
  update [--module <id>] [--no-backup] [--allow-destructive] [--dry-run] [--json]
1140
1203
  Bring the system to the audit-determined READY state
1141
1204
 
1205
+ migrate Apply pending database migrations (idempotent; safe to re-run)
1206
+
1142
1207
  doctor [--fix] Diagnose system prerequisites and @celilo/* version drift
1143
1208
 
1144
1209
  Runbook:
@@ -1608,6 +1673,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1608
1673
  return handleModuleVersion(parsed.args, parsed.flags);
1609
1674
  case 'changeset':
1610
1675
  return handleModuleChangeset(parsed.args, parsed.flags);
1676
+ case 'jail':
1677
+ return handleModuleJail(parsed.args, parsed.flags);
1611
1678
  case 'audit':
1612
1679
  return moduleAudit(parsed.args, parsed.flags);
1613
1680
  case 'verify':
@@ -2238,7 +2305,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2238
2305
  }
2239
2306
  const configArgs = parsed.args.slice(1);
2240
2307
  if (configSubcommand === 'set') {
2241
- return handleSystemConfigSet(configArgs);
2308
+ return await handleSystemConfigSet(configArgs, parsed.flags);
2242
2309
  }
2243
2310
  if (configSubcommand === 'get') {
2244
2311
  return handleSystemConfigGet(configArgs);
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { getArg, getFlag, hasFlag, parseArguments, validateRequiredArgs } from './parser';
2
+ import { COMMANDS } from '@celilo/core';
3
+ import {
4
+ getArg,
5
+ getFlag,
6
+ hasFlag,
7
+ parseArguments,
8
+ validateFlags,
9
+ validateRequiredArgs,
10
+ } from './parser';
3
11
 
4
12
  describe('CLI Parser', () => {
5
13
  describe('parseArguments', () => {
@@ -238,3 +246,31 @@ describe('CLI Parser', () => {
238
246
  });
239
247
  });
240
248
  });
249
+
250
+ describe('a flag a handler reads is a flag the registry declares', () => {
251
+ // `handleSystemConfigSet` has read `flags.force` since the hooks.jail_policy
252
+ // interview gate landed, and its unit test drove the handler directly with
253
+ // `{ force: true }`. The registry declared no flags for `system config set`,
254
+ // so the parser answered `--force` with "does not accept any flags" and the
255
+ // escape hatch was tested and unreachable from a terminal. The suite could
256
+ // not see it, because nothing in it went through the parser.
257
+ const leaf = (path: readonly string[]) =>
258
+ path.slice(1).reduce<{ name: string; subcommands?: unknown[] } | undefined>(
259
+ (def, name) =>
260
+ (def as { subcommands?: { name: string }[] } | undefined)?.subcommands?.find(
261
+ (s) => s.name === name,
262
+ ),
263
+ COMMANDS.find((c) => c.name === path[0]),
264
+ );
265
+
266
+ test('system config set accepts --force through the real registry', () => {
267
+ const def = leaf(['system', 'config', 'set']);
268
+ expect(def).toBeDefined();
269
+ expect(validateFlags({ force: true }, def as never)).toBeNull();
270
+ });
271
+
272
+ test('and still refuses a flag nobody declared', () => {
273
+ const def = leaf(['system', 'config', 'set']);
274
+ expect(validateFlags({ frce: true }, def as never)).toContain('Unknown flag');
275
+ });
276
+ });
@@ -16,6 +16,7 @@ import { join } from 'node:path';
16
16
  import { closeDb, getDb } from '../db/client';
17
17
  import { runMigrations } from '../db/migrate';
18
18
  import { modules } from '../db/schema';
19
+ import { resetTestDbPath } from '../test-utils/db-path';
19
20
  import { runCli } from './index';
20
21
 
21
22
  describe('celilo restore', () => {
@@ -32,10 +33,10 @@ describe('celilo restore', () => {
32
33
 
33
34
  afterEach(() => {
34
35
  closeDb();
35
- process.env.CELILO_DB_PATH = undefined;
36
- process.env.CELILO_DATA_DIR = undefined;
37
- process.env.CELILO_MASTER_KEY_PATH = undefined;
38
- process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
36
+ resetTestDbPath();
37
+ delete process.env.CELILO_DATA_DIR;
38
+ delete process.env.CELILO_MASTER_KEY_PATH;
39
+ delete process.env.CELILO_SUPPRESS_DEPRECATION;
39
40
  try {
40
41
  rmSync(dir, { recursive: true, force: true });
41
42
  } catch {
@@ -28,6 +28,7 @@ import { getOrCreateMasterKey } from '../secrets/master-key';
28
28
  import { encryptFileToFile } from '../services/backup-cipher';
29
29
  import { buildManifest } from '../services/backup-manifest';
30
30
  import { migrateRestoredDb } from '../services/restore-from-file';
31
+ import { resetTestDbPath } from '../test-utils/db-path';
31
32
  import { runCli } from './index';
32
33
 
33
34
  const FIXTURES_DIR = join(import.meta.dir, '../hooks/test-fixtures');
@@ -97,9 +98,9 @@ describe('restore migration failure reporting (celilo#1269)', () => {
97
98
 
98
99
  afterEach(() => {
99
100
  closeDb();
100
- process.env.CELILO_DB_PATH = undefined;
101
- process.env.CELILO_DATA_DIR = undefined;
102
- process.env.CELILO_MASTER_KEY_PATH = undefined;
101
+ resetTestDbPath();
102
+ delete process.env.CELILO_DATA_DIR;
103
+ delete process.env.CELILO_MASTER_KEY_PATH;
103
104
  try {
104
105
  rmSync(dir, { recursive: true, force: true });
105
106
  } catch {
@@ -92,11 +92,13 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
92
92
  'interface_classification',
93
93
  'module_integrity',
94
94
  'detect_without_converge',
95
+ 'jail_exemptions',
95
96
  ];
96
97
 
97
98
  export const CATEGORY_LABELS: Record<DriftCategory, string> = {
98
99
  module_integrity: 'Module integrity',
99
100
  detect_without_converge: 'Drift without converge',
101
+ jail_exemptions: 'Hook jail exemptions',
100
102
  interface_classification: 'Firewall interfaces',
101
103
  cli_version: 'CLI version',
102
104
  schema: 'Schema migrations',
@@ -21,14 +21,14 @@ describe('paths configuration', () => {
21
21
  describe('getModuleStoragePath', () => {
22
22
  it('returns custom path when CELILO_DATA_DIR is set', () => {
23
23
  process.env.CELILO_DATA_DIR = '/custom/data';
24
- process.env.ENVIRONMENT = undefined;
24
+ delete process.env.ENVIRONMENT;
25
25
 
26
26
  const path = getModuleStoragePath();
27
27
  expect(path).toBe('/custom/data/modules');
28
28
  });
29
29
 
30
30
  it('returns dev path when ENVIRONMENT=dev', () => {
31
- process.env.CELILO_DATA_DIR = undefined;
31
+ delete process.env.CELILO_DATA_DIR;
32
32
  process.env.ENVIRONMENT = 'dev';
33
33
 
34
34
  const path = getModuleStoragePath();
@@ -38,8 +38,8 @@ describe('paths configuration', () => {
38
38
  it.skipIf(skipIntegration({ platform: 'darwin' }))(
39
39
  'returns platform-specific path by default',
40
40
  () => {
41
- process.env.CELILO_DATA_DIR = undefined;
42
- process.env.ENVIRONMENT = undefined;
41
+ delete process.env.CELILO_DATA_DIR;
42
+ delete process.env.ENVIRONMENT;
43
43
 
44
44
  const path = getModuleStoragePath();
45
45
 
@@ -63,14 +63,14 @@ describe('paths configuration', () => {
63
63
  describe('getDataDir', () => {
64
64
  it('returns custom path when CELILO_DATA_DIR is set', () => {
65
65
  process.env.CELILO_DATA_DIR = '/custom/data';
66
- process.env.ENVIRONMENT = undefined;
66
+ delete process.env.ENVIRONMENT;
67
67
 
68
68
  const path = getDataDir();
69
69
  expect(path).toBe('/custom/data');
70
70
  });
71
71
 
72
72
  it('returns dev path when ENVIRONMENT=dev', () => {
73
- process.env.CELILO_DATA_DIR = undefined;
73
+ delete process.env.CELILO_DATA_DIR;
74
74
  process.env.ENVIRONMENT = 'dev';
75
75
 
76
76
  const path = getDataDir();
@@ -80,8 +80,8 @@ describe('paths configuration', () => {
80
80
  it.skipIf(skipIntegration({ platform: 'darwin' }))(
81
81
  'returns platform-specific path by default',
82
82
  () => {
83
- process.env.CELILO_DATA_DIR = undefined;
84
- process.env.ENVIRONMENT = undefined;
83
+ delete process.env.CELILO_DATA_DIR;
84
+ delete process.env.ENVIRONMENT;
85
85
 
86
86
  const path = getDataDir();
87
87
 
@@ -103,7 +103,7 @@ describe('paths configuration', () => {
103
103
  });
104
104
 
105
105
  it('returns data dir + master.key when CELILO_MASTER_KEY_PATH not set', () => {
106
- process.env.CELILO_MASTER_KEY_PATH = undefined;
106
+ delete process.env.CELILO_MASTER_KEY_PATH;
107
107
  process.env.CELILO_DATA_DIR = '/custom/data';
108
108
 
109
109
  const path = getMasterKeyPath();
@@ -113,9 +113,9 @@ describe('paths configuration', () => {
113
113
  it.skipIf(skipIntegration({ platform: 'darwin' }))(
114
114
  'uses platform-specific data dir by default',
115
115
  () => {
116
- process.env.CELILO_MASTER_KEY_PATH = undefined;
117
- process.env.CELILO_DATA_DIR = undefined;
118
- process.env.ENVIRONMENT = undefined;
116
+ delete process.env.CELILO_MASTER_KEY_PATH;
117
+ delete process.env.CELILO_DATA_DIR;
118
+ delete process.env.ENVIRONMENT;
119
119
 
120
120
  const path = getMasterKeyPath();
121
121
 
@@ -131,8 +131,8 @@ describe('paths configuration', () => {
131
131
  describe('getDbPath', () => {
132
132
  it('returns custom path when CELILO_DB_PATH is set', () => {
133
133
  process.env.CELILO_DB_PATH = '/custom/path/celilo.db';
134
- process.env.CELILO_DATA_DIR = undefined;
135
- process.env.ENVIRONMENT = undefined;
134
+ delete process.env.CELILO_DATA_DIR;
135
+ delete process.env.ENVIRONMENT;
136
136
 
137
137
  const path = getDbPath();
138
138
  expect(path).toBe('/custom/path/celilo.db');
@@ -141,9 +141,9 @@ describe('paths configuration', () => {
141
141
  it.skipIf(skipIntegration({ platform: 'darwin' }))(
142
142
  'returns data dir + celilo.db on macOS in production',
143
143
  () => {
144
- process.env.CELILO_DB_PATH = undefined;
145
- process.env.CELILO_DATA_DIR = undefined;
146
- process.env.ENVIRONMENT = undefined;
144
+ delete process.env.CELILO_DB_PATH;
145
+ delete process.env.CELILO_DATA_DIR;
146
+ delete process.env.ENVIRONMENT;
147
147
 
148
148
  const path = getDbPath();
149
149
 
@@ -156,8 +156,8 @@ describe('paths configuration', () => {
156
156
  );
157
157
 
158
158
  it('returns celilo-data/celilo.db in development mode', () => {
159
- process.env.CELILO_DB_PATH = undefined;
160
- process.env.CELILO_DATA_DIR = undefined;
159
+ delete process.env.CELILO_DB_PATH;
160
+ delete process.env.CELILO_DATA_DIR;
161
161
  process.env.ENVIRONMENT = 'dev';
162
162
 
163
163
  const path = getDbPath();
@@ -165,9 +165,9 @@ describe('paths configuration', () => {
165
165
  });
166
166
 
167
167
  it('respects CELILO_DATA_DIR override', () => {
168
- process.env.CELILO_DB_PATH = undefined;
168
+ delete process.env.CELILO_DB_PATH;
169
169
  process.env.CELILO_DATA_DIR = '/custom/data';
170
- process.env.ENVIRONMENT = undefined;
170
+ delete process.env.ENVIRONMENT;
171
171
 
172
172
  const path = getDbPath();
173
173
  expect(path).toBe('/custom/data/celilo.db');
@@ -176,7 +176,7 @@ describe('paths configuration', () => {
176
176
  it('prioritizes CELILO_DB_PATH over CELILO_DATA_DIR', () => {
177
177
  process.env.CELILO_DB_PATH = '/explicit/db.db';
178
178
  process.env.CELILO_DATA_DIR = '/custom/data';
179
- process.env.ENVIRONMENT = undefined;
179
+ delete process.env.ENVIRONMENT;
180
180
 
181
181
  const path = getDbPath();
182
182
  expect(path).toBe('/explicit/db.db');
@@ -3,7 +3,8 @@ import { afterEach, describe, expect, test } from 'bun:test';
3
3
  import { rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { createDbClient } from './client';
6
+ import { resetTestDbPath } from '../test-utils/db-path';
7
+ import { closeDb, createDbClient, getDb } from './client';
7
8
 
8
9
  // #798: a command that opened the db while a deploy still held it died on
9
10
  // SQLITE_BUSY immediately, because the connection carried no busy timeout.
@@ -61,3 +62,47 @@ describe('createDbClient concurrency', () => {
61
62
  }
62
63
  });
63
64
  });
65
+
66
+ /**
67
+ * The tripwire from celilo#1343: under bun test, a default-path open must be
68
+ * refused instead of landing on the operator's real database. The scratch-DB
69
+ * preload (apps/celilo/test-preload.ts) arms CELILO_DB_PATH only when bun
70
+ * runs from apps/celilo/, so every one of these tests strips the redirects
71
+ * itself to reproduce the out-of-scope state.
72
+ */
73
+ describe('createDbClient operator-database tripwire (celilo#1343)', () => {
74
+ afterEach(() => {
75
+ closeDb();
76
+ resetTestDbPath();
77
+ delete process.env.CELILO_DATA_DIR;
78
+ delete process.env.ENVIRONMENT;
79
+ });
80
+
81
+ test('default-path open under bun test throws, naming the fix', () => {
82
+ delete process.env.CELILO_DB_PATH;
83
+ delete process.env.CELILO_DATA_DIR;
84
+ delete process.env.ENVIRONMENT;
85
+
86
+ expect(() => getDb()).toThrow(/CELILO_DB_PATH/);
87
+ });
88
+
89
+ test('CELILO_DATA_DIR redirect keeps the default-path open allowed', () => {
90
+ delete process.env.CELILO_DB_PATH;
91
+ delete process.env.ENVIRONMENT;
92
+ process.env.CELILO_DATA_DIR = join(tmpdir(), 'celilo-tripwire-redirect');
93
+
94
+ const db = createDbClient();
95
+ db.$client.close();
96
+ });
97
+
98
+ test('explicit config path bypasses the tripwire', () => {
99
+ delete process.env.CELILO_DB_PATH;
100
+ delete process.env.CELILO_DATA_DIR;
101
+ delete process.env.ENVIRONMENT;
102
+
103
+ const db = createDbClient({
104
+ path: join(tmpdir(), 'celilo-tripwire-explicit', 'celilo.db'),
105
+ });
106
+ db.$client.close();
107
+ });
108
+ });
package/src/db/client.ts CHANGED
@@ -49,6 +49,32 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
49
49
  const dbPath = config?.path ?? getDbPath();
50
50
  const readonly = config?.readonly ?? false;
51
51
 
52
+ // Tripwire against opening the operator's real database from a test
53
+ // (celilo#1343). The scratch-DB preload in apps/celilo/test-preload.ts only
54
+ // arms when bun runs from apps/celilo/ — bun reads the bunfig at the working
55
+ // directory — so the same suite run from the repo root reaches this function
56
+ // with every override unset and would open, and migrate, the operator's
57
+ // ~/Library/.../celilo.db. The condition below is exactly that resolution:
58
+ // under bun test, with every redirect unset, getDbPath() falls through to
59
+ // the platform data directory. A run that is properly scoped (preload armed,
60
+ // CELILO_DATA_DIR redirected, or an explicit config.path) never satisfies
61
+ // it and pays one string comparison.
62
+ if (
63
+ !config?.path &&
64
+ process.env.NODE_ENV === 'test' &&
65
+ !process.env.CELILO_DB_PATH &&
66
+ !process.env.CELILO_DATA_DIR &&
67
+ process.env.ENVIRONMENT !== 'dev'
68
+ ) {
69
+ throw new Error(
70
+ 'Refusing to open the celilo database under bun test with no database path set ' +
71
+ '(celilo#1343): the scratch-DB preload in apps/celilo/test-preload.ts only arms ' +
72
+ "when bun runs from apps/celilo/, so this default path is the operator's real " +
73
+ 'celilo.db. Run tests from apps/celilo/, or set CELILO_DB_PATH (or ' +
74
+ 'CELILO_DATA_DIR) to a test-scoped path.',
75
+ );
76
+ }
77
+
52
78
  // bun:sqlite's `create: true` makes the file but not the parent directory.
53
79
  // First-run after a fresh install hits this — without recursive mkdir we
54
80
  // get SQLITE_CANTOPEN before init can write its config.
@@ -20,6 +20,7 @@ import { describe, expect, it } from 'bun:test';
20
20
  import { mkdtempSync, rmSync } from 'node:fs';
21
21
  import { tmpdir } from 'node:os';
22
22
  import { join } from 'node:path';
23
+ import { resetTestDbPath } from '../test-utils/db-path';
23
24
  import { closeDb, getDb } from './client';
24
25
  import { runMigrations } from './migrate';
25
26
  import { systemConfig } from './schema';
@@ -48,7 +49,7 @@ describe('runMigrations connection lifecycle', () => {
48
49
  closeDb();
49
50
  } finally {
50
51
  closeDb();
51
- process.env.CELILO_DB_PATH = undefined;
52
+ resetTestDbPath();
52
53
  rmSync(dir, { recursive: true, force: true });
53
54
  }
54
55
  });
package/src/db/migrate.ts CHANGED
@@ -80,26 +80,26 @@ export function runMigrationsOn(db: DbClient): void {
80
80
  }
81
81
 
82
82
  /**
83
- * Run database migrations (standalone entrypoint — `bun run src/db/migrate.ts`).
84
- * createDbClient already migrates on open; this re-asserts for explicit use.
83
+ * Open a database at `dbPath` with migrations applied, then close it.
84
+ * Standalone entrypoint (`bun run src/db/migrate.ts`); tests also use it to
85
+ * prepare a fresh scratch database.
86
+ *
87
+ * createDbClient migrates synchronously on open (its own runMigrationsOn
88
+ * call), so this function is open + close. It deliberately does NOT call
89
+ * runMigrationsOn again: a second pass is always a ledger no-op when the
90
+ * first succeeded, and keeping it invited a double-migrate everywhere this
91
+ * entrypoint is used (celilo#1315).
85
92
  */
86
93
  export async function runMigrations(dbPath?: string) {
87
94
  console.log('Running database migrations...');
88
95
  const db = createDbClient(dbPath ? { path: dbPath } : undefined);
89
- try {
90
- runMigrationsOn(db);
91
- console.log('Migrations completed successfully');
92
- } catch (error) {
93
- console.error('Migration failed:', error);
94
- throw error;
95
- } finally {
96
- // Close the connection THIS call created. It is not the singleton —
97
- // createDbClient does not register one — so the old closeDb() here closed
98
- // whatever else was open (usually nothing) and leaked this connection,
99
- // which then held the file in WAL and blocked every later journal-mode
100
- // switch on it (celilo#1269).
101
- db.$client.close();
102
- }
96
+ console.log('Migrations completed successfully');
97
+ // Close the connection THIS call created. It is not the singleton —
98
+ // createDbClient does not register one — so an old closeDb() here closed
99
+ // whatever else was open (usually nothing) and leaked this connection,
100
+ // which then held the file in WAL and blocked every later journal-mode
101
+ // switch on it (celilo#1269).
102
+ db.$client.close();
103
103
  }
104
104
 
105
105
  // Run migrations if executed directly
package/src/db/schema.ts CHANGED
@@ -212,6 +212,45 @@ export const capabilityBindings = sqliteTable(
212
212
  }),
213
213
  );
214
214
 
215
+ /**
216
+ * One build-bus `on_upstream_publish` hook execution (services/build-bus/hook-dispatcher.ts).
217
+ *
218
+ * The self-update path had no durable record: outcomes were console output
219
+ * inside the receiver daemon, so a CLI that never upgraded looked like a
220
+ * healthy fleet (celilo#1304). One row per (event, module, hook) run — the
221
+ * newest row answers "did the self-update for @celilo/cli@X fire and succeed?".
222
+ *
223
+ * `exit_code` is NULL when the script never ran to an exit (spawn failure).
224
+ * Not an event-bus delivery: hooks run from the receiver's in-process
225
+ * dispatcher, which no `deliveries` row was ever written for.
226
+ *
227
+ * @owner celilo — build-bus self-update audit trail (celilo#1304)
228
+ */
229
+ export const buildBusHookRuns = sqliteTable(
230
+ 'build_bus_hook_runs',
231
+ {
232
+ id: integer('id').primaryKey({ autoIncrement: true }),
233
+ /** The PublishEvent's dedup id — joins back to the bus's `build-bus.publish` event. */
234
+ eventId: text('event_id').notNull(),
235
+ packageName: text('package_name').notNull(),
236
+ packageVersion: text('package_version').notNull(),
237
+ tag: text('tag').notNull(),
238
+ moduleId: text('module_id').notNull(),
239
+ hookName: text('hook_name').notNull(),
240
+ scriptPath: text('script_path').notNull(),
241
+ /** Null means the script never reached an exit (spawn failure). */
242
+ exitCode: integer('exit_code'),
243
+ timedOut: integer('timed_out', { mode: 'boolean' }).notNull().default(false),
244
+ durationMs: integer('duration_ms').notNull(),
245
+ stdoutTail: text('stdout_tail'),
246
+ stderrTail: text('stderr_tail'),
247
+ ranAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
248
+ },
249
+ (table) => ({
250
+ ranAtLookup: index('build_bus_hook_runs_created_at_idx').on(table.ranAt),
251
+ }),
252
+ );
253
+
215
254
  /**
216
255
  * Capability secrets table - stores encrypted secrets owned by capabilities
217
256
  * Values are encrypted with AES-256-GCM using master key
@@ -310,6 +349,28 @@ export const moduleIntegrity = sqliteTable('module_integrity', {
310
349
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
311
350
  });
312
351
 
352
+ /**
353
+ * Per-module hook jail policy — the operator's row in the four-step
354
+ * precedence (per-module-jail-policy task 1.1), ranked between the
355
+ * `CELILO_HOOK_JAIL` environment variable and the system-wide
356
+ * `hooks.jail_policy` key.
357
+ *
358
+ * One row per module; an absent row means "follow the system", so there is
359
+ * no default column to get wrong. The value is validated at set time by the
360
+ * `module jail` verb and re-validated at read time by `resolveJailPolicy`,
361
+ * which throws rather than coercing — a restored or hand-edited row outside
362
+ * the accepted set must fail loudly, the same rule the system key follows
363
+ * (peba's ruling on ce-8832).
364
+ * @owner celilo — operator-set per-module jail policy
365
+ */
366
+ export const moduleJailPolicies = sqliteTable('module_jail_policies', {
367
+ moduleId: text('module_id')
368
+ .primaryKey()
369
+ .references(() => modules.id, { onDelete: 'cascade' }),
370
+ policy: text('policy').$type<'auto' | 'off' | 'required'>().notNull(),
371
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
372
+ });
373
+
313
374
  /**
314
375
  * Lifecycle states an instance moves through.
315
376
  *
@@ -1579,3 +1640,5 @@ export type NotificationDelivery = typeof notificationDeliveries.$inferSelect;
1579
1640
  export type NewNotificationDelivery = typeof notificationDeliveries.$inferInsert;
1580
1641
  export type CapabilityBinding = typeof capabilityBindings.$inferSelect;
1581
1642
  export type NewCapabilityBinding = typeof capabilityBindings.$inferInsert;
1643
+ export type BuildBusHookRun = typeof buildBusHookRuns.$inferSelect;
1644
+ export type NewBuildBusHookRun = typeof buildBusHookRuns.$inferInsert;