@celilo/cli 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +21 -2
  3. package/package.json +3 -3
  4. package/src/capabilities/public-web-helpers.test.ts +12 -6
  5. package/src/capabilities/public-web-publish.test.ts +24 -13
  6. package/src/capabilities/validation.test.ts +31 -0
  7. package/src/cli/commands/alerts-list.ts +16 -1
  8. package/src/cli/commands/backup-list.test.ts +82 -1
  9. package/src/cli/commands/backup-list.ts +113 -4
  10. package/src/cli/commands/console-get-chain.test.ts +96 -0
  11. package/src/cli/commands/console.ts +130 -0
  12. package/src/cli/commands/module-list.ts +3 -41
  13. package/src/cli/commands/notify-config.test.ts +79 -0
  14. package/src/cli/commands/notify-config.ts +13 -2
  15. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  16. package/src/cli/completion.ts +6 -0
  17. package/src/cli/index.ts +31 -1
  18. package/src/console/closure.test.ts +322 -0
  19. package/src/console/closure.ts +294 -0
  20. package/src/console/control-plane-boundary.test.ts +75 -0
  21. package/src/console/projection.test.ts +293 -0
  22. package/src/console/projection.ts +364 -0
  23. package/src/db/schema.ts +19 -14
  24. package/src/hooks/broker.test.ts +4 -6
  25. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  26. package/src/hooks/capability-loader.ts +67 -10
  27. package/src/hooks/executor.test.ts +85 -4
  28. package/src/hooks/executor.ts +164 -9
  29. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  30. package/src/hooks/hook-state-dir.test.ts +14 -2
  31. package/src/hooks/hook-timeout.test.ts +2 -4
  32. package/src/hooks/hook-trespass.test.ts +50 -5
  33. package/src/hooks/jail.test.ts +370 -0
  34. package/src/hooks/jail.ts +491 -0
  35. package/src/hooks/mount-set.ts +24 -0
  36. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  37. package/src/manifest/contracts/v1.ts +22 -1
  38. package/src/manifest/schema.ts +35 -0
  39. package/src/manifest/validate.test.ts +142 -0
  40. package/src/manifest/validate.ts +126 -4
  41. package/src/module/import.test.ts +116 -0
  42. package/src/module/import.ts +73 -1
  43. package/src/module/packaging/audit.ts +103 -1
  44. package/src/module/packaging/classify-module-path.test.ts +36 -0
  45. package/src/module/packaging/package-rules.ts +18 -0
  46. package/src/module/web-root.ts +35 -0
  47. package/src/policy/capability-shape-baseline.ts +8 -0
  48. package/src/policy/module-business-baseline.ts +26 -2
  49. package/src/policy/module-script-scan.test.ts +22 -0
  50. package/src/policy/module-script-scan.ts +32 -0
  51. package/src/services/alerting/observed-health.ts +71 -0
  52. package/src/services/api-principal-enrolment.test.ts +252 -0
  53. package/src/services/api-principal-enrolment.ts +158 -0
  54. package/src/services/audit/backups.ts +10 -1
  55. package/src/services/backup-create.ts +33 -7
  56. package/src/services/backup-metadata.ts +19 -11
  57. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  58. package/src/services/consumer-cleanup.ts +31 -5
  59. package/src/services/fleet-key.test.ts +47 -0
  60. package/src/services/fleet-key.ts +75 -0
  61. package/src/services/instance-ops.test.ts +302 -0
  62. package/src/services/instance-ops.ts +292 -0
  63. package/src/services/module-instances.test.ts +428 -42
  64. package/src/services/module-instances.ts +219 -26
  65. package/src/services/restore-from-file.ts +6 -5
  66. package/src/services/system-state-stage.test.ts +165 -0
  67. package/src/services/system-state-stage.ts +196 -0
@@ -10,6 +10,7 @@ import {
10
10
  listBackups,
11
11
  loadBackupHistory,
12
12
  } from '../../services/backup-metadata';
13
+ import type { BackupHistory } from '../../services/backup-schedule';
13
14
  import { getBackupStorage } from '../../services/backup-storage';
14
15
  import { celiloIntro } from '../prompts';
15
16
  import type { CommandResult } from '../types';
@@ -136,28 +137,136 @@ export function lastSuccessSummaryLines(
136
137
  });
137
138
  }
138
139
 
140
+ /**
141
+ * The listing as data, for anything that is not a person reading a terminal.
142
+ *
143
+ * This exists because the human output cannot be parsed back into facts. Ages
144
+ * are rendered relative and BUCKETED — everything past six days collapses to
145
+ * "last week", then "2 weeks ago", then "last month" — so a caller reading the
146
+ * text can place a backup on a day for six days and not one day further.
147
+ *
148
+ * That is fine for a person, who is asking "is this recent". It is not fine for
149
+ * the web console, which draws one square per module per day: a row it cannot
150
+ * place is a row it must leave out, and a missing row renders as a day on which
151
+ * no backup ran. The one thing a coverage grid must never do is invent a gap.
152
+ *
153
+ * So the timestamps go out as epoch ms, exactly as stored, and the caller
154
+ * decides how to say them. Everything else here is what the human path already
155
+ * shows — the same rows and the same per-module summary — because a second
156
+ * source of truth for "what backups exist" is worth less than no second source.
157
+ */
158
+ /**
159
+ * The two database reads this needs, injectable so the shape can be tested
160
+ * without one (Rule 2.3). The defaults are the real thing.
161
+ */
162
+ export interface BackupListJsonDeps {
163
+ storageNameOf: (storageId: string) => string;
164
+ historyOf: (moduleId: string) => BackupHistory;
165
+ }
166
+
167
+ const LIVE_LOOKUPS: BackupListJsonDeps = {
168
+ storageNameOf: (storageId) => getBackupStorage(storageId)?.storageId ?? 'unknown',
169
+ historyOf: loadBackupHistory,
170
+ };
171
+
172
+ export function backupListJson(
173
+ backupList: Backup[],
174
+ windowDays: number | null,
175
+ deps: BackupListJsonDeps = LIVE_LOOKUPS,
176
+ ): CommandResult {
177
+ // One lookup per distinct destination rather than one per row: a month of a
178
+ // failing module is hundreds of rows and two or three storages.
179
+ const storageNames = new Map<string, string>();
180
+ for (const backup of backupList) {
181
+ if (storageNames.has(backup.storageId)) continue;
182
+ storageNames.set(backup.storageId, deps.storageNameOf(backup.storageId));
183
+ }
184
+
185
+ const payload = {
186
+ asOf: Date.now(),
187
+ /**
188
+ * How far back the rows are COMPLETE for, or null when unbounded.
189
+ *
190
+ * The caller needs this to tell "nothing ran that day" from "you did not
191
+ * ask about that day". They look identical in the data and only one of them
192
+ * is somebody's missing backup.
193
+ */
194
+ windowDays,
195
+ backups: backupList.map((backup) => ({
196
+ id: backup.id,
197
+ shortId: backup.id.substring(0, 8),
198
+ moduleId: backup.moduleId,
199
+ backupType: backup.backupType,
200
+ status: backup.status,
201
+ sizeBytes: backup.sizeBytes,
202
+ // Epoch ms, both of them. `startedAt` is when the attempt began and
203
+ // `completedAt` is when it finished; a failed attempt has no second one.
204
+ startedAt: backup.startedAt.getTime(),
205
+ completedAt: backup.completedAt ? backup.completedAt.getTime() : null,
206
+ storage: storageNames.get(backup.storageId) ?? 'unknown',
207
+ storagePath: backup.storagePath,
208
+ moduleVersion: backup.moduleVersion,
209
+ schemaVersion: backup.schemaVersion,
210
+ name: backup.name,
211
+ error: backup.errorMessage,
212
+ })),
213
+ modules: backedUpModuleIds(backupList).map((moduleId) => {
214
+ const history = deps.historyOf(moduleId);
215
+ return {
216
+ moduleId,
217
+ lastSuccessAt: history.lastSuccessAt ? history.lastSuccessAt.getTime() : null,
218
+ lastAttemptAt: history.lastAttemptAt ? history.lastAttemptAt.getTime() : null,
219
+ consecutiveFailures: history.consecutiveFailures,
220
+ };
221
+ }),
222
+ };
223
+
224
+ // `rawOutput` keeps the payload out of the decorating renderer, which would
225
+ // wrap it and stop it parsing (celilo#698).
226
+ return { success: true, message: JSON.stringify(payload, null, 2), rawOutput: true };
227
+ }
228
+
139
229
  export async function handleBackupList(
140
230
  args: string[],
141
231
  flags: Record<string, boolean | string> = {},
142
232
  ): Promise<CommandResult> {
143
233
  try {
144
234
  const moduleIdOrBackupId = args[0];
145
- const limit = typeof flags.limit === 'string' ? Number.parseInt(flags.limit, 10) : 20;
235
+ // A JSON caller is a program with a window in mind, not a person skimming a
236
+ // screen, so 20 is the wrong ceiling for it. It still takes --limit.
237
+ const defaultLimit = flags.json ? 5000 : 20;
238
+ const limit = typeof flags.limit === 'string' ? Number.parseInt(flags.limit, 10) : defaultLimit;
239
+ const windowDays = typeof flags.since === 'string' ? Number.parseInt(flags.since, 10) : null;
240
+ if (windowDays !== null && (!Number.isFinite(windowDays) || windowDays <= 0)) {
241
+ return {
242
+ success: false,
243
+ error: `--since takes a positive number of days, got: ${flags.since}`,
244
+ };
245
+ }
246
+ const since = windowDays === null ? undefined : Date.now() - windowDays * 86_400_000;
146
247
 
147
248
  // If the argument looks like a backup ID (hex chars), show detail view
148
249
  if (moduleIdOrBackupId && /^[0-9a-f]{8,}$/i.test(moduleIdOrBackupId)) {
149
250
  const backup = getBackup(moduleIdOrBackupId);
150
251
  if (backup) {
252
+ // The same envelope as the listing, holding one row. A caller that can
253
+ // parse `backup list --json` can parse this without a second shape, and
254
+ // asking about one backup is the commonest scripted case there is.
255
+ if (flags.json) return backupListJson([backup], null);
151
256
  celiloIntro('Backup Detail');
152
257
  return showBackupDetail(moduleIdOrBackupId);
153
258
  }
154
259
  // Fall through to module filter if not a backup ID
155
260
  }
156
261
 
157
- celiloIntro('Available Backups');
158
-
159
262
  const moduleId = moduleIdOrBackupId;
160
- const backupList = listBackups({ moduleId, limit });
263
+ const backupList = listBackups({ moduleId, limit, since });
264
+
265
+ // Before the banner: --json must emit JSON and nothing else, or the first
266
+ // thing a parser meets is a celilo logo.
267
+ if (flags.json) return backupListJson(backupList, windowDays);
268
+
269
+ celiloIntro('Available Backups');
161
270
 
162
271
  if (backupList.length === 0) {
163
272
  console.log('No backups found.\n');
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The wiring, end to end: does `celilo console get` actually carry the chain?
3
+ *
4
+ * `orderFirewallChain` is unit-tested, `computeClosure` is unit-tested, and
5
+ * `loadClosureInputs` is unit-tested against real rows. None of that says the
6
+ * command joins them up. A dropped argument at this one call site returns a
7
+ * perfectly well-formed answer with an empty `chains` — which reads as a fleet
8
+ * with no delegation, and is the failure mode CLAUDE.md names.
9
+ *
10
+ * So this drives the REAL command against a REAL database and asserts on what
11
+ * it emits.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15
+ import { mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ let testDir: string;
20
+
21
+ beforeEach(() => {
22
+ testDir = mkdtempSync(join(tmpdir(), 'celilo-console-chain-'));
23
+ process.env.CELILO_DB_PATH = join(testDir, 'test.db');
24
+ process.env.CELILO_DATA_DIR = testDir;
25
+ });
26
+
27
+ afterEach(() => {
28
+ rmSync(testDir, { recursive: true, force: true });
29
+ process.env.CELILO_DB_PATH = undefined;
30
+ });
31
+
32
+ /** The live fleet's firewall shape: caddy on iptables, iptables on the router. */
33
+ async function seedFleet() {
34
+ const { getDb } = await import('../../db/client');
35
+ const db = getDb();
36
+ const consumer = JSON.stringify({ requires: { capabilities: [{ name: 'firewall' }] } });
37
+
38
+ db.$client.run(
39
+ `INSERT INTO modules (id, name, version, source_path, manifest_data, state)
40
+ VALUES ('caddy', 'Caddy', '1.0.0', '/p', '${consumer}', 'VERIFIED')`,
41
+ );
42
+ for (const id of ['iptables', 'axon']) {
43
+ db.$client.run(
44
+ `INSERT INTO modules (id, name, version, source_path, manifest_data, state)
45
+ VALUES ('${id}', '${id}', '1.0.0', '/p', '{}', 'VERIFIED')`,
46
+ );
47
+ }
48
+ db.$client.run(
49
+ `INSERT INTO capabilities (module_id, capability_name, version, data)
50
+ VALUES ('iptables', 'firewall', '1.0.0', '{"nat_ip":"192.168.0.253"}')`,
51
+ );
52
+ db.$client.run(
53
+ `INSERT INTO capabilities (module_id, capability_name, version, data)
54
+ VALUES ('axon', 'firewall', '1.0.0', '{"has_external":true}')`,
55
+ );
56
+ }
57
+
58
+ describe('celilo console get, and the delegation chain', () => {
59
+ test('--json carries the chain, in order', async () => {
60
+ await seedFleet();
61
+ const { handleConsoleGet } = await import('./console');
62
+ const result = handleConsoleGet(['caddy'], { json: true });
63
+
64
+ expect(result.success).toBe(true);
65
+ const payload = JSON.parse(result.success ? result.message : '{}');
66
+ expect(payload.chains).toEqual([{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }]);
67
+ });
68
+
69
+ test('the router is in the closure, though no manifest mentions it', async () => {
70
+ // caddy requires `firewall` and nothing else. Walking manifests alone stops
71
+ // at the two registered providers and has no way to know one stands on the
72
+ // other — which is the whole reason the chain is derived.
73
+ await seedFleet();
74
+ const { handleConsoleGet } = await import('./console');
75
+ const payload = JSON.parse(
76
+ (() => {
77
+ const r = handleConsoleGet(['caddy'], { json: true });
78
+ return r.success ? r.message : '{}';
79
+ })(),
80
+ );
81
+
82
+ const axon = payload.nodes.find((n: { moduleId: string }) => n.moduleId === 'axon');
83
+ expect(axon).toBeDefined();
84
+ });
85
+
86
+ test('the human output prints it as a path, not as more rows', async () => {
87
+ // A list of two firewalls is exactly the claim being corrected. The arrow is
88
+ // the information.
89
+ await seedFleet();
90
+ const { handleConsoleGet } = await import('./console');
91
+ const result = handleConsoleGet(['caddy'], {});
92
+
93
+ expect(result.success).toBe(true);
94
+ expect(result.success ? result.message : '').toContain('iptables -> axon');
95
+ });
96
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `celilo console …` — the narrow reads the web console polls.
3
+ *
4
+ * A NOTE ON THE VERB NAMES, because they read oddly on purpose. celilo's remote
5
+ * API authorises at two levels (`command:subcommand`), and a leaf is classified
6
+ * read-only by its subcommand token against `READ_VERBS` in `@celilo/core`. So
7
+ * these are named from that vocabulary — `status`, `list`, `get` — rather than
8
+ * for prose. `console roster` would be a nicer name and would classify as a
9
+ * WRITE, which would either deny the console its own data or require widening
10
+ * the read-verb set for every principal in the fleet.
11
+ *
12
+ * All three are reads. The console's principal is granted read ops only, so a
13
+ * mutating verb added to this file would be refused at the API boundary rather
14
+ * than running.
15
+ */
16
+
17
+ import {
18
+ DEFAULT_CLOSURE_DEPTH,
19
+ UNBOUNDED_CLOSURE_DEPTH,
20
+ computeClosure,
21
+ } from '../../console/closure';
22
+ import {
23
+ consoleStatus,
24
+ loadBindings,
25
+ loadClosureInputs,
26
+ moduleExists,
27
+ } from '../../console/projection';
28
+ import { getDb } from '../../db/client';
29
+ import { loadCapabilityProviderRows } from '../../services/consumer-cleanup';
30
+ import { hasFlag } from '../parser';
31
+ import type { CommandResult } from '../types';
32
+
33
+ /** `celilo console status [--json]` — the dashboard's single poll. */
34
+ export function handleConsoleStatus(flags: Record<string, string | boolean> = {}): CommandResult {
35
+ const payload = consoleStatus(getDb());
36
+
37
+ if (hasFlag(flags, 'json')) {
38
+ return { success: true, message: JSON.stringify(payload), rawOutput: true, data: payload };
39
+ }
40
+
41
+ const lines = payload.modules.map(
42
+ (m) =>
43
+ `${m.id.padEnd(24)} ${m.state.padEnd(12)} ${m.health.cell.padEnd(14)} ${
44
+ m.systems.map((s) => `${s.hostname}@${s.zone}`).join(', ') || '(no system)'
45
+ }`,
46
+ );
47
+ return {
48
+ success: true,
49
+ message: [`zones: ${payload.zones.join(' > ')}`, '', ...lines].join('\n'),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * `celilo console get <module-id> [--depth N] [--json]` — one module's bounded
55
+ * capability closure.
56
+ *
57
+ * `--depth 0` walks the whole graph. The walk terminates on cycles either way.
58
+ */
59
+ export function handleConsoleGet(
60
+ args: string[],
61
+ flags: Record<string, string | boolean> = {},
62
+ ): CommandResult {
63
+ const moduleId = args[0];
64
+ if (!moduleId) {
65
+ return { success: false, error: 'Module ID required\n\nUsage: celilo console get <module-id>' };
66
+ }
67
+
68
+ const db = getDb();
69
+ if (!moduleExists(db, moduleId)) {
70
+ return { success: false, error: `Module not found: ${moduleId}` };
71
+ }
72
+
73
+ const depth = parseDepth(flags.depth);
74
+ if (depth instanceof Error) return { success: false, error: depth.message };
75
+
76
+ const { manifests, providerStates, chains } = loadClosureInputs(db);
77
+
78
+ // Real bindings, for every module the walk might reach. The console draws what
79
+ // the fleet IS doing, not what its manifests permit.
80
+ const bindings = new Map([...manifests.keys()].map((id) => [id, loadBindings(db, id)] as const));
81
+
82
+ const result = computeClosure({
83
+ rootModuleId: moduleId,
84
+ manifests,
85
+ providerRows: loadCapabilityProviderRows(db),
86
+ providerStates,
87
+ depth,
88
+ bindings,
89
+ chains,
90
+ });
91
+
92
+ if (hasFlag(flags, 'json')) {
93
+ return { success: true, message: JSON.stringify(result), rawOutput: true, data: result };
94
+ }
95
+
96
+ if (result.nodes.length === 0) {
97
+ // An answer, not a failure. The console says the same thing in words rather
98
+ // than rendering an empty picture that reads as still loading.
99
+ return { success: true, message: `${moduleId} depends on nothing.` };
100
+ }
101
+
102
+ // The chain is printed as a path rather than as more rows, because that is the
103
+ // whole claim: these providers are ordered, and a list of them is not.
104
+ const chainLines = result.chains.map(
105
+ (chain) => `${chain.capability}: ${chain.moduleIds.join(' -> ')}`,
106
+ );
107
+
108
+ return {
109
+ success: true,
110
+ message: [
111
+ ...result.nodes.map(
112
+ (n) =>
113
+ `hop ${n.hop} ${n.moduleId.padEnd(24)} ${n.optional ? '(optional)' : ' '} via ${n.via.join(', ')}`,
114
+ ),
115
+ ...(chainLines.length > 0 ? ['', 'delegates upstream:', ...chainLines] : []),
116
+ ].join('\n'),
117
+ };
118
+ }
119
+
120
+ /** Depth flag, or an Error explaining what was wrong with it. */
121
+ function parseDepth(raw: string | boolean | undefined): number | Error {
122
+ if (raw === undefined || raw === true) return DEFAULT_CLOSURE_DEPTH;
123
+ const parsed = Number(raw);
124
+ if (!Number.isInteger(parsed) || parsed < 0) {
125
+ return new Error(
126
+ `Invalid --depth: ${raw}\n\nExpected a non-negative integer (${UNBOUNDED_CLOSURE_DEPTH} walks the whole graph).`,
127
+ );
128
+ }
129
+ return parsed;
130
+ }
@@ -1,8 +1,6 @@
1
- import { eq } from 'drizzle-orm';
2
- import { type DbClient, getDb } from '../../db/client';
3
- import { modules, modules as modulesTable, monitors } from '../../db/schema';
4
- import { moduleHealthCell } from '../../services/alerting/format';
5
- import { loadAllLiveAlerts, summariseByModule } from '../../services/alerting/store';
1
+ import { getDb } from '../../db/client';
2
+ import { modules } from '../../db/schema';
3
+ import { loadObservedHealth } from '../../services/alerting/observed-health';
6
4
  import { formatPausedDuration } from '../../services/module-pause';
7
5
  import { hasFlag } from '../parser';
8
6
  import type { CommandResult } from '../types';
@@ -77,39 +75,3 @@ export async function handleModuleList(
77
75
  data: moduleRows,
78
76
  };
79
77
  }
80
-
81
- /**
82
- * Observed health per module: `ok`, `N firing`, `suppressed`, or
83
- * `not observed`.
84
- *
85
- * Only computed for DEPLOYED modules — a module still being imported has
86
- * nothing to observe, and reporting it as unwatched would be noise rather
87
- * than a finding.
88
- */
89
- function loadObservedHealth(db: DbClient): Map<string, string> {
90
- const monitored = new Set(
91
- db
92
- .select({ target: monitors.target })
93
- .from(monitors)
94
- .where(eq(monitors.enabled, true))
95
- .all()
96
- .map((m) => m.target),
97
- );
98
- const byModule = summariseByModule(loadAllLiveAlerts(db));
99
-
100
- const result = new Map<string, string>();
101
- for (const module of db.select().from(modulesTable).all()) {
102
- if (module.state !== 'INSTALLED' && module.state !== 'VERIFIED') continue;
103
-
104
- const summary = byModule.get(module.id) ?? [];
105
- result.set(
106
- module.id,
107
- moduleHealthCell({
108
- monitored: monitored.has(module.id),
109
- firingCount: summary.filter((s) => !s.suppressed).length,
110
- suppressed: summary.length > 0 && summary.every((s) => s.suppressed),
111
- }),
112
- );
113
- }
114
- return result;
115
- }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `celilo person list --json` — the shape the web console maps an identity
3
+ * against.
4
+ *
5
+ * Worth its own test for a reason that is not obvious from the three lines it
6
+ * covers. The console server decides whether an authenticated browser subject
7
+ * corresponds to a real person, and it decides it by reading THIS. Its own
8
+ * tests answer that question against a fake celilo, so they prove the console
9
+ * parses what it was told to expect and say nothing about what celilo emits.
10
+ * This is the end that closes.
11
+ *
12
+ * If the payload ever stops being `[{ name, timezone }]`, the console does not
13
+ * error: it maps nobody, draws no acknowledge control, and reports every
14
+ * operator as unknown to the fleet. That reads as a configuration problem for
15
+ * as long as anyone is willing to believe it.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+ import { mkdtempSync, rmSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { closeDb, getDb } from '../../db/client';
23
+ import { createPerson } from '../../services/alerting/people';
24
+ import { setupTestDatabaseAt } from '../../test-utils/database';
25
+ import { handlePerson } from './notify-config';
26
+
27
+ let dir: string;
28
+ const previousDbPath = process.env.CELILO_DB_PATH;
29
+
30
+ beforeEach(async () => {
31
+ dir = mkdtempSync(join(tmpdir(), 'celilo-person-'));
32
+ const dbPath = join(dir, 'test.db');
33
+ const db = await setupTestDatabaseAt(dbPath);
34
+ createPerson(db, { name: 'peter', timezone: 'America/Los_Angeles' });
35
+ createPerson(db, { name: 'ada', timezone: 'Europe/London' });
36
+ db.$client.close();
37
+ process.env.CELILO_DB_PATH = dbPath;
38
+ });
39
+
40
+ afterEach(() => {
41
+ closeDb();
42
+ if (previousDbPath === undefined) delete process.env.CELILO_DB_PATH;
43
+ else process.env.CELILO_DB_PATH = previousDbPath;
44
+ rmSync(dir, { recursive: true, force: true });
45
+ });
46
+
47
+ /** Run `person list`, insisting it succeeded before reading what it said. */
48
+ async function personList(flags: Record<string, boolean | string>): Promise<string> {
49
+ const result = await handlePerson('list', [], flags);
50
+ if (!result.success) throw new Error(`person list failed: ${result.error}`);
51
+ return result.message;
52
+ }
53
+
54
+ async function listJson(): Promise<{ name: string; timezone: string }[]> {
55
+ return JSON.parse(await personList({ json: true }));
56
+ }
57
+
58
+ describe('celilo person list --json', () => {
59
+ test('names every person, sorted the way listPeople sorts them', async () => {
60
+ expect((await listJson()).map((p) => p.name)).toEqual(['ada', 'peter']);
61
+ });
62
+
63
+ test('carries the timezone, which is the only other thing a person IS', async () => {
64
+ const ada = (await listJson()).find((p) => p.name === 'ada');
65
+ expect(ada?.timezone).toBe('Europe/London');
66
+ });
67
+
68
+ test('an empty fleet is an empty array, not a sentence about it', async () => {
69
+ // The human listing prints "Nobody configured." and a hint. A caller that
70
+ // parsed that would throw, which is a better failure than the one this
71
+ // avoids — but the console polls this, so it gets JSON either way.
72
+ getDb().$client.run('DELETE FROM people');
73
+ expect(await listJson()).toEqual([]);
74
+ });
75
+
76
+ test('without --json it stays the human table, and says how many', async () => {
77
+ expect(await personList({})).toContain('2 person');
78
+ });
79
+ });
@@ -33,6 +33,7 @@ import {
33
33
  listRoutes,
34
34
  } from '../../services/alerting/people';
35
35
  import { parseClockTime } from '../../services/alerting/quiet-hours';
36
+ import { hasFlag } from '../parser';
36
37
  import type { CommandResult } from '../types';
37
38
 
38
39
  const NO_SCHEMAS = defineEvents({});
@@ -109,8 +110,18 @@ function personAdd(args: string[], flags: Record<string, boolean | string>): Com
109
110
  return { success: true, message: `Added ${name} (${timezone}${quietNote})` };
110
111
  }
111
112
 
112
- function personList(): CommandResult {
113
+ function personList(flags: Record<string, boolean | string> = {}): CommandResult {
113
114
  const people = listPeople(getDb());
115
+
116
+ // The web console's subject-to-person mapping reads this. It never invents a
117
+ // person and never lets celilo fall back to `people[0]`, so it has to be able
118
+ // to ASK who exists. `person:list` is already in the read-only grant set, so
119
+ // the flag adds a shape rather than an authority.
120
+ if (hasFlag(flags, 'json')) {
121
+ const payload = people.map((p) => ({ name: p.name, timezone: p.timezone }));
122
+ return { success: true, message: JSON.stringify(payload), rawOutput: true, data: payload };
123
+ }
124
+
114
125
  if (people.length === 0) {
115
126
  console.log('\nNobody configured.\n');
116
127
  console.log(
@@ -158,7 +169,7 @@ export async function handlePerson(
158
169
  switch (subcommand) {
159
170
  case undefined:
160
171
  case 'list':
161
- return personList();
172
+ return personList(flags);
162
173
  case 'add':
163
174
  return personAdd(args, flags);
164
175
  case 'remove':
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `celilo system ensure-fleet-key` — mint celilo's fleet SSH keypair if it
3
+ * is absent, record its public half, and print it.
4
+ *
5
+ * The celilo-side half of "minting the fleet key is not the module's job"
6
+ * (openspec/changes/hook-process-boundary, design D9b). celilo-mgmt's
7
+ * `on_install` used to generate the keypair itself, writing into celilo's
8
+ * data directory from inside a hook — the one directory the hook jail
9
+ * exists to keep out of the mount set. The hook now asks for the key
10
+ * through this command and receives the public half, rather than producing
11
+ * it.
12
+ *
13
+ * Idempotent: an existing key is reused. Re-keying would strand every
14
+ * machine whose authorized_keys holds the old public half.
15
+ */
16
+
17
+ import { getDb } from '../../db/client';
18
+ import { ensureFleetKey, getFleetSshDir } from '../../services/fleet-key';
19
+ import { initializeSystem } from '../../services/system-init';
20
+ import type { CommandResult } from '../types';
21
+
22
+ export async function handleSystemEnsureFleetKey(): Promise<CommandResult> {
23
+ let key: ReturnType<typeof ensureFleetKey>;
24
+ try {
25
+ key = ensureFleetKey();
26
+ } catch (err) {
27
+ return {
28
+ success: false,
29
+ error: `Could not create the fleet SSH key in ${getFleetSshDir()}: ${
30
+ err instanceof Error ? err.message : String(err)
31
+ }`,
32
+ };
33
+ }
34
+
35
+ try {
36
+ initializeSystem(getDb(), { 'ssh.public_key': key.publicKey });
37
+ } catch (err) {
38
+ return {
39
+ success: false,
40
+ error: `Fleet key is on disk but recording ssh.public_key failed: ${
41
+ err instanceof Error ? err.message : String(err)
42
+ }`,
43
+ };
44
+ }
45
+
46
+ // The public key alone, and `rawOutput` to say so on the record: this
47
+ // command exists to be read by celilo-mgmt's on_install as much as by an
48
+ // operator, and the key IS the answer either way (an operator pastes it into
49
+ // a machine's authorized_keys). Wrapping it in prose would make the caller
50
+ // parse for it, which is how a hook ends up depending on a sentence.
51
+ return { success: true, message: key.publicKey, rawOutput: true };
52
+ }
@@ -37,6 +37,7 @@ export async function getCompletions(words: string[], current: number): Promise<
37
37
  'backup',
38
38
  'capability',
39
39
  'commands',
40
+ 'console',
40
41
  'dns',
41
42
  'completion',
42
43
  'alerts',
@@ -658,6 +659,10 @@ export async function getCompletions(words: string[], current: number): Promise<
658
659
  }
659
660
 
660
661
  // Completion subcommands
662
+ if (command === 'console' && currentIndex === 1) {
663
+ return filterSuggestions(['status', 'get'], args[1] || '');
664
+ }
665
+
661
666
  if (command === 'completion' && currentIndex === 1) {
662
667
  const subcommands = ['bash', 'zsh'];
663
668
  return filterSuggestions(subcommands, args[1] || '');
@@ -669,6 +674,7 @@ export async function getCompletions(words: string[], current: number): Promise<
669
674
  'init',
670
675
  'apply-config',
671
676
  'discover-network',
677
+ 'ensure-fleet-key',
672
678
  'config',
673
679
  'secret',
674
680
  'vault-password',