@celilo/cli 2.2.1 → 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/src/__integration__/container-services-cli.integration.test.ts +1 -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.ts +1 -0
- 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 +6 -6
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +4 -1
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +23 -1
- package/src/cli/commands/module-verify.test.ts +1 -1
- 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 +1 -1
- package/src/cli/commands/registry-token.test.ts +1 -1
- package/src/cli/commands/system-audit.ts +20 -6
- 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 +5 -0
- package/src/cli/completion.ts +8 -2
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/index.ts +68 -2
- 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/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/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +86 -12
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-unreachability.test.ts +17 -18
- package/src/hooks/hook-trespass.test.ts +9 -2
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/manifest/contracts/v1.ts +13 -0
- 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 +2 -2
- package/src/secrets/storage.test.ts +1 -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 +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 -1
- package/src/services/audit/module-integrity.ts +7 -2
- 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-mgmt.test.ts +48 -0
- package/src/services/build-bus/hook-dispatcher.ts +49 -1
- 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/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/deployed-systems.ts +1 -1
- package/src/services/fleet-checks.test.ts +123 -4
- package/src/services/fleet-checks.ts +176 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +40 -11
- 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.ts +12 -4
- package/src/services/module-subscriptions.test.ts +2 -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/update/orchestrator.test.ts +2 -0
- package/src/test-utils/bus-responder.ts +1 -1
|
@@ -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
|
+
});
|
package/src/cli/index.ts
CHANGED
|
@@ -80,6 +80,7 @@ import { handleModuleDeploy } from './commands/module-deploy';
|
|
|
80
80
|
import { handleModuleGenerate } from './commands/module-generate';
|
|
81
81
|
import { handleModuleHealth } from './commands/module-health';
|
|
82
82
|
import { handleModuleImport } from './commands/module-import';
|
|
83
|
+
import { handleModuleJail } from './commands/module-jail';
|
|
83
84
|
import { handleModuleJournal } from './commands/module-journal';
|
|
84
85
|
import { handleModuleList } from './commands/module-list';
|
|
85
86
|
import { handleModuleLogs } from './commands/module-logs';
|
|
@@ -315,6 +316,7 @@ Subcommands:
|
|
|
315
316
|
resync-subscriptions Rebuild subscribers from deployed modules' manifests (after a restore/migration)
|
|
316
317
|
list-pending [--subscriber] List pending deliveries
|
|
317
318
|
list-failed [--subscriber] List failed/abandoned deliveries with a true total
|
|
319
|
+
list-unanswered List interview questions nobody has answered yet
|
|
318
320
|
drain [--concurrency N] Process pending deliveries once and return
|
|
319
321
|
run [--poll-ms N] Run the long-running dispatcher (foreground)
|
|
320
322
|
emit <type> [<payload>] Emit an event (operator/test path)
|
|
@@ -588,6 +590,32 @@ Subcommands:
|
|
|
588
590
|
--auto-generate-secrets Auto-generate all secrets without prompting
|
|
589
591
|
|
|
590
592
|
list List all installed modules
|
|
593
|
+
Options:
|
|
594
|
+
--json Output the module roster as stable JSON
|
|
595
|
+
|
|
596
|
+
status <id> Show detailed module status
|
|
597
|
+
|
|
598
|
+
where <id> Show which host(s) serve a module (host discovery)
|
|
599
|
+
Options:
|
|
600
|
+
--json Output host discovery as stable JSON
|
|
601
|
+
|
|
602
|
+
logs <id> Show deploy logs for a module
|
|
603
|
+
Options:
|
|
604
|
+
--tail <n> Show last N lines
|
|
605
|
+
--last Show only the most recent deploy run
|
|
606
|
+
|
|
607
|
+
journal <id> Tail a deployed module's runtime daemon journal on its host (no SSH)
|
|
608
|
+
Options:
|
|
609
|
+
--unit <glob> systemd unit or glob (default: <module-id>*)
|
|
610
|
+
--lines <n> Lines to return (default 100)
|
|
611
|
+
--since <window> Time window, e.g. '30 min ago'
|
|
612
|
+
--grep <text> Only lines containing this fixed string
|
|
613
|
+
--json Machine-readable JSON output
|
|
614
|
+
|
|
615
|
+
operations list|clear Show or release in-flight module operations (the backup/restore lock)
|
|
616
|
+
Options:
|
|
617
|
+
--all With clear: also release operations that still look alive
|
|
618
|
+
--abandoned With list: also show abandoned rows (hidden by default)
|
|
591
619
|
|
|
592
620
|
remove <id> Remove a module and all its data
|
|
593
621
|
|
|
@@ -605,8 +633,12 @@ Subcommands:
|
|
|
605
633
|
--dry-run Print the ordered plan and change nothing
|
|
606
634
|
--yes Skip the cascade confirmation
|
|
607
635
|
|
|
608
|
-
verify <id> Verify module integrity (signature + checksums)
|
|
609
|
-
|
|
636
|
+
verify <id> Verify module integrity (signature + checksums) (legacy alias: 'audit')
|
|
637
|
+
Options:
|
|
638
|
+
--deep Deep verification (re-check all files)
|
|
639
|
+
--json Machine-readable JSON output
|
|
640
|
+
|
|
641
|
+
validate <id> Pre-flight check: validate module is ready for deployment
|
|
610
642
|
|
|
611
643
|
config set <id> <key> <value> Set module configuration value
|
|
612
644
|
config get <id> [key] Get module configuration value(s)
|
|
@@ -616,6 +648,11 @@ Subcommands:
|
|
|
616
648
|
secret get <id> <key> Get a decrypted module secret value
|
|
617
649
|
secret list <id> List module secrets
|
|
618
650
|
|
|
651
|
+
jail <id> [policy] Get or set one module's hook jail policy (auto|off|required)
|
|
652
|
+
Options:
|
|
653
|
+
--clear Remove the module's policy; follow the system's hooks.jail_policy again
|
|
654
|
+
--force Skip the confirmation when the new policy is weaker than the system's
|
|
655
|
+
|
|
619
656
|
show-config <id> Show all config including auto-derived values
|
|
620
657
|
show-zone <id> Show module's network zone and config
|
|
621
658
|
|
|
@@ -647,6 +684,11 @@ Subcommands:
|
|
|
647
684
|
Options:
|
|
648
685
|
--registry <url> Use a custom registry (sweep mode only)
|
|
649
686
|
|
|
687
|
+
upgrade <id> Upgrade a module to its latest registry version (update, posture-gated backup, deploy, verify)
|
|
688
|
+
Options:
|
|
689
|
+
--poll Run the CD poll: upgrade every auto_upgrade module with a newer version
|
|
690
|
+
--registry <url> Registry URL (defaults to configured registry)
|
|
691
|
+
|
|
650
692
|
Registry:
|
|
651
693
|
search [query] Search the registry for modules
|
|
652
694
|
Options:
|
|
@@ -662,6 +704,19 @@ Registry:
|
|
|
662
704
|
--allow-dirty Permit publishing from a dirty git tree
|
|
663
705
|
--allow-stale Skip the manifest-vs-src stale-check. Use sparingly.
|
|
664
706
|
|
|
707
|
+
changeset <module-dir> Author a version changeset for a module (.changeset/<name>.md)
|
|
708
|
+
Options:
|
|
709
|
+
--bump <type> Version bump type: major, minor or patch (required)
|
|
710
|
+
--message <text> Changeset body text
|
|
711
|
+
--name <stem> Changeset filename stem (default: auto-generated slug)
|
|
712
|
+
|
|
713
|
+
backup <module-id> Create a backup of a module's data (invokes its on_backup hook)
|
|
714
|
+
Options:
|
|
715
|
+
--force Ignore schedule, back up now
|
|
716
|
+
--now Ignore schedule, back up now (alias for --force)
|
|
717
|
+
--storage <id> Storage destination ID
|
|
718
|
+
--no-interactive Non-interactive mode (for cron)
|
|
719
|
+
|
|
665
720
|
check [path] Check a module for drift against the framework (default: .)
|
|
666
721
|
Options:
|
|
667
722
|
--no-build Skip the TypeScript build check
|
|
@@ -836,6 +891,12 @@ Subcommands:
|
|
|
836
891
|
--storage <id> Use specific storage destination
|
|
837
892
|
--yes Skip confirmation prompt
|
|
838
893
|
|
|
894
|
+
pull Download another box's backup artifact from a storage destination
|
|
895
|
+
Options:
|
|
896
|
+
--storage <name> Storage destination name
|
|
897
|
+
--module <id> Module ID whose backup to pull (default: system state)
|
|
898
|
+
--output <path> Local path to write the artifact
|
|
899
|
+
|
|
839
900
|
Description:
|
|
840
901
|
Backups are encrypted with the master key and uploaded to a configured
|
|
841
902
|
storage destination. System state backups capture the Celilo database.
|
|
@@ -1056,6 +1117,7 @@ Usage:
|
|
|
1056
1117
|
Resources:
|
|
1057
1118
|
vmid Manage VMID reservations and allocations
|
|
1058
1119
|
ip Manage IP address reservations
|
|
1120
|
+
show Show comprehensive IPAM summary
|
|
1059
1121
|
list-allocations List all IPAM allocations (VMIDs and IPs)
|
|
1060
1122
|
|
|
1061
1123
|
VMID Commands:
|
|
@@ -1140,6 +1202,8 @@ Subcommands:
|
|
|
1140
1202
|
update [--module <id>] [--no-backup] [--allow-destructive] [--dry-run] [--json]
|
|
1141
1203
|
Bring the system to the audit-determined READY state
|
|
1142
1204
|
|
|
1205
|
+
migrate Apply pending database migrations (idempotent; safe to re-run)
|
|
1206
|
+
|
|
1143
1207
|
doctor [--fix] Diagnose system prerequisites and @celilo/* version drift
|
|
1144
1208
|
|
|
1145
1209
|
Runbook:
|
|
@@ -1609,6 +1673,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1609
1673
|
return handleModuleVersion(parsed.args, parsed.flags);
|
|
1610
1674
|
case 'changeset':
|
|
1611
1675
|
return handleModuleChangeset(parsed.args, parsed.flags);
|
|
1676
|
+
case 'jail':
|
|
1677
|
+
return handleModuleJail(parsed.args, parsed.flags);
|
|
1612
1678
|
case 'audit':
|
|
1613
1679
|
return moduleAudit(parsed.args, parsed.flags);
|
|
1614
1680
|
case 'verify':
|
package/src/cli/parser.test.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import { COMMANDS } from '@celilo/core';
|
|
3
|
+
import {
|
|
4
|
+
getArg,
|
|
5
|
+
getFlag,
|
|
6
|
+
hasFlag,
|
|
7
|
+
parseArguments,
|
|
8
|
+
validateFlags,
|
|
9
|
+
validateRequiredArgs,
|
|
10
|
+
} from './parser';
|
|
3
11
|
|
|
4
12
|
describe('CLI Parser', () => {
|
|
5
13
|
describe('parseArguments', () => {
|
|
@@ -238,3 +246,31 @@ describe('CLI Parser', () => {
|
|
|
238
246
|
});
|
|
239
247
|
});
|
|
240
248
|
});
|
|
249
|
+
|
|
250
|
+
describe('a flag a handler reads is a flag the registry declares', () => {
|
|
251
|
+
// `handleSystemConfigSet` has read `flags.force` since the hooks.jail_policy
|
|
252
|
+
// interview gate landed, and its unit test drove the handler directly with
|
|
253
|
+
// `{ force: true }`. The registry declared no flags for `system config set`,
|
|
254
|
+
// so the parser answered `--force` with "does not accept any flags" and the
|
|
255
|
+
// escape hatch was tested and unreachable from a terminal. The suite could
|
|
256
|
+
// not see it, because nothing in it went through the parser.
|
|
257
|
+
const leaf = (path: readonly string[]) =>
|
|
258
|
+
path.slice(1).reduce<{ name: string; subcommands?: unknown[] } | undefined>(
|
|
259
|
+
(def, name) =>
|
|
260
|
+
(def as { subcommands?: { name: string }[] } | undefined)?.subcommands?.find(
|
|
261
|
+
(s) => s.name === name,
|
|
262
|
+
),
|
|
263
|
+
COMMANDS.find((c) => c.name === path[0]),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
test('system config set accepts --force through the real registry', () => {
|
|
267
|
+
const def = leaf(['system', 'config', 'set']);
|
|
268
|
+
expect(def).toBeDefined();
|
|
269
|
+
expect(validateFlags({ force: true }, def as never)).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('and still refuses a flag nobody declared', () => {
|
|
273
|
+
const def = leaf(['system', 'config', 'set']);
|
|
274
|
+
expect(validateFlags({ frce: true }, def as never)).toContain('Unknown flag');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -34,9 +34,9 @@ describe('celilo restore', () => {
|
|
|
34
34
|
afterEach(() => {
|
|
35
35
|
closeDb();
|
|
36
36
|
resetTestDbPath();
|
|
37
|
-
process.env.CELILO_DATA_DIR
|
|
38
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
39
|
-
process.env.CELILO_SUPPRESS_DEPRECATION
|
|
37
|
+
delete process.env.CELILO_DATA_DIR;
|
|
38
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
39
|
+
delete process.env.CELILO_SUPPRESS_DEPRECATION;
|
|
40
40
|
try {
|
|
41
41
|
rmSync(dir, { recursive: true, force: true });
|
|
42
42
|
} catch {
|
|
@@ -99,8 +99,8 @@ describe('restore migration failure reporting (celilo#1269)', () => {
|
|
|
99
99
|
afterEach(() => {
|
|
100
100
|
closeDb();
|
|
101
101
|
resetTestDbPath();
|
|
102
|
-
process.env.CELILO_DATA_DIR
|
|
103
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
102
|
+
delete process.env.CELILO_DATA_DIR;
|
|
103
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
104
104
|
try {
|
|
105
105
|
rmSync(dir, { recursive: true, force: true });
|
|
106
106
|
} catch {
|
|
@@ -92,11 +92,13 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
|
|
|
92
92
|
'interface_classification',
|
|
93
93
|
'module_integrity',
|
|
94
94
|
'detect_without_converge',
|
|
95
|
+
'jail_exemptions',
|
|
95
96
|
];
|
|
96
97
|
|
|
97
98
|
export const CATEGORY_LABELS: Record<DriftCategory, string> = {
|
|
98
99
|
module_integrity: 'Module integrity',
|
|
99
100
|
detect_without_converge: 'Drift without converge',
|
|
101
|
+
jail_exemptions: 'Hook jail exemptions',
|
|
100
102
|
interface_classification: 'Firewall interfaces',
|
|
101
103
|
cli_version: 'CLI version',
|
|
102
104
|
schema: 'Schema migrations',
|
package/src/config/paths.test.ts
CHANGED
|
@@ -21,14 +21,14 @@ describe('paths configuration', () => {
|
|
|
21
21
|
describe('getModuleStoragePath', () => {
|
|
22
22
|
it('returns custom path when CELILO_DATA_DIR is set', () => {
|
|
23
23
|
process.env.CELILO_DATA_DIR = '/custom/data';
|
|
24
|
-
process.env.ENVIRONMENT
|
|
24
|
+
delete process.env.ENVIRONMENT;
|
|
25
25
|
|
|
26
26
|
const path = getModuleStoragePath();
|
|
27
27
|
expect(path).toBe('/custom/data/modules');
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
it('returns dev path when ENVIRONMENT=dev', () => {
|
|
31
|
-
process.env.CELILO_DATA_DIR
|
|
31
|
+
delete process.env.CELILO_DATA_DIR;
|
|
32
32
|
process.env.ENVIRONMENT = 'dev';
|
|
33
33
|
|
|
34
34
|
const path = getModuleStoragePath();
|
|
@@ -38,8 +38,8 @@ describe('paths configuration', () => {
|
|
|
38
38
|
it.skipIf(skipIntegration({ platform: 'darwin' }))(
|
|
39
39
|
'returns platform-specific path by default',
|
|
40
40
|
() => {
|
|
41
|
-
process.env.CELILO_DATA_DIR
|
|
42
|
-
process.env.ENVIRONMENT
|
|
41
|
+
delete process.env.CELILO_DATA_DIR;
|
|
42
|
+
delete process.env.ENVIRONMENT;
|
|
43
43
|
|
|
44
44
|
const path = getModuleStoragePath();
|
|
45
45
|
|
|
@@ -63,14 +63,14 @@ describe('paths configuration', () => {
|
|
|
63
63
|
describe('getDataDir', () => {
|
|
64
64
|
it('returns custom path when CELILO_DATA_DIR is set', () => {
|
|
65
65
|
process.env.CELILO_DATA_DIR = '/custom/data';
|
|
66
|
-
process.env.ENVIRONMENT
|
|
66
|
+
delete process.env.ENVIRONMENT;
|
|
67
67
|
|
|
68
68
|
const path = getDataDir();
|
|
69
69
|
expect(path).toBe('/custom/data');
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
it('returns dev path when ENVIRONMENT=dev', () => {
|
|
73
|
-
process.env.CELILO_DATA_DIR
|
|
73
|
+
delete process.env.CELILO_DATA_DIR;
|
|
74
74
|
process.env.ENVIRONMENT = 'dev';
|
|
75
75
|
|
|
76
76
|
const path = getDataDir();
|
|
@@ -80,8 +80,8 @@ describe('paths configuration', () => {
|
|
|
80
80
|
it.skipIf(skipIntegration({ platform: 'darwin' }))(
|
|
81
81
|
'returns platform-specific path by default',
|
|
82
82
|
() => {
|
|
83
|
-
process.env.CELILO_DATA_DIR
|
|
84
|
-
process.env.ENVIRONMENT
|
|
83
|
+
delete process.env.CELILO_DATA_DIR;
|
|
84
|
+
delete process.env.ENVIRONMENT;
|
|
85
85
|
|
|
86
86
|
const path = getDataDir();
|
|
87
87
|
|
|
@@ -103,7 +103,7 @@ describe('paths configuration', () => {
|
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
it('returns data dir + master.key when CELILO_MASTER_KEY_PATH not set', () => {
|
|
106
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
106
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
107
107
|
process.env.CELILO_DATA_DIR = '/custom/data';
|
|
108
108
|
|
|
109
109
|
const path = getMasterKeyPath();
|
|
@@ -113,9 +113,9 @@ describe('paths configuration', () => {
|
|
|
113
113
|
it.skipIf(skipIntegration({ platform: 'darwin' }))(
|
|
114
114
|
'uses platform-specific data dir by default',
|
|
115
115
|
() => {
|
|
116
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
117
|
-
process.env.CELILO_DATA_DIR
|
|
118
|
-
process.env.ENVIRONMENT
|
|
116
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
117
|
+
delete process.env.CELILO_DATA_DIR;
|
|
118
|
+
delete process.env.ENVIRONMENT;
|
|
119
119
|
|
|
120
120
|
const path = getMasterKeyPath();
|
|
121
121
|
|
|
@@ -131,8 +131,8 @@ describe('paths configuration', () => {
|
|
|
131
131
|
describe('getDbPath', () => {
|
|
132
132
|
it('returns custom path when CELILO_DB_PATH is set', () => {
|
|
133
133
|
process.env.CELILO_DB_PATH = '/custom/path/celilo.db';
|
|
134
|
-
process.env.CELILO_DATA_DIR
|
|
135
|
-
process.env.ENVIRONMENT
|
|
134
|
+
delete process.env.CELILO_DATA_DIR;
|
|
135
|
+
delete process.env.ENVIRONMENT;
|
|
136
136
|
|
|
137
137
|
const path = getDbPath();
|
|
138
138
|
expect(path).toBe('/custom/path/celilo.db');
|
|
@@ -142,8 +142,8 @@ describe('paths configuration', () => {
|
|
|
142
142
|
'returns data dir + celilo.db on macOS in production',
|
|
143
143
|
() => {
|
|
144
144
|
delete process.env.CELILO_DB_PATH;
|
|
145
|
-
process.env.CELILO_DATA_DIR
|
|
146
|
-
process.env.ENVIRONMENT
|
|
145
|
+
delete process.env.CELILO_DATA_DIR;
|
|
146
|
+
delete process.env.ENVIRONMENT;
|
|
147
147
|
|
|
148
148
|
const path = getDbPath();
|
|
149
149
|
|
|
@@ -157,7 +157,7 @@ describe('paths configuration', () => {
|
|
|
157
157
|
|
|
158
158
|
it('returns celilo-data/celilo.db in development mode', () => {
|
|
159
159
|
delete process.env.CELILO_DB_PATH;
|
|
160
|
-
process.env.CELILO_DATA_DIR
|
|
160
|
+
delete process.env.CELILO_DATA_DIR;
|
|
161
161
|
process.env.ENVIRONMENT = 'dev';
|
|
162
162
|
|
|
163
163
|
const path = getDbPath();
|
|
@@ -167,7 +167,7 @@ describe('paths configuration', () => {
|
|
|
167
167
|
it('respects CELILO_DATA_DIR override', () => {
|
|
168
168
|
delete process.env.CELILO_DB_PATH;
|
|
169
169
|
process.env.CELILO_DATA_DIR = '/custom/data';
|
|
170
|
-
process.env.ENVIRONMENT
|
|
170
|
+
delete process.env.ENVIRONMENT;
|
|
171
171
|
|
|
172
172
|
const path = getDbPath();
|
|
173
173
|
expect(path).toBe('/custom/data/celilo.db');
|
|
@@ -176,7 +176,7 @@ describe('paths configuration', () => {
|
|
|
176
176
|
it('prioritizes CELILO_DB_PATH over CELILO_DATA_DIR', () => {
|
|
177
177
|
process.env.CELILO_DB_PATH = '/explicit/db.db';
|
|
178
178
|
process.env.CELILO_DATA_DIR = '/custom/data';
|
|
179
|
-
process.env.ENVIRONMENT
|
|
179
|
+
delete process.env.ENVIRONMENT;
|
|
180
180
|
|
|
181
181
|
const path = getDbPath();
|
|
182
182
|
expect(path).toBe('/explicit/db.db');
|