@celilo/cli 4.0.0 → 4.1.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_SUBSYSTEMS.md +1 -0
- package/package.json +5 -4
- package/src/cli/command-tree-parser.test.ts +18 -0
- package/src/cli/command-tree-parser.ts +12 -0
- package/src/cli/commands/api.ts +67 -14
- package/src/cli/commands/events.ts +5 -2
- package/src/cli/commands/machine-reclassify.ts +79 -0
- package/src/cli/commands/system-audit.ts +22 -0
- package/src/cli/commands/system-reboot.ts +126 -0
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +3 -2
- package/src/cli/index.ts +19 -0
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/hooks/broker.ts +31 -0
- package/src/hooks/capability-loader.ts +8 -2
- package/src/hooks/executor.ts +20 -2
- package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
- package/src/hooks/timeout-names-provider.test.ts +87 -0
- package/src/services/api-access.test.ts +44 -0
- package/src/services/audit/index.test.ts +7 -0
- package/src/services/audit/index.ts +15 -0
- package/src/services/audit/machine-interface-zones.test.ts +30 -0
- package/src/services/audit/machine-interface-zones.ts +43 -0
- package/src/services/audit/reboot-pending.test.ts +63 -0
- package/src/services/audit/reboot-pending.ts +85 -0
- package/src/services/audit/recurrence-gate.test.ts +21 -0
- package/src/services/audit/server-bun-pin.test.ts +44 -0
- package/src/services/audit/server-bun-pin.ts +95 -0
- package/src/services/audit/types.ts +2 -0
- package/src/services/capability-compat.test.ts +22 -0
- package/src/services/capability-compat.ts +15 -2
- package/src/services/celilo-events.test.ts +39 -0
- package/src/services/celilo-events.ts +41 -4
- package/src/services/deploy-preflight.ts +12 -3
- package/src/services/deploy-validation.test.ts +81 -2
- package/src/services/deploy-validation.ts +19 -0
- package/src/services/fleet-checks.ts +40 -10
- package/src/services/machine-interface-zones.test.ts +73 -0
- package/src/services/machine-interface-zones.ts +101 -0
- package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
- package/src/services/module-deploy.ts +31 -10
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/system-reboot.test.ts +231 -0
- package/src/services/system-reboot.ts +357 -0
- package/src/services/update/orchestrator.test.ts +164 -0
- package/src/services/update/orchestrator.ts +43 -12
- package/src/services/update/types.ts +10 -1
- package/src/templates/generator.test.ts +15 -4
- package/src/templates/generator.ts +12 -4
- package/src/variables/context.ts +14 -1
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -212,6 +212,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
|
|
|
212
212
|
- **Inbound** (`reconcileAspectsForSystems`): every approved aspect applied to systems that have just come into existence. Called from `module-deploy.ts` between `waitForSSH` and `executeAnsible` (so a module's playbook and `on_install` see a correctly configured host) and from `machine-add.ts`. Eligibility is `applicable_zones` + approval; the aspect's `triggers` list is deliberately NOT consulted, so convergence is not opt-in per manifest. A failure here IS fatal to the deploy that created the system — the aspect is a prerequisite of that host — while a failure on `machine add` is only a warning. PAUSED providers are skipped, which is the escape hatch for a wedged aspect. Nothing is rolled back: rows, guest and IPAM allocation persist and a re-run converges on the same system. Fixes celilo#902, where a system provisioned after its provider deployed silently never received the aspect. See `openspec/changes/aspect-fanout-new-systems/`.
|
|
213
213
|
- **Coverage verification** — `verifyAspectCoverage` + `celilo system doctor --deep [--fix]`. Answers "is any system missing an aspect its zone entitles it to" WITHOUT stored state: the entitled set is `planAspectFanOut` itself, and coverage is measured by evaluating the role against the host in Ansible check mode (`executeAnsible({check:true})` + `parseAnsibleRecap`). Three outcomes, not two — `changed=0,skipped=0` applied, `changed>0` missing, **`skipped>0` unknown**, because check mode SKIPS a task it cannot evaluate and a `command`/`shell` role would otherwise report clean having never run.
|
|
214
214
|
- **In-flight operation lock** — `apps/celilo/src/services/module-operations.ts` — `startOperation`/`completeOperation`/`failOperation` record deploy/uninstall/backup/restore in `module_operations`; `refuseIfInFlight`/`checkInFlight` are what backup and restore consult. Deploy and uninstall REGISTER but never check: it is a one-way guard protecting backup/restore consistency, not a general mutex (`openspec/specs/management-server-backup/spec.md` "In-flight operation refusal"). A row stops holding the lock once it is GONE, STOPPED/zombie (`isPidRunnable`, `ps -o state=` — `kill(pid,0)` calls a Ctrl-Z'd process alive), or older than `OPERATION_TTL_MS` (2h). The TTL is not redundancy: a pid is a recycled number, and once the pid space wraps an old row names an unrelated healthy process. Operator surface: `celilo module operations [list|clear] [--abandoned] [--all]` (`apps/celilo/src/cli/commands/module-operations.ts`); `list` shows only what holds the lock, abandoned rows are summarised unless `--abandoned`. Abandoned rows are reclaimed hourly by the `celilo-operations-sweep` bus subscriber (`timer.tick.1h` → `celilo module operations clear`, armed by `ensureOperationsSweepSubscriber` from module registration and `celilo system migrate`). `clear` MARKS rows failed rather than deleting them, and that is load-bearing: the `abandoned_operations` audit reads exactly those released rows to notice one module's operation dying over and over.
|
|
215
|
+
- **Management-server reboot** — `apps/celilo/src/services/system-reboot.ts`, surfaced as `celilo system reboot [--allow-reboot] [--verify]` (`apps/celilo/src/cli/commands/system-reboot.ts`) and reachable over MCP like any other registry leaf. Rebooting THIS box takes down the dispatcher, the MCP server and the remote API, so `requestReboot` refuses while `checkInFlight` reports a module operation running and names it, and consent follows the flag-or-interview rule (`--allow-reboot` wins, otherwise an event-bus question — never stdin). **Verification is a second call, by construction**: the process asking for the reboot dies in it, so no single invocation can both reboot the host and report that it came back. `requestReboot` records `state/reboot-requested.json` carrying the boot id at request time; `verifyReturn` (`--verify`) passes only once that boot id has CHANGED and the dispatcher heartbeat and installed CLI both answer, and names which of the three is missing when it has not. It refuses up front when the boot id is unreadable, because a reboot whose return could never be verified is the out-of-band `sudo reboot` this replaces. `judgeRebootPending` compares the running kernel against the newest installed one and reads Debian's `/var/run/reboot-required`; either tell is enough, neither readable is `unmeasured` rather than clean. Both facts surface as the `reboot_pending` audit category (`apps/celilo/src/services/audit/reboot-pending.ts`): a pending reboot is `drift`, an UNVERIFIED one is `blocked` — that is a control plane that may not have come back. celilo#1378.
|
|
215
216
|
- **Module pause / unpause (control-plane quiescence)** — `apps/celilo/src/services/module-pause.ts` — pure `planPause`/`planUnpause` producing an ordered plan, `executePause`/`executeUnpause` performing it, plus `listPausedModules`/`pausedAmong`/`formatPausedDuration`/`describePausedModule` (the ONE place an age is formatted). CLI: `apps/celilo/src/cli/commands/module-pause.ts` (`celilo module pause|unpause <id> [--cascade] [--stop-infra] [--reason] [--dry-run] [--yes]`). Pausing takes a module out of the CONTROL plane — no dispatched events, no timer hooks, no health checks, alerts suppressed — while leaving the DATA plane running, because capability consumption is deploy-time: every consumer calls `firewall`/`dhcp_server` from `on_install` and nothing calls it while serving. Config, secrets, IPAM/VMID and placement are preserved; `on_uninstall` does NOT run. Quiescence is enforced in two places: pausing drops the module's bus subscriptions (`unregisterModuleSubscriptions`), and `run-named-hook.ts` refuses any non-lifecycle hook for a PAUSED module (`skippedPaused`), which catches the paths that skip the bus — `events resync-subscriptions`, a restore that starts events.db empty, aspect fan-out, public-web republish. `on_install`/`on_uninstall` are exempt by hook NAME (not a caller flag): unpause redeploys through `on_install`, and removing a paused provider needs `on_uninstall`. Unpause always REDEPLOYS (`deployModule`) — that is what rebinds a consumer to a replacement provider and recreates provider-local state from the consumers that own it — and re-registers subscriptions, which a plain deploy does not do. A failed unpause restores `PAUSED` rather than leaving the module live and mis-bound. Cascade order reuses `services/update/dep-graph.ts` unchanged (pause = consumers first, unpause = providers first) and is computed from the GRAPH, never from which modules are currently paused, so a cascade walks THROUGH already-done members and is resumable. See `openspec/specs/module-pause/spec.md`.
|
|
216
217
|
- **Provider-removal guard** — `apps/celilo/src/services/remove-guard.ts` — `findRemovalBlockers`/`describeRemovalRefusal`, called from `apps/celilo/src/cli/commands/module-remove.ts`. A PAUSED module is not a dependent (unpause cannot return it to service without a redeploy, and a redeploy re-resolves capabilities), which is what makes a provider swap possible at all. A dependent is one declaring the capability under `requires` **or** `optional` — the same relation `dep-graph.ts` uses, so the guard and the cascade agree on the set; the guard previously read `requires` alone, which let a removal silently orphan `technitium`'s `optional` `dhcp_server`. Refusals name each blocker AND which declaration makes it one. It deliberately does NOT exempt a dependent because another provider of the same capability exists (celilo#683).
|
|
217
218
|
- **Consumer-removal cleanup (every provider is told)** — `apps/celilo/src/services/consumer-cleanup.ts` — pure `planConsumerCleanup` + `loadConsumerCleanupPlan` + `runConsumerCleanup`, called from `apps/celilo/src/cli/commands/module-remove.ts` after `on_uninstall` and before `terraform destroy`. A capability is two-sided: the consumer asks, the provider mints something in ITS world (a caddy site block, a DNAT rule, an OIDC client at authentik, a registered CI runner), and removal only ever touched one side — the FK cascade dropped celilo's row, so the provider's next converge had no way to learn the thing existed. This dispatches the `on_consumer_removed` hook to every provider of every capability the departing module declared under `requires` OR `optional` (the same relation `remove-guard.ts` counts as a dependency edge), **once per provider** rather than once per capability, sorted by provider id. The hook receives one input, `consumer`, and NOTHING else: a provider that cannot answer "what do I hold for this module" without being told has a different defect — the consumer's id was never recorded at mint time. It replaces `services/web-route-cleanup.ts`, which did the same job for exactly one capability, by name, from core. Semantics that are easy to get backwards: **a failed withdrawal never blocks the removal** — the consumer goes and the failing PROVIDER is marked `ERROR` with the departing consumer named in `error_message` (surfaced as a `blocked` finding by `services/audit/undeployed-modules.ts`), because the hook is a full converge and after a failure the provider's state is unknown rather than "one thing missed". **Dispatch continues past a failure**, so one broken provider cannot leave the others holding state. A PAUSED provider is skipped with a warning naming what it keeps (`run-named-hook.ts` refuses non-lifecycle hooks on a paused module, and `on_consumer_removed` must NOT join `LIFECYCLE_HOOKS`), and a never-deployed one is skipped silently. Providers implementing it: caddy, caddy-internal, iptables, greenwave, axon, authentik, forgejo, generic-cpanel-hosting-provider. See `openspec/changes/consumer-removal-cleanup/`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"dev": "bun run src/cli/index.ts",
|
|
36
36
|
"test": "bun test --timeout 60000 src/",
|
|
37
37
|
"test:integration": "bun test --timeout 60000 test-integration/",
|
|
38
|
+
"test:integration:pr": "bun test --timeout 60000 test-integration/alerting test-integration/ansible test-integration/api test-integration/cli test-integration/completion test-integration/deploy test-integration/golden test-integration/machine test-integration/module test-integration/publish test-integration/secrets test-integration/service test-integration/sizing test-integration/system test-integration/values test-integration/module-integrity-baseline.test.ts",
|
|
38
39
|
"test:integration-slow": "bun test --timeout 300000 test-integration/",
|
|
39
40
|
"test:completion": "bun test --timeout 60000 test-integration/completion/",
|
|
40
41
|
"test:all": "bun test --timeout 60000 src/ test-integration/",
|
|
@@ -58,10 +59,10 @@
|
|
|
58
59
|
"dependencies": {
|
|
59
60
|
"@aws-sdk/client-s3": "^3.1109.0",
|
|
60
61
|
"@aws-sdk/lib-storage": "^3.1101.0",
|
|
61
|
-
"@celilo/capabilities": "^5.1.
|
|
62
|
+
"@celilo/capabilities": "^5.1.1",
|
|
62
63
|
"@celilo/cli-display": "^0.2.0",
|
|
63
|
-
"@celilo/core": "^0.
|
|
64
|
-
"@celilo/event-bus": "^0.
|
|
64
|
+
"@celilo/core": "^0.14.0",
|
|
65
|
+
"@celilo/event-bus": "^0.7.0",
|
|
65
66
|
"ajv": "^8.18.0",
|
|
66
67
|
"drizzle-orm": "^0.36.4",
|
|
67
68
|
"ink": "^7.0.1",
|
|
@@ -162,6 +162,24 @@ Commands:
|
|
|
162
162
|
expect(commands.filter((c: string) => c === 'module')).toHaveLength(1);
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
+
test('treats wrapped description lines as continuations, not subcommands', () => {
|
|
166
|
+
// celilo#1409: a wrapped help description's continuation lines matched the
|
|
167
|
+
// subcommand regex, so prose words were reported as missing registry entries.
|
|
168
|
+
const helpText = `
|
|
169
|
+
Commands:
|
|
170
|
+
reboot Reboot the system if an operation
|
|
171
|
+
dies, and it
|
|
172
|
+
came back
|
|
173
|
+
status Show system status
|
|
174
|
+
`;
|
|
175
|
+
|
|
176
|
+
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
|
|
177
|
+
const parseMethod = (parser as any).parseHelpText.bind(parser);
|
|
178
|
+
const commands = parseMethod(helpText);
|
|
179
|
+
|
|
180
|
+
expect(commands).toEqual(['reboot', 'status']);
|
|
181
|
+
});
|
|
182
|
+
|
|
165
183
|
test('handles hyphenated command names', () => {
|
|
166
184
|
const helpText = `
|
|
167
185
|
Commands:
|
|
@@ -100,12 +100,16 @@ export class CommandTreeParser {
|
|
|
100
100
|
|
|
101
101
|
// Track whether we're in a Commands/Subcommands section
|
|
102
102
|
let inCommandSection = false;
|
|
103
|
+
// Column where the last parsed entry's description starts; anything indented
|
|
104
|
+
// that far is wrapped prose, not a subcommand.
|
|
105
|
+
let descriptionColumn: number | null = null;
|
|
103
106
|
|
|
104
107
|
for (const line of lines) {
|
|
105
108
|
// Check if we're entering a Commands section
|
|
106
109
|
// Match " Commands:" or "│ Commands:"
|
|
107
110
|
if (/^[│\s]*(Commands|Subcommands):\s*$/i.test(line)) {
|
|
108
111
|
inCommandSection = true;
|
|
112
|
+
descriptionColumn = null;
|
|
109
113
|
continue;
|
|
110
114
|
}
|
|
111
115
|
|
|
@@ -134,8 +138,16 @@ export class CommandTreeParser {
|
|
|
134
138
|
// " command-name Description"
|
|
135
139
|
const match = line.match(/^[│\s]*([a-z][a-z0-9-]*)(?:\s{2,}|\s+[<a-z])/);
|
|
136
140
|
|
|
141
|
+
// A wrapped description's continuation lines are indented to the previous
|
|
142
|
+
// entry's description column and match the pattern above (celilo#1409).
|
|
143
|
+
const indent = line.search(/[^│\s]/);
|
|
144
|
+
if (descriptionColumn !== null && indent >= descriptionColumn) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
137
148
|
if (match) {
|
|
138
149
|
const cmdName = match[1];
|
|
150
|
+
descriptionColumn = match[0].length;
|
|
139
151
|
|
|
140
152
|
// Skip examples (lines starting with "celilo")
|
|
141
153
|
if (cmdName === 'celilo') {
|
package/src/cli/commands/api.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import {
|
|
12
|
+
getPrincipalByName,
|
|
12
13
|
grantPrincipal,
|
|
13
14
|
listPrincipals,
|
|
14
15
|
renderAuthorizedKeys,
|
|
@@ -22,7 +23,25 @@ function errMsg(error: unknown): string {
|
|
|
22
23
|
return error instanceof Error ? error.message : String(error);
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
function grantList(value: unknown): string[] {
|
|
27
|
+
return typeof value === 'string'
|
|
28
|
+
? value
|
|
29
|
+
.split(',')
|
|
30
|
+
.map((g) => g.trim())
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
: [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `celilo api grant <principal> [--key <pubkey|path>] --can|--add|--remove <grant[,...]>`
|
|
37
|
+
*
|
|
38
|
+
* `--can` REPLACES the grant set; `--add`/`--remove` amend it. Replace-only was
|
|
39
|
+
* the whole surface, so adding one permission meant re-supplying the full list
|
|
40
|
+
* plus the key, and a typo in either silently narrowed the principal or changed
|
|
41
|
+
* its key (celilo#1404). So: `--key` is required only when CREATING a principal,
|
|
42
|
+
* and a `--can` that drops grants an existing principal holds is refused unless
|
|
43
|
+
* `--force` says the narrowing is intended.
|
|
44
|
+
*/
|
|
26
45
|
export async function handleApiGrant(
|
|
27
46
|
args: string[],
|
|
28
47
|
flags: Record<string, boolean | string> = {},
|
|
@@ -32,37 +51,71 @@ export async function handleApiGrant(
|
|
|
32
51
|
if (!name) {
|
|
33
52
|
return {
|
|
34
53
|
success: false,
|
|
35
|
-
error:
|
|
54
|
+
error:
|
|
55
|
+
'Usage: celilo api grant <principal> [--key <pubkey|path>] --can|--add|--remove <grant[,grant...]>',
|
|
36
56
|
};
|
|
37
57
|
}
|
|
38
58
|
|
|
39
|
-
const
|
|
40
|
-
|
|
59
|
+
const replace = grantList(flags.can);
|
|
60
|
+
const added = grantList(flags.add);
|
|
61
|
+
const removed = grantList(flags.remove);
|
|
62
|
+
if (replace.length > 0 && (added.length > 0 || removed.length > 0)) {
|
|
41
63
|
return {
|
|
42
64
|
success: false,
|
|
43
|
-
error: '--
|
|
65
|
+
error: '--can replaces the grant set; use it OR --add/--remove, not both.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (replace.length === 0 && added.length === 0 && removed.length === 0) {
|
|
69
|
+
return {
|
|
70
|
+
success: false,
|
|
71
|
+
error:
|
|
72
|
+
'One of --can <grants> (replace), --add <grants>, or --remove <grants> is required (e.g. --add module:journal).',
|
|
44
73
|
};
|
|
45
74
|
}
|
|
46
75
|
|
|
47
|
-
const
|
|
48
|
-
if (!
|
|
76
|
+
const existing = await getPrincipalByName(name);
|
|
77
|
+
if (!existing && (added.length > 0 || removed.length > 0)) {
|
|
49
78
|
return {
|
|
50
79
|
success: false,
|
|
51
|
-
error:
|
|
80
|
+
error: `No API principal named "${name}" — --add/--remove amend an existing one. Create it with --key and --can.`,
|
|
52
81
|
};
|
|
53
82
|
}
|
|
54
83
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
84
|
+
const keyArg = typeof flags.key === 'string' ? flags.key : '';
|
|
85
|
+
if (!keyArg && !existing) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
error: '--key <public-key-or-path> is required (e.g. ~/.ssh/id_ed25519.pub)',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Keep the enrolled key when none is supplied, so amending grants can never
|
|
92
|
+
// rotate the key by omission.
|
|
93
|
+
const publicKey = keyArg
|
|
94
|
+
? existsSync(keyArg)
|
|
95
|
+
? readFileSync(keyArg, 'utf8').trim()
|
|
96
|
+
: keyArg.trim()
|
|
97
|
+
: (existing?.publicKey ?? '');
|
|
98
|
+
|
|
99
|
+
const current = existing?.grants ?? [];
|
|
100
|
+
let grants: string[];
|
|
101
|
+
if (replace.length > 0) {
|
|
102
|
+
const dropped = current.filter((g) => !replace.includes(g));
|
|
103
|
+
if (dropped.length > 0 && flags.force !== true) {
|
|
104
|
+
return {
|
|
105
|
+
success: false,
|
|
106
|
+
error: `Refusing to narrow "${name}": --can would drop ${dropped.join(', ')}. Use --add/--remove to amend, or --force to replace anyway.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
grants = replace;
|
|
110
|
+
} else {
|
|
111
|
+
grants = [...new Set([...current, ...added])].filter((g) => !removed.includes(g));
|
|
112
|
+
}
|
|
60
113
|
|
|
61
114
|
const { principal, created } = await grantPrincipal({ name, publicKey, grants });
|
|
62
115
|
|
|
63
116
|
return {
|
|
64
117
|
success: true,
|
|
65
|
-
message: `${created ? 'Granted' : 'Updated'} API access for "${principal.name}" → ${grants.join(', ')}\n\nInstall the forced-command line on celilo-mgr with:\n celilo api authorized-keys >> ~celilo-api/.ssh/authorized_keys`,
|
|
118
|
+
message: `${created ? 'Granted' : 'Updated'} API access for "${principal.name}" → ${grants.join(', ') || '(none)'}\n\nInstall the forced-command line on celilo-mgr with:\n celilo api authorized-keys >> ~celilo-api/.ssh/authorized_keys`,
|
|
66
119
|
};
|
|
67
120
|
} catch (error) {
|
|
68
121
|
return { success: false, error: `Failed to grant API access: ${errMsg(error)}` };
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* second tool.
|
|
7
7
|
*
|
|
8
8
|
* Subcommands:
|
|
9
|
-
* status bus.
|
|
9
|
+
* status bus.healthConfirmed() as JSON
|
|
10
10
|
* tail recent events
|
|
11
11
|
* list-subscribers persistent subscribers
|
|
12
12
|
* list-pending pending deliveries (subscriber fan-out — NOT questions)
|
|
@@ -80,7 +80,10 @@ function jsonResult(data: unknown): CommandResult {
|
|
|
80
80
|
export async function handleEventsStatus(): Promise<CommandResult> {
|
|
81
81
|
const bus = openCliBus();
|
|
82
82
|
try {
|
|
83
|
-
|
|
83
|
+
// Confirmed: the ansible task and the fleet check both read this verdict and
|
|
84
|
+
// act on it, so it must not say `wedged` about a dispatcher the reconcile path
|
|
85
|
+
// calls `lagging` (celilo#1399). One threshold, one answer.
|
|
86
|
+
return jsonResult(await bus.healthConfirmed());
|
|
84
87
|
} finally {
|
|
85
88
|
bus.close();
|
|
86
89
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine Reclassify Command
|
|
3
|
+
*
|
|
4
|
+
* Re-runs zone detection over a machine's STORED interface labels and reports
|
|
5
|
+
* old → new. Dry-run by default: an operator sees every relabel before any of
|
|
6
|
+
* them is written. `--apply` writes.
|
|
7
|
+
*
|
|
8
|
+
* See services/machine-interface-zones.ts for why stale labels happen.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { eq } from 'drizzle-orm';
|
|
12
|
+
import { getDb } from '../../db/client';
|
|
13
|
+
import { machines } from '../../db/schema';
|
|
14
|
+
import { applyReclassification, diffInterfaceZones } from '../../services/machine-interface-zones';
|
|
15
|
+
import { getMachineByHostname, getMachineByIp } from '../../services/machine-pool';
|
|
16
|
+
import { detectZoneFromIp } from '../../services/zone-detector';
|
|
17
|
+
import { celiloIntro, celiloOutro } from '../prompts';
|
|
18
|
+
import type { CommandResult } from '../types';
|
|
19
|
+
|
|
20
|
+
const USAGE =
|
|
21
|
+
'Usage:\n celilo machine reclassify <hostname|ip> # show what would change\n celilo machine reclassify <hostname|ip> --apply # write the new labels';
|
|
22
|
+
|
|
23
|
+
export async function handleMachineReclassify(
|
|
24
|
+
args: string[],
|
|
25
|
+
flags: Record<string, boolean | string> = {},
|
|
26
|
+
): Promise<CommandResult> {
|
|
27
|
+
try {
|
|
28
|
+
celiloIntro('Re-classify Machine Interfaces');
|
|
29
|
+
|
|
30
|
+
const identifier = args[0];
|
|
31
|
+
if (!identifier) {
|
|
32
|
+
return { success: false, error: `Machine hostname or IP is required\n\n${USAGE}` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isIp = /^\d+\.\d+\.\d+\.\d+$/.test(identifier);
|
|
36
|
+
const machine = isIp
|
|
37
|
+
? await getMachineByIp(identifier)
|
|
38
|
+
: await getMachineByHostname(identifier);
|
|
39
|
+
|
|
40
|
+
if (!machine) {
|
|
41
|
+
return { success: false, error: `Machine not found: ${identifier}` };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const interfaces = machine.interfaces ?? [];
|
|
45
|
+
const changes = await diffInterfaceZones(interfaces, detectZoneFromIp);
|
|
46
|
+
|
|
47
|
+
if (changes.length === 0) {
|
|
48
|
+
celiloOutro(
|
|
49
|
+
`${machine.hostname}: all ${interfaces.length} interface zone label(s) already match the current zone model`,
|
|
50
|
+
);
|
|
51
|
+
return { success: true, message: `${machine.hostname}: no re-classification needed` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lines = changes.map((c) => ` ${c.name} (${c.ipAddress}): ${c.from} → ${c.to}`);
|
|
55
|
+
const summary = `${machine.hostname}: ${changes.length} interface(s) would be re-classified\n${lines.join('\n')}`;
|
|
56
|
+
|
|
57
|
+
if (!flags.apply) {
|
|
58
|
+
celiloOutro(`${summary}\n\nRe-run with --apply to write these labels.`);
|
|
59
|
+
return { success: true, message: summary };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getDb()
|
|
63
|
+
.update(machines)
|
|
64
|
+
.set({ interfaces: applyReclassification(interfaces, changes) })
|
|
65
|
+
.where(eq(machines.id, machine.id))
|
|
66
|
+
.run();
|
|
67
|
+
|
|
68
|
+
celiloOutro(`${summary}\n\nWritten.`);
|
|
69
|
+
return {
|
|
70
|
+
success: true,
|
|
71
|
+
message: `${machine.hostname}: re-classified ${changes.length} interface(s)`,
|
|
72
|
+
};
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
success: false,
|
|
76
|
+
error: `Failed to re-classify machine interfaces: ${error instanceof Error ? error.message : String(error)}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -26,6 +26,7 @@ import { testProxmoxConnection } from '../../api-clients/proxmox';
|
|
|
26
26
|
import { getDb } from '../../db/client';
|
|
27
27
|
import {
|
|
28
28
|
capabilitySecrets,
|
|
29
|
+
machines,
|
|
29
30
|
moduleConfigs as moduleConfigsTbl,
|
|
30
31
|
modules,
|
|
31
32
|
secrets,
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
} from '../../services/audit/public-dns-source';
|
|
53
54
|
import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
|
|
54
55
|
import type { SecretCheckResult } from '../../services/audit/secrets-decryptable';
|
|
56
|
+
import { collectServerBunPinDeps } from '../../services/audit/server-bun-pin';
|
|
55
57
|
import type { ServiceCredentialsResult } from '../../services/audit/services-credentials';
|
|
56
58
|
import type { ServiceReachableResult } from '../../services/audit/services-reachable';
|
|
57
59
|
import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
|
|
@@ -63,6 +65,7 @@ import { collectJailExemptions } from '../../services/jail-exemptions';
|
|
|
63
65
|
import { probeMachines } from '../../services/machine-probe';
|
|
64
66
|
import { parseStoredConfigValue } from '../../services/module-config';
|
|
65
67
|
import { createPublicDnsProbe, loadPublicDnsProbeSettings } from '../../services/public-dns-probe';
|
|
68
|
+
import { collectRebootPendingDeps } from '../../services/system-reboot';
|
|
66
69
|
import { buildTerraformEnvForModule } from '../../services/terraform-env';
|
|
67
70
|
import { hasFlag } from '../parser';
|
|
68
71
|
import type { CommandResult } from '../types';
|
|
@@ -368,6 +371,22 @@ async function buildAuditDeps(
|
|
|
368
371
|
probeDiskUsage(db),
|
|
369
372
|
]);
|
|
370
373
|
|
|
374
|
+
// Stale interface zone labels. Reads only what is already stored — the
|
|
375
|
+
// detector matches an address against the declared subnets, so this costs
|
|
376
|
+
// no SSH and cannot be confused by an unreachable machine.
|
|
377
|
+
const { diffInterfaceZones } = await import('../../services/machine-interface-zones');
|
|
378
|
+
const { detectZoneFromIp } = await import('../../services/zone-detector');
|
|
379
|
+
const machineZoneLabels = await Promise.all(
|
|
380
|
+
db
|
|
381
|
+
.select()
|
|
382
|
+
.from(machines)
|
|
383
|
+
.all()
|
|
384
|
+
.map(async (m) => ({
|
|
385
|
+
hostname: m.hostname,
|
|
386
|
+
changes: await diffInterfaceZones(m.interfaces, detectZoneFromIp),
|
|
387
|
+
})),
|
|
388
|
+
);
|
|
389
|
+
|
|
371
390
|
const migrationsFolder = findMigrationsFolderSafe();
|
|
372
391
|
|
|
373
392
|
return {
|
|
@@ -388,6 +407,8 @@ async function buildAuditDeps(
|
|
|
388
407
|
})),
|
|
389
408
|
},
|
|
390
409
|
browserPin: collectBrowserPinDeps(deployedModules),
|
|
410
|
+
serverBunPin: collectServerBunPinDeps(),
|
|
411
|
+
rebootPending: collectRebootPendingDeps(),
|
|
391
412
|
terraformPlan: {
|
|
392
413
|
modules: terraformModules,
|
|
393
414
|
run: realTerraformPlan,
|
|
@@ -429,6 +450,7 @@ async function buildAuditDeps(
|
|
|
429
450
|
secretsDecryptable: { results: secretResults },
|
|
430
451
|
servicesReachable: { results: serviceReachableResults },
|
|
431
452
|
machinesReachable: { results: machineReachableResults },
|
|
453
|
+
machineInterfaceZones: { machines: machineZoneLabels },
|
|
432
454
|
// The only check here whose vantage point is OUTSIDE the fleet. Its
|
|
433
455
|
// undetermined counters are read but not written from this path: an
|
|
434
456
|
// operator-run audit is not a run of a schedule, so counting it towards
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo system reboot` — reboot the management server (celilo#1378).
|
|
3
|
+
*
|
|
4
|
+
* Thin adapter (Rule 10.5): parse flags, obtain consent, compose the real
|
|
5
|
+
* host's dependencies, call the service, format the result. Every judgement —
|
|
6
|
+
* the in-flight refusal, the boot-id bookkeeping, what counts as "came back" —
|
|
7
|
+
* lives in `services/system-reboot.ts` and is tested against a fake host.
|
|
8
|
+
*
|
|
9
|
+
* Consent follows the repo's flag-or-interview rule: `--allow-reboot` (or
|
|
10
|
+
* `--yes`) wins, otherwise the question goes on the event bus so a headless
|
|
11
|
+
* responder can answer it. No stdin prompt.
|
|
12
|
+
*
|
|
13
|
+
* `--verify` is the other half, and it exists because the process asking for a
|
|
14
|
+
* reboot dies in it: no single call can both reboot this host and report that
|
|
15
|
+
* it came back. See the service's header.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
21
|
+
import { getEventBusPath } from '../../config/paths';
|
|
22
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
23
|
+
import {
|
|
24
|
+
BOOT_ID_PATH,
|
|
25
|
+
collectRebootPendingDeps,
|
|
26
|
+
requestReboot,
|
|
27
|
+
verifyReturn,
|
|
28
|
+
} from '../../services/system-reboot';
|
|
29
|
+
import { hasFlag } from '../parser';
|
|
30
|
+
import type { CommandResult } from '../types';
|
|
31
|
+
|
|
32
|
+
/** The deb's wrapper — the same binary `apt-upgrade` re-execs for migrations. */
|
|
33
|
+
const CELILO_BIN = '/usr/local/bin/celilo';
|
|
34
|
+
|
|
35
|
+
/** This command publishes nothing; it only reads the bus's health. */
|
|
36
|
+
const NO_SCHEMAS = defineEvents({});
|
|
37
|
+
|
|
38
|
+
function readBootId(): string | null {
|
|
39
|
+
try {
|
|
40
|
+
const id = readFileSync(BOOT_ID_PATH, 'utf-8').trim();
|
|
41
|
+
return id.length > 0 ? id : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Does the installed CLI answer? Its version, or null. */
|
|
48
|
+
function installedCliVersion(): string | null {
|
|
49
|
+
try {
|
|
50
|
+
const { status, stdout } = spawnSync(CELILO_BIN, ['--version'], { encoding: 'utf-8' });
|
|
51
|
+
if (status !== 0) return null;
|
|
52
|
+
const version = (stdout ?? '').trim();
|
|
53
|
+
return version.length > 0 ? version : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Is the dispatcher alive? Confirmed rather than sampled — a dispatcher
|
|
61
|
+
* starved on a box that just booted looks exactly like a dead one for one
|
|
62
|
+
* sample (celilo#1399).
|
|
63
|
+
*/
|
|
64
|
+
async function dispatcherHeartbeatLive(): Promise<boolean> {
|
|
65
|
+
let bus: ReturnType<typeof openBus> | undefined;
|
|
66
|
+
try {
|
|
67
|
+
bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
|
|
68
|
+
const health = await bus.healthConfirmed();
|
|
69
|
+
return health.status !== 'no_dispatcher' && health.status !== 'wedged';
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
} finally {
|
|
73
|
+
bus?.close?.();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function handleSystemReboot(
|
|
78
|
+
_args: string[],
|
|
79
|
+
flags: Record<string, string | boolean>,
|
|
80
|
+
): Promise<CommandResult> {
|
|
81
|
+
if (hasFlag(flags, 'verify')) {
|
|
82
|
+
const result = await verifyReturn({
|
|
83
|
+
bootId: readBootId,
|
|
84
|
+
heartbeatLive: dispatcherHeartbeatLive,
|
|
85
|
+
cliVersion: installedCliVersion,
|
|
86
|
+
});
|
|
87
|
+
return result.success
|
|
88
|
+
? { success: true, message: result.message ?? 'Reboot verified.' }
|
|
89
|
+
: { success: false, error: result.error ?? 'Reboot could not be verified.' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Report the condition before asking, so the operator is answering about
|
|
93
|
+
// the host in front of them rather than about the word "reboot".
|
|
94
|
+
const { verdict } = collectRebootPendingDeps();
|
|
95
|
+
const why = verdict.pending
|
|
96
|
+
? verdict.reasons.join('; ')
|
|
97
|
+
: 'no pending kernel or deferred restart was detected';
|
|
98
|
+
|
|
99
|
+
const consented =
|
|
100
|
+
flags['allow-reboot'] === true ||
|
|
101
|
+
hasFlag(flags, 'yes') ||
|
|
102
|
+
(await withInterviewSession(() =>
|
|
103
|
+
askConfirm({
|
|
104
|
+
scope: 'system-reboot',
|
|
105
|
+
key: 'reboot',
|
|
106
|
+
message: `Reboot the management server? This takes down the dispatcher, the MCP server and the remote API. Condition: ${why}.`,
|
|
107
|
+
defaultValue: false,
|
|
108
|
+
}),
|
|
109
|
+
));
|
|
110
|
+
|
|
111
|
+
if (!consented) {
|
|
112
|
+
return { success: false, error: 'Reboot not approved — re-run with --allow-reboot.' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const result = requestReboot(
|
|
116
|
+
{
|
|
117
|
+
bootId: readBootId,
|
|
118
|
+
issueReboot: () => spawnSync('sudo', ['systemctl', 'reboot'], { stdio: 'inherit' }),
|
|
119
|
+
},
|
|
120
|
+
why,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return result.success
|
|
124
|
+
? { success: true, message: result.message ?? 'Reboot issued.' }
|
|
125
|
+
: { success: false, error: result.error ?? 'Reboot refused.' };
|
|
126
|
+
}
|
|
@@ -31,12 +31,14 @@ import { collectBrowserPinDeps } from '../../services/audit/browser-pin';
|
|
|
31
31
|
import { fetchLatestCliVersion } from '../../services/audit/cli-version';
|
|
32
32
|
import { unusedPublicDnsProbe } from '../../services/audit/public-dns';
|
|
33
33
|
import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
|
|
34
|
+
import { collectServerBunPinDeps } from '../../services/audit/server-bun-pin';
|
|
34
35
|
import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
|
|
35
36
|
import { BACKUP_SCHEDULE_CONFIG_KEY } from '../../services/backup-schedule';
|
|
36
37
|
import { runAllHealthChecks, runModuleHealthCheck } from '../../services/health-runner';
|
|
37
38
|
import { collectJailExemptions } from '../../services/jail-exemptions';
|
|
38
39
|
import { configOverride, parseStoredConfigValue } from '../../services/module-config';
|
|
39
40
|
import { deployModule } from '../../services/module-deploy';
|
|
41
|
+
import { collectRebootPendingDeps } from '../../services/system-reboot';
|
|
40
42
|
import { buildModuleGraph } from '../../services/update/dep-graph';
|
|
41
43
|
import {
|
|
42
44
|
type ModuleSnapshot,
|
|
@@ -549,6 +551,8 @@ export async function handleSystemUpdate(
|
|
|
549
551
|
})),
|
|
550
552
|
},
|
|
551
553
|
browserPin: collectBrowserPinDeps(upgradableModules),
|
|
554
|
+
serverBunPin: collectServerBunPinDeps(),
|
|
555
|
+
rebootPending: collectRebootPendingDeps(),
|
|
552
556
|
terraformPlan: {
|
|
553
557
|
modules: upgradableModules.map((m) => ({
|
|
554
558
|
id: m.id,
|
|
@@ -616,6 +620,7 @@ export async function handleSystemUpdate(
|
|
|
616
620
|
secretsDecryptable: { results: [] },
|
|
617
621
|
servicesReachable: { results: [] },
|
|
618
622
|
machinesReachable: { results: [] },
|
|
623
|
+
machineInterfaceZones: { machines: [] },
|
|
619
624
|
// Public reachability needs a network round trip per name; the update
|
|
620
625
|
// flow's partial audit does no probing. `system audit` and the scheduled
|
|
621
626
|
// monitor own this check.
|
package/src/cli/completion.ts
CHANGED
|
@@ -439,14 +439,14 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
439
439
|
}
|
|
440
440
|
|
|
441
441
|
if (command === 'machine' && currentIndex === 1) {
|
|
442
|
-
const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'detect'];
|
|
442
|
+
const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'reclassify', 'detect'];
|
|
443
443
|
return filterSuggestions(subcommands, args[1] || '');
|
|
444
444
|
}
|
|
445
445
|
|
|
446
446
|
// Machine remove/earmark - complete with machine hostnames and IPs
|
|
447
447
|
if (
|
|
448
448
|
command === 'machine' &&
|
|
449
|
-
(args[1] === 'remove' || args[1] === 'earmark') &&
|
|
449
|
+
(args[1] === 'remove' || args[1] === 'earmark' || args[1] === 'reclassify') &&
|
|
450
450
|
currentIndex === 2
|
|
451
451
|
) {
|
|
452
452
|
const machineList = await listMachines();
|
|
@@ -704,6 +704,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
704
704
|
'update',
|
|
705
705
|
'doctor',
|
|
706
706
|
'migrate',
|
|
707
|
+
'reboot',
|
|
707
708
|
];
|
|
708
709
|
return filterSuggestions(subcommands, args[1] || '');
|
|
709
710
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
import { handleMachineAdd } from './commands/machine-add';
|
|
66
66
|
import { handleMachineEarmark } from './commands/machine-earmark';
|
|
67
67
|
import { handleMachineList } from './commands/machine-list';
|
|
68
|
+
import { handleMachineReclassify } from './commands/machine-reclassify';
|
|
68
69
|
import { handleMachineRemove } from './commands/machine-remove';
|
|
69
70
|
import { handleMachineStatus } from './commands/machine-status';
|
|
70
71
|
import { moduleAudit } from './commands/module-audit';
|
|
@@ -132,6 +133,7 @@ import { handleSystemDoctor } from './commands/system-doctor';
|
|
|
132
133
|
import { handleSystemEnsureFleetKey } from './commands/system-ensure-fleet-key';
|
|
133
134
|
import { handleSystemInit } from './commands/system-init';
|
|
134
135
|
import { handleSystemMigrate } from './commands/system-migrate';
|
|
136
|
+
import { handleSystemReboot } from './commands/system-reboot';
|
|
135
137
|
import { handleSystemSecretGet } from './commands/system-secret-get';
|
|
136
138
|
import { handleSystemSecretSet } from './commands/system-secret-set';
|
|
137
139
|
import { handleSystemUpdate } from './commands/system-update';
|
|
@@ -1097,6 +1099,10 @@ Examples:
|
|
|
1097
1099
|
celilo machine earmark 10.0.10.10 caddy
|
|
1098
1100
|
celilo machine earmark pi-01 --clear
|
|
1099
1101
|
|
|
1102
|
+
# Re-classify stored interface zone labels after a zone-model change
|
|
1103
|
+
celilo machine reclassify celilo-mgr
|
|
1104
|
+
celilo machine reclassify celilo-mgr --apply
|
|
1105
|
+
|
|
1100
1106
|
# Remove machine (by hostname or IP)
|
|
1101
1107
|
celilo machine remove pi-01
|
|
1102
1108
|
celilo machine remove 10.0.10.10 --force
|
|
@@ -1214,6 +1220,11 @@ Subcommands:
|
|
|
1214
1220
|
|
|
1215
1221
|
doctor [--fix] Diagnose system prerequisites and @celilo/* version drift
|
|
1216
1222
|
|
|
1223
|
+
reboot [--allow-reboot] [--verify] Reboot the management server. Refuses while a module
|
|
1224
|
+
operation is in flight and names it. The process asking
|
|
1225
|
+
dies in the reboot, so --verify is what confirms the host
|
|
1226
|
+
came back: boot id changed, dispatcher live, CLI answering.
|
|
1227
|
+
|
|
1217
1228
|
Runbook:
|
|
1218
1229
|
https://celilo.computer/docs/system-update
|
|
1219
1230
|
(offline summary in apps/celilo/docs/INDEX.md)
|
|
@@ -2269,6 +2280,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2269
2280
|
return handleMachineEarmark(parsed.args, parsed.flags);
|
|
2270
2281
|
}
|
|
2271
2282
|
|
|
2283
|
+
if (parsed.subcommand === 'reclassify') {
|
|
2284
|
+
return handleMachineReclassify(parsed.args, parsed.flags);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2272
2287
|
return {
|
|
2273
2288
|
success: false,
|
|
2274
2289
|
error: `Unknown machine subcommand: ${parsed.subcommand}\n\nRun "celilo machine --help" for usage`,
|
|
@@ -2369,6 +2384,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2369
2384
|
return handleSystemMigrate(parsed.args, parsed.flags);
|
|
2370
2385
|
}
|
|
2371
2386
|
|
|
2387
|
+
if (parsed.subcommand === 'reboot') {
|
|
2388
|
+
return handleSystemReboot(parsed.args, parsed.flags);
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2372
2391
|
return {
|
|
2373
2392
|
success: false,
|
|
2374
2393
|
error: `Unknown system subcommand: ${parsed.subcommand}\n\nRun "celilo system --help" for usage`,
|