@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.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +1 -1
- package/README.md +1 -1
- package/drizzle/0032_module_jail_policies.sql +28 -0
- package/drizzle/0033_build_bus_hook_runs.sql +37 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/api/serve.ts +13 -1
- package/src/api/sessions.test.ts +3 -3
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +3 -3
- package/src/cli/cli.test.ts +1 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +2 -2
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +12 -3
- package/src/cli/commands/module-import-registry.test.ts +1 -1
- package/src/cli/commands/module-jail.test.ts +242 -0
- package/src/cli/commands/module-jail.ts +227 -0
- package/src/cli/commands/module-list-jail.test.ts +136 -0
- package/src/cli/commands/module-list.ts +30 -3
- package/src/cli/commands/module-publish.test.ts +28 -6
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +73 -18
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +66 -4
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +23 -1
- package/src/cli/commands/publish/module-registry.test.ts +24 -1
- package/src/cli/commands/publish/module-registry.ts +23 -4
- package/src/cli/commands/publish/plan.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +20 -0
- package/src/cli/commands/registry-owner.test.ts +1 -1
- package/src/cli/commands/registry-token.test.ts +1 -1
- package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +34 -11
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +76 -10
- package/src/cli/commands/system-doctor.ts +34 -3
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-update.ts +16 -6
- package/src/cli/completion.ts +18 -3
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +80 -2
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +3 -3
- package/src/cli/restore-migration-failure.test.ts +2 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/cli/types.ts +9 -0
- package/src/config/paths.test.ts +19 -19
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/schema.ts +63 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +91 -14
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +27 -20
- package/src/hooks/hook-trespass.test.ts +22 -6
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +32 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +63 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- package/src/policy/module-script-scan.ts +52 -68
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -14
- package/src/registry/client.test.ts +67 -2
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +70 -4
- package/src/secrets/storage.ts +71 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/audit/health.test.ts +155 -2
- package/src/services/audit/health.ts +76 -2
- package/src/services/audit/index.test.ts +23 -1
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/jail-exemptions.test.ts +42 -0
- package/src/services/audit/jail-exemptions.ts +44 -0
- package/src/services/audit/module-integrity.test.ts +23 -1
- package/src/services/audit/module-integrity.ts +7 -2
- package/src/services/audit/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +1 -1
- package/src/services/backup-in-flight-refusal.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +143 -17
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- package/src/services/bus-ensure-flow.test.ts +1 -1
- package/src/services/bus-interview-park.test.ts +2 -2
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/container-service.test.ts +1 -1
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +159 -4
- package/src/services/fleet-checks.ts +206 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +83 -16
- package/src/services/infrastructure-selector.test.ts +1 -1
- package/src/services/jail-exemptions.test.ts +125 -0
- package/src/services/jail-exemptions.ts +81 -0
- package/src/services/machine-pool.test.ts +1 -1
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +84 -129
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +4 -4
- package/src/services/restore-preflight.test.ts +1 -1
- package/src/services/ssh-key-manager.test.ts +2 -2
- package/src/services/system-state-stage.test.ts +2 -2
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +3 -1
- package/src/test-utils/bus-responder.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import type
|
|
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: '
|
|
66
|
-
envValue:
|
|
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('
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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}
|
|
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
|
-
|
|
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
|
|
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(
|
|
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,
|
package/src/cli/completion.ts
CHANGED
|
@@ -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 = [
|
|
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
|
+
});
|