@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
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `celilo subscribers install-daemon` — write a systemd unit (Linux) or
3
+ * launchd plist (macOS) that runs the build-bus webhook receiver
4
+ * (`celilo subscribers serve`) under supervision. Mirrors
5
+ * `celilo events install-daemon` for the dispatcher: default is a
6
+ * per-user unit; `--system` writes the system-scope unit that survives
7
+ * logout/reboot — the management-plane shape. Writes the file but
8
+ * doesn't touch supervisor state — the operator (or the celilo-mgmt
9
+ * Ansible role) runs the enable/bootstrap steps themselves.
10
+ *
11
+ * The rendered unit runs `subscribers serve --no-dispatch` by default:
12
+ * the standing dispatcher (celilo-events.service) owns dispatch on a
13
+ * management box, and a second dispatcher polling the same bus is the
14
+ * celilo#580/#610 failure. Pass --dispatch for a combined unit on a box
15
+ * that has no events daemon.
16
+ *
17
+ * celilo#1304: nothing installed the receiver anywhere, so celilo-mgr's
18
+ * on_upstream_publish self-update could never fire. This command is the
19
+ * missing installer.
20
+ */
21
+
22
+ import {
23
+ installReceiverDaemon,
24
+ planReceiverInstall,
25
+ uninstallReceiverDaemon,
26
+ } from '../../services/build-bus/receiver-daemon';
27
+ import type { CommandResult } from '../types';
28
+ import { resolveServeSecret } from './subscribers-serve';
29
+
30
+ function shortenPath(p: string): string {
31
+ return p.startsWith(process.env.HOME ?? '\0') ? p.replace(process.env.HOME ?? '', '~') : p;
32
+ }
33
+
34
+ function daemonScopeFrom(flags: Record<string, string | boolean>): 'user' | 'system' {
35
+ return flags.system === true ? 'system' : 'user';
36
+ }
37
+
38
+ export async function handleSubscribersInstallDaemon(
39
+ _args: string[],
40
+ flags: Record<string, string | boolean>,
41
+ env: Record<string, string | undefined> = process.env,
42
+ ): Promise<CommandResult> {
43
+ try {
44
+ const options = {
45
+ celiloPath: typeof flags['celilo-path'] === 'string' ? flags['celilo-path'] : undefined,
46
+ port: flags.port ? Number(flags.port) : undefined,
47
+ // Same resolution as `serve`: the flag wins, CELILO_BUS_SECRET is
48
+ // the documented fallback (the doctor remediation names it), and
49
+ // null means the renderer refuses rather than invent a secret.
50
+ secret: resolveServeSecret(flags, env) ?? undefined,
51
+ scope: daemonScopeFrom(flags),
52
+ dispatch: flags.dispatch === true,
53
+ };
54
+ // --print: render-only, raw unit content on stdout. The celilo-mgmt
55
+ // Ansible role captures this and does the root-owned write itself
56
+ // (the deb wrapper runs celilo as the unprivileged celilo user, so
57
+ // a direct --system write would EACCES on /etc/systemd/system).
58
+ if (flags.print) {
59
+ const plan = planReceiverInstall(options);
60
+ return { success: true, message: plan.unitContent, rawOutput: true, data: plan };
61
+ }
62
+ const result = installReceiverDaemon(options);
63
+ const lines = [
64
+ `Wrote ${result.platform} ${result.scope} unit: ${shortenPath(result.unitPath)}`,
65
+ ` celilo: ${shortenPath(result.celiloPath)}`,
66
+ ` port: ${result.port}`,
67
+ ` dispatch: ${result.dispatch ? 'combined (receiver + hooks)' : 'receiver-only (--no-dispatch)'}`,
68
+ ...(result.runAsUser ? [` runs as: ${result.runAsUser}`] : []),
69
+ '',
70
+ 'The unit embeds the shared secret the publisher must sign webhooks with —',
71
+ "register this host in the publisher's build-bus-subscribers.json with the",
72
+ 'same value (`celilo subscribers add <this-host-url> --secret <same secret>`).',
73
+ '',
74
+ 'Next steps (run these yourself — install does not touch supervisor state):',
75
+ ...result.nextSteps.map((s) => ` ${s}`),
76
+ ];
77
+ return { success: true, message: lines.join('\n'), data: result };
78
+ } catch (err) {
79
+ return {
80
+ success: false,
81
+ error: err instanceof Error ? err.message : String(err),
82
+ };
83
+ }
84
+ }
85
+
86
+ export async function handleSubscribersUninstallDaemon(
87
+ _args: string[],
88
+ flags: Record<string, string | boolean>,
89
+ ): Promise<CommandResult> {
90
+ try {
91
+ const result = uninstallReceiverDaemon({ scope: daemonScopeFrom(flags) });
92
+ const lines = [
93
+ result.removed
94
+ ? `Removed unit: ${shortenPath(result.unitPath)}`
95
+ : `No unit file at ${shortenPath(result.unitPath)} — nothing to remove.`,
96
+ '',
97
+ 'Next steps:',
98
+ ...result.nextSteps.map((s) => ` ${s}`),
99
+ ];
100
+ return { success: true, message: lines.join('\n'), data: result };
101
+ } catch (err) {
102
+ return {
103
+ success: false,
104
+ error: err instanceof Error ? err.message : String(err),
105
+ };
106
+ }
107
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { resolveServeSecret } from './subscribers-serve';
3
+
4
+ describe('resolveServeSecret', () => {
5
+ it('prefers the --secret flag over the environment', () => {
6
+ expect(resolveServeSecret({ secret: 'flag-secret' }, { CELILO_BUS_SECRET: 'env-secret' })).toBe(
7
+ 'flag-secret',
8
+ );
9
+ });
10
+
11
+ it('falls back to CELILO_BUS_SECRET when the flag is absent', () => {
12
+ expect(resolveServeSecret({}, { CELILO_BUS_SECRET: 'env-secret' })).toBe('env-secret');
13
+ });
14
+
15
+ it('ignores an empty env value', () => {
16
+ expect(resolveServeSecret({}, { CELILO_BUS_SECRET: '' })).toBeNull();
17
+ });
18
+
19
+ it('returns null when neither source has a secret', () => {
20
+ expect(resolveServeSecret({}, {})).toBeNull();
21
+ });
22
+ });
@@ -16,6 +16,24 @@ import { startHookDispatcher } from '../../services/build-bus/hook-dispatcher';
16
16
  import { startReceiverServer } from '../../services/build-bus/receiver-server';
17
17
  import type { CommandResult } from '../types';
18
18
 
19
+ /**
20
+ * Resolve the receiver's shared HMAC secret. The `--secret` flag wins;
21
+ * otherwise `CELILO_BUS_SECRET` from the environment — which is what
22
+ * the supervisor unit (celilo-build-bus-receiver.service / the launchd
23
+ * plist) sets, and what the doctor's build-bus-publishing remediation
24
+ * has always told operators to export. Returns null when neither has a
25
+ * usable value; the caller turns that into the flag-required error.
26
+ */
27
+ export function resolveServeSecret(
28
+ flags: Record<string, string | boolean>,
29
+ env: Record<string, string | undefined> = process.env,
30
+ ): string | null {
31
+ if (typeof flags.secret === 'string' && flags.secret) return flags.secret;
32
+ const fromEnv = env.CELILO_BUS_SECRET;
33
+ if (typeof fromEnv === 'string' && fromEnv) return fromEnv;
34
+ return null;
35
+ }
36
+
19
37
  export async function handleSubscribersServe(
20
38
  _args: string[],
21
39
  flags: Record<string, string | boolean>,
@@ -27,11 +45,11 @@ export async function handleSubscribersServe(
27
45
  error: `Invalid --port value: ${flags.port}. Must be 1–65535.`,
28
46
  };
29
47
  }
30
- const secret = flags.secret;
31
- if (typeof secret !== 'string' || !secret) {
48
+ const secret = resolveServeSecret(flags);
49
+ if (!secret) {
32
50
  return {
33
51
  success: false,
34
- error: '--secret <shared-hmac-secret> is required.',
52
+ error: '--secret <shared-hmac-secret> is required (or export CELILO_BUS_SECRET).',
35
53
  };
36
54
  }
37
55
 
@@ -47,7 +65,7 @@ export async function handleSubscribersServe(
47
65
  secret,
48
66
  onEvent: dispatcher
49
67
  ? async (envelope) => {
50
- // handleEvent returns HookRunResult[]; the receiver's
68
+ // handleEvent returns UpstreamHookResult[]; the receiver's
51
69
  // onEvent contract is void | Promise<void>. Discard the
52
70
  // array — the dispatcher logs its own outcomes.
53
71
  await dispatcher.handleEvent(envelope.event);
@@ -45,7 +45,6 @@ import {
45
45
  type LatestCliVersionFetcher,
46
46
  fetchLatestCliVersion,
47
47
  } from '../../services/audit/cli-version';
48
- import type { MachineReachableResult } from '../../services/audit/machines-reachable';
49
48
  import type { ModuleVersionFetcher } from '../../services/audit/module-versions';
50
49
  import {
51
50
  loadPublicDnsEvidence,
@@ -57,8 +56,10 @@ import type { ServiceCredentialsResult } from '../../services/audit/services-cre
57
56
  import type { ServiceReachableResult } from '../../services/audit/services-reachable';
58
57
  import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
59
58
  import { getServiceCredentials, listContainerServices } from '../../services/container-service';
59
+ import { probeDiskUsage } from '../../services/disk-probe';
60
60
  import { collectFirewallReach } from '../../services/firewall-reach';
61
61
  import { runAllHealthChecks } from '../../services/health-runner';
62
+ import { collectJailExemptions } from '../../services/jail-exemptions';
62
63
  import { probeMachines } from '../../services/machine-probe';
63
64
  import { parseStoredConfigValue } from '../../services/module-config';
64
65
  import { createPublicDnsProbe, loadPublicDnsProbeSettings } from '../../services/public-dns-probe';
@@ -153,8 +154,14 @@ function findMigrationsFolderSafe(): string | null {
153
154
  * indicators (FuelGauge) inside the audit subroutines and routes
154
155
  * progress messages to the caller instead — used by the TUI so
155
156
  * audit progress appears inside the alt-screen render.
157
+ *
158
+ * `quiet` (when true) silences them outright: the caller is going to
159
+ * emit a JSON document on stdout, and a gauge's frames are not JSON
160
+ * (celilo#1362).
156
161
  */
157
- async function buildAuditDeps(onProgress?: (msg: string) => void) {
162
+ async function buildAuditDeps(
163
+ options: { onProgress?: (msg: string) => void; quiet?: boolean } = {},
164
+ ) {
158
165
  const db = getDb();
159
166
 
160
167
  const installed = db.select().from(modules).all();
@@ -209,7 +216,10 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
209
216
  envVars: terraformEnvByModule.get(m.id) ?? {},
210
217
  }));
211
218
 
212
- const healthResults = await runAllHealthChecks(db, { onProgress });
219
+ const healthResults = await runAllHealthChecks(db, {
220
+ onProgress: options.onProgress,
221
+ quiet: options.quiet,
222
+ });
213
223
 
214
224
  // Module integrity. Shallow by default: the installed tree against its
215
225
  // baseline, and the generated project against the installed tree. Both are
@@ -341,11 +351,22 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
341
351
  }),
342
352
  );
343
353
 
344
- // Machines-reachable: probe each pool machine. Shared with the monitor sweep
345
- // (services/machine-probe.ts) — this block used to be a second copy of the
346
- // same SSH logic, and both copies reported the local management box as
347
- // unreachable because celilo does not hold an SSH key for itself.
348
- const machineReachableResults: MachineReachableResult[] = await probeMachines();
354
+ // Machines-reachable and disk-space both SSH every system, so they run
355
+ // concurrently here rather than paying each other's latency. The machine
356
+ // probe is shared with the monitor sweep (services/machine-probe.ts) —
357
+ // this block used to be a second copy of the same SSH logic, and both
358
+ // copies reported the local management box as unreachable because celilo
359
+ // does not hold an SSH key for itself.
360
+ //
361
+ // The disk probe measures every system's root filesystem — the machine
362
+ // pool AND every provisioned instance, the systems celilo#1133 filled —
363
+ // in the same pre-computed measurement shape the disk_space monitor
364
+ // sweep consumes, so an audit finding and an alert page carry the same
365
+ // numbers.
366
+ const [machineReachableResults, diskUsageResults] = await Promise.all([
367
+ probeMachines(),
368
+ probeDiskUsage(db),
369
+ ]);
349
370
 
350
371
  const migrationsFolder = findMigrationsFolderSafe();
351
372
 
@@ -384,6 +405,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
384
405
  manifest: m.manifestData as ModuleManifest,
385
406
  })),
386
407
  },
408
+ jailExemptions: { exemptions: collectJailExemptions(db) },
387
409
  health: { results: healthResults },
388
410
  backups: { modules: installedBackupInfo },
389
411
  abandonedOperations: { records: loadAbandonedOperations(db) },
@@ -417,6 +439,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
417
439
  probe: createPublicDnsProbe(loadPublicDnsProbeSettings(db)),
418
440
  evidence: loadPublicDnsEvidence(db),
419
441
  },
442
+ diskSpace: { results: diskUsageResults },
420
443
  // Reads the record the poller already writes — this check never performs a
421
444
  // read of its own. One that did would drain the queue and eat the
422
445
  // acknowledgement it exists to protect (#541).
@@ -428,7 +451,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
428
451
  // still catching a transport that has genuinely stopped being readable.
429
452
  staleAfterMs: 30 * 60_000,
430
453
  },
431
- trustedSources: { firewalls: collectFirewallReach(db) },
454
+ trustedSources: collectFirewallReach(db),
432
455
  };
433
456
  }
434
457
 
@@ -528,7 +551,7 @@ export async function handleSystemAudit(
528
551
  // and so the spinner caption keeps changing during the
529
552
  // parallel-audit phase (which doesn't emit its own).
530
553
  onProgress('Reading system state…');
531
- const deps = await buildAuditDeps(onProgress);
554
+ const deps = await buildAuditDeps({ onProgress });
532
555
  onProgress('Analyzing drift across categories…');
533
556
  return runAudit(deps, onCategory);
534
557
  },
@@ -545,7 +568,7 @@ export async function handleSystemAudit(
545
568
 
546
569
  let deps: Awaited<ReturnType<typeof buildAuditDeps>>;
547
570
  try {
548
- deps = await buildAuditDeps();
571
+ deps = await buildAuditDeps({ quiet: json });
549
572
  } catch (err) {
550
573
  return {
551
574
  success: false,
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Doctor remediation-arrow gate (celilo#1308).
3
+ *
4
+ * `celilo system doctor` renders `→ <command>` remediation lines under failing
5
+ * checks. Those strings are built by hand in three files and were never checked
6
+ * against the CLI's own command table, so doctor prescribed commands that
7
+ * cannot run:
8
+ *
9
+ * - `celilo module update <module-id>` — `module update` takes a PATH, so a
10
+ * module id names an argument form that does not exist (the lunacycle case
11
+ * in #1308).
12
+ * - `celilo system doctor --deep` — the doctor's own instruction, rejected by
13
+ * `checkFlags` because `deep` was never declared on `system doctor`.
14
+ * - `celilo capability chain <module> <var>` — a command the ISS-0115 rework
15
+ * removed from the registry.
16
+ *
17
+ * The gate scans the three files that produce the doctor's `→` surface
18
+ * (system-doctor.ts, fleet-checks.ts, module-integrity.ts) for `celilo ...`
19
+ * command spans and validates each against COMMANDS from @celilo/core:
20
+ *
21
+ * 1. every command word must resolve along the registry tree,
22
+ * 2. every flag must be declared on the leaf command,
23
+ * 3. positional count must fit the leaf command's declared args,
24
+ * 4. a value known to be a module id must never fill a path slot (an arg whose
25
+ * completion is `directories` or `files`), variadic or not.
26
+ *
27
+ * Commands are parsed, never executed. A span is "strict" when a quote or
28
+ * backtick delimits it (the author wrote a command); a `celilo ...` occurrence
29
+ * without a delimiter is a hint embedded in a sentence and is only validated
30
+ * when everything after the command path is argument-shaped — trailing plain
31
+ * words mean the line talks ABOUT celilo ("celilo knows where its control
32
+ * plane runs"), which is prose, not a prescription.
33
+ *
34
+ * Reach floors: the scan asserts it resolved a real number of command spans
35
+ * and contains the anchors it exists to police, because a scan that resolves
36
+ * nothing exits 0 and proves nothing.
37
+ */
38
+
39
+ import { describe, expect, it } from 'bun:test';
40
+ import { readFileSync } from 'node:fs';
41
+ import { join } from 'node:path';
42
+ import { type ArgDef, COMMANDS, type CommandDef } from '@celilo/core';
43
+
44
+ const SOURCES = [
45
+ join(import.meta.dir, 'system-doctor.ts'),
46
+ join(import.meta.dir, '../../services/fleet-checks.ts'),
47
+ join(import.meta.dir, '../../services/audit/module-integrity.ts'),
48
+ ];
49
+
50
+ /** Completion kinds whose value is a filesystem path, not an id. */
51
+ const PATH_COMPLETIONS = new Set(['directories', 'files']);
52
+
53
+ const INTERP = '\u0000';
54
+
55
+ /** `${...}` expressions found by masking, in order. */
56
+ const interps: string[] = [];
57
+
58
+ function maskInterpolations(source: string): string {
59
+ interps.length = 0;
60
+ return source.replace(/\$\{([^{}]*)\}/g, (_m, expr: string) => {
61
+ interps.push(expr);
62
+ return `${INTERP}${interps.length - 1}${INTERP}`;
63
+ });
64
+ }
65
+
66
+ function interpText(masked: string): string {
67
+ const m = masked.match(new RegExp(`${INTERP}(\\d+)${INTERP}`));
68
+ return m ? (interps[Number(m[1])] ?? masked) : masked;
69
+ }
70
+
71
+ interface ParsedToken {
72
+ kind: 'word' | 'flag' | 'positional';
73
+ text: string;
74
+ /** True when the token's runtime value is a module id. */
75
+ isModuleId: boolean;
76
+ }
77
+
78
+ /**
79
+ * One `celilo ...` occurrence: the tokens after the word `celilo`, plus
80
+ * whether a quote delimited it (strict).
81
+ */
82
+ interface Span {
83
+ tokens: ParsedToken[];
84
+ strict: boolean;
85
+ raw: string;
86
+ }
87
+
88
+ const FLAG = /^--[a-z0-9][a-z0-9-]*/;
89
+ const PLACEHOLDER = /^<[^<>\s]+>/;
90
+
91
+ function tokenize(text: string): ParsedToken[] {
92
+ const tokens: ParsedToken[] = [];
93
+ let rest = text;
94
+ while (rest.length > 0) {
95
+ rest = rest.replace(/^[\s,;]+/, '');
96
+ if (rest.length === 0) break;
97
+ let m = rest.match(FLAG);
98
+ if (m) {
99
+ tokens.push({ kind: 'flag', text: m[0].slice(2), isModuleId: false });
100
+ rest = rest.slice(m[0].length);
101
+ continue;
102
+ }
103
+ m = rest.match(PLACEHOLDER);
104
+ if (m) {
105
+ tokens.push({ kind: 'positional', text: m[0], isModuleId: false });
106
+ rest = rest.slice(m[0].length);
107
+ continue;
108
+ }
109
+ m = rest.match(/^\u0000(\d+)\u0000/);
110
+ if (m) {
111
+ const expr = interps[Number(m[1])] ?? '';
112
+ rest = rest.slice(m[0].length);
113
+ // ANSI chrome (${ANSI.reset} and friends) renders as an escape code,
114
+ // not as a value the operator types.
115
+ if (/^ANSI\.\w+$/.test(expr.trim())) continue;
116
+ tokens.push({ kind: 'positional', text: expr, isModuleId: /\bmoduleId\b/.test(expr) });
117
+ continue;
118
+ }
119
+ m = rest.match(/^[a-z][a-z0-9-]*(?:\.\u0000\d+\u0000[a-z0-9-]*)*/);
120
+ if (m) {
121
+ // A word that carries an interpolation (a key built at runtime, like
122
+ // network.<zone>.subnet) is a value, never a subcommand.
123
+ const isInterp = m[0].includes(INTERP);
124
+ const isModuleId = isInterp
125
+ ? (m[0].match(/\u0000(\d+)\u0000/g) ?? []).some((mark) =>
126
+ /\bmoduleId\b/.test(interpText(mark)),
127
+ )
128
+ : false;
129
+ tokens.push({ kind: isInterp ? 'positional' : 'word', text: m[0], isModuleId });
130
+ rest = rest.slice(m[0].length);
131
+ continue;
132
+ }
133
+ break; // punctuation, a closing quote, prose — the command part is over
134
+ }
135
+ return tokens;
136
+ }
137
+
138
+ /** Extract every `celilo ` occurrence with its span, per the rules above. */
139
+ function extractSpans(maskedLine: string): Span[] {
140
+ const spans: Span[] = [];
141
+ const re = /\bcelilo(?=\s)/g;
142
+ let m: RegExpExecArray | null = re.exec(maskedLine);
143
+ while (m !== null) {
144
+ const before = m.index > 0 ? maskedLine[m.index - 1] : '';
145
+ const delimiter = before === '`' || before === "'" || before === '"' ? before : null;
146
+ const start = m.index + 'celilo'.length;
147
+ let content: string;
148
+ let strict: boolean;
149
+ if (delimiter) {
150
+ const closer = maskedLine.indexOf(delimiter, start);
151
+ strict = true;
152
+ content = closer === -1 ? maskedLine.slice(start) : maskedLine.slice(start, closer);
153
+ if (closer !== -1) re.lastIndex = closer + 1;
154
+ } else {
155
+ strict = false;
156
+ content = maskedLine.slice(start);
157
+ }
158
+ const tokens = tokenize(content);
159
+ if (tokens.length > 0) spans.push({ tokens, strict, raw: content.trim() });
160
+ if (re.lastIndex <= m.index) re.lastIndex = start;
161
+ m = re.exec(maskedLine);
162
+ }
163
+ return spans;
164
+ }
165
+
166
+ function walkPath(tokens: ParsedToken[]): { path: CommandDef[]; rest: ParsedToken[] } {
167
+ const path: CommandDef[] = [];
168
+ let node: CommandDef | undefined = COMMANDS.find((c) => c.name === tokens[0]?.text);
169
+ if (!node) return { path, rest: tokens };
170
+ path.push(node);
171
+ let i = 1;
172
+ while (i < tokens.length && tokens[i].kind === 'word') {
173
+ const next: CommandDef | undefined = node.subcommands?.find((s) => s.name === tokens[i].text);
174
+ if (!next) break;
175
+ node = next;
176
+ path.push(next);
177
+ i++;
178
+ }
179
+ return { path, rest: tokens.slice(i) };
180
+ }
181
+
182
+ /** Which declared arg the i-th positional fills (variadic absorbs the tail). */
183
+ function slotFor(args: ArgDef[], i: number): ArgDef | undefined {
184
+ const variadicIndex = args.findIndex((a) => a.variadic);
185
+ const fixed = variadicIndex === -1 ? args.length : variadicIndex;
186
+ if (i < fixed) return args[i];
187
+ return variadicIndex === -1 ? undefined : args[variadicIndex];
188
+ }
189
+
190
+ function validateSpan(span: Span): string[] {
191
+ const { path, rest } = walkPath(span.tokens);
192
+ if (path.length === 0) return []; // first word is not a command: prose about celilo
193
+
194
+ // A hint embedded in prose is only a prescription when everything after the
195
+ // command path is argument-shaped. Trailing plain words mean the sentence
196
+ // continues ("celilo system migrate` to apply pending migrations...").
197
+ if (!span.strict && rest.some((t) => t.kind === 'word')) return [];
198
+
199
+ const name = `celilo ${path.map((c) => c.name).join(' ')}`;
200
+ const leaf = path[path.length - 1];
201
+ const violations: string[] = [];
202
+
203
+ for (const t of rest.filter((t) => t.kind === 'word')) {
204
+ violations.push(`${name}: unknown command word '${t.text}'`);
205
+ }
206
+
207
+ const declaredFlags = new Set([...(leaf.flags ?? []).map((f) => f.name), 'help', 'h']);
208
+ for (const t of rest.filter((t) => t.kind === 'flag')) {
209
+ if (!declaredFlags.has(t.text)) {
210
+ violations.push(`${name}: flag --${t.text} is not declared in the command registry`);
211
+ }
212
+ }
213
+
214
+ const args = leaf.args ?? [];
215
+ const positionals = rest.filter((t) => t.kind === 'positional');
216
+ if (!args.some((a) => a.variadic) && positionals.length > args.length) {
217
+ violations.push(
218
+ `${name}: ${positionals.length} positional value(s) but only ${args.length} declared arg(s)`,
219
+ );
220
+ }
221
+ positionals.forEach((token, i) => {
222
+ const slot = slotFor(args, i);
223
+ if (!slot) return;
224
+ if (
225
+ token.isModuleId &&
226
+ slot.completion !== undefined &&
227
+ PATH_COMPLETIONS.has(slot.completion)
228
+ ) {
229
+ violations.push(
230
+ `${name}: module id '${token.text}' fills the '${slot.name}' slot, which takes a path (${slot.completion})`,
231
+ );
232
+ }
233
+ });
234
+
235
+ return violations;
236
+ }
237
+
238
+ describe('doctor remediation arrow gate (celilo#1308)', () => {
239
+ it('flags a module id passed into a path slot, even a variadic one', () => {
240
+ // Rule 7.6 anchor: the exact defect shape from #1308 (`module update
241
+ // <module-id>`), pinned synthetically so the rule cannot silently stop
242
+ // matching the registry.
243
+ maskInterpolations(''); // reset
244
+ interps.push('result.moduleId');
245
+ const violations = validateSpan({
246
+ tokens: tokenize(`module update ${INTERP}0${INTERP}`),
247
+ strict: true,
248
+ raw: 'module update ${result.moduleId}',
249
+ });
250
+ expect(violations.some((v) => v.includes('takes a path'))).toBe(true);
251
+ });
252
+
253
+ it('every command the doctor prescribes exists, with declared flags and fitting args', () => {
254
+ const violations: string[] = [];
255
+ let resolvedCount = 0;
256
+ const resolvedHeads = new Set<string>();
257
+
258
+ for (const file of SOURCES) {
259
+ const raw = readFileSync(file, 'utf8');
260
+ // Strip block comments and whole-line // comments so doc headers that
261
+ // mention commands historically are not prescribed commands now.
262
+ const stripped = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
263
+ const masked = maskInterpolations(stripped);
264
+ const shortFile = file.split('/').pop() ?? file;
265
+
266
+ for (const line of masked.split('\n')) {
267
+ for (const span of extractSpans(line)) {
268
+ const found = validateSpan(span);
269
+ if (found.length === 0 && span.strict) resolvedCount++;
270
+ for (const v of found) violations.push(`[${shortFile}] ${v} (span: "${span.raw}")`);
271
+ const head = span.tokens
272
+ .slice(0, 2)
273
+ .map((t) => t.text)
274
+ .join(' ');
275
+ if (head) resolvedHeads.add(head);
276
+ }
277
+ }
278
+ }
279
+
280
+ // Reach floors. A scan that resolves nothing proves nothing.
281
+ expect(resolvedCount).toBeGreaterThanOrEqual(12);
282
+ for (const anchor of [
283
+ 'system doctor',
284
+ 'module verify',
285
+ 'monitor add',
286
+ 'events repair',
287
+ 'system migrate',
288
+ ]) {
289
+ const found = [...resolvedHeads].some((h) => h.startsWith(anchor));
290
+ expect(found).toBe(true);
291
+ }
292
+
293
+ expect(violations).toEqual([]);
294
+ });
295
+ });