@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.
- package/CELILO_CORE_MODULES.md +1 -0
- 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/schemas/system_config.json +5 -0
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +5 -4
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +5 -4
- package/src/cli/cli.test.ts +3 -2
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +4 -3
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-config.test.ts +2 -1
- package/src/cli/commands/module-deploy.ts +8 -2
- package/src/cli/commands/module-generate.test.ts +53 -0
- package/src/cli/commands/module-generate.ts +31 -26
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +3 -2
- 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-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +11 -18
- package/src/cli/commands/module-update.test.ts +28 -12
- package/src/cli/commands/module-update.ts +4 -1
- package/src/cli/commands/module-upgrade.test.ts +130 -1
- package/src/cli/commands/module-upgrade.ts +77 -3
- package/src/cli/commands/module-verify.test.ts +3 -4
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/notify-config.test.ts +5 -3
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/index.ts +11 -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.ts +1 -1
- package/src/cli/commands/publish/types.ts +7 -0
- package/src/cli/commands/registry-owner.test.ts +3 -2
- package/src/cli/commands/registry-token.test.ts +3 -2
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- package/src/cli/commands/system-audit.ts +20 -6
- package/src/cli/commands/system-config.test.ts +148 -0
- package/src/cli/commands/system-config.ts +26 -1
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +143 -6
- package/src/cli/commands/system-doctor.ts +142 -25
- package/src/cli/commands/system-init-deprecation.test.ts +7 -4
- package/src/cli/commands/system-migrate.test.ts +2 -1
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +8 -2
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/index.ts +70 -3
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +5 -4
- package/src/cli/restore-migration-failure.test.ts +4 -3
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/config/paths.test.ts +22 -22
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/migrate.test.ts +2 -1
- package/src/db/migrate.ts +16 -16
- package/src/db/schema.ts +63 -0
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.test.ts +78 -0
- package/src/hooks/capability-loader.ts +45 -2
- package/src/hooks/executor.test.ts +161 -1
- package/src/hooks/executor.ts +87 -21
- package/src/hooks/hook-jail-unreachability.test.ts +17 -18
- package/src/hooks/hook-store.test.ts +6 -0
- package/src/hooks/hook-trespass.test.ts +26 -9
- package/src/hooks/jail.test.ts +152 -5
- package/src/hooks/jail.ts +93 -4
- package/src/manifest/contracts/v1.ts +13 -0
- package/src/module/packaging/audit.ts +9 -26
- package/src/module/packaging/build-paths.test.ts +127 -0
- package/src/module/packaging/build-paths.ts +175 -0
- package/src/module/packaging/build.test.ts +71 -1
- package/src/module/packaging/build.ts +60 -0
- package/src/module/packaging/extract.ts +1 -5
- package/src/policy/module-script-scan.ts +51 -107
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -15
- package/src/registry/client.test.ts +2 -2
- package/src/secrets/storage.test.ts +1 -1
- package/src/services/alerting/ack.test.ts +2 -1
- package/src/services/alerting/cadence-migration.test.ts +3 -2
- package/src/services/alerting/coverage-source.test.ts +2 -1
- package/src/services/alerting/deferral.test.ts +2 -1
- package/src/services/alerting/delivery-loop.test.ts +2 -1
- package/src/services/alerting/deploy-hooks.test.ts +2 -1
- package/src/services/alerting/inbound-poller.test.ts +2 -1
- package/src/services/alerting/inbound.test.ts +2 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/alerting/notification-responder.test.ts +2 -1
- package/src/services/alerting/run-monitor.test.ts +2 -1
- package/src/services/alerting/store.test.ts +2 -1
- package/src/services/alerting/sweep-runner.test.ts +2 -1
- package/src/services/alerting/tokens.test.ts +2 -1
- package/src/services/aspect-approvals.test.ts +2 -1
- package/src/services/aspect-reconcile.test.ts +4 -3
- package/src/services/aspect-runner.test.ts +2 -1
- package/src/services/audit/health.test.ts +97 -2
- package/src/services/audit/health.ts +64 -2
- package/src/services/audit/index.test.ts +22 -0
- package/src/services/audit/index.ts +8 -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 -22
- package/src/services/audit/module-integrity.ts +7 -16
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +3 -2
- package/src/services/backup-in-flight-refusal.test.ts +3 -2
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
- package/src/services/build-bus/hook-dispatcher.ts +49 -1
- package/src/services/bus-ensure-flow.test.ts +3 -2
- package/src/services/bus-interview-park.test.ts +4 -3
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +3 -2
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +3 -2
- package/src/services/control-plane-bootstrap.test.ts +2 -1
- package/src/services/cross-module-read.test.ts +4 -3
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deploy-validation.test.ts +3 -2
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/dns-internal-records.test.ts +3 -2
- package/src/services/dns-provider-backfill.test.ts +2 -1
- package/src/services/dns-registrations.test.ts +2 -1
- package/src/services/ensure-interview.test.ts +3 -2
- package/src/services/fleet-checks.test.ts +126 -6
- package/src/services/fleet-checks.ts +176 -3
- package/src/services/fleet-key.test.ts +3 -2
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +40 -11
- package/src/services/infrastructure-selector.test.ts +3 -2
- package/src/services/infrastructure-variable-resolver.test.ts +2 -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 +3 -2
- package/src/services/module-config.test.ts +2 -1
- package/src/services/module-deploy.dns-repoint.test.ts +2 -1
- package/src/services/module-deploy.ts +48 -24
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +4 -3
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/port-forwards.test.ts +2 -1
- package/src/services/programmatic-responder.aspect.test.ts +2 -1
- package/src/services/proxmox-reconcile.test.ts +2 -1
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +7 -6
- package/src/services/restore-preflight.test.ts +3 -2
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +4 -3
- package/src/services/static-content-converge.test.ts +5 -4
- package/src/services/static-content-converge.ts +33 -10
- package/src/services/system-state-stage.test.ts +4 -3
- package/src/services/trusted-sources.test.ts +3 -2
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/services/zone-detector.test.ts +2 -1
- package/src/test-utils/bus-responder.ts +6 -4
- package/src/test-utils/db-path.ts +25 -0
- package/src/test-utils/integration.ts +7 -0
- package/src/test-utils/module-fixtures.ts +5 -6
- package/src/module/packaging/generated-plane.test.ts +0 -79
- 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}
|
|
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
|
|
740
|
-
// throws on a typo'd
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
|
45
|
+
delete process.env.CELILO_SUPPRESS_DEPRECATION;
|
|
47
46
|
});
|
|
48
47
|
|
|
49
48
|
afterEach(() => {
|
|
50
|
-
|
|
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
|
-
|
|
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
|
};
|
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();
|
|
@@ -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
|
+
});
|