@celilo/cli 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/CELILO_CORE_MODULES.md +1 -0
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/schemas/system_config.json +5 -0
  9. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  10. package/src/ansible/inventory.test.ts +2 -1
  11. package/src/api/sessions.test.ts +5 -4
  12. package/src/api-clients/proxmox.ts +10 -7
  13. package/src/cli/backup-rename.test.ts +5 -4
  14. package/src/cli/cli.test.ts +3 -2
  15. package/src/cli/commands/console-get-chain.test.ts +2 -1
  16. package/src/cli/commands/events.test.ts +2 -2
  17. package/src/cli/commands/firewall-interface-list.test.ts +4 -3
  18. package/src/cli/commands/machine-list.test.ts +57 -0
  19. package/src/cli/commands/machine-list.ts +35 -4
  20. package/src/cli/commands/module-config.test.ts +2 -1
  21. package/src/cli/commands/module-deploy.ts +8 -2
  22. package/src/cli/commands/module-generate.test.ts +53 -0
  23. package/src/cli/commands/module-generate.ts +31 -26
  24. package/src/cli/commands/module-health.ts +1 -0
  25. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  26. package/src/cli/commands/module-import-registry.test.ts +3 -2
  27. package/src/cli/commands/module-jail.test.ts +242 -0
  28. package/src/cli/commands/module-jail.ts +227 -0
  29. package/src/cli/commands/module-list-jail.test.ts +136 -0
  30. package/src/cli/commands/module-list.ts +30 -3
  31. package/src/cli/commands/module-operations.test.ts +2 -1
  32. package/src/cli/commands/module-publish.test.ts +11 -18
  33. package/src/cli/commands/module-update.test.ts +28 -12
  34. package/src/cli/commands/module-update.ts +4 -1
  35. package/src/cli/commands/module-upgrade.test.ts +130 -1
  36. package/src/cli/commands/module-upgrade.ts +77 -3
  37. package/src/cli/commands/module-verify.test.ts +3 -4
  38. package/src/cli/commands/module-verify.ts +0 -1
  39. package/src/cli/commands/notify-config.test.ts +5 -3
  40. package/src/cli/commands/publish/execute.ts +4 -1
  41. package/src/cli/commands/publish/index.ts +11 -1
  42. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  43. package/src/cli/commands/publish/module-registry.ts +23 -4
  44. package/src/cli/commands/publish/plan.ts +1 -1
  45. package/src/cli/commands/publish/types.ts +7 -0
  46. package/src/cli/commands/registry-owner.test.ts +3 -2
  47. package/src/cli/commands/registry-token.test.ts +3 -2
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  49. package/src/cli/commands/system-audit.ts +20 -6
  50. package/src/cli/commands/system-config.test.ts +148 -0
  51. package/src/cli/commands/system-config.ts +26 -1
  52. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  53. package/src/cli/commands/system-doctor.test.ts +143 -6
  54. package/src/cli/commands/system-doctor.ts +142 -25
  55. package/src/cli/commands/system-init-deprecation.test.ts +7 -4
  56. package/src/cli/commands/system-migrate.test.ts +2 -1
  57. package/src/cli/commands/system-update.ts +5 -0
  58. package/src/cli/completion.ts +8 -2
  59. package/src/cli/flag-surface-gate.test.ts +279 -0
  60. package/src/cli/index.ts +70 -3
  61. package/src/cli/parser.test.ts +37 -1
  62. package/src/cli/restore-command.test.ts +5 -4
  63. package/src/cli/restore-migration-failure.test.ts +4 -3
  64. package/src/cli/tui/audit-state.ts +2 -0
  65. package/src/config/paths.test.ts +22 -22
  66. package/src/db/client.test.ts +46 -1
  67. package/src/db/client.ts +26 -0
  68. package/src/db/migrate.test.ts +2 -1
  69. package/src/db/migrate.ts +16 -16
  70. package/src/db/schema.ts +63 -0
  71. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  72. package/src/hooks/capability-loader.test.ts +78 -0
  73. package/src/hooks/capability-loader.ts +45 -2
  74. package/src/hooks/executor.test.ts +161 -1
  75. package/src/hooks/executor.ts +87 -21
  76. package/src/hooks/hook-jail-unreachability.test.ts +17 -18
  77. package/src/hooks/hook-store.test.ts +6 -0
  78. package/src/hooks/hook-trespass.test.ts +26 -9
  79. package/src/hooks/jail.test.ts +152 -5
  80. package/src/hooks/jail.ts +93 -4
  81. package/src/manifest/contracts/v1.ts +13 -0
  82. package/src/module/packaging/audit.ts +9 -26
  83. package/src/module/packaging/build-paths.test.ts +127 -0
  84. package/src/module/packaging/build-paths.ts +175 -0
  85. package/src/module/packaging/build.test.ts +71 -1
  86. package/src/module/packaging/build.ts +60 -0
  87. package/src/module/packaging/extract.ts +1 -5
  88. package/src/policy/module-script-scan.ts +51 -107
  89. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +14 -15
  91. package/src/registry/client.test.ts +2 -2
  92. package/src/secrets/storage.test.ts +1 -1
  93. package/src/services/alerting/ack.test.ts +2 -1
  94. package/src/services/alerting/cadence-migration.test.ts +3 -2
  95. package/src/services/alerting/coverage-source.test.ts +2 -1
  96. package/src/services/alerting/deferral.test.ts +2 -1
  97. package/src/services/alerting/delivery-loop.test.ts +2 -1
  98. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  99. package/src/services/alerting/inbound-poller.test.ts +2 -1
  100. package/src/services/alerting/inbound.test.ts +2 -1
  101. package/src/services/alerting/keys.test.ts +4 -0
  102. package/src/services/alerting/keys.ts +12 -2
  103. package/src/services/alerting/notification-responder.test.ts +2 -1
  104. package/src/services/alerting/run-monitor.test.ts +2 -1
  105. package/src/services/alerting/store.test.ts +2 -1
  106. package/src/services/alerting/sweep-runner.test.ts +2 -1
  107. package/src/services/alerting/tokens.test.ts +2 -1
  108. package/src/services/aspect-approvals.test.ts +2 -1
  109. package/src/services/aspect-reconcile.test.ts +4 -3
  110. package/src/services/aspect-runner.test.ts +2 -1
  111. package/src/services/audit/health.test.ts +97 -2
  112. package/src/services/audit/health.ts +64 -2
  113. package/src/services/audit/index.test.ts +22 -0
  114. package/src/services/audit/index.ts +8 -2
  115. package/src/services/audit/jail-exemptions.test.ts +42 -0
  116. package/src/services/audit/jail-exemptions.ts +44 -0
  117. package/src/services/audit/module-integrity.test.ts +23 -22
  118. package/src/services/audit/module-integrity.ts +7 -16
  119. package/src/services/audit/types.ts +2 -1
  120. package/src/services/backup-age-agreement.test.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +3 -2
  123. package/src/services/backup-in-flight-refusal.test.ts +3 -2
  124. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
  125. package/src/services/build-bus/hook-dispatcher.ts +49 -1
  126. package/src/services/bus-ensure-flow.test.ts +3 -2
  127. package/src/services/bus-interview-park.test.ts +4 -3
  128. package/src/services/bus-interview.test.ts +2 -2
  129. package/src/services/bus-secret-flow.test.ts +3 -2
  130. package/src/services/capability-table-rows.test.ts +2 -1
  131. package/src/services/celilo-events.test.ts +1 -1
  132. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  133. package/src/services/consumer-cleanup.test.ts +3 -2
  134. package/src/services/container-service.test.ts +3 -2
  135. package/src/services/control-plane-bootstrap.test.ts +2 -1
  136. package/src/services/cross-module-read.test.ts +4 -3
  137. package/src/services/deploy-preflight.ts +7 -0
  138. package/src/services/deploy-validation.test.ts +3 -2
  139. package/src/services/deployed-systems.ts +1 -1
  140. package/src/services/dns-internal-records.test.ts +3 -2
  141. package/src/services/dns-provider-backfill.test.ts +2 -1
  142. package/src/services/dns-registrations.test.ts +2 -1
  143. package/src/services/ensure-interview.test.ts +3 -2
  144. package/src/services/fleet-checks.test.ts +126 -6
  145. package/src/services/fleet-checks.ts +176 -3
  146. package/src/services/fleet-key.test.ts +3 -2
  147. package/src/services/health-runner.test.ts +87 -2
  148. package/src/services/health-runner.ts +40 -11
  149. package/src/services/infrastructure-selector.test.ts +3 -2
  150. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  151. package/src/services/jail-exemptions.test.ts +125 -0
  152. package/src/services/jail-exemptions.ts +81 -0
  153. package/src/services/machine-pool.test.ts +3 -2
  154. package/src/services/module-config.test.ts +2 -1
  155. package/src/services/module-deploy.dns-repoint.test.ts +2 -1
  156. package/src/services/module-deploy.ts +48 -24
  157. package/src/services/module-operations.test.ts +2 -1
  158. package/src/services/module-subscriptions.test.ts +4 -3
  159. package/src/services/network-discovery.test.ts +64 -1
  160. package/src/services/network-discovery.ts +30 -5
  161. package/src/services/port-forwards.test.ts +2 -1
  162. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  163. package/src/services/proxmox-reconcile.test.ts +2 -1
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +7 -6
  166. package/src/services/restore-preflight.test.ts +3 -2
  167. package/src/services/secret-schema-loader.test.ts +2 -1
  168. package/src/services/ssh-key-manager.test.ts +4 -3
  169. package/src/services/static-content-converge.test.ts +5 -4
  170. package/src/services/static-content-converge.ts +33 -10
  171. package/src/services/system-state-stage.test.ts +4 -3
  172. package/src/services/trusted-sources.test.ts +3 -2
  173. package/src/services/update/orchestrator.test.ts +2 -0
  174. package/src/services/zone-detector.test.ts +2 -1
  175. package/src/test-utils/bus-responder.ts +6 -4
  176. package/src/test-utils/db-path.ts +25 -0
  177. package/src/test-utils/integration.ts +7 -0
  178. package/src/test-utils/module-fixtures.ts +5 -6
  179. package/src/module/packaging/generated-plane.test.ts +0 -79
  180. package/src/module/packaging/generated-plane.ts +0 -134
@@ -44,11 +44,12 @@ import {
44
44
  type JailAvailability,
45
45
  type JailModeRecord,
46
46
  type JailPolicy,
47
+ type JailPolicySource,
47
48
  SANDBOX_EXEC_AUTO_DEFERRED_REASON,
48
49
  autoJailDefers,
49
50
  detectJailBackend,
50
- jailPolicy,
51
51
  readJailMode,
52
+ resolveJailPolicy,
52
53
  } from '../../hooks/jail';
53
54
  import type { ModuleManifest } from '../../manifest/schema';
54
55
  import { HOOK_JAIL_CHECK } from '../../services/alerting/hook-jail';
@@ -59,6 +60,7 @@ import {
59
60
  checkSubscribers,
60
61
  runFleetChecks,
61
62
  } from '../../services/fleet-checks';
63
+ import { type JailExemption, collectJailExemptions } from '../../services/jail-exemptions';
62
64
  import { resyncAllSubscriptions } from '../../services/module-subscriptions';
63
65
  import { checkAllPrerequisites, failingPrerequisites } from '../../system/prereqs';
64
66
  import type { CommandResult } from '../types';
@@ -353,6 +355,12 @@ function provisionedBrowserFlavor(): string | null {
353
355
  export interface HookExecutionInput {
354
356
  availability: JailAvailability;
355
357
  policy: JailPolicy;
358
+ /** Which source decided `policy` (D4 of hook-jail-config-surface). */
359
+ source: JailPolicySource;
360
+ /** Raw `CELILO_HOOK_JAIL` value, when set; rendered on the source line. */
361
+ envValue: string | undefined;
362
+ /** Raw stored `hooks.jail_policy` value, when present; rendered on the disagreement line. */
363
+ configValue: string | undefined;
356
364
  /** What the last real module-hook run recorded, if any. */
357
365
  record: JailModeRecord | undefined;
358
366
  host: string;
@@ -361,6 +369,12 @@ export interface HookExecutionInput {
361
369
  * ask (a dev box), which also suppresses the add-the-monitor hint.
362
370
  */
363
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[];
364
378
  }
365
379
 
366
380
  /**
@@ -390,13 +404,41 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
390
404
  let failCount = 0;
391
405
  let warnCount = 0;
392
406
 
407
+ // Which source decided the policy (D4 of hook-jail-config-surface). When
408
+ // env and config both hold values and disagree, name the loser too: a
409
+ // control whose two sources disagree and whose rendering hides one of them
410
+ // is the exact failure this change fixes, one level down.
411
+ if (input.source === 'env' && input.envValue !== undefined) {
412
+ lines.push(
413
+ ` ${ANSI.dim}policy from CELILO_HOOK_JAIL=${input.envValue} (environment override)${ANSI.reset}`,
414
+ );
415
+ } else if (input.source === 'config') {
416
+ lines.push(` ${ANSI.dim}policy from system config (hooks.jail_policy)${ANSI.reset}`);
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.
421
+ lines.push(
422
+ ` ${ANSI.dim}policy default (${policy}) — set hooks.jail_policy or CELILO_HOOK_JAIL to change${ANSI.reset}`,
423
+ );
424
+ }
425
+ if (
426
+ input.source === 'env' &&
427
+ input.configValue !== undefined &&
428
+ input.envValue !== input.configValue
429
+ ) {
430
+ lines.push(
431
+ ` ${ANSI.dim}system config holds '${input.configValue}' but CELILO_HOOK_JAIL='${input.envValue}' overrides it${ANSI.reset}`,
432
+ );
433
+ }
434
+
393
435
  // ce-29z: auto defers on sandbox-exec, so the doctor must not report a jail
394
436
  // the executor will not build. One predicate, two consumers.
395
437
  const autoDeferred = policy === 'auto' && autoJailDefers(availability);
396
438
  const liveJailed = policy !== 'off' && availability.backend !== 'none' && !autoDeferred;
397
439
  if (policy === 'required' && availability.backend === 'none') {
398
440
  lines.push(
399
- ` ${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`,
400
442
  );
401
443
  lines.push(` ${ANSI.dim}${availability.reason ?? 'no reason was recorded'}${ANSI.reset}`);
402
444
  failCount++;
@@ -417,6 +459,18 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
417
459
  lines.push(` ${ANSI.green}✔${ANSI.reset} hooks run jailed (${availability.backend})`);
418
460
  }
419
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
+
420
474
  const regressed =
421
475
  record && record.host === host && record.mode === 'unjailed' && record.lastJailed;
422
476
  if (regressed && record.lastJailed) {
@@ -456,6 +510,79 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
456
510
  return { lines, failCount, warnCount };
457
511
  }
458
512
 
513
+ /** Where `assembleHookExecutionSection` gets what the machine says. Every
514
+ * reader is a thunk so any of their throws (a bad stored row, a corrupt
515
+ * record) land in the one catch that renders this section. */
516
+ export interface HookExecutionIO {
517
+ envValue: () => string | undefined;
518
+ configValue: () => string | undefined;
519
+ availability: () => JailAvailability;
520
+ record: () => JailModeRecord | undefined;
521
+ host: () => string;
522
+ monitored: () => boolean | null;
523
+ /** Modules exempted from the system's jail policy (per-module-jail-policy task 3.2). */
524
+ exemptions: () => JailExemption[];
525
+ }
526
+
527
+ /**
528
+ * Resolve the effective jail policy from its two sources and render the
529
+ * Hook execution section (hook-jail-config-surface D2/D4).
530
+ *
531
+ * The single catch is the section's one error path: a typo'd env value or a
532
+ * bad stored row renders as a failing check rather than crashing the doctor,
533
+ * because the same throw is about to fail every hook invocation too.
534
+ */
535
+ export function assembleHookExecutionSection(io: HookExecutionIO): {
536
+ lines: string[];
537
+ failCount: number;
538
+ warnCount: number;
539
+ } {
540
+ try {
541
+ const envValue = io.envValue();
542
+ const configValue = io.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);
549
+ return renderHookExecutionSection({
550
+ availability: io.availability(),
551
+ policy: resolved.policy,
552
+ source: resolved.source,
553
+ envValue,
554
+ configValue,
555
+ record: io.record(),
556
+ host: io.host(),
557
+ monitored: io.monitored(),
558
+ exemptions: io.exemptions(),
559
+ });
560
+ } catch (error) {
561
+ return {
562
+ lines: [
563
+ 'Hook execution',
564
+ ` ${ANSI.red}✗${ANSI.reset} ${error instanceof Error ? error.message : String(error)}`,
565
+ ],
566
+ failCount: 1,
567
+ warnCount: 0,
568
+ };
569
+ }
570
+ }
571
+
572
+ /**
573
+ * The stored `hooks.jail_policy` value, or undefined when there is no row or
574
+ * no celilo DB to ask (a dev box). Same row-read pattern as
575
+ * `ipam/auto-allocator.ts`; the drizzle table is `systemConfig` in
576
+ * `db/schema.ts`.
577
+ */
578
+ function readStoredJailPolicy(): string | undefined {
579
+ if (!existsSync(getDbPath())) return undefined;
580
+ const row = getDb()
581
+ .$client.prepare('SELECT value FROM system_config WHERE key = ?')
582
+ .get('hooks.jail_policy') as { value: string } | undefined;
583
+ return row?.value;
584
+ }
585
+
459
586
  /**
460
587
  * mtime (ms) of the installed dispatcher code (`@celilo/event-bus`
461
588
  * package.json). The fleet dispatcher check compares this against the
@@ -736,30 +863,20 @@ export async function handleSystemDoctor(
736
863
  lines.push(...prereqResult.lines);
737
864
  lines.push('');
738
865
 
739
- // Hook execution mode (hook-process-boundary task 4.6). The policy read
740
- // throws on a typo'd CELILO_HOOK_JAIL — report that rather than crash the
866
+ // Hook execution mode (hook-process-boundary task 4.6; sources per D4 of
867
+ // hook-jail-config-surface). The policy read throws on a typo'd
868
+ // CELILO_HOOK_JAIL or a bad stored row — report that rather than crash the
741
869
  // doctor, since the same throw is about to fail every hook invocation too.
742
- let hookExec: { lines: string[]; failCount: number; warnCount: number };
743
- try {
744
- hookExec = renderHookExecutionSection({
745
- availability: detectJailBackend(),
746
- policy: jailPolicy(),
747
- record: readJailMode(),
748
- host: hostname(),
749
- monitored: existsSync(getDbPath())
750
- ? findMonitorByTarget(getDb(), HOOK_JAIL_CHECK) !== undefined
751
- : null,
752
- });
753
- } catch (error) {
754
- hookExec = {
755
- lines: [
756
- 'Hook execution',
757
- ` ${ANSI.red}✗${ANSI.reset} ${error instanceof Error ? error.message : String(error)}`,
758
- ],
759
- failCount: 1,
760
- warnCount: 0,
761
- };
762
- }
870
+ const hookExec = assembleHookExecutionSection({
871
+ envValue: () => process.env.CELILO_HOOK_JAIL,
872
+ configValue: () => readStoredJailPolicy(),
873
+ availability: () => detectJailBackend(),
874
+ record: () => readJailMode(),
875
+ host: () => hostname(),
876
+ monitored: () =>
877
+ existsSync(getDbPath()) ? findMonitorByTarget(getDb(), HOOK_JAIL_CHECK) !== undefined : null,
878
+ exemptions: () => (existsSync(getDbPath()) ? collectJailExemptions(getDb()) : []),
879
+ });
763
880
  lines.push(...hookExec.lines);
764
881
  lines.push('');
765
882
 
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
12
12
  import { mkdtempSync, rmSync } from 'node:fs';
13
13
  import { tmpdir } from 'node:os';
14
14
  import { join } from 'node:path';
15
+ import { resetTestDbPath } from '../../test-utils/db-path';
15
16
  import { handleSystemInit } from './system-init';
16
17
 
17
18
  interface CapturedStream {
@@ -35,19 +36,21 @@ function captureStderr(): CapturedStream {
35
36
 
36
37
  describe('celilo system init deprecation banner', () => {
37
38
  let tmpDir: string;
38
- let savedDbPath: string | undefined;
39
39
  let savedSuppress: string | undefined;
40
40
 
41
41
  beforeEach(() => {
42
42
  tmpDir = mkdtempSync(join(tmpdir(), 'celilo-deprecation-test-'));
43
- savedDbPath = process.env.CELILO_DB_PATH;
44
43
  savedSuppress = process.env.CELILO_SUPPRESS_DEPRECATION;
45
44
  process.env.CELILO_DB_PATH = join(tmpDir, 'init.db');
46
- process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
45
+ delete process.env.CELILO_SUPPRESS_DEPRECATION;
47
46
  });
48
47
 
49
48
  afterEach(() => {
50
- process.env.CELILO_DB_PATH = savedDbPath;
49
+ // Never restore the previous value: it may be another suite's temp
50
+ // database (or, worse, unset — which sends the next var-less reader to
51
+ // the operator's real celilo.db). The scratch path is the neutral state
52
+ // (celilo#1315).
53
+ resetTestDbPath();
51
54
  process.env.CELILO_SUPPRESS_DEPRECATION = savedSuppress;
52
55
  rmSync(tmpDir, { recursive: true, force: true });
53
56
  });
@@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { closeDb, findMigrationsFolder } from '../../db/client';
7
+ import { resetTestDbPath } from '../../test-utils/db-path';
7
8
  import { handleSystemMigrate } from './system-migrate';
8
9
 
9
10
  /**
@@ -32,7 +33,7 @@ describe('handleSystemMigrate', () => {
32
33
  });
33
34
  afterEach(() => {
34
35
  closeDb();
35
- process.env.CELILO_DB_PATH = undefined;
36
+ resetTestDbPath();
36
37
  try {
37
38
  rmSync(dir, { recursive: true, force: true });
38
39
  } catch {
@@ -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';
@@ -576,6 +577,7 @@ export async function handleSystemUpdate(
576
577
  manifest: m.manifestData as ModuleManifest,
577
578
  })),
578
579
  },
580
+ jailExemptions: { exemptions: collectJailExemptions(db) },
579
581
  health: { results: healthResults },
580
582
  backups: {
581
583
  modules: upgradableModules.map((m) => ({
@@ -613,6 +615,9 @@ export async function handleSystemUpdate(
613
615
  // flow's partial audit does no probing. `system audit` and the scheduled
614
616
  // monitor own this check.
615
617
  publicDns: { records: [], probe: unusedPublicDnsProbe },
618
+ // Disk probing is one SSH per system; the update flow's partial audit
619
+ // does no probing. `system audit` and the monitor sweep own this check.
620
+ diskSpace: { results: [] },
616
621
  transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
617
622
  trustedSources: { firewalls: [] },
618
623
  };
@@ -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();
@@ -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
+ });