@celilo/cli 0.13.3 → 0.14.1

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 (88) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +71 -2
  3. package/docs/ALERTING.md +298 -0
  4. package/docs/INDEX.md +103 -0
  5. package/drizzle/0016_trusted_sources.sql +10 -0
  6. package/drizzle/0017_alerting.sql +127 -0
  7. package/drizzle/meta/_journal.json +15 -1
  8. package/package.json +3 -2
  9. package/schemas/system_config.json +9 -0
  10. package/src/capabilities/well-known.ts +11 -1
  11. package/src/cli/commands/alerts-act.ts +107 -0
  12. package/src/cli/commands/alerts-list.ts +62 -0
  13. package/src/cli/commands/alerts-poll.ts +129 -0
  14. package/src/cli/commands/alerts-sweep.ts +156 -0
  15. package/src/cli/commands/module-list.ts +50 -3
  16. package/src/cli/commands/module-show.ts +11 -3
  17. package/src/cli/commands/monitor.ts +178 -0
  18. package/src/cli/commands/notify-config.ts +453 -0
  19. package/src/cli/commands/system-audit.ts +2 -0
  20. package/src/cli/commands/system-update.ts +1 -0
  21. package/src/cli/completion.ts +26 -0
  22. package/src/cli/generate-zsh-completion.ts +2 -0
  23. package/src/cli/index.ts +58 -0
  24. package/src/cli/tui/audit-state.ts +2 -0
  25. package/src/db/schema.ts +371 -2
  26. package/src/hooks/capability-loader.ts +158 -46
  27. package/src/hooks/capability-map-coverage.test.ts +101 -0
  28. package/src/manifest/schema.ts +77 -4
  29. package/src/services/alerting/ack.test.ts +212 -0
  30. package/src/services/alerting/ack.ts +119 -0
  31. package/src/services/alerting/builtin-monitors.test.ts +132 -0
  32. package/src/services/alerting/builtin-monitors.ts +84 -0
  33. package/src/services/alerting/builtin-source.ts +82 -0
  34. package/src/services/alerting/coverage-source.ts +38 -0
  35. package/src/services/alerting/deferral.test.ts +161 -0
  36. package/src/services/alerting/delivery-loop.test.ts +396 -0
  37. package/src/services/alerting/deploy-hooks.test.ts +125 -0
  38. package/src/services/alerting/deploy-hooks.ts +111 -0
  39. package/src/services/alerting/escalation.test.ts +207 -0
  40. package/src/services/alerting/escalation.ts +151 -0
  41. package/src/services/alerting/format.test.ts +193 -0
  42. package/src/services/alerting/format.ts +150 -0
  43. package/src/services/alerting/health-coverage.ts +81 -0
  44. package/src/services/alerting/inbound-poller.test.ts +298 -0
  45. package/src/services/alerting/inbound-poller.ts +236 -0
  46. package/src/services/alerting/inbound.test.ts +201 -0
  47. package/src/services/alerting/inbound.ts +112 -0
  48. package/src/services/alerting/interview-responder.test.ts +169 -0
  49. package/src/services/alerting/interview-responder.ts +158 -0
  50. package/src/services/alerting/keys.test.ts +155 -0
  51. package/src/services/alerting/keys.ts +190 -0
  52. package/src/services/alerting/monitors.ts +185 -0
  53. package/src/services/alerting/notification-responder.test.ts +290 -0
  54. package/src/services/alerting/notification-responder.ts +260 -0
  55. package/src/services/alerting/notifier.ts +219 -0
  56. package/src/services/alerting/people.ts +178 -0
  57. package/src/services/alerting/quiet-hours.test.ts +140 -0
  58. package/src/services/alerting/quiet-hours.ts +99 -0
  59. package/src/services/alerting/reconcile.test.ts +190 -0
  60. package/src/services/alerting/reconcile.ts +166 -0
  61. package/src/services/alerting/run-monitor.test.ts +185 -0
  62. package/src/services/alerting/run-monitor.ts +177 -0
  63. package/src/services/alerting/store.test.ts +222 -0
  64. package/src/services/alerting/store.ts +289 -0
  65. package/src/services/alerting/suppression.test.ts +228 -0
  66. package/src/services/alerting/suppression.ts +142 -0
  67. package/src/services/alerting/sweep-runner.test.ts +229 -0
  68. package/src/services/alerting/sweep-runner.ts +204 -0
  69. package/src/services/alerting/sweep.test.ts +61 -0
  70. package/src/services/alerting/sweep.ts +41 -0
  71. package/src/services/alerting/tokens.test.ts +152 -0
  72. package/src/services/alerting/tokens.ts +119 -0
  73. package/src/services/alerting/transport-loader.ts +48 -0
  74. package/src/services/audit/index.test.ts +1 -0
  75. package/src/services/audit/index.ts +3 -0
  76. package/src/services/audit/trusted-sources.test.ts +137 -0
  77. package/src/services/audit/trusted-sources.ts +124 -0
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/firewall-reach.ts +83 -0
  80. package/src/services/health-runner.test.ts +50 -0
  81. package/src/services/health-runner.ts +116 -82
  82. package/src/services/machine-pool.ts +2 -1
  83. package/src/services/module-deploy.ts +17 -0
  84. package/src/services/system-config-validator.test.ts +31 -1
  85. package/src/services/trusted-sources.test.ts +221 -0
  86. package/src/services/trusted-sources.ts +159 -0
  87. package/src/services/update/orchestrator.test.ts +1 -0
  88. package/src/templates/generator.ts +6 -29
@@ -0,0 +1,453 @@
1
+ /**
2
+ * `celilo person` / `route` / `escalation-policy` — who celilo can reach.
3
+ *
4
+ * One file because the three are a single concept split across three tables,
5
+ * and splitting the CLI too would mean three copies of the same lookup-by-name
6
+ * and table-printing code.
7
+ *
8
+ * Everything is addressed by name. A UUID never reaches the operator
9
+ * (CLAUDE.md), and a route is identified by `<person>/<transport>` rather than
10
+ * by an id, because that is what someone actually knows about it.
11
+ */
12
+
13
+ import { defineEvents, openBus } from '@celilo/event-bus';
14
+ import { eq } from 'drizzle-orm';
15
+ import { getEventBusPath } from '../../config/paths';
16
+ import { getDb } from '../../db/client';
17
+ import { type AlertSeverity, modules, monitors } from '../../db/schema';
18
+ import { ensureInboundSubscriber, findMonitorByTarget } from '../../services/alerting/monitors';
19
+ import {
20
+ addPolicyStep,
21
+ createPerson,
22
+ createPolicy,
23
+ createRoute,
24
+ deletePerson,
25
+ deletePolicy,
26
+ deleteRoute,
27
+ findPerson,
28
+ findPolicy,
29
+ findRoute,
30
+ listPeople,
31
+ listPolicies,
32
+ listPolicySteps,
33
+ listRoutes,
34
+ } from '../../services/alerting/people';
35
+ import { parseClockTime } from '../../services/alerting/quiet-hours';
36
+ import type { CommandResult } from '../types';
37
+
38
+ const NO_SCHEMAS = defineEvents({});
39
+
40
+ function table(headers: string[], rows: string[][]): string {
41
+ if (rows.length === 0) return '';
42
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
43
+ const line = (cells: string[]) =>
44
+ cells
45
+ .map((c, i) => (i === cells.length - 1 ? c : c.padEnd(widths[i])))
46
+ .join(' ')
47
+ .trimEnd();
48
+ return [line(headers), ...rows.map(line)].join('\n');
49
+ }
50
+
51
+ const flagString = (flags: Record<string, boolean | string>, name: string): string | undefined =>
52
+ typeof flags[name] === 'string' ? (flags[name] as string) : undefined;
53
+
54
+ /**
55
+ * Whether a module declares the `notification` capability.
56
+ *
57
+ * Read from the manifest rather than the capabilities table: a module can be
58
+ * imported but not yet deployed, and configuring a route ahead of deploying
59
+ * the transport is a reasonable order to work in.
60
+ */
61
+ function providesNotification(db: ReturnType<typeof getDb>, moduleId: string): boolean {
62
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
63
+ if (!module) return false;
64
+ const manifest = module.manifestData as {
65
+ provides?: { capabilities?: { name?: string }[] };
66
+ };
67
+ return Boolean(manifest.provides?.capabilities?.some((c) => c.name === 'notification'));
68
+ }
69
+
70
+ // ── celilo person ───────────────────────────────────────────────────────────
71
+
72
+ function personAdd(args: string[], flags: Record<string, boolean | string>): CommandResult {
73
+ const name = args[0];
74
+ if (!name) {
75
+ return {
76
+ success: false,
77
+ error: 'Usage: celilo person add <name> --timezone <IANA> [--quiet-hours 22:00-07:00]',
78
+ };
79
+ }
80
+
81
+ const db = getDb();
82
+ if (findPerson(db, name)) return { success: false, error: `Person "${name}" already exists.` };
83
+
84
+ const timezone = flagString(flags, 'timezone');
85
+ if (!timezone) {
86
+ return {
87
+ success: false,
88
+ error: 'A --timezone is required (e.g. America/Los_Angeles) — quiet hours are local to it.',
89
+ };
90
+ }
91
+
92
+ let quietStart: string | null = null;
93
+ let quietEnd: string | null = null;
94
+ const quiet = flagString(flags, 'quiet-hours');
95
+ if (quiet) {
96
+ const [start, end] = quiet.split('-');
97
+ if (!start || !end || parseClockTime(start) === null || parseClockTime(end) === null) {
98
+ return {
99
+ success: false,
100
+ error: `Invalid --quiet-hours "${quiet}". Use HH:MM-HH:MM, e.g. 22:00-07:00.`,
101
+ };
102
+ }
103
+ quietStart = start;
104
+ quietEnd = end;
105
+ }
106
+
107
+ createPerson(db, { name, timezone, quietHoursStart: quietStart, quietHoursEnd: quietEnd });
108
+ const quietNote = quiet ? `, quiet ${quiet}` : ', always reachable';
109
+ return { success: true, message: `Added ${name} (${timezone}${quietNote})` };
110
+ }
111
+
112
+ function personList(): CommandResult {
113
+ const people = listPeople(getDb());
114
+ if (people.length === 0) {
115
+ console.log('\nNobody configured.\n');
116
+ console.log(
117
+ ' celilo person add peter --timezone America/Los_Angeles --quiet-hours 22:00-07:00\n',
118
+ );
119
+ return { success: true, message: 'No people configured' };
120
+ }
121
+
122
+ console.log('');
123
+ console.log(
124
+ table(
125
+ ['NAME', 'TIMEZONE', 'QUIET HOURS'],
126
+ people.map((p) => [
127
+ p.name,
128
+ p.timezone,
129
+ p.quietHoursStart && p.quietHoursEnd
130
+ ? `${p.quietHoursStart}-${p.quietHoursEnd}`
131
+ : 'always reachable',
132
+ ]),
133
+ ),
134
+ );
135
+ console.log('');
136
+ return { success: true, message: `${people.length} person(s)` };
137
+ }
138
+
139
+ function personRemove(args: string[]): CommandResult {
140
+ const name = args[0];
141
+ if (!name) return { success: false, error: 'Usage: celilo person remove <name>' };
142
+
143
+ const db = getDb();
144
+ const person = findPerson(db, name);
145
+ if (!person) return { success: false, error: `No person named "${name}".` };
146
+
147
+ // Routes cascade, and so do the escalation steps pointing at them — a step
148
+ // aimed at a deleted route would otherwise skip silently at page time.
149
+ deletePerson(db, person.id);
150
+ return { success: true, message: `Removed ${name} and their routes` };
151
+ }
152
+
153
+ export async function handlePerson(
154
+ subcommand: string | undefined,
155
+ args: string[],
156
+ flags: Record<string, boolean | string> = {},
157
+ ): Promise<CommandResult> {
158
+ switch (subcommand) {
159
+ case undefined:
160
+ case 'list':
161
+ return personList();
162
+ case 'add':
163
+ return personAdd(args, flags);
164
+ case 'remove':
165
+ return personRemove(args);
166
+ default:
167
+ return {
168
+ success: false,
169
+ error: `Unknown person subcommand: ${subcommand}\n\nUse: list, add, remove`,
170
+ };
171
+ }
172
+ }
173
+
174
+ // ── celilo route ────────────────────────────────────────────────────────────
175
+
176
+ function routeAdd(args: string[], flags: Record<string, boolean | string>): CommandResult {
177
+ const [personName, transport] = args;
178
+ const address = flagString(flags, 'address');
179
+ if (!personName || !transport || !address) {
180
+ return {
181
+ success: false,
182
+ error:
183
+ 'Usage: celilo route add <person> <transport-module> --address <addr> [--severity-floor warning|critical]',
184
+ };
185
+ }
186
+
187
+ const db = getDb();
188
+ const person = findPerson(db, personName);
189
+ if (!person) return { success: false, error: `No person named "${personName}".` };
190
+
191
+ // Check the transport BEFORE inserting. The routes table has a foreign key
192
+ // to modules, so a typo would otherwise surface as a raw
193
+ // SQLITE_CONSTRAINT_FOREIGNKEY stack trace — which tells an operator
194
+ // nothing about what they got wrong.
195
+ const module = db.select().from(modules).where(eq(modules.id, transport)).get();
196
+ if (!module) {
197
+ const available = db.select({ id: modules.id }).from(modules).all();
198
+ const providers = available.filter((m) => providesNotification(db, m.id)).map((m) => m.id);
199
+ const hint = providers.length
200
+ ? `Available notification transports: ${providers.join(', ')}`
201
+ : 'No module providing the `notification` capability is installed yet.';
202
+ return { success: false, error: `No module "${transport}" is installed.\n\n${hint}` };
203
+ }
204
+
205
+ // A route to a module that cannot send is a route that silently never pages.
206
+ if (!providesNotification(db, transport)) {
207
+ return {
208
+ success: false,
209
+ error: `Module "${transport}" does not provide the \`notification\` capability, so it cannot page anyone.`,
210
+ };
211
+ }
212
+
213
+ if (findRoute(db, person.id, transport)) {
214
+ return { success: false, error: `${personName} already has a ${transport} route.` };
215
+ }
216
+
217
+ const floor = flagString(flags, 'severity-floor') ?? 'warning';
218
+ if (floor !== 'warning' && floor !== 'critical') {
219
+ return { success: false, error: `--severity-floor must be "warning" or "critical".` };
220
+ }
221
+
222
+ // A transport that can receive is one whose module is deployed AND provides
223
+ // a receive path. Until the inbound poller lands we record the operator's
224
+ // intent; a route that cannot ack simply never stops escalation.
225
+ const canAck = flags['can-ack'] === true;
226
+
227
+ createRoute(db, {
228
+ personId: person.id,
229
+ transportModuleId: transport,
230
+ address,
231
+ severityFloor: floor as AlertSeverity,
232
+ canAck,
233
+ });
234
+ // A route that can receive is what makes inbound polling worth doing, so
235
+ // that is when the subscriber is registered.
236
+ if (canAck) {
237
+ const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
238
+ try {
239
+ ensureInboundSubscriber(bus);
240
+ } finally {
241
+ bus.close();
242
+ }
243
+ }
244
+
245
+ return {
246
+ success: true,
247
+ message: `Reaching ${personName} via ${transport} at ${address} (floor: ${floor})`,
248
+ };
249
+ }
250
+
251
+ function routeList(): CommandResult {
252
+ const db = getDb();
253
+ const routes = listRoutes(db);
254
+ if (routes.length === 0) {
255
+ console.log('\nNo routes configured — nothing can be paged.\n');
256
+ console.log(' celilo route add peter signal --address +15551234567 --can-ack\n');
257
+ return { success: true, message: 'No routes configured' };
258
+ }
259
+
260
+ const people = new Map(listPeople(db).map((p) => [p.id, p.name]));
261
+ console.log('');
262
+ console.log(
263
+ table(
264
+ ['PERSON', 'TRANSPORT', 'ADDRESS', 'FLOOR', 'ACK', 'STATE'],
265
+ routes.map((r) => [
266
+ people.get(r.personId) ?? '(unknown)',
267
+ r.transportModuleId,
268
+ r.address,
269
+ r.severityFloor,
270
+ r.canAck ? 'yes' : 'no',
271
+ r.enabled ? 'enabled' : 'disabled',
272
+ ]),
273
+ ),
274
+ );
275
+ console.log('');
276
+ return { success: true, message: `${routes.length} route(s)` };
277
+ }
278
+
279
+ function routeRemove(args: string[]): CommandResult {
280
+ const [personName, transport] = args;
281
+ if (!personName || !transport) {
282
+ return { success: false, error: 'Usage: celilo route remove <person> <transport-module>' };
283
+ }
284
+
285
+ const db = getDb();
286
+ const person = findPerson(db, personName);
287
+ if (!person) return { success: false, error: `No person named "${personName}".` };
288
+ const route = findRoute(db, person.id, transport);
289
+ if (!route) return { success: false, error: `${personName} has no ${transport} route.` };
290
+
291
+ deleteRoute(db, route.id);
292
+ return { success: true, message: `Removed ${personName}'s ${transport} route` };
293
+ }
294
+
295
+ export async function handleRoute(
296
+ subcommand: string | undefined,
297
+ args: string[],
298
+ flags: Record<string, boolean | string> = {},
299
+ ): Promise<CommandResult> {
300
+ switch (subcommand) {
301
+ case undefined:
302
+ case 'list':
303
+ return routeList();
304
+ case 'add':
305
+ return routeAdd(args, flags);
306
+ case 'remove':
307
+ return routeRemove(args);
308
+ default:
309
+ return {
310
+ success: false,
311
+ error: `Unknown route subcommand: ${subcommand}\n\nUse: list, add, remove`,
312
+ };
313
+ }
314
+ }
315
+
316
+ // ── celilo escalation-policy ────────────────────────────────────────────────
317
+
318
+ function policyAdd(args: string[]): CommandResult {
319
+ const name = args[0];
320
+ if (!name) return { success: false, error: 'Usage: celilo escalation-policy add <name>' };
321
+
322
+ const db = getDb();
323
+ if (findPolicy(db, name)) return { success: false, error: `Policy "${name}" already exists.` };
324
+
325
+ createPolicy(db, name);
326
+ return {
327
+ success: true,
328
+ message: `Created policy "${name}" — add steps with: celilo escalation-policy step ${name} <person> <transport> --after 0m`,
329
+ };
330
+ }
331
+
332
+ function policyStep(args: string[], flags: Record<string, boolean | string>): CommandResult {
333
+ const [policyName, personName, transport] = args;
334
+ if (!policyName || !personName || !transport) {
335
+ return {
336
+ success: false,
337
+ error:
338
+ 'Usage: celilo escalation-policy step <policy> <person> <transport-module> --after <minutes>',
339
+ };
340
+ }
341
+
342
+ const db = getDb();
343
+ const policy = findPolicy(db, policyName);
344
+ if (!policy) return { success: false, error: `No policy named "${policyName}".` };
345
+ const person = findPerson(db, personName);
346
+ if (!person) return { success: false, error: `No person named "${personName}".` };
347
+ const route = findRoute(db, person.id, transport);
348
+ if (!route) {
349
+ return {
350
+ success: false,
351
+ error: `${personName} has no ${transport} route. Add one first:\n celilo route add ${personName} ${transport} --address <addr>`,
352
+ };
353
+ }
354
+
355
+ const after = flagString(flags, 'after') ?? '0';
356
+ const delayMinutes = Number.parseInt(after.replace(/m$/, ''), 10);
357
+ if (!Number.isFinite(delayMinutes) || delayMinutes < 0) {
358
+ return { success: false, error: `Invalid --after "${after}". Use minutes, e.g. 0, 10, 30.` };
359
+ }
360
+
361
+ const step = addPolicyStep(db, policy.id, route.id, delayMinutes);
362
+ return {
363
+ success: true,
364
+ message: `Step ${step.stepIndex}: ${personName} via ${transport} after ${delayMinutes}m`,
365
+ };
366
+ }
367
+
368
+ function policyList(): CommandResult {
369
+ const db = getDb();
370
+ const policies = listPolicies(db);
371
+ if (policies.length === 0) {
372
+ console.log('\nNo escalation policies.\n');
373
+ console.log(' celilo escalation-policy add default\n');
374
+ return { success: true, message: 'No policies configured' };
375
+ }
376
+
377
+ const people = new Map(listPeople(db).map((p) => [p.id, p.name]));
378
+ const routes = new Map(listRoutes(db).map((r) => [r.id, r]));
379
+
380
+ console.log('');
381
+ for (const policy of policies) {
382
+ const steps = listPolicySteps(db, policy.id);
383
+ console.log(`${policy.name}${policy.bypassQuietHours ? ' (bypasses quiet hours)' : ''}`);
384
+ if (steps.length === 0) {
385
+ console.log(' (no steps — nothing will be paged)');
386
+ }
387
+ for (const step of steps) {
388
+ const route = routes.get(step.routeId);
389
+ const who = route ? (people.get(route.personId) ?? '(unknown)') : '(deleted route)';
390
+ const via = route ? route.transportModuleId : '?';
391
+ console.log(` ${step.stepIndex}. ${who} via ${via} after ${step.delayMinutes}m`);
392
+ }
393
+ console.log('');
394
+ }
395
+ return { success: true, message: `${policies.length} polic(ies)` };
396
+ }
397
+
398
+ function policyRemove(args: string[]): CommandResult {
399
+ const name = args[0];
400
+ if (!name) return { success: false, error: 'Usage: celilo escalation-policy remove <name>' };
401
+
402
+ const db = getDb();
403
+ const policy = findPolicy(db, name);
404
+ if (!policy) return { success: false, error: `No policy named "${name}".` };
405
+
406
+ deletePolicy(db, policy.id);
407
+ return { success: true, message: `Removed policy "${name}"` };
408
+ }
409
+
410
+ function policyAssign(args: string[]): CommandResult {
411
+ const [policyName, target] = args;
412
+ if (!policyName || !target) {
413
+ return { success: false, error: 'Usage: celilo escalation-policy assign <policy> <monitor>' };
414
+ }
415
+
416
+ const db = getDb();
417
+ const policy = findPolicy(db, policyName);
418
+ if (!policy) return { success: false, error: `No policy named "${policyName}".` };
419
+ const monitor = findMonitorByTarget(db, target);
420
+ if (!monitor) return { success: false, error: `No monitor for "${target}".` };
421
+
422
+ db.update(monitors)
423
+ .set({ escalationPolicyId: policy.id })
424
+ .where(eq(monitors.id, monitor.id))
425
+ .run();
426
+
427
+ return { success: true, message: `${target} now escalates via "${policyName}"` };
428
+ }
429
+
430
+ export async function handleEscalationPolicy(
431
+ subcommand: string | undefined,
432
+ args: string[],
433
+ flags: Record<string, boolean | string> = {},
434
+ ): Promise<CommandResult> {
435
+ switch (subcommand) {
436
+ case undefined:
437
+ case 'list':
438
+ return policyList();
439
+ case 'add':
440
+ return policyAdd(args);
441
+ case 'step':
442
+ return policyStep(args, flags);
443
+ case 'assign':
444
+ return policyAssign(args);
445
+ case 'remove':
446
+ return policyRemove(args);
447
+ default:
448
+ return {
449
+ success: false,
450
+ error: `Unknown escalation-policy subcommand: ${subcommand}\n\nUse: list, add, step, assign, remove`,
451
+ };
452
+ }
453
+ }
@@ -50,6 +50,7 @@ import type { ServiceCredentialsResult } from '../../services/audit/services-cre
50
50
  import type { ServiceReachableResult } from '../../services/audit/services-reachable';
51
51
  import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
52
52
  import { getServiceCredentials, listContainerServices } from '../../services/container-service';
53
+ import { collectFirewallReach } from '../../services/firewall-reach';
53
54
  import { runAllHealthChecks } from '../../services/health-runner';
54
55
  import { listMachines } from '../../services/machine-pool';
55
56
  import { parseStoredConfigValue } from '../../services/module-config';
@@ -436,6 +437,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
436
437
  secretsDecryptable: { results: secretResults },
437
438
  servicesReachable: { results: serviceReachableResults },
438
439
  machinesReachable: { results: machineReachableResults },
440
+ trustedSources: { firewalls: collectFirewallReach(db) },
439
441
  };
440
442
  }
441
443
 
@@ -585,6 +585,7 @@ export async function handleSystemUpdate(
585
585
  secretsDecryptable: { results: [] },
586
586
  servicesReachable: { results: [] },
587
587
  machinesReachable: { results: [] },
588
+ trustedSources: { firewalls: [] },
588
589
  };
589
590
 
590
591
  if (dryRun) {
@@ -37,16 +37,21 @@ export async function getCompletions(words: string[], current: number): Promise<
37
37
  'commands',
38
38
  'dns',
39
39
  'completion',
40
+ 'alerts',
41
+ 'escalation-policy',
40
42
  'events',
41
43
  'help',
42
44
  'hook',
43
45
  'ipam',
44
46
  'machine',
45
47
  'module',
48
+ 'monitor',
46
49
  'package',
50
+ 'person',
47
51
  'proxmox',
48
52
  'publish',
49
53
  'registry',
54
+ 'route',
50
55
  'restore',
51
56
  'service',
52
57
  'status',
@@ -449,6 +454,27 @@ export async function getCompletions(words: string[], current: number): Promise<
449
454
  return filterSuggestions(subcommands, args[1] || '');
450
455
  }
451
456
 
457
+ // Person / route / escalation-policy subcommands
458
+ if (command === 'person' && currentIndex === 1) {
459
+ return filterSuggestions(['list', 'add', 'remove'], args[1] || '');
460
+ }
461
+ if (command === 'route' && currentIndex === 1) {
462
+ return filterSuggestions(['list', 'add', 'remove'], args[1] || '');
463
+ }
464
+ if (command === 'escalation-policy' && currentIndex === 1) {
465
+ return filterSuggestions(['list', 'add', 'step', 'assign', 'remove'], args[1] || '');
466
+ }
467
+
468
+ // Monitor subcommands
469
+ if (command === 'monitor' && currentIndex === 1) {
470
+ return filterSuggestions(['list', 'add', 'run', 'enable', 'disable'], args[1] || '');
471
+ }
472
+
473
+ // Alerts subcommands
474
+ if (command === 'alerts' && currentIndex === 1) {
475
+ return filterSuggestions(['list', 'ack', 'silence', 'resolve', 'sweep', 'poll'], args[1] || '');
476
+ }
477
+
452
478
  // Storage subcommands
453
479
  if (command === 'storage' && currentIndex === 1) {
454
480
  const subcommands = ['add', 'list', 'remove', 'verify', 'set-default'];
@@ -357,6 +357,8 @@ _celilo_system_config_keys() {
357
357
  'network.internal.gateway:Internal gateway IP'
358
358
  'network.secure-mgmt.subnet:Control-plane subnet (celilo-mgr own network)'
359
359
  'network.secure-mgmt.gateway:Control-plane gateway IP'
360
+ 'network.vpn.subnet:VPN client subnet (WireGuard remote access)'
361
+ 'firewall.trusted_subnets:Extra subnets that reach every managed zone (comma-separated CIDRs)'
360
362
  'dns.primary:Primary DNS server'
361
363
  'dns.fallback:Fallback DNS servers'
362
364
  'routing.internal_gateway:Internal gateway IP'
package/src/cli/index.ts CHANGED
@@ -178,6 +178,11 @@ Usage:
178
178
  Commands:
179
179
  status Show system and module status
180
180
  audit Top-level alias for 'system audit'
181
+ alerts View alerts raised by monitors
182
+ monitor Manage what celilo watches (health checks on a schedule)
183
+ person Manage people celilo can reach
184
+ route Manage how each person is reached (transport + address)
185
+ escalation-policy Manage who gets paged, and in what order
181
186
  events SQLite event-bus operations (status, tail, run dispatcher, etc.)
182
187
  capability View registered module capabilities
183
188
  dns View DNS bookkeeping (registrations ledger)
@@ -1708,6 +1713,59 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1708
1713
  }
1709
1714
  }
1710
1715
 
1716
+ if (parsed.command === 'person') {
1717
+ const { handlePerson } = await import('./commands/notify-config');
1718
+ return handlePerson(parsed.subcommand, parsed.args, parsed.flags);
1719
+ }
1720
+
1721
+ if (parsed.command === 'route') {
1722
+ const { handleRoute } = await import('./commands/notify-config');
1723
+ return handleRoute(parsed.subcommand, parsed.args, parsed.flags);
1724
+ }
1725
+
1726
+ if (parsed.command === 'escalation-policy') {
1727
+ const { handleEscalationPolicy } = await import('./commands/notify-config');
1728
+ return handleEscalationPolicy(parsed.subcommand, parsed.args, parsed.flags);
1729
+ }
1730
+
1731
+ if (parsed.command === 'monitor') {
1732
+ const { handleMonitor } = await import('./commands/monitor');
1733
+ return handleMonitor(parsed.subcommand, parsed.args, parsed.flags);
1734
+ }
1735
+
1736
+ if (parsed.command === 'alerts') {
1737
+ if (parsed.subcommand === 'ack') {
1738
+ const { handleAlertsAck } = await import('./commands/alerts-act');
1739
+ return handleAlertsAck(parsed.args, parsed.flags);
1740
+ }
1741
+ if (parsed.subcommand === 'silence') {
1742
+ const { handleAlertsSilence } = await import('./commands/alerts-act');
1743
+ return handleAlertsSilence(parsed.args, parsed.flags);
1744
+ }
1745
+ if (parsed.subcommand === 'resolve') {
1746
+ const { handleAlertsResolve } = await import('./commands/alerts-act');
1747
+ return handleAlertsResolve(parsed.args);
1748
+ }
1749
+ if (parsed.subcommand === 'poll') {
1750
+ const { handleAlertsPoll } = await import('./commands/alerts-poll');
1751
+ return handleAlertsPoll(parsed.flags);
1752
+ }
1753
+ if (parsed.subcommand === 'sweep') {
1754
+ const { handleAlertsSweep } = await import('./commands/alerts-sweep');
1755
+ return handleAlertsSweep();
1756
+ }
1757
+ if (!parsed.subcommand || parsed.subcommand === 'list') {
1758
+ const alertsFlagError = checkFlags('alerts', 'list', parsed.flags, parsed.args);
1759
+ if (alertsFlagError) return alertsFlagError;
1760
+ const { handleAlertsList } = await import('./commands/alerts-list');
1761
+ return handleAlertsList(parsed.args, parsed.flags);
1762
+ }
1763
+ return {
1764
+ success: false,
1765
+ error: `Unknown alerts subcommand: ${parsed.subcommand}\n\nUse: list, ack, silence, resolve, sweep, poll`,
1766
+ };
1767
+ }
1768
+
1711
1769
  if (parsed.command === 'storage') {
1712
1770
  if (parsed.flags.help || parsed.flags.h) {
1713
1771
  return displayStorageHelp();
@@ -83,6 +83,7 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
83
83
  'secrets_decryptable',
84
84
  'services_reachable',
85
85
  'machines_reachable',
86
+ 'trusted_sources',
86
87
  ];
87
88
 
88
89
  export const CATEGORY_LABELS: Record<DriftCategory, string> = {
@@ -100,6 +101,7 @@ export const CATEGORY_LABELS: Record<DriftCategory, string> = {
100
101
  secrets_decryptable: 'Secrets',
101
102
  services_reachable: 'Service reachability',
102
103
  machines_reachable: 'Machine reachability',
104
+ trusted_sources: 'Trusted networks',
103
105
  };
104
106
 
105
107
  /** Total lines we keep across the command log before evicting oldest. */