@celilo/cli 2.2.1 → 3.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 (173) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  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/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/serve.ts +13 -1
  10. package/src/api/sessions.test.ts +3 -3
  11. package/src/api-clients/proxmox.ts +10 -7
  12. package/src/cli/backup-rename.test.ts +3 -3
  13. package/src/cli/cli.test.ts +1 -1
  14. package/src/cli/commands/events.test.ts +2 -2
  15. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  16. package/src/cli/commands/machine-list.test.ts +57 -0
  17. package/src/cli/commands/machine-list.ts +35 -4
  18. package/src/cli/commands/module-health.test.ts +35 -0
  19. package/src/cli/commands/module-health.ts +12 -3
  20. package/src/cli/commands/module-import-registry.test.ts +1 -1
  21. package/src/cli/commands/module-jail.test.ts +242 -0
  22. package/src/cli/commands/module-jail.ts +227 -0
  23. package/src/cli/commands/module-list-jail.test.ts +136 -0
  24. package/src/cli/commands/module-list.ts +30 -3
  25. package/src/cli/commands/module-publish.test.ts +28 -6
  26. package/src/cli/commands/module-publish.ts +14 -12
  27. package/src/cli/commands/module-update.test.ts +22 -7
  28. package/src/cli/commands/module-update.ts +73 -18
  29. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  30. package/src/cli/commands/module-upgrade.test.ts +115 -1
  31. package/src/cli/commands/module-upgrade.ts +66 -4
  32. package/src/cli/commands/module-verify.test.ts +1 -1
  33. package/src/cli/commands/publish/execute.ts +4 -1
  34. package/src/cli/commands/publish/helpers.ts +4 -3
  35. package/src/cli/commands/publish/index.ts +23 -1
  36. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  37. package/src/cli/commands/publish/module-registry.ts +23 -4
  38. package/src/cli/commands/publish/plan.test.ts +64 -0
  39. package/src/cli/commands/publish/plan.ts +52 -19
  40. package/src/cli/commands/publish/types.ts +20 -0
  41. package/src/cli/commands/registry-owner.test.ts +1 -1
  42. package/src/cli/commands/registry-token.test.ts +1 -1
  43. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  44. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  45. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  46. package/src/cli/commands/subscribers-serve.ts +22 -4
  47. package/src/cli/commands/system-audit.ts +34 -11
  48. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  49. package/src/cli/commands/system-doctor.test.ts +76 -10
  50. package/src/cli/commands/system-doctor.ts +34 -3
  51. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  52. package/src/cli/commands/system-update.ts +16 -6
  53. package/src/cli/completion.ts +18 -3
  54. package/src/cli/flag-surface-gate.test.ts +279 -0
  55. package/src/cli/fuel-gauge.ts +12 -4
  56. package/src/cli/index.ts +80 -2
  57. package/src/cli/json-output.test.ts +81 -0
  58. package/src/cli/parser.test.ts +37 -1
  59. package/src/cli/restore-command.test.ts +3 -3
  60. package/src/cli/restore-migration-failure.test.ts +2 -2
  61. package/src/cli/tui/audit-state.ts +2 -0
  62. package/src/cli/types.ts +9 -0
  63. package/src/config/paths.test.ts +19 -19
  64. package/src/db/client.test.ts +46 -1
  65. package/src/db/client.ts +26 -0
  66. package/src/db/schema.ts +63 -0
  67. package/src/hooks/broker.test.ts +9 -3
  68. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  69. package/src/hooks/capability-loader.ts +8 -0
  70. package/src/hooks/executor.test.ts +91 -14
  71. package/src/hooks/executor.ts +31 -1
  72. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  73. package/src/hooks/hook-jail-unreachability.test.ts +27 -20
  74. package/src/hooks/hook-trespass.test.ts +22 -6
  75. package/src/hooks/jail.test.ts +105 -17
  76. package/src/hooks/jail.ts +47 -10
  77. package/src/hooks/run-named-hook.ts +19 -16
  78. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  79. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  80. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  81. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  82. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  83. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  84. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  85. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  86. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  87. package/src/manifest/contracts/v1.ts +32 -14
  88. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  89. package/src/manifest/schema.ts +66 -16
  90. package/src/manifest/validate.test.ts +47 -0
  91. package/src/policy/capability-shape-baseline.ts +63 -21
  92. package/src/policy/capability-shape-drift.test.ts +53 -1
  93. package/src/policy/capability-shape.test.ts +105 -0
  94. package/src/policy/capability-shape.ts +283 -2
  95. package/src/policy/module-script-scan.ts +52 -68
  96. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  97. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  98. package/src/registry/client.test.ts +67 -2
  99. package/src/registry/client.ts +7 -7
  100. package/src/secrets/storage.test.ts +70 -4
  101. package/src/secrets/storage.ts +71 -1
  102. package/src/services/alerting/keys.test.ts +4 -0
  103. package/src/services/alerting/keys.ts +12 -2
  104. package/src/services/audit/health.test.ts +155 -2
  105. package/src/services/audit/health.ts +76 -2
  106. package/src/services/audit/index.test.ts +23 -1
  107. package/src/services/audit/index.ts +8 -2
  108. package/src/services/audit/interface-classification.test.ts +16 -5
  109. package/src/services/audit/interface-classification.ts +25 -2
  110. package/src/services/audit/jail-exemptions.test.ts +42 -0
  111. package/src/services/audit/jail-exemptions.ts +44 -0
  112. package/src/services/audit/module-integrity.test.ts +23 -1
  113. package/src/services/audit/module-integrity.ts +7 -2
  114. package/src/services/audit/module-versions.ts +5 -1
  115. package/src/services/audit/public-dns.test.ts +20 -0
  116. package/src/services/audit/public-dns.ts +7 -2
  117. package/src/services/audit/recurrence-gate.test.ts +225 -0
  118. package/src/services/audit/trusted-sources.test.ts +17 -0
  119. package/src/services/audit/trusted-sources.ts +21 -0
  120. package/src/services/audit/types.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  123. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  124. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  125. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
  126. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  127. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  128. package/src/services/build-bus/hook-dispatch.ts +99 -121
  129. package/src/services/build-bus/hook-dispatcher.ts +143 -17
  130. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  131. package/src/services/build-bus/receiver-daemon.ts +355 -0
  132. package/src/services/build-bus/self-update.ts +156 -0
  133. package/src/services/bus-ensure-flow.test.ts +1 -1
  134. package/src/services/bus-interview-park.test.ts +2 -2
  135. package/src/services/bus-interview.test.ts +2 -2
  136. package/src/services/bus-secret-flow.test.ts +1 -1
  137. package/src/services/capability-compat.test.ts +90 -0
  138. package/src/services/capability-compat.ts +128 -0
  139. package/src/services/celilo-events.test.ts +1 -1
  140. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  141. package/src/services/container-service.test.ts +1 -1
  142. package/src/services/cross-module-read.test.ts +2 -2
  143. package/src/services/deploy-preflight.ts +7 -0
  144. package/src/services/deploy-terraform.ts +38 -1
  145. package/src/services/deployed-systems.ts +1 -1
  146. package/src/services/events-daemon.test.ts +57 -0
  147. package/src/services/events-daemon.ts +76 -0
  148. package/src/services/firewall-reach.ts +21 -8
  149. package/src/services/fleet-checks.test.ts +159 -4
  150. package/src/services/fleet-checks.ts +206 -3
  151. package/src/services/health-runner.test.ts +87 -2
  152. package/src/services/health-runner.ts +83 -16
  153. package/src/services/infrastructure-selector.test.ts +1 -1
  154. package/src/services/jail-exemptions.test.ts +125 -0
  155. package/src/services/jail-exemptions.ts +81 -0
  156. package/src/services/machine-pool.test.ts +1 -1
  157. package/src/services/module-deploy-prune.test.ts +89 -0
  158. package/src/services/module-deploy.ts +84 -129
  159. package/src/services/module-subscriptions.test.ts +2 -2
  160. package/src/services/module-types-drift.test.ts +1 -1
  161. package/src/services/module-validator/capability-versions.test.ts +13 -2
  162. package/src/services/network-discovery.test.ts +64 -1
  163. package/src/services/network-discovery.ts +30 -5
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +4 -4
  166. package/src/services/restore-preflight.test.ts +1 -1
  167. package/src/services/ssh-key-manager.test.ts +2 -2
  168. package/src/services/system-state-stage.test.ts +2 -2
  169. package/src/services/terraform-safety.test.ts +83 -0
  170. package/src/services/terraform-safety.ts +53 -0
  171. package/src/services/update/orchestrator.test.ts +3 -1
  172. package/src/test-utils/bus-responder.ts +1 -1
  173. package/tsconfig.json +2 -13
@@ -56,11 +56,19 @@ export class FuelGauge {
56
56
  private linesDrawn = 0; // Track exactly how many lines were drawn last frame
57
57
  private startTime = Date.now();
58
58
  private lastOutputTime = Date.now();
59
+ /**
60
+ * Whether this gauge may draw frames on its output stream. A redirected
61
+ * or piped stream is a data channel, not a terminal (celilo#699), so the
62
+ * gauge tracks state silently there instead of writing frames into
63
+ * whatever file or protocol lies downstream (celilo#1362).
64
+ */
65
+ private readonly interactive: boolean;
59
66
 
60
67
  constructor(title: string, options: FuelGaugeOptions = {}) {
61
68
  this.title = title;
62
69
  this.output = options.output || stdout;
63
70
  this.skipAnimation = options.skipAnimation || false;
71
+ this.interactive = !this.skipAnimation && Boolean(this.output.isTTY);
64
72
  this.onBackground = options.onBackground;
65
73
  }
66
74
 
@@ -68,8 +76,8 @@ export class FuelGauge {
68
76
  * Start the fuel-gauge animation
69
77
  */
70
78
  start(): void {
71
- if (this.skipAnimation) {
72
- // Test mode: just track state
79
+ if (this.skipAnimation || !this.interactive) {
80
+ // Test mode or non-interactive output: just track state
73
81
  this.running = true;
74
82
  return;
75
83
  }
@@ -188,7 +196,7 @@ export class FuelGauge {
188
196
 
189
197
  this.running = false;
190
198
 
191
- if (this.skipAnimation) {
199
+ if (!this.interactive) {
192
200
  return;
193
201
  }
194
202
 
@@ -233,7 +241,7 @@ export class FuelGauge {
233
241
  stopSilent(): void {
234
242
  if (!this.running) return;
235
243
  this.running = false;
236
- if (this.skipAnimation) return;
244
+ if (!this.interactive) return;
237
245
  const display = getActiveDisplay();
238
246
  if (display) {
239
247
  display.abandon();
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';
@@ -114,6 +115,10 @@ import { handleServiceSetCredentials } from './commands/service-set-credentials'
114
115
  import { handleServiceVerify } from './commands/service-verify';
115
116
  import { handleStatus } from './commands/status';
116
117
  import { handleSubscribersAdd } from './commands/subscribers-add';
118
+ import {
119
+ handleSubscribersInstallDaemon,
120
+ handleSubscribersUninstallDaemon,
121
+ } from './commands/subscribers-install-daemon';
117
122
  import { handleSubscribersList } from './commands/subscribers-list';
118
123
  import { handleSubscribersRemove } from './commands/subscribers-remove';
119
124
  import { handleSubscribersServe } from './commands/subscribers-serve';
@@ -315,6 +320,7 @@ Subcommands:
315
320
  resync-subscriptions Rebuild subscribers from deployed modules' manifests (after a restore/migration)
316
321
  list-pending [--subscriber] List pending deliveries
317
322
  list-failed [--subscriber] List failed/abandoned deliveries with a true total
323
+ list-unanswered List interview questions nobody has answered yet
318
324
  drain [--concurrency N] Process pending deliveries once and return
319
325
  run [--poll-ms N] Run the long-running dispatcher (foreground)
320
326
  emit <type> [<payload>] Emit an event (operator/test path)
@@ -363,6 +369,10 @@ Subcommands:
363
369
  test <url> Fire a synthetic event at a subscriber; report delivery
364
370
  serve --secret <hmac-secret> Run the receiver daemon (verifies + emits to local bus)
365
371
  [--port <p>] [--no-dispatch]
372
+ install-daemon --secret <s> Write a supervisor unit for the receiver (celilo#1304:
373
+ [--system] [--port <p>] nothing else installs it). Runs serve --no-dispatch so the
374
+ [--dispatch] [--print] standing dispatcher keeps owning hook delivery.
375
+ uninstall-daemon [--system] Remove the receiver supervisor unit
366
376
  status Per-subscriber delivery history (success rate, recent failures)
367
377
  --help, -h Show this help
368
378
 
@@ -588,6 +598,32 @@ Subcommands:
588
598
  --auto-generate-secrets Auto-generate all secrets without prompting
589
599
 
590
600
  list List all installed modules
601
+ Options:
602
+ --json Output the module roster as stable JSON
603
+
604
+ status <id> Show detailed module status
605
+
606
+ where <id> Show which host(s) serve a module (host discovery)
607
+ Options:
608
+ --json Output host discovery as stable JSON
609
+
610
+ logs <id> Show deploy logs for a module
611
+ Options:
612
+ --tail <n> Show last N lines
613
+ --last Show only the most recent deploy run
614
+
615
+ journal <id> Tail a deployed module's runtime daemon journal on its host (no SSH)
616
+ Options:
617
+ --unit <glob> systemd unit or glob (default: <module-id>*)
618
+ --lines <n> Lines to return (default 100)
619
+ --since <window> Time window, e.g. '30 min ago'
620
+ --grep <text> Only lines containing this fixed string
621
+ --json Machine-readable JSON output
622
+
623
+ operations list|clear Show or release in-flight module operations (the backup/restore lock)
624
+ Options:
625
+ --all With clear: also release operations that still look alive
626
+ --abandoned With list: also show abandoned rows (hidden by default)
591
627
 
592
628
  remove <id> Remove a module and all its data
593
629
 
@@ -605,8 +641,12 @@ Subcommands:
605
641
  --dry-run Print the ordered plan and change nothing
606
642
  --yes Skip the cascade confirmation
607
643
 
608
- verify <id> Verify module integrity (signature + checksums)
609
- (legacy alias: 'audit')
644
+ verify <id> Verify module integrity (signature + checksums) (legacy alias: 'audit')
645
+ Options:
646
+ --deep Deep verification (re-check all files)
647
+ --json Machine-readable JSON output
648
+
649
+ validate <id> Pre-flight check: validate module is ready for deployment
610
650
 
611
651
  config set <id> <key> <value> Set module configuration value
612
652
  config get <id> [key] Get module configuration value(s)
@@ -616,6 +656,11 @@ Subcommands:
616
656
  secret get <id> <key> Get a decrypted module secret value
617
657
  secret list <id> List module secrets
618
658
 
659
+ jail <id> [policy] Get or set one module's hook jail policy (auto|off|required)
660
+ Options:
661
+ --clear Remove the module's policy; follow the system's hooks.jail_policy again
662
+ --force Skip the confirmation when the new policy is weaker than the system's
663
+
619
664
  show-config <id> Show all config including auto-derived values
620
665
  show-zone <id> Show module's network zone and config
621
666
 
@@ -647,6 +692,11 @@ Subcommands:
647
692
  Options:
648
693
  --registry <url> Use a custom registry (sweep mode only)
649
694
 
695
+ upgrade <id> Upgrade a module to its latest registry version (update, posture-gated backup, deploy, verify)
696
+ Options:
697
+ --poll Run the CD poll: upgrade every auto_upgrade module with a newer version
698
+ --registry <url> Registry URL (defaults to configured registry)
699
+
650
700
  Registry:
651
701
  search [query] Search the registry for modules
652
702
  Options:
@@ -662,6 +712,19 @@ Registry:
662
712
  --allow-dirty Permit publishing from a dirty git tree
663
713
  --allow-stale Skip the manifest-vs-src stale-check. Use sparingly.
664
714
 
715
+ changeset <module-dir> Author a version changeset for a module (.changeset/<name>.md)
716
+ Options:
717
+ --bump <type> Version bump type: major, minor or patch (required)
718
+ --message <text> Changeset body text
719
+ --name <stem> Changeset filename stem (default: auto-generated slug)
720
+
721
+ backup <module-id> Create a backup of a module's data (invokes its on_backup hook)
722
+ Options:
723
+ --force Ignore schedule, back up now
724
+ --now Ignore schedule, back up now (alias for --force)
725
+ --storage <id> Storage destination ID
726
+ --no-interactive Non-interactive mode (for cron)
727
+
665
728
  check [path] Check a module for drift against the framework (default: .)
666
729
  Options:
667
730
  --no-build Skip the TypeScript build check
@@ -836,6 +899,12 @@ Subcommands:
836
899
  --storage <id> Use specific storage destination
837
900
  --yes Skip confirmation prompt
838
901
 
902
+ pull Download another box's backup artifact from a storage destination
903
+ Options:
904
+ --storage <name> Storage destination name
905
+ --module <id> Module ID whose backup to pull (default: system state)
906
+ --output <path> Local path to write the artifact
907
+
839
908
  Description:
840
909
  Backups are encrypted with the master key and uploaded to a configured
841
910
  storage destination. System state backups capture the Celilo database.
@@ -1056,6 +1125,7 @@ Usage:
1056
1125
  Resources:
1057
1126
  vmid Manage VMID reservations and allocations
1058
1127
  ip Manage IP address reservations
1128
+ show Show comprehensive IPAM summary
1059
1129
  list-allocations List all IPAM allocations (VMIDs and IPs)
1060
1130
 
1061
1131
  VMID Commands:
@@ -1140,6 +1210,8 @@ Subcommands:
1140
1210
  update [--module <id>] [--no-backup] [--allow-destructive] [--dry-run] [--json]
1141
1211
  Bring the system to the audit-determined READY state
1142
1212
 
1213
+ migrate Apply pending database migrations (idempotent; safe to re-run)
1214
+
1143
1215
  doctor [--fix] Diagnose system prerequisites and @celilo/* version drift
1144
1216
 
1145
1217
  Runbook:
@@ -1609,6 +1681,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1609
1681
  return handleModuleVersion(parsed.args, parsed.flags);
1610
1682
  case 'changeset':
1611
1683
  return handleModuleChangeset(parsed.args, parsed.flags);
1684
+ case 'jail':
1685
+ return handleModuleJail(parsed.args, parsed.flags);
1612
1686
  case 'audit':
1613
1687
  return moduleAudit(parsed.args, parsed.flags);
1614
1688
  case 'verify':
@@ -1843,6 +1917,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1843
1917
  return handleSubscribersTest(parsed.args);
1844
1918
  case 'serve':
1845
1919
  return handleSubscribersServe(parsed.args, parsed.flags);
1920
+ case 'install-daemon':
1921
+ return handleSubscribersInstallDaemon(parsed.args, parsed.flags);
1922
+ case 'uninstall-daemon':
1923
+ return handleSubscribersUninstallDaemon(parsed.args, parsed.flags);
1846
1924
  case 'status':
1847
1925
  return handleSubscribersStatus();
1848
1926
  default:
@@ -27,6 +27,7 @@
27
27
  * have failed anything. See celilo#703.
28
28
  */
29
29
 
30
+ import { Database } from 'bun:sqlite';
30
31
  import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
31
32
  import { spawnSync } from 'node:child_process';
32
33
  import { readFileSync, readdirSync } from 'node:fs';
@@ -49,6 +50,7 @@ const JSON_COMMANDS = [
49
50
  'module list --json',
50
51
  'module health --json',
51
52
  'system audit --json',
53
+ 'system update --dry-run --json',
52
54
  'alerts list --json',
53
55
  'commands --json',
54
56
  ] as const;
@@ -90,6 +92,85 @@ describe('celilo#698 — JSON commands emit parseable JSON', () => {
90
92
  }
91
93
  });
92
94
 
95
+ /**
96
+ * celilo#1362 — a --json command must stay parseable while its health checks
97
+ * run. The celilo#698 gates above all fire against an EMPTY fleet, where no
98
+ * health check ever executes and the pollution path never wakes up: the
99
+ * gauge silently animated frames onto stdout (plus plain `[module:hook]`
100
+ * logger lines) for every check, and `system audit --json` redirected to a
101
+ * file carried ~1 MB of `Testing app` frames before the first `{`.
102
+ *
103
+ * This gate seeds a module with a logging, sleeping health_check hook
104
+ * (`test-fixtures/modules/gauge-pollution-test`) so the checks genuinely
105
+ * run, then parses each fleet-wide --json command's stdout verbatim.
106
+ */
107
+ describe('celilo#1362 — --json output stays parseable while health checks run', () => {
108
+ let ctx: IntegrationTestContext;
109
+ const FIXTURE_PATH = join(import.meta.dir, '../../test-fixtures/modules/gauge-pollution-test');
110
+ const MODULE_ID = 'gauge-pollution-test';
111
+ const SPAWN_ENV = () => ({
112
+ ...process.env,
113
+ CELILO_DB_PATH: ctx.dbPath,
114
+ CELILO_DATA_DIR: ctx.dataDir,
115
+ CELILO_SUPPRESS_DEPRECATION: '1',
116
+ });
117
+
118
+ beforeAll(async () => {
119
+ ctx = await setupIntegrationTest();
120
+
121
+ const imported = spawnSync(
122
+ 'bun',
123
+ ['run', 'src/cli/index.ts', 'module', 'import', FIXTURE_PATH],
124
+ { encoding: 'utf8', env: SPAWN_ENV(), stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000 },
125
+ );
126
+ expect(
127
+ imported.status,
128
+ `module import exited ${imported.status}\nstdout: ${imported.stdout}\nstderr: ${imported.stderr}`,
129
+ ).toBe(0);
130
+
131
+ // runAllHealthChecks only checks INSTALLED/VERIFIED modules. Seeded
132
+ // the same way capability-abi-mismatch.test.ts seeds providers: the
133
+ // lifecycle under test is output purity, not the deploy flow.
134
+ const db = new Database(ctx.dbPath);
135
+ try {
136
+ db.run(`UPDATE modules SET state = 'VERIFIED' WHERE id = '${MODULE_ID}'`);
137
+ } finally {
138
+ db.close();
139
+ }
140
+ });
141
+
142
+ afterAll(async () => {
143
+ await ctx.cleanup();
144
+ });
145
+
146
+ for (const command of [
147
+ 'system audit --json',
148
+ 'system update --dry-run --json',
149
+ 'module health --json',
150
+ ]) {
151
+ test(`celilo ${command} emits parseable JSON with a health-checked module deployed`, () => {
152
+ const result = spawnSync('bun', ['run', 'src/cli/index.ts', ...command.split(' ')], {
153
+ encoding: 'utf8',
154
+ env: SPAWN_ENV(),
155
+ stdio: ['ignore', 'pipe', 'pipe'],
156
+ timeout: 120_000,
157
+ });
158
+
159
+ expect(
160
+ result.status,
161
+ `celilo ${command} exited ${result.status}\nstderr: ${result.stderr}`,
162
+ ).toBe(0);
163
+
164
+ // Verbatim JSON.parse — the gauge frames and plain logger lines both
165
+ // land here pre-fix, and either one alone breaks the parse.
166
+ expect(() => JSON.parse(result.stdout)).not.toThrow();
167
+
168
+ // And the fixture's own log lines must not ride along on stdout.
169
+ expect(result.stdout).not.toContain('gauge-pollution step');
170
+ });
171
+ }
172
+ });
173
+
93
174
  /**
94
175
  * Walk from the index of a `{` to the index just past its matching `}`,
95
176
  * skipping over string literals so a brace inside a string doesn't unbalance
@@ -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
+ });
@@ -34,9 +34,9 @@ describe('celilo restore', () => {
34
34
  afterEach(() => {
35
35
  closeDb();
36
36
  resetTestDbPath();
37
- process.env.CELILO_DATA_DIR = undefined;
38
- process.env.CELILO_MASTER_KEY_PATH = undefined;
39
- process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
37
+ delete process.env.CELILO_DATA_DIR;
38
+ delete process.env.CELILO_MASTER_KEY_PATH;
39
+ delete process.env.CELILO_SUPPRESS_DEPRECATION;
40
40
  try {
41
41
  rmSync(dir, { recursive: true, force: true });
42
42
  } catch {
@@ -99,8 +99,8 @@ describe('restore migration failure reporting (celilo#1269)', () => {
99
99
  afterEach(() => {
100
100
  closeDb();
101
101
  resetTestDbPath();
102
- process.env.CELILO_DATA_DIR = undefined;
103
- process.env.CELILO_MASTER_KEY_PATH = undefined;
102
+ delete process.env.CELILO_DATA_DIR;
103
+ delete process.env.CELILO_MASTER_KEY_PATH;
104
104
  try {
105
105
  rmSync(dir, { recursive: true, force: true });
106
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',
package/src/cli/types.ts CHANGED
@@ -35,6 +35,15 @@ export interface CommandError {
35
35
  success: false;
36
36
  error: string;
37
37
  details?: unknown;
38
+ /**
39
+ * Marks a refusal that is not a failure: the action was skipped BEFORE any
40
+ * state changed and will be retried when its precondition holds (the
41
+ * registry poll defers a consumer upgrade whose capability requirements the
42
+ * deployed providers cannot serve — celilo#1361). Callers that read only
43
+ * `success` still see a non-deploy; the poll uses this to report deferrals
44
+ * separately from failures instead of counting them as red.
45
+ */
46
+ deferred?: boolean;
38
47
  }
39
48
 
40
49
  export type CommandResult = CommandSuccess | CommandError;
@@ -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');
@@ -142,8 +142,8 @@ describe('paths configuration', () => {
142
142
  'returns data dir + celilo.db on macOS in production',
143
143
  () => {
144
144
  delete process.env.CELILO_DB_PATH;
145
- process.env.CELILO_DATA_DIR = undefined;
146
- process.env.ENVIRONMENT = undefined;
145
+ delete process.env.CELILO_DATA_DIR;
146
+ delete process.env.ENVIRONMENT;
147
147
 
148
148
  const path = getDbPath();
149
149
 
@@ -157,7 +157,7 @@ describe('paths configuration', () => {
157
157
 
158
158
  it('returns celilo-data/celilo.db in development mode', () => {
159
159
  delete process.env.CELILO_DB_PATH;
160
- process.env.CELILO_DATA_DIR = undefined;
160
+ delete process.env.CELILO_DATA_DIR;
161
161
  process.env.ENVIRONMENT = 'dev';
162
162
 
163
163
  const path = getDbPath();
@@ -167,7 +167,7 @@ describe('paths configuration', () => {
167
167
  it('respects CELILO_DATA_DIR override', () => {
168
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.