@celilo/cli 0.5.0-alpha.3 → 0.5.0-alpha.5

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.
@@ -0,0 +1,12 @@
1
+ CREATE TABLE `dns_internal_records` (
2
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
+ `provider_module_id` text NOT NULL,
4
+ `consumer_module_id` text NOT NULL,
5
+ `host` text NOT NULL,
6
+ `ip` text NOT NULL,
7
+ `registered_at` integer DEFAULT (unixepoch()) NOT NULL,
8
+ FOREIGN KEY (`provider_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade,
9
+ FOREIGN KEY (`consumer_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
10
+ );
11
+ --> statement-breakpoint
12
+ CREATE UNIQUE INDEX `dns_internal_records_provider_host_idx` ON `dns_internal_records` (`provider_module_id`,`host`);
@@ -0,0 +1 @@
1
+ ALTER TABLE `backups` ADD `name` text;
@@ -71,6 +71,20 @@
71
71
  "when": 1781280898000,
72
72
  "tag": "0009_dns_registrations",
73
73
  "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "6",
78
+ "when": 1781481600000,
79
+ "tag": "0010_dns_internal_records",
80
+ "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "6",
85
+ "when": 1781481660000,
86
+ "tag": "0011_backups_name",
87
+ "breakpoints": true
74
88
  }
75
89
  ]
76
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.5.0-alpha.3",
3
+ "version": "0.5.0-alpha.5",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -106,6 +106,11 @@ export const COMMANDS: CommandDef[] = [
106
106
  ],
107
107
  },
108
108
  { name: 'list-subscribers', description: 'List persistent bus subscribers' },
109
+ {
110
+ name: 'resync-subscriptions',
111
+ description:
112
+ "Rebuild subscribers from deployed modules' manifests (after a restore/migration)",
113
+ },
109
114
  {
110
115
  name: 'list-pending',
111
116
  description: 'List pending deliveries',
@@ -951,14 +956,25 @@ export const COMMANDS: CommandDef[] = [
951
956
  },
952
957
  ],
953
958
  },
959
+ {
960
+ name: 'migrate',
961
+ description: 'Apply pending database migrations (idempotent; safe to re-run)',
962
+ },
954
963
  {
955
964
  name: 'doctor',
956
- description: 'Diagnose system prerequisites and @celilo/* version drift',
965
+ description:
966
+ 'Diagnose system prerequisites, @celilo/* version drift, and (on a management plane) fleet-runtime drift',
957
967
  flags: [
958
968
  {
959
969
  name: 'fix',
960
970
  description:
961
- 'Repair drift by `bun link`-ing each drifted @celilo/* package from the workspace',
971
+ 'Repair the auto-fixable findings: `bun link` drifted @celilo/* packages and resync bus subscribers',
972
+ takesValue: false,
973
+ },
974
+ {
975
+ name: 'fleet',
976
+ description:
977
+ 'Force the fleet-runtime section (dispatcher, subscribers, capability chains) even without a celilo DB',
962
978
  takesValue: false,
963
979
  },
964
980
  ],
@@ -192,6 +192,34 @@ export async function handleEventsListSubscribers(): Promise<CommandResult> {
192
192
  }
193
193
  }
194
194
 
195
+ /**
196
+ * `celilo events resync-subscriptions` — rebuild the bus `subscribers` table
197
+ * from every deployed module's manifest. The reactive layer is registered at
198
+ * DEPLOY time and lives in the bus, which a restore/migration starts EMPTY — so
199
+ * after a cutover this re-establishes who-reacts-to-what without redeploying
200
+ * every module (ISS-0088). Idempotent.
201
+ */
202
+ export async function handleEventsResyncSubscriptions(): Promise<CommandResult> {
203
+ try {
204
+ const { resyncAllSubscriptions } = await import('../../services/module-subscriptions');
205
+ const result = resyncAllSubscriptions();
206
+ const lines = [
207
+ `Re-registered ${result.registered} subscription(s) from ${result.modules} deployed module(s).`,
208
+ ];
209
+ if (result.failures.length > 0) {
210
+ const detail = result.failures.map((f) => ` ${f.moduleId}: ${f.error}`).join('\n');
211
+ return {
212
+ success: false,
213
+ error: `${result.failures.length} module(s) failed to re-register subscriptions:\n${detail}`,
214
+ };
215
+ }
216
+ lines.push('', 'A running dispatcher will now deliver events to these handlers.');
217
+ return { success: true, message: lines.join('\n'), data: result };
218
+ } catch (err) {
219
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
220
+ }
221
+ }
222
+
195
223
  export async function handleEventsListPending(
196
224
  _args: string[],
197
225
  flags: Record<string, string | boolean>,
@@ -238,6 +238,30 @@ export async function publishOneModule(
238
238
  }
239
239
  }
240
240
 
241
+ // ISS-0104: a module's hooks resolve @celilo/* from its OWN bundled
242
+ // scripts/node_modules (gitignored, prone to going stale). Refresh that
243
+ // closure and refuse to ship a stale capability SDK — a stale bundle
244
+ // silently runs old capability code even after a clean republish.
245
+ const { refreshAndVerifyBundledDeps } = await import(
246
+ '../../services/module-validator/bundled-deps'
247
+ );
248
+ const depCheck = refreshAndVerifyBundledDeps(resolvedDir);
249
+ if (depCheck.mismatches.length > 0 && !opts.allowStale) {
250
+ return {
251
+ moduleDir,
252
+ status: 'failed',
253
+ message: [
254
+ `${moduleDir}: bundled @celilo dependency is stale vs the workspace (ISS-0104):`,
255
+ ...depCheck.mismatches.map(
256
+ (m) => ` ${m.pkg}: bundled ${m.bundled}, workspace ${m.workspace}`,
257
+ ),
258
+ ' The deployed module would run the bundled (older) capability code.',
259
+ ` Fix: cd ${join(moduleDir, 'scripts')} && bun install (then retry)`,
260
+ ' --allow-stale skips this check.',
261
+ ].join('\n'),
262
+ };
263
+ }
264
+
241
265
  // Assemble release metadata for the .netapp.
242
266
  const releaseMetadata = buildReleaseMetadata({
243
267
  moduleId: name,
@@ -93,9 +93,38 @@ export async function handleRestore(
93
93
  );
94
94
  }
95
95
 
96
+ // Re-register event-bus subscriptions from the restored modules' manifests.
97
+ // The bus (events.db) is NOT in the backup envelope, so a restore/migration
98
+ // starts with an EMPTY subscribers table — every event-driven reconcile (caddy
99
+ // public_web, dns register, namecheap's 15m DDNS refresh, …) is dormant until
100
+ // subscriptions are re-registered (ISS-0088). Reconstruct them from durable
101
+ // celilo.db state rather than requiring a redeploy of every module. Best-effort:
102
+ // the DB was just swapped + reopened, so on an unhappy reopen, point the
103
+ // operator at the standalone command (which runs in a fresh process).
104
+ let subsResynced: { modules: number; registered: number } | undefined;
105
+ try {
106
+ const { resyncAllSubscriptions } = await import('../../services/module-subscriptions');
107
+ const r = resyncAllSubscriptions();
108
+ subsResynced = { modules: r.modules, registered: r.registered };
109
+ if (r.failures.length > 0) {
110
+ log.warn(
111
+ `Re-registered ${r.registered} subscription(s); ${r.failures.length} module(s) failed — run \`celilo events resync-subscriptions\` to retry.`,
112
+ );
113
+ }
114
+ } catch (err) {
115
+ log.warn(
116
+ `Restore landed but event-bus subscriptions were not re-registered: ${err instanceof Error ? err.message : String(err)}. Event-driven reconciles stay dormant until you run \`celilo events resync-subscriptions\`.`,
117
+ );
118
+ }
119
+
96
120
  // Build the success message.
97
121
  const lines: string[] = ['Restore complete.'];
98
122
  if (result.systemDbApplied) lines.push(' • celilo.db swapped into place');
123
+ if (subsResynced) {
124
+ lines.push(
125
+ ` • re-registered ${subsResynced.registered} event-bus subscription(s) from ${subsResynced.modules} deployed module(s)`,
126
+ );
127
+ }
99
128
  if (result.masterKeyApplied) lines.push(' • master.key swapped into place');
100
129
  if (result.sshKeyApplied) lines.push(' • fleet SSH key restored to <data-dir>/.ssh/');
101
130
  if (result.moduleSourcesApplied && result.moduleSourcesApplied > 0) {
@@ -30,10 +30,20 @@
30
30
  */
31
31
 
32
32
  import { spawnSync } from 'node:child_process';
33
- import { existsSync, readFileSync } from 'node:fs';
33
+ import { existsSync, readFileSync, statSync } from 'node:fs';
34
34
  import { createRequire } from 'node:module';
35
35
  import { dirname, join, resolve } from 'node:path';
36
+ import { defineEvents, openBus } from '@celilo/event-bus';
36
37
  import cliPkg from '../../../package.json' with { type: 'json' };
38
+ import { getDbPath, getEventBusPath } from '../../config/paths';
39
+ import { getDb } from '../../db/client';
40
+ import {
41
+ type FleetFinding,
42
+ type FleetFindingStatus,
43
+ checkSubscribers,
44
+ runFleetChecks,
45
+ } from '../../services/fleet-checks';
46
+ import { resyncAllSubscriptions } from '../../services/module-subscriptions';
37
47
  import { checkAllPrerequisites, failingPrerequisites } from '../../system/prereqs';
38
48
  import type { CommandResult } from '../types';
39
49
 
@@ -296,6 +306,93 @@ function renderPrereqSection(): { lines: string[]; failingCount: number } {
296
306
  return { lines, failingCount: failingPrerequisites(checks).length };
297
307
  }
298
308
 
309
+ /**
310
+ * mtime (ms) of the installed dispatcher code (`@celilo/event-bus`
311
+ * package.json). The fleet dispatcher check compares this against the
312
+ * running dispatcher's start time to spot a process on stale code. Null
313
+ * when the package can't be located (the staleness aspect is then skipped).
314
+ */
315
+ function installedEventBusMtime(): number | null {
316
+ try {
317
+ const require = createRequire(import.meta.url);
318
+ return statSync(require.resolve('@celilo/event-bus/package.json')).mtimeMs;
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
324
+ const FLEET_GLYPH: Record<FleetFindingStatus, string> = {
325
+ ok: `${ANSI.green}✔${ANSI.reset}`,
326
+ warn: `${ANSI.yellow}⚠${ANSI.reset}`,
327
+ fail: `${ANSI.red}✗${ANSI.reset}`,
328
+ };
329
+
330
+ function renderFleetFinding(f: FleetFinding): string[] {
331
+ const lines = [` ${FLEET_GLYPH[f.status]} ${f.title} ${ANSI.dim}— ${f.summary}${ANSI.reset}`];
332
+ for (const d of f.detail) lines.push(` ${ANSI.dim}${d}${ANSI.reset}`);
333
+ if (f.remediation && f.status !== 'ok')
334
+ lines.push(` ${ANSI.dim}→ ${f.remediation}${ANSI.reset}`);
335
+ return lines;
336
+ }
337
+
338
+ /**
339
+ * The fleet-runtime section: dispatcher / subscribers / capability-chain
340
+ * drift, read from the celilo DB + event bus. State-aware, so it's skipped
341
+ * cleanly on a fresh dev box with no DB unless `--fleet` forces it. `--fix`
342
+ * runs only the auto-fixable checks (today: subscribers resync).
343
+ */
344
+ async function renderFleetSection(opts: { forced: boolean; fix: boolean }): Promise<{
345
+ lines: string[];
346
+ failCount: number;
347
+ warnCount: number;
348
+ }> {
349
+ const lines: string[] = ['Fleet runtime'];
350
+ const dbExists = existsSync(getDbPath());
351
+
352
+ if (!dbExists) {
353
+ if (opts.forced) {
354
+ lines.push(
355
+ ` ${ANSI.dim}skipped — no celilo database at ${getDbPath()} (not a management plane)${ANSI.reset}`,
356
+ );
357
+ return { lines, failCount: 0, warnCount: 0 };
358
+ }
359
+ // Dev box, no --fleet: don't render the section at all.
360
+ return { lines: [], failCount: 0, warnCount: 0 };
361
+ }
362
+
363
+ const bus = openBus({ dbPath: getEventBusPath(), events: defineEvents({}) });
364
+ try {
365
+ const db = getDb();
366
+ let findings = await runFleetChecks(bus, db, {
367
+ installedCodeMtimeMs: installedEventBusMtime(),
368
+ });
369
+
370
+ if (opts.fix) {
371
+ const subscribers = findings.find((f) => f.id === 'subscribers');
372
+ if (subscribers?.autoFixable && subscribers.status !== 'ok') {
373
+ const r = resyncAllSubscriptions();
374
+ lines.push(
375
+ ` ${ANSI.dim}--fix: re-registered ${r.registered} subscription(s) from ${r.modules} module(s).${ANSI.reset}`,
376
+ );
377
+ // Re-evaluate the subscribers finding so the section shows the healed state.
378
+ const healed = checkSubscribers(bus, db);
379
+ findings = findings.map((f) => (f.id === 'subscribers' ? healed : f));
380
+ }
381
+ }
382
+
383
+ let failCount = 0;
384
+ let warnCount = 0;
385
+ for (const f of findings) {
386
+ lines.push(...renderFleetFinding(f));
387
+ if (f.status === 'fail') failCount++;
388
+ else if (f.status === 'warn') warnCount++;
389
+ }
390
+ return { lines, failCount, warnCount };
391
+ } finally {
392
+ bus.close();
393
+ }
394
+ }
395
+
299
396
  export async function handleSystemDoctor(
300
397
  _args: string[],
301
398
  flags: Record<string, string | boolean>,
@@ -397,61 +494,59 @@ export async function handleSystemDoctor(
397
494
 
398
495
  const fix = flags.fix === true;
399
496
 
497
+ // Drift repair (bun link). Does NOT early-return — the fleet section
498
+ // still runs on a --fix invocation so a single `--fix` heals both.
400
499
  if (fix && drifted.length > 0) {
401
500
  lines.push(`Repairing ${drifted.length} drifted package(s) with \`bun link\`:`);
402
501
  lines.push(...applyFix(drifted, cliRoot));
502
+ lines.push(`${ANSI.dim}\`bun unlink\` from each workspace dir reverses.${ANSI.reset}`);
503
+ lines.push('');
504
+ // Linked from the workspace now — no longer drift for the summary.
505
+ driftCount = 0;
506
+ drifted.length = 0;
507
+ } else if (fix && drifted.length === 0) {
508
+ lines.push(`${ANSI.dim}--fix: no drifted packages to repair.${ANSI.reset}`);
403
509
  lines.push('');
510
+ } else if (drifted.length > 0) {
404
511
  lines.push(
405
- `${ANSI.dim}Re-run \`celilo system doctor\` to verify; \`bun unlink\` from each workspace dir reverses.${ANSI.reset}`,
512
+ `${ANSI.dim}Run \`celilo system doctor --fix\` to bun-link drifted packages from the workspace.${ANSI.reset}`,
406
513
  );
407
- // Note: --fix only addresses drift, not missing prereqs. If prereqs
408
- // failed, surface that even on a successful --fix run.
409
- if (prereqResult.failingCount > 0) {
410
- return {
411
- success: false,
412
- error: `${prereqResult.failingCount} system prerequisite(s) missing or below minimum — install before running celilo`,
413
- details: lines.join('\n'),
414
- };
415
- }
416
- return {
417
- success: true,
418
- message: lines.join('\n'),
419
- rawOutput: true,
420
- };
514
+ lines.push('');
421
515
  }
422
516
 
423
- if (fix && drifted.length === 0) {
424
- lines.push(`${ANSI.dim}--fix: nothing to repair.${ANSI.reset}`);
517
+ // Fleet-runtime section (state-aware; only renders on a management
518
+ // plane with a celilo DB, or when --fleet forces it).
519
+ const fleet = await renderFleetSection({ forced: flags.fleet === true, fix });
520
+ if (fleet.lines.length > 0) {
521
+ lines.push(...fleet.lines);
522
+ lines.push('');
425
523
  }
426
524
 
427
- if (driftCount > 0 || unresolvedCount > 0 || prereqResult.failingCount > 0) {
428
- const summary: string[] = [];
429
- if (prereqResult.failingCount > 0) {
430
- summary.push(`${prereqResult.failingCount} prerequisite(s) missing/below-minimum`);
431
- }
432
- if (driftCount > 0) summary.push(`${driftCount} package(s) behind workspace`);
433
- if (unresolvedCount > 0) summary.push(`${unresolvedCount} unresolved`);
434
- if (drifted.length > 0) {
435
- lines.push(
436
- `${ANSI.dim}Run \`celilo system doctor --fix\` to bun-link drifted packages from the workspace.${ANSI.reset}`,
437
- );
438
- }
439
- // Distinguish prereq-only from drift-only from both — the
440
- // operator's next move is different.
441
- const errorPrefix =
442
- driftCount === 0 && unresolvedCount === 0
443
- ? 'System prerequisites missing'
444
- : prereqResult.failingCount === 0
445
- ? 'Drift detected'
446
- : 'Issues detected';
525
+ // Unified summary: anything that fails the run, with a per-cause count.
526
+ const problems: string[] = [];
527
+ if (prereqResult.failingCount > 0) {
528
+ problems.push(`${prereqResult.failingCount} prerequisite(s) missing/below-minimum`);
529
+ }
530
+ if (driftCount > 0) problems.push(`${driftCount} package(s) behind workspace`);
531
+ if (unresolvedCount > 0) problems.push(`${unresolvedCount} unresolved`);
532
+ if (fleet.failCount > 0) problems.push(`${fleet.failCount} fleet check(s) failing`);
533
+
534
+ if (problems.length > 0) {
447
535
  return {
448
536
  success: false,
449
- error: `${errorPrefix}: ${summary.join(', ')}`,
537
+ error: `Issues detected: ${problems.join(', ')}`,
450
538
  details: lines.join('\n'),
451
539
  };
452
540
  }
453
541
 
454
- lines.push(`${ANSI.green}OK${ANSI.reset} no issues detected`);
542
+ // Fleet warnings don't fail the run, but they shouldn't read as a clean bill.
543
+ if (fleet.warnCount > 0) {
544
+ lines.push(
545
+ `${ANSI.yellow}OK with warnings${ANSI.reset} — ${fleet.warnCount} fleet warning(s); see above`,
546
+ );
547
+ } else {
548
+ lines.push(`${ANSI.green}OK${ANSI.reset} — no issues detected`);
549
+ }
455
550
  return {
456
551
  success: true,
457
552
  message: lines.join('\n'),
@@ -0,0 +1,40 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { closeDb } from '../../db/client';
6
+ import { handleSystemMigrate } from './system-migrate';
7
+
8
+ describe('handleSystemMigrate', () => {
9
+ let dir: string;
10
+
11
+ beforeEach(() => {
12
+ dir = mkdtempSync(join(tmpdir(), 'sysmig-'));
13
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
14
+ });
15
+ afterEach(() => {
16
+ closeDb();
17
+ process.env.CELILO_DB_PATH = undefined;
18
+ try {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ } catch {
21
+ /* ignore */
22
+ }
23
+ });
24
+
25
+ it('reports a fresh/current DB as up to date with the full schema', async () => {
26
+ const result = await handleSystemMigrate();
27
+ expect(result.success).toBe(true);
28
+ if (result.success) {
29
+ expect(result.message).toContain('up to date');
30
+ expect(result.message).toContain('tables');
31
+ }
32
+ });
33
+
34
+ it('is idempotent — a second run is also clean', async () => {
35
+ (await handleSystemMigrate()).success;
36
+ closeDb();
37
+ const second = await handleSystemMigrate();
38
+ expect(second.success).toBe(true);
39
+ });
40
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `celilo system migrate` — apply pending DB migrations (ISS-0100).
3
+ *
4
+ * Drizzle's migrator is the single migration mechanism; createDbClient already
5
+ * auto-migrates on open, so this command is the explicit, operator-visible
6
+ * entrypoint the .deb postinst and celilo-mgmt deploy call. Idempotent: a
7
+ * current DB reports "up to date".
8
+ */
9
+
10
+ import type { Database } from 'bun:sqlite';
11
+ import { getDb } from '../../db/client';
12
+ import { runMigrationsOn } from '../../db/migrate';
13
+ import { findSchemaDrift } from '../../db/schema-introspection';
14
+ import type { CommandResult } from '../types';
15
+
16
+ function countApplied(sqlite: Database): number {
17
+ try {
18
+ const row = sqlite
19
+ .query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`')
20
+ .get();
21
+ return row?.c ?? 0;
22
+ } catch {
23
+ return 0;
24
+ }
25
+ }
26
+
27
+ export async function handleSystemMigrate(): Promise<CommandResult> {
28
+ // getDb() auto-migrates on open; do it inside try so an existing DB that
29
+ // predates the drizzle-authoritative change fails with an actionable message
30
+ // instead of a raw migrator error.
31
+ let db: ReturnType<typeof getDb>;
32
+ try {
33
+ db = getDb();
34
+ } catch (error) {
35
+ const msg = error instanceof Error ? error.message : String(error);
36
+ return {
37
+ success: false,
38
+ error: `Migration failed: ${msg}\n\nIf this DB predates the drizzle-authoritative migration change, it needs a one-time remediation (stamp \`__drizzle_migrations\` to the latest migration + create any missing table) before the migrator can run cleanly — see ISS-0100.`,
39
+ };
40
+ }
41
+
42
+ const sqlite = db.$client;
43
+ const before = countApplied(sqlite);
44
+ try {
45
+ runMigrationsOn(db); // idempotent re-assert
46
+ } catch (error) {
47
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
48
+ }
49
+ const applied = countApplied(sqlite) - before;
50
+
51
+ const drift = findSchemaDrift(sqlite);
52
+ if (drift.missingTables.length > 0 || drift.missingColumns.length > 0) {
53
+ const missing = [...drift.missingTables, ...drift.missingColumns].join(', ');
54
+ return {
55
+ success: false,
56
+ error: `Schema still behind after migrate — missing: ${missing}. This DB likely needs one-time remediation — see ISS-0100.`,
57
+ };
58
+ }
59
+
60
+ const lines = [
61
+ applied > 0 ? `Applied ${applied} migration(s).` : 'Schema already up to date.',
62
+ `Schema current: ${drift.tableCount} tables.`,
63
+ ];
64
+ return { success: true, message: lines.join('\n') };
65
+ }
@@ -82,6 +82,7 @@ export async function getCompletions(words: string[], current: number): Promise<
82
82
  'status',
83
83
  'tail',
84
84
  'list-subscribers',
85
+ 'resync-subscriptions',
85
86
  'list-pending',
86
87
  'drain',
87
88
  'run',
@@ -459,6 +460,7 @@ export async function getCompletions(words: string[], current: number): Promise<
459
460
  'audit',
460
461
  'update',
461
462
  'doctor',
463
+ 'migrate',
462
464
  ];
463
465
  return filterSuggestions(subcommands, args[1] || '');
464
466
  }
package/src/cli/index.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  handleEventsRepair,
23
23
  handleEventsReply,
24
24
  handleEventsRespond,
25
+ handleEventsResyncSubscriptions,
25
26
  handleEventsRun,
26
27
  handleEventsRunHook,
27
28
  handleEventsShowDaemon,
@@ -88,6 +89,7 @@ import { handleSystemAudit } from './commands/system-audit';
88
89
  import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
89
90
  import { handleSystemDoctor } from './commands/system-doctor';
90
91
  import { handleSystemInit } from './commands/system-init';
92
+ import { handleSystemMigrate } from './commands/system-migrate';
91
93
  import { handleSystemSecretGet } from './commands/system-secret-get';
92
94
  import { handleSystemSecretSet } from './commands/system-secret-set';
93
95
  import { handleSystemUpdate } from './commands/system-update';
@@ -257,6 +259,7 @@ Subcommands:
257
259
  status Print bus health as JSON
258
260
  tail [--type T] [--limit N] Recent events as JSON
259
261
  list-subscribers List persistent bus subscribers
262
+ resync-subscriptions Rebuild subscribers from deployed modules' manifests (after a restore/migration)
260
263
  list-pending [--subscriber] List pending deliveries
261
264
  drain [--concurrency N] Process pending deliveries once and return
262
265
  run [--poll-ms N] Run the long-running dispatcher (foreground)
@@ -1194,6 +1197,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1194
1197
  return handleEventsTail(parsed.args, parsed.flags);
1195
1198
  case 'list-subscribers':
1196
1199
  return handleEventsListSubscribers();
1200
+ case 'resync-subscriptions':
1201
+ return handleEventsResyncSubscriptions();
1197
1202
  case 'list-pending':
1198
1203
  return handleEventsListPending(parsed.args, parsed.flags);
1199
1204
  case 'drain':
@@ -1779,6 +1784,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1779
1784
  return handleSystemDoctor(parsed.args, parsed.flags);
1780
1785
  }
1781
1786
 
1787
+ if (parsed.subcommand === 'migrate') {
1788
+ return handleSystemMigrate();
1789
+ }
1790
+
1782
1791
  return {
1783
1792
  success: false,
1784
1793
  error: `Unknown system subcommand: ${parsed.subcommand}\n\nRun "celilo system --help" for usage`,