@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
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import type { JailModeRecord } from '../../hooks/jail';
2
+ import { type JailModeRecord, resolveJailPolicy } from '../../hooks/jail';
3
3
  import {
4
4
  type HookExecutionInput,
5
5
  assembleHookExecutionSection,
@@ -62,12 +62,13 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
62
62
  renderHookExecutionSection({
63
63
  availability: { backend: 'bubblewrap' },
64
64
  policy: 'auto',
65
- source: 'default',
66
- envValue: undefined,
65
+ source: 'env',
66
+ envValue: 'auto',
67
67
  configValue: undefined,
68
68
  record: jailedRecord,
69
69
  host: HOST,
70
70
  monitored: true,
71
+ exemptions: [],
71
72
  ...over,
72
73
  });
73
74
 
@@ -96,10 +97,31 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
96
97
  expect(r.failCount).toBe(0);
97
98
  });
98
99
 
99
- test('CELILO_HOOK_JAIL=off is reported as the operator’s own act', () => {
100
- const r = render({ policy: 'off', record: undefined });
101
- expect(text(r)).toContain('CELILO_HOOK_JAIL=off');
102
- expect(r.warnCount).toBe(1);
100
+ test('an off policy warns, and the reason claims no source of its own', () => {
101
+ // This test used to be named "CELILO_HOOK_JAIL=off is reported as the
102
+ // operator's own act" and asserted that string. It stopped being true when
103
+ // ce-rez7 made `off` the DEFAULT: on a host where nobody set anything, the
104
+ // doctor named a variable that was unset and an act nobody performed. The
105
+ // source belongs on the source line, which is rendered separately.
106
+ const fromDefault = render({
107
+ policy: 'off',
108
+ source: 'default',
109
+ envValue: undefined,
110
+ record: undefined,
111
+ });
112
+ expect(text(fromDefault)).toContain('hooks run unjailed');
113
+ expect(text(fromDefault)).toContain('policy default (off)');
114
+ expect(text(fromDefault)).not.toContain('CELILO_HOOK_JAIL=off');
115
+ expect(fromDefault.warnCount).toBe(1);
116
+
117
+ const fromEnv = render({
118
+ policy: 'off',
119
+ source: 'env',
120
+ envValue: 'off',
121
+ record: undefined,
122
+ });
123
+ expect(text(fromEnv)).toContain('policy from CELILO_HOOK_JAIL=off (environment override)');
124
+ expect(fromEnv.warnCount).toBe(1);
103
125
  });
104
126
 
105
127
  test('auto deferral on sandbox-exec reads as unjailed, not jailed (ce-29z)', () => {
@@ -119,7 +141,7 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
119
141
  availability: { backend: 'none', reason: 'the profile did not load' },
120
142
  record: undefined,
121
143
  });
122
- expect(text(r)).toContain('CELILO_HOOK_JAIL=required and no jail is available');
144
+ expect(text(r)).toContain("the hook jail policy is 'required' and no jail is available");
123
145
  expect(r.failCount).toBe(1);
124
146
  });
125
147
 
@@ -180,9 +202,21 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
180
202
  });
181
203
 
182
204
  test('the source line names the default and how to change it (D4)', () => {
183
- const r = render({ policy: 'auto', source: 'default', record: undefined });
205
+ // Ask the resolver what the default IS rather than naming it. The literal
206
+ // that stood here said `auto`, and it kept passing after ce-rez7 flipped
207
+ // the default to `off`, because it rendered a { policy, source } pair the
208
+ // resolver can no longer produce. A correct assertion about a state that
209
+ // never arrives.
210
+ const fallback = resolveJailPolicy(undefined, undefined, undefined);
211
+ const r = render({
212
+ policy: fallback.policy,
213
+ source: fallback.source,
214
+ envValue: undefined,
215
+ record: undefined,
216
+ });
217
+ expect(fallback.source).toBe('default');
184
218
  expect(text(r)).toContain(
185
- 'policy default (auto) — set hooks.jail_policy or CELILO_HOOK_JAIL to change',
219
+ `policy default (${fallback.policy}) — set hooks.jail_policy or CELILO_HOOK_JAIL to change`,
186
220
  );
187
221
  });
188
222
 
@@ -218,10 +252,42 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
218
252
  record: () => undefined,
219
253
  host: () => HOST,
220
254
  monitored: () => true,
255
+ exemptions: () => [],
221
256
  });
222
257
  expect(r.lines[0]).toBe('Hook execution');
223
258
  expect(r.lines[1]).toContain("hooks.jail_policy='alwayssafe' is not a hook jail policy");
224
259
  expect(r.failCount).toBe(1);
225
260
  expect(r.warnCount).toBe(0);
226
261
  });
262
+
263
+ test('an exemption is counted and named (per-module-jail-policy task 3.2)', () => {
264
+ const r = render({
265
+ exemptions: [
266
+ { moduleId: 'caddy', policy: 'off', systemPolicy: 'auto' },
267
+ { moduleId: 'vault', policy: 'auto', systemPolicy: 'auto' },
268
+ ],
269
+ });
270
+ expect(text(r)).toContain("2 modules run under a weaker jail policy than the system's");
271
+ expect(text(r)).toContain('caddy (off)');
272
+ expect(text(r)).toContain('vault (auto)');
273
+ expect(r.warnCount).toBe(1);
274
+ });
275
+
276
+ test('one exemption reads as singular', () => {
277
+ const r = render({
278
+ exemptions: [{ moduleId: 'caddy', policy: 'off', systemPolicy: 'auto' }],
279
+ });
280
+ expect(text(r)).toContain('1 module runs under a weaker jail policy');
281
+ expect(text(r)).not.toContain('modules run');
282
+ });
283
+
284
+ test('no exemptions render no exemption line (per-module-jail-policy task 3.4)', () => {
285
+ // The negative half: a surface that reports an exemption nobody set is
286
+ // as bad as one that hides a real one. With no rows the section must be
287
+ // word-for-word what it was before this slice.
288
+ const r = render({});
289
+ expect(text(r)).not.toContain('weaker jail policy');
290
+ expect(text(r)).not.toContain('exempt');
291
+ expect(r.warnCount).toBe(0);
292
+ });
227
293
  });
@@ -60,6 +60,7 @@ import {
60
60
  checkSubscribers,
61
61
  runFleetChecks,
62
62
  } from '../../services/fleet-checks';
63
+ import { type JailExemption, collectJailExemptions } from '../../services/jail-exemptions';
63
64
  import { resyncAllSubscriptions } from '../../services/module-subscriptions';
64
65
  import { checkAllPrerequisites, failingPrerequisites } from '../../system/prereqs';
65
66
  import type { CommandResult } from '../types';
@@ -368,6 +369,12 @@ export interface HookExecutionInput {
368
369
  * ask (a dev box), which also suppresses the add-the-monitor hint.
369
370
  */
370
371
  monitored: boolean | null;
372
+ /**
373
+ * Modules running under a recorded policy weaker than the system's
374
+ * (per-module-jail-policy task 3.2), from the shared collector. Empty is
375
+ * the normal state and renders nothing.
376
+ */
377
+ exemptions: readonly JailExemption[];
371
378
  }
372
379
 
373
380
  /**
@@ -408,8 +415,11 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
408
415
  } else if (input.source === 'config') {
409
416
  lines.push(` ${ANSI.dim}policy from system config (hooks.jail_policy)${ANSI.reset}`);
410
417
  } else {
418
+ // Read the default off `policy` rather than naming it. The literal that
419
+ // stood here said `auto` and stayed saying it after ce-rez7 flipped the
420
+ // default to `off`, so the doctor named the one value not in play.
411
421
  lines.push(
412
- ` ${ANSI.dim}policy default (auto) — set hooks.jail_policy or CELILO_HOOK_JAIL to change${ANSI.reset}`,
422
+ ` ${ANSI.dim}policy default (${policy}) — set hooks.jail_policy or CELILO_HOOK_JAIL to change${ANSI.reset}`,
413
423
  );
414
424
  }
415
425
  if (
@@ -428,7 +438,7 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
428
438
  const liveJailed = policy !== 'off' && availability.backend !== 'none' && !autoDeferred;
429
439
  if (policy === 'required' && availability.backend === 'none') {
430
440
  lines.push(
431
- ` ${ANSI.red}✗${ANSI.reset} CELILO_HOOK_JAIL=required and no jail is available — every module hook on this host fails rather than run unjailed`,
441
+ ` ${ANSI.red}✗${ANSI.reset} the hook jail policy is 'required' and no jail is available — every module hook on this host fails rather than run unjailed`,
432
442
  );
433
443
  lines.push(` ${ANSI.dim}${availability.reason ?? 'no reason was recorded'}${ANSI.reset}`);
434
444
  failCount++;
@@ -449,6 +459,18 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
449
459
  lines.push(` ${ANSI.green}✔${ANSI.reset} hooks run jailed (${availability.backend})`);
450
460
  }
451
461
 
462
+ // Per-module exemptions (per-module-jail-policy task 3.2). Loud and
463
+ // countable is the whole design: the count AND the names, every time, and
464
+ // nothing at all when there are none.
465
+ if (input.exemptions.length > 0) {
466
+ const n = input.exemptions.length;
467
+ const named = input.exemptions.map((e) => `${e.moduleId} (${e.policy})`).join(', ');
468
+ lines.push(
469
+ ` ${ANSI.yellow}⚠${ANSI.reset} ${n} module${n === 1 ? '' : 's'} run${n === 1 ? 's' : ''} under a weaker jail policy than the system's: ${named}`,
470
+ );
471
+ warnCount++;
472
+ }
473
+
452
474
  const regressed =
453
475
  record && record.host === host && record.mode === 'unjailed' && record.lastJailed;
454
476
  if (regressed && record.lastJailed) {
@@ -498,6 +520,8 @@ export interface HookExecutionIO {
498
520
  record: () => JailModeRecord | undefined;
499
521
  host: () => string;
500
522
  monitored: () => boolean | null;
523
+ /** Modules exempted from the system's jail policy (per-module-jail-policy task 3.2). */
524
+ exemptions: () => JailExemption[];
501
525
  }
502
526
 
503
527
  /**
@@ -516,7 +540,12 @@ export function assembleHookExecutionSection(io: HookExecutionIO): {
516
540
  try {
517
541
  const envValue = io.envValue();
518
542
  const configValue = io.configValue();
519
- const resolved = resolveJailPolicy(envValue, configValue);
543
+ // The module step of the four-step precedence stays out of the headline
544
+ // resolution — the headline is the policy the EXECUTOR would use for a
545
+ // module with no row of its own. Per-module rows are surfaced separately
546
+ // as exemptions (per-module-jail-policy task 3.2), which is the shape the
547
+ // operator acts on.
548
+ const resolved = resolveJailPolicy(envValue, undefined, configValue);
520
549
  return renderHookExecutionSection({
521
550
  availability: io.availability(),
522
551
  policy: resolved.policy,
@@ -526,6 +555,7 @@ export function assembleHookExecutionSection(io: HookExecutionIO): {
526
555
  record: io.record(),
527
556
  host: io.host(),
528
557
  monitored: io.monitored(),
558
+ exemptions: io.exemptions(),
529
559
  });
530
560
  } catch (error) {
531
561
  return {
@@ -845,6 +875,7 @@ export async function handleSystemDoctor(
845
875
  host: () => hostname(),
846
876
  monitored: () =>
847
877
  existsSync(getDbPath()) ? findMonitorByTarget(getDb(), HOOK_JAIL_CHECK) !== undefined : null,
878
+ exemptions: () => (existsSync(getDbPath()) ? collectJailExemptions(getDb()) : []),
848
879
  });
849
880
  lines.push(...hookExec.lines);
850
881
  lines.push('');
@@ -42,7 +42,7 @@ describe('celilo system init deprecation banner', () => {
42
42
  tmpDir = mkdtempSync(join(tmpdir(), 'celilo-deprecation-test-'));
43
43
  savedSuppress = process.env.CELILO_SUPPRESS_DEPRECATION;
44
44
  process.env.CELILO_DB_PATH = join(tmpDir, 'init.db');
45
- process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
45
+ delete process.env.CELILO_SUPPRESS_DEPRECATION;
46
46
  });
47
47
 
48
48
  afterEach(() => {
@@ -34,6 +34,7 @@ import { makeJournalReader, readAppliedMigrations } from '../../services/audit/s
34
34
  import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
35
35
  import { BACKUP_SCHEDULE_CONFIG_KEY } from '../../services/backup-schedule';
36
36
  import { runAllHealthChecks, runModuleHealthCheck } from '../../services/health-runner';
37
+ import { collectJailExemptions } from '../../services/jail-exemptions';
37
38
  import { configOverride, parseStoredConfigValue } from '../../services/module-config';
38
39
  import { deployModule } from '../../services/module-deploy';
39
40
  import { buildModuleGraph } from '../../services/update/dep-graph';
@@ -285,7 +286,11 @@ async function buildSnapshots(
285
286
  * - `health` calls `runModuleHealthCheck`.
286
287
  * - `snapshotCeliloDb` calls `createSystemStateBackup`.
287
288
  */
288
- function buildOps(registry: RegistryClient, wasDeployed: Set<string>): OrchestratorOps {
289
+ function buildOps(
290
+ registry: RegistryClient,
291
+ wasDeployed: Set<string>,
292
+ quiet: boolean,
293
+ ): OrchestratorOps {
289
294
  return {
290
295
  backup: async (moduleId, _updateId) => {
291
296
  // IMPORTED modules don't have running state to back up; skip
@@ -361,9 +366,10 @@ function buildOps(registry: RegistryClient, wasDeployed: Set<string>): Orchestra
361
366
  return { status: 'no-checks', detail: 'not deployed; health check skipped' };
362
367
  }
363
368
  // We can call the existing health-runner directly today — that's
364
- // already a clean service.
369
+ // already a clean service. quiet keeps the check's gauge and plain
370
+ // log lines off stdout when the caller asked for JSON (celilo#1362).
365
371
  const db = getDb();
366
- const r = await runModuleHealthCheck(id, db);
372
+ const r = await runModuleHealthCheck(id, db, { quiet });
367
373
  return { status: r.status, detail: r.error };
368
374
  },
369
375
  snapshotCeliloDb: async (_updateId) => {
@@ -493,7 +499,7 @@ export async function handleSystemUpdate(
493
499
  // Build audit deps. (Mostly mirrors system-audit.ts; could be refactored
494
500
  // into a shared helper in Phase 5.)
495
501
  const migrationsFolder = findMigrationsFolderSafe();
496
- const healthResults = await runAllHealthChecks(db);
502
+ const healthResults = await runAllHealthChecks(db, { quiet: json });
497
503
 
498
504
  // Module integrity, shallow. Local and milliseconds; the host plane is one
499
505
  // SSH per system and belongs to `module verify --deep`.
@@ -576,6 +582,7 @@ export async function handleSystemUpdate(
576
582
  manifest: m.manifestData as ModuleManifest,
577
583
  })),
578
584
  },
585
+ jailExemptions: { exemptions: collectJailExemptions(db) },
579
586
  health: { results: healthResults },
580
587
  backups: {
581
588
  modules: upgradableModules.map((m) => ({
@@ -613,8 +620,11 @@ export async function handleSystemUpdate(
613
620
  // flow's partial audit does no probing. `system audit` and the scheduled
614
621
  // monitor own this check.
615
622
  publicDns: { records: [], probe: unusedPublicDnsProbe },
623
+ // Disk probing is one SSH per system; the update flow's partial audit
624
+ // does no probing. `system audit` and the monitor sweep own this check.
625
+ diskSpace: { results: [] },
616
626
  transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
617
- trustedSources: { firewalls: [] },
627
+ trustedSources: { firewalls: [], unreachableFirewalls: [] },
618
628
  };
619
629
 
620
630
  if (dryRun) {
@@ -682,7 +692,7 @@ export async function handleSystemUpdate(
682
692
  }
683
693
  }
684
694
 
685
- const ops = buildOps(registry, wasDeployed);
695
+ const ops = buildOps(registry, wasDeployed, json);
686
696
 
687
697
  const result = await runSystemUpdate({
688
698
  audit: auditDeps,
@@ -171,6 +171,7 @@ export async function getCompletions(words: string[], current: number): Promise<
171
171
  if (command === 'module' && currentIndex === 1) {
172
172
  const subcommands = [
173
173
  'check',
174
+ 'changeset',
174
175
  'import',
175
176
  'list',
176
177
  'publish',
@@ -187,7 +188,6 @@ export async function getCompletions(words: string[], current: number): Promise<
187
188
  'build',
188
189
  'generate',
189
190
  'deploy',
190
- 'destroy',
191
191
  'health',
192
192
  'logs',
193
193
  'journal',
@@ -201,6 +201,7 @@ export async function getCompletions(words: string[], current: number): Promise<
201
201
  'validate',
202
202
  'pause',
203
203
  'unpause',
204
+ 'jail',
204
205
  ];
205
206
  return filterSuggestions(subcommands, args[1] || '');
206
207
  }
@@ -470,7 +471,6 @@ export async function getCompletions(words: string[], current: number): Promise<
470
471
  const moduleCommands = [
471
472
  'generate',
472
473
  'deploy',
473
- 'destroy',
474
474
  'logs',
475
475
  'journal',
476
476
  'remove',
@@ -483,6 +483,7 @@ export async function getCompletions(words: string[], current: number): Promise<
483
483
  'audit', // deprecation alias for `verify`
484
484
  'pause',
485
485
  'unpause',
486
+ 'jail',
486
487
  ];
487
488
  if (moduleCommands.includes(args[1] || '')) {
488
489
  const db = getDb();
@@ -492,6 +493,11 @@ export async function getCompletions(words: string[], current: number): Promise<
492
493
  }
493
494
  }
494
495
 
496
+ // Module jail <module-id> - complete with policy values
497
+ if (command === 'module' && args[1] === 'jail' && currentIndex === 3) {
498
+ return filterSuggestions(['auto', 'off', 'required'], args[3] || '');
499
+ }
500
+
495
501
  // Module run-hook <module-id> - complete with hook names from manifest
496
502
  if (command === 'module' && args[1] === 'run-hook' && currentIndex === 3) {
497
503
  const db = getDb();
@@ -509,7 +515,16 @@ export async function getCompletions(words: string[], current: number): Promise<
509
515
 
510
516
  // Subscribers subcommands
511
517
  if (command === 'subscribers' && currentIndex === 1) {
512
- const subcommands = ['list', 'add', 'remove', 'test', 'serve', 'status'];
518
+ const subcommands = [
519
+ 'list',
520
+ 'add',
521
+ 'remove',
522
+ 'test',
523
+ 'serve',
524
+ 'status',
525
+ 'install-daemon',
526
+ 'uninstall-daemon',
527
+ ];
513
528
  return filterSuggestions(subcommands, args[1] || '');
514
529
  }
515
530
 
@@ -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
+ });