@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.
- package/drizzle/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +1 -1
- package/src/cli/command-registry.ts +18 -2
- package/src/cli/commands/events.ts +28 -0
- package/src/cli/commands/module-publish.ts +24 -0
- package/src/cli/commands/restore.ts +29 -0
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +2 -0
- package/src/cli/index.ts +9 -0
- package/src/db/client.ts +15 -146
- package/src/db/migrate.ts +14 -6
- package/src/db/schema-introspection.ts +88 -0
- package/src/db/schema.ts +38 -0
- package/src/hooks/capability-loader.ts +28 -15
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/fleet-checks.test.ts +495 -0
- package/src/services/fleet-checks.ts +663 -0
- package/src/services/module-subscriptions.test.ts +88 -0
- package/src/services/module-subscriptions.ts +50 -1
- package/src/services/module-validator/bundled-deps.test.ts +55 -0
- package/src/services/module-validator/bundled-deps.ts +115 -0
- package/src/templates/generator.ts +21 -0
|
@@ -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
|
@@ -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:
|
|
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
|
|
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}
|
|
512
|
+
`${ANSI.dim}Run \`celilo system doctor --fix\` to bun-link drifted packages from the workspace.${ANSI.reset}`,
|
|
406
513
|
);
|
|
407
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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:
|
|
537
|
+
error: `Issues detected: ${problems.join(', ')}`,
|
|
450
538
|
details: lines.join('\n'),
|
|
451
539
|
};
|
|
452
540
|
}
|
|
453
541
|
|
|
454
|
-
|
|
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
|
+
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -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`,
|