@celilo/cli 2.2.1 → 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 (112) 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/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/sessions.test.ts +3 -3
  10. package/src/api-clients/proxmox.ts +10 -7
  11. package/src/cli/backup-rename.test.ts +3 -3
  12. package/src/cli/cli.test.ts +1 -1
  13. package/src/cli/commands/events.test.ts +2 -2
  14. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  15. package/src/cli/commands/machine-list.test.ts +57 -0
  16. package/src/cli/commands/machine-list.ts +35 -4
  17. package/src/cli/commands/module-health.ts +1 -0
  18. package/src/cli/commands/module-import-registry.test.ts +1 -1
  19. package/src/cli/commands/module-jail.test.ts +242 -0
  20. package/src/cli/commands/module-jail.ts +227 -0
  21. package/src/cli/commands/module-list-jail.test.ts +136 -0
  22. package/src/cli/commands/module-list.ts +30 -3
  23. package/src/cli/commands/module-publish.test.ts +6 -6
  24. package/src/cli/commands/module-update.test.ts +22 -7
  25. package/src/cli/commands/module-update.ts +4 -1
  26. package/src/cli/commands/module-upgrade.test.ts +115 -1
  27. package/src/cli/commands/module-upgrade.ts +23 -1
  28. package/src/cli/commands/module-verify.test.ts +1 -1
  29. package/src/cli/commands/publish/execute.ts +4 -1
  30. package/src/cli/commands/publish/index.ts +11 -1
  31. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  32. package/src/cli/commands/publish/module-registry.ts +23 -4
  33. package/src/cli/commands/publish/plan.ts +1 -1
  34. package/src/cli/commands/publish/types.ts +7 -0
  35. package/src/cli/commands/registry-owner.test.ts +1 -1
  36. package/src/cli/commands/registry-token.test.ts +1 -1
  37. package/src/cli/commands/system-audit.ts +20 -6
  38. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  39. package/src/cli/commands/system-doctor.test.ts +76 -10
  40. package/src/cli/commands/system-doctor.ts +34 -3
  41. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  42. package/src/cli/commands/system-update.ts +5 -0
  43. package/src/cli/completion.ts +8 -2
  44. package/src/cli/flag-surface-gate.test.ts +279 -0
  45. package/src/cli/index.ts +68 -2
  46. package/src/cli/parser.test.ts +37 -1
  47. package/src/cli/restore-command.test.ts +3 -3
  48. package/src/cli/restore-migration-failure.test.ts +2 -2
  49. package/src/cli/tui/audit-state.ts +2 -0
  50. package/src/config/paths.test.ts +19 -19
  51. package/src/db/client.test.ts +46 -1
  52. package/src/db/client.ts +26 -0
  53. package/src/db/schema.ts +63 -0
  54. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  55. package/src/hooks/capability-loader.ts +8 -0
  56. package/src/hooks/executor.test.ts +86 -12
  57. package/src/hooks/executor.ts +31 -1
  58. package/src/hooks/hook-jail-unreachability.test.ts +17 -18
  59. package/src/hooks/hook-trespass.test.ts +9 -2
  60. package/src/hooks/jail.test.ts +105 -17
  61. package/src/hooks/jail.ts +47 -10
  62. package/src/manifest/contracts/v1.ts +13 -0
  63. package/src/policy/module-script-scan.ts +52 -68
  64. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  65. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  66. package/src/registry/client.test.ts +2 -2
  67. package/src/secrets/storage.test.ts +1 -1
  68. package/src/services/alerting/keys.test.ts +4 -0
  69. package/src/services/alerting/keys.ts +12 -2
  70. package/src/services/audit/health.test.ts +97 -2
  71. package/src/services/audit/health.ts +64 -2
  72. package/src/services/audit/index.test.ts +22 -0
  73. package/src/services/audit/index.ts +8 -2
  74. package/src/services/audit/jail-exemptions.test.ts +42 -0
  75. package/src/services/audit/jail-exemptions.ts +44 -0
  76. package/src/services/audit/module-integrity.test.ts +23 -1
  77. package/src/services/audit/module-integrity.ts +7 -2
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/backup-create.ts +9 -0
  80. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  81. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  82. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
  83. package/src/services/build-bus/hook-dispatcher.ts +49 -1
  84. package/src/services/bus-ensure-flow.test.ts +1 -1
  85. package/src/services/bus-interview-park.test.ts +2 -2
  86. package/src/services/bus-interview.test.ts +2 -2
  87. package/src/services/bus-secret-flow.test.ts +1 -1
  88. package/src/services/celilo-events.test.ts +1 -1
  89. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  90. package/src/services/container-service.test.ts +1 -1
  91. package/src/services/cross-module-read.test.ts +2 -2
  92. package/src/services/deploy-preflight.ts +7 -0
  93. package/src/services/deployed-systems.ts +1 -1
  94. package/src/services/fleet-checks.test.ts +123 -4
  95. package/src/services/fleet-checks.ts +176 -3
  96. package/src/services/health-runner.test.ts +87 -2
  97. package/src/services/health-runner.ts +40 -11
  98. package/src/services/infrastructure-selector.test.ts +1 -1
  99. package/src/services/jail-exemptions.test.ts +125 -0
  100. package/src/services/jail-exemptions.ts +81 -0
  101. package/src/services/machine-pool.test.ts +1 -1
  102. package/src/services/module-deploy.ts +12 -4
  103. package/src/services/module-subscriptions.test.ts +2 -2
  104. package/src/services/network-discovery.test.ts +64 -1
  105. package/src/services/network-discovery.ts +30 -5
  106. package/src/services/responder-probe.test.ts +1 -1
  107. package/src/services/restore-from-file.test.ts +4 -4
  108. package/src/services/restore-preflight.test.ts +1 -1
  109. package/src/services/ssh-key-manager.test.ts +2 -2
  110. package/src/services/system-state-stage.test.ts +2 -2
  111. package/src/services/update/orchestrator.test.ts +2 -0
  112. package/src/test-utils/bus-responder.ts +1 -1
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Flag-surface gate (celilo#1327) and subcommand-surface gate (celilo#1337).
3
+ *
4
+ * A CLI flag lives in two places: the handler reads it off `flags.X`, and
5
+ * packages/core/src/command-registry.ts declares it so the parser accepts it.
6
+ * When only the first happens, the flag is fully implemented — often unit
7
+ * tested, because the test drives the handler directly and never consults
8
+ * validateFlags — and still rejected at the terminal with
9
+ * "Command 'X' does not accept any flags".
10
+ *
11
+ * Instances of exactly that shape: #1308 (doctor remediation arrows),
12
+ * #1316 (module deploy --keep), #1325 (system config set --force), and three
13
+ * handler reads found the first time this gate ran (module check --no-build
14
+ * and --strict, module changeset --bump — their subcommands were missing from
15
+ * the registry entirely, so validation was skipped, not failed).
16
+ *
17
+ * A subcommand drifts the opposite way: the registry declares it, dispatch
18
+ * runs it, and the hand-written help block never mentions it, so a user
19
+ * reading `celilo <command> --help` cannot learn the subcommand exists.
20
+ * #1337 measured ten such subcommands under `celilo module` (upgrade, audit,
21
+ * validate, changeset, status, where, operations, logs, journal, backup), and
22
+ * the first run of this scan found four more elsewhere (events
23
+ * list-unanswered, system migrate, ipam show, backup pull).
24
+ *
25
+ * All scans are deliberately coarse: a flag name must be declared SOMEWHERE
26
+ * in the registry, and a subcommand name must merely APPEAR SOMEWHERE in its
27
+ * command's help block — word-boundary match, not a parsed Subcommands:
28
+ * section, because help blocks vary in shape. A per-subcommand check would be
29
+ * tighter, but every known instance was declared nowhere / printed nowhere,
30
+ * and a coarse gate that runs beats a precise one nobody finishes.
31
+ *
32
+ * Reach floors: each scan asserts it walked a real surface (minimum distinct
33
+ * flag names / files / help blocks / commands). A scan that resolves no files
34
+ * exits 0 and proves nothing, so the gate fails loudly instead.
35
+ */
36
+
37
+ import { describe, expect, it } from 'bun:test';
38
+ import { readFileSync, readdirSync } from 'node:fs';
39
+ import { join } from 'node:path';
40
+ import { COMMANDS } from '@celilo/core';
41
+
42
+ const commandsDir = join(import.meta.dir, 'commands');
43
+ const indexSource = readFileSync(join(import.meta.dir, 'index.ts'), 'utf8');
44
+
45
+ /** Every flag name declared at any depth of the command registry. */
46
+ function collectDeclaredFlags(): Set<string> {
47
+ const declared = new Set<string>();
48
+ function walk(defs: typeof COMMANDS): void {
49
+ for (const def of defs) {
50
+ for (const flag of def.flags ?? []) declared.add(flag.name);
51
+ if (def.subcommands) walk(def.subcommands);
52
+ }
53
+ }
54
+ walk(COMMANDS);
55
+ return declared;
56
+ }
57
+
58
+ const declaredFlags = collectDeclaredFlags();
59
+
60
+ /**
61
+ * Flags accepted without a registry entry, each with the reason:
62
+ * - help/h: universal, added by validateFlags itself (parser.ts).
63
+ * - remote: top-level transport flag, intercepted at the top of runCli by
64
+ * resolveRemote (packages/core/src/remote-client.ts) before any dispatch.
65
+ */
66
+ const flagsOutsideTheRegistry = new Set(['help', 'h', 'remote']);
67
+
68
+ interface HandlerScan {
69
+ byName: Map<string, string[]>;
70
+ fileCount: number;
71
+ totalReads: number;
72
+ }
73
+
74
+ /**
75
+ * Every flag literal a command handler reads via hasFlag(flags, 'x') or
76
+ * getFlag(flags, 'x'). Reads that pass a variable instead of a literal are
77
+ * invisible to this scan — accepted coarseness, see the header.
78
+ */
79
+ function scanHandlerFlagReads(): HandlerScan {
80
+ const byName = new Map<string, string[]>();
81
+ const files = readdirSync(commandsDir).filter(
82
+ (f) => f.endsWith('.ts') && !f.endsWith('.test.ts'),
83
+ );
84
+ let totalReads = 0;
85
+
86
+ for (const file of files) {
87
+ const source = readFileSync(join(commandsDir, file), 'utf8');
88
+ const reads = source.matchAll(/(?:hasFlag|getFlag)\(\s*flags\s*,\s*'([^']+)'/g);
89
+ for (const read of reads) {
90
+ totalReads++;
91
+ const flagName = read[1];
92
+ if (!flagName) continue;
93
+ const holders = byName.get(flagName) ?? [];
94
+ holders.push(file);
95
+ byName.set(flagName, holders);
96
+ }
97
+ }
98
+
99
+ return { byName, fileCount: files.length, totalReads };
100
+ }
101
+
102
+ /**
103
+ * Source of each displayXxxHelp function in index.ts (the help-text blocks).
104
+ * Each block is bounded by the NEXT top-level declaration, not by the next
105
+ * display function and not by end-of-source. The original end-of-source bound
106
+ * let the last block swallow runCli's body, so --principal, --get-completions
107
+ * and --version were read out of dispatch code and reported as advertised.
108
+ */
109
+ function extractHelpBlocks(source: string): string[] {
110
+ const fns = [...source.matchAll(/function display\w*Help\(\)/g)];
111
+ const topLevelDecl = /^(?:export )?(?:async )?function \w+|^export const \w+/gm;
112
+ const blocks: string[] = [];
113
+ for (const fn of fns) {
114
+ if (fn.index === undefined) continue;
115
+ const start = fn.index + fn[0].length;
116
+ topLevelDecl.lastIndex = start;
117
+ const next = topLevelDecl.exec(source);
118
+ const end = next?.index ?? source.length;
119
+ blocks.push(source.slice(start, end));
120
+ }
121
+ return blocks;
122
+ }
123
+
124
+ /** Distinct --flag names advertised across all help blocks. */
125
+ function scanAdvertisedFlags(): { byName: Set<string>; blockCount: number } {
126
+ const byName = new Set<string>();
127
+ let blockCount = 0;
128
+
129
+ for (const block of extractHelpBlocks(indexSource)) {
130
+ // External-tool example lines (ansible-vault, ansible-playbook) carry the
131
+ // other tool's flags. Not celilo's surface. The flag often sits on the
132
+ // continuation line, so drop --vault-password-file by name as well as the
133
+ // ansible- command lines. // @psbanka - 2026-09
134
+ const celiloLines = block
135
+ .split('\n')
136
+ .filter((line) => !line.includes('ansible-'))
137
+ .filter((line) => !line.includes('vault-password-file'));
138
+ blockCount++;
139
+ for (const line of celiloLines) {
140
+ for (const match of line.matchAll(/--([a-zA-Z0-9][a-zA-Z0-9-]*)/g)) {
141
+ const flagName = match[1];
142
+ if (flagName) byName.add(flagName);
143
+ }
144
+ }
145
+ }
146
+
147
+ return { byName, blockCount };
148
+ }
149
+
150
+ /** `module` -> `Module`, `escalation-policy` -> `EscalationPolicy`. */
151
+ function pascalCase(name: string): string {
152
+ return name.replace(/(^|-)([a-z])/g, (_match, prefix, char) => prefix + char.toUpperCase());
153
+ }
154
+
155
+ /**
156
+ * Help blocks keyed by the display function's name suffix: displayModuleHelp
157
+ * -> "Module". A command with subcommands but no dedicated display function
158
+ * (api, alerts, route, ...) is simply absent from the map and skipped by the
159
+ * subcommand scan — it has no help block to drift from.
160
+ */
161
+ function extractNamedHelpBlocks(): Map<string, string> {
162
+ const byName = new Map<string, string>();
163
+ for (const match of indexSource.matchAll(/function display(\w*)Help\(\)/g)) {
164
+ if (match.index === undefined) continue;
165
+ const start = match.index + match[0].length;
166
+ const topLevelDecl = /^(?:export )?(?:async )?function \w+|^export const \w+/gm;
167
+ topLevelDecl.lastIndex = start;
168
+ const next = topLevelDecl.exec(indexSource);
169
+ byName.set(match[1], indexSource.slice(start, next?.index ?? indexSource.length));
170
+ }
171
+ return byName;
172
+ }
173
+
174
+ /**
175
+ * Registry subcommands that appear nowhere in their command's help block.
176
+ * Commands without a dedicated help block are skipped (nothing to diff).
177
+ */
178
+ function scanSubcommandDrift(): {
179
+ missing: string[];
180
+ commandCount: number;
181
+ subcommandCount: number;
182
+ } {
183
+ const helpBlocks = extractNamedHelpBlocks();
184
+ const missing: string[] = [];
185
+ let commandCount = 0;
186
+ let subcommandCount = 0;
187
+
188
+ for (const command of COMMANDS) {
189
+ if (!command.subcommands?.length) continue;
190
+ const name = pascalCase(command.name);
191
+ const block = helpBlocks.get(name);
192
+ if (block === undefined) continue;
193
+ commandCount++;
194
+ subcommandCount += command.subcommands.length;
195
+ for (const sub of command.subcommands) {
196
+ if (!new RegExp(`\\b${sub.name}\\b`).test(block)) {
197
+ missing.push(`celilo ${command.name} ${sub.name}`);
198
+ }
199
+ }
200
+ }
201
+
202
+ return { missing, commandCount, subcommandCount };
203
+ }
204
+
205
+ describe('subcommand-surface gate: help text vs command registry', () => {
206
+ it('scanned a real surface (reach floor)', () => {
207
+ const scan = scanSubcommandDrift();
208
+ if (scan.commandCount < 14 || scan.subcommandCount < 60) {
209
+ throw new Error(
210
+ `Subcommand scan walked too little to prove anything: ${scan.commandCount} commands with help blocks, ${scan.subcommandCount} registry subcommands. Expected >= 14 commands, >= 60 subcommands. If the help-block surface genuinely shrank, update the floors here consciously.`,
211
+ );
212
+ }
213
+ expect(scan.commandCount).toBeGreaterThanOrEqual(14);
214
+ });
215
+
216
+ it("every registry subcommand appears in its command's help block", () => {
217
+ const scan = scanSubcommandDrift();
218
+
219
+ if (scan.missing.length > 0) {
220
+ throw new Error(
221
+ `${scan.missing.length} registry subcommand(s) appear nowhere in their command's help text (scanned ${scan.commandCount} commands, ${scan.subcommandCount} subcommands):\n${scan.missing.map((entry) => ` ${entry}`).join('\n')}\nFix: add the subcommand to its displayXxxHelp block in apps/celilo/src/cli/index.ts, or remove it from packages/core/src/command-registry.ts if it is no longer dispatched.`,
222
+ );
223
+ }
224
+ expect(scan.missing).toEqual([]);
225
+ });
226
+ });
227
+
228
+ describe('flag-surface gate: handler reads vs command registry', () => {
229
+ it('scanned a real surface (reach floor)', () => {
230
+ const scan = scanHandlerFlagReads();
231
+ if (scan.fileCount < 10 || scan.byName.size < 30 || scan.totalReads < 60) {
232
+ throw new Error(
233
+ `Handler scan walked too little to prove anything: ${scan.fileCount} files, ${scan.byName.size} distinct flags, ${scan.totalReads} reads. Expected >= 10 files, >= 30 distinct, >= 60 reads. If the handler surface genuinely shrank, update the floors here consciously.`,
234
+ );
235
+ }
236
+ expect(scan.byName.size).toBeGreaterThanOrEqual(30);
237
+ });
238
+
239
+ it('every flag a handler reads is declared in the command registry', () => {
240
+ const scan = scanHandlerFlagReads();
241
+ const undeclared = [...scan.byName.entries()].filter(([name]) => !declaredFlags.has(name));
242
+
243
+ if (undeclared.length > 0) {
244
+ const detail = undeclared
245
+ .map(([name, files]) => ` --${name} read by: ${[...new Set(files)].join(', ')}`)
246
+ .join('\n');
247
+ throw new Error(
248
+ `${undeclared.length} handler-read flag(s) are declared nowhere in COMMANDS (scanned ${scan.fileCount} handler files, ${scan.totalReads} reads, ${scan.byName.size} distinct flags):\n${detail}\nFix: declare each flag on its command in packages/core/src/command-registry.ts.`,
249
+ );
250
+ }
251
+ expect(undeclared).toEqual([]);
252
+ });
253
+ });
254
+
255
+ describe('flag-surface gate: help text vs command registry', () => {
256
+ it('scanned a real surface (reach floor)', () => {
257
+ const scan = scanAdvertisedFlags();
258
+ if (scan.blockCount < 15 || scan.byName.size < 60) {
259
+ throw new Error(
260
+ `Help-text scan walked too little to prove anything: ${scan.blockCount} help blocks, ${scan.byName.size} distinct advertised flags. Expected >= 15 blocks, >= 60 distinct. If the help surface genuinely shrank, update the floors here consciously.`,
261
+ );
262
+ }
263
+ expect(scan.byName.size).toBeGreaterThanOrEqual(60);
264
+ });
265
+
266
+ it('every flag advertised in help text is declared in the command registry', () => {
267
+ const scan = scanAdvertisedFlags();
268
+ const undeclared = [...scan.byName].filter(
269
+ (name) => !declaredFlags.has(name) && !flagsOutsideTheRegistry.has(name),
270
+ );
271
+
272
+ if (undeclared.length > 0) {
273
+ throw new Error(
274
+ `${undeclared.length} flag(s) advertised in index.ts help text are declared nowhere in COMMANDS (scanned ${scan.blockCount} help blocks, ${scan.byName.size} distinct advertised flags): ${undeclared.map((n) => `--${n}`).join(', ')}\nFix: declare each flag on its command in packages/core/src/command-registry.ts.`,
275
+ );
276
+ }
277
+ expect(undeclared).toEqual([]);
278
+ });
279
+ });
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
 
@@ -647,6 +684,11 @@ Subcommands:
647
684
  Options:
648
685
  --registry <url> Use a custom registry (sweep mode only)
649
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
+
650
692
  Registry:
651
693
  search [query] Search the registry for modules
652
694
  Options:
@@ -662,6 +704,19 @@ Registry:
662
704
  --allow-dirty Permit publishing from a dirty git tree
663
705
  --allow-stale Skip the manifest-vs-src stale-check. Use sparingly.
664
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
+
665
720
  check [path] Check a module for drift against the framework (default: .)
666
721
  Options:
667
722
  --no-build Skip the TypeScript build check
@@ -836,6 +891,12 @@ Subcommands:
836
891
  --storage <id> Use specific storage destination
837
892
  --yes Skip confirmation prompt
838
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
+
839
900
  Description:
840
901
  Backups are encrypted with the master key and uploaded to a configured
841
902
  storage destination. System state backups capture the Celilo database.
@@ -1056,6 +1117,7 @@ Usage:
1056
1117
  Resources:
1057
1118
  vmid Manage VMID reservations and allocations
1058
1119
  ip Manage IP address reservations
1120
+ show Show comprehensive IPAM summary
1059
1121
  list-allocations List all IPAM allocations (VMIDs and IPs)
1060
1122
 
1061
1123
  VMID Commands:
@@ -1140,6 +1202,8 @@ Subcommands:
1140
1202
  update [--module <id>] [--no-backup] [--allow-destructive] [--dry-run] [--json]
1141
1203
  Bring the system to the audit-determined READY state
1142
1204
 
1205
+ migrate Apply pending database migrations (idempotent; safe to re-run)
1206
+
1143
1207
  doctor [--fix] Diagnose system prerequisites and @celilo/* version drift
1144
1208
 
1145
1209
  Runbook:
@@ -1609,6 +1673,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1609
1673
  return handleModuleVersion(parsed.args, parsed.flags);
1610
1674
  case 'changeset':
1611
1675
  return handleModuleChangeset(parsed.args, parsed.flags);
1676
+ case 'jail':
1677
+ return handleModuleJail(parsed.args, parsed.flags);
1612
1678
  case 'audit':
1613
1679
  return moduleAudit(parsed.args, parsed.flags);
1614
1680
  case 'verify':
@@ -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',
@@ -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');