@celilo/cli 0.5.0-alpha.4 → 0.5.0-alpha.6

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 (46) hide show
  1. package/drizzle/0010_dns_internal_records.sql +12 -0
  2. package/drizzle/0011_backups_name.sql +1 -0
  3. package/drizzle/meta/_journal.json +14 -0
  4. package/package.json +2 -2
  5. package/src/ansible/inventory.test.ts +10 -10
  6. package/src/ansible/validation.test.ts +25 -15
  7. package/src/cli/command-registry.ts +13 -2
  8. package/src/cli/commands/events.test.ts +4 -4
  9. package/src/cli/commands/events.ts +2 -2
  10. package/src/cli/commands/service-add-proxmox.ts +9 -0
  11. package/src/cli/commands/system-doctor.ts +135 -40
  12. package/src/cli/commands/system-migrate.test.ts +40 -0
  13. package/src/cli/commands/system-migrate.ts +65 -0
  14. package/src/cli/completion.ts +1 -0
  15. package/src/cli/index.ts +7 -2
  16. package/src/config/paths.test.ts +61 -48
  17. package/src/db/client.ts +15 -146
  18. package/src/db/migrate.ts +14 -6
  19. package/src/db/schema-introspection.ts +88 -0
  20. package/src/db/schema.ts +38 -0
  21. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  22. package/src/hooks/capability-loader.ts +24 -15
  23. package/src/infrastructure/property-extractor.test.ts +15 -0
  24. package/src/infrastructure/property-extractor.ts +12 -0
  25. package/src/manifest/schema.ts +7 -0
  26. package/src/manifest/validate.test.ts +53 -0
  27. package/src/services/bus-interview.test.ts +2 -2
  28. package/src/services/bus-secret-flow.test.ts +2 -2
  29. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  30. package/src/services/deploy-preflight.ts +25 -0
  31. package/src/services/deploy-validation.test.ts +2 -2
  32. package/src/services/dns-internal-records.test.ts +126 -0
  33. package/src/services/dns-internal-records.ts +119 -0
  34. package/src/services/dns-provider-backfill.test.ts +2 -2
  35. package/src/services/dns-registrations.test.ts +10 -10
  36. package/src/services/fleet-checks.test.ts +495 -0
  37. package/src/services/fleet-checks.ts +663 -0
  38. package/src/services/module-build.test.ts +43 -38
  39. package/src/templates/generator.test.ts +62 -12
  40. package/src/templates/generator.ts +69 -50
  41. package/src/test-utils/fixtures.test.ts +1 -1
  42. package/src/test-utils/integration-guard.ts +33 -0
  43. package/src/types/infrastructure.ts +6 -0
  44. package/src/variables/computed/computed-integration.test.ts +3 -3
  45. package/src/variables/computed/computed.test.ts +5 -5
  46. package/src/variables/declarative-derivation.test.ts +6 -6
@@ -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.4",
3
+ "version": "0.5.0-alpha.6",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,7 +54,7 @@
54
54
  "@aws-sdk/client-s3": "^3.1024.0",
55
55
  "@celilo/capabilities": "^0.4.2",
56
56
  "@celilo/cli-display": "^0.1.9",
57
- "@celilo/event-bus": "^0.1.6",
57
+ "@celilo/event-bus": "^0.1.7",
58
58
  "@clack/prompts": "^1.1.0",
59
59
  "ajv": "^8.18.0",
60
60
  "drizzle-orm": "^0.36.4",
@@ -61,7 +61,7 @@ describe('generateHostsIni', () => {
61
61
  },
62
62
  {
63
63
  hostname: 'dns-ext',
64
- ansibleHost: '188.166.157.2',
64
+ ansibleHost: '192.0.2.20',
65
65
  ansibleUser: 'root',
66
66
  groups: ['dns_external'],
67
67
  },
@@ -72,7 +72,7 @@ describe('generateHostsIni', () => {
72
72
  expect(result).toContain('[homebridge]');
73
73
  expect(result).toContain('[dns_external]');
74
74
  expect(result).toContain('iot ansible_host=192.168.0.110 ansible_user=root');
75
- expect(result).toContain('dns-ext ansible_host=188.166.157.2 ansible_user=root');
75
+ expect(result).toContain('dns-ext ansible_host=192.0.2.20 ansible_user=root');
76
76
  });
77
77
 
78
78
  test('includes SSH private key file path when provided', () => {
@@ -136,8 +136,8 @@ describe('generateHostVarsYaml', () => {
136
136
  test('generates YAML for arrays', () => {
137
137
  const vars = {
138
138
  zone_records: [
139
- { name: 'ns1', type: 'A', value: '188.166.157.2' },
140
- { name: 'www', type: 'A', value: '71.36.99.96' },
139
+ { name: 'ns1', type: 'A', value: '192.0.2.20' },
140
+ { name: 'www', type: 'A', value: '203.0.113.10' },
141
141
  ],
142
142
  };
143
143
 
@@ -146,9 +146,9 @@ describe('generateHostVarsYaml', () => {
146
146
  expect(result).toContain('zone_records:');
147
147
  expect(result).toContain('- name: ns1');
148
148
  expect(result).toContain('type: A');
149
- expect(result).toContain('value: 188.166.157.2');
149
+ expect(result).toContain('value: 192.0.2.20');
150
150
  expect(result).toContain('- name: www');
151
- expect(result).toContain('value: 71.36.99.96');
151
+ expect(result).toContain('value: 203.0.113.10');
152
152
  });
153
153
 
154
154
  test('generates YAML for nested objects', () => {
@@ -359,13 +359,13 @@ describe('Database integration', () => {
359
359
  );
360
360
 
361
361
  upsertModuleConfig(db, 'dns', 'zone_records', [
362
- { name: 'ns1', type: 'A', value: '188.166.157.2' },
362
+ { name: 'ns1', type: 'A', value: '192.0.2.20' },
363
363
  ]);
364
364
 
365
365
  const vars = buildHostVars('dns', db);
366
366
 
367
367
  expect(Array.isArray(vars.zone_records)).toBe(true);
368
- expect(vars.zone_records).toEqual([{ name: 'ns1', type: 'A', value: '188.166.157.2' }]);
368
+ expect(vars.zone_records).toEqual([{ name: 'ns1', type: 'A', value: '192.0.2.20' }]);
369
369
  });
370
370
  });
371
371
 
@@ -416,13 +416,13 @@ describe('Database integration', () => {
416
416
  );
417
417
 
418
418
  upsertModuleConfig(db, 'dns-external', 'hostname', 'dns-ext');
419
- upsertModuleConfig(db, 'dns-external', 'vps_ip', '188.166.157.2');
419
+ upsertModuleConfig(db, 'dns-external', 'vps_ip', '192.0.2.20');
420
420
 
421
421
  const host = extractInventoryHost('dns-external', db);
422
422
 
423
423
  expect(host).not.toBeNull();
424
424
  expect(host?.hostname).toBe('dns-ext');
425
- expect(host?.ansibleHost).toBe('188.166.157.2'); // VPS IP used directly
425
+ expect(host?.ansibleHost).toBe('192.0.2.20'); // VPS IP used directly
426
426
  expect(host?.ansibleUser).toBe('root');
427
427
  expect(host?.groups).toEqual(['dns-external']);
428
428
  });
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import { skipIntegration } from '../test-utils/integration-guard';
2
3
  import {
3
4
  isAnsibleInventoryAvailable,
4
5
  isAnsibleLintAvailable,
@@ -54,17 +55,23 @@ describe('validateWithAnsibleLint', () => {
54
55
  });
55
56
 
56
57
  describe('validatePlaybookSyntax', () => {
57
- test('returns error when playbook does not exist', async () => {
58
- const result = await validatePlaybookSyntax('/nonexistent/playbook.yml', '/tmp');
59
- expect(result.success).toBe(false);
60
- expect(result.error).toContain('Playbook not found');
61
- });
58
+ test.skipIf(skipIntegration({ tools: ['ansible'] }))(
59
+ 'returns error when playbook does not exist',
60
+ async () => {
61
+ const result = await validatePlaybookSyntax('/nonexistent/playbook.yml', '/tmp');
62
+ expect(result.success).toBe(false);
63
+ expect(result.error).toContain('Playbook not found');
64
+ },
65
+ );
62
66
 
63
- test('returns error when inventory does not exist', async () => {
64
- const result = await validatePlaybookSyntax('/tmp/playbook.yml', '/nonexistent/inventory');
65
- expect(result.success).toBe(false);
66
- expect(result.error).toContain('not found');
67
- });
67
+ test.skipIf(skipIntegration({ tools: ['ansible'] }))(
68
+ 'returns error when inventory does not exist',
69
+ async () => {
70
+ const result = await validatePlaybookSyntax('/tmp/playbook.yml', '/nonexistent/inventory');
71
+ expect(result.success).toBe(false);
72
+ expect(result.error).toContain('not found');
73
+ },
74
+ );
68
75
 
69
76
  test('returns error when ansible-playbook not installed', async () => {
70
77
  if (!isAnsiblePlaybookAvailable()) {
@@ -76,11 +83,14 @@ describe('validatePlaybookSyntax', () => {
76
83
  });
77
84
 
78
85
  describe('validateInventory', () => {
79
- test('returns error when inventory does not exist', async () => {
80
- const result = await validateInventory('/nonexistent/inventory');
81
- expect(result.success).toBe(false);
82
- expect(result.error).toContain('Inventory not found');
83
- });
86
+ test.skipIf(skipIntegration({ tools: ['ansible'] }))(
87
+ 'returns error when inventory does not exist',
88
+ async () => {
89
+ const result = await validateInventory('/nonexistent/inventory');
90
+ expect(result.success).toBe(false);
91
+ expect(result.error).toContain('Inventory not found');
92
+ },
93
+ );
84
94
 
85
95
  test('returns error when ansible-inventory not installed', async () => {
86
96
  if (!isAnsibleInventoryAvailable()) {
@@ -956,14 +956,25 @@ export const COMMANDS: CommandDef[] = [
956
956
  },
957
957
  ],
958
958
  },
959
+ {
960
+ name: 'migrate',
961
+ description: 'Apply pending database migrations (idempotent; safe to re-run)',
962
+ },
959
963
  {
960
964
  name: 'doctor',
961
- 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',
962
967
  flags: [
963
968
  {
964
969
  name: 'fix',
965
970
  description:
966
- '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',
967
978
  takesValue: false,
968
979
  },
969
980
  ],
@@ -165,20 +165,20 @@ describe('celilo events command handlers', () => {
165
165
  });
166
166
  setupBus.close();
167
167
 
168
- const res = await handleEventsReply([String(query.id), '"lunacycle.net"'], {});
168
+ const res = await handleEventsReply([String(query.id), '"example.net"'], {});
169
169
  expect(res.success).toBe(true);
170
170
  if (!res.success) throw new Error('expected success');
171
171
  const data = res.data as { status: string; family: string; value: unknown };
172
172
  expect(data.status).toBe('replied');
173
173
  expect(data.family).toBe('config');
174
- expect(data.value).toBe('lunacycle.net');
174
+ expect(data.value).toBe('example.net');
175
175
 
176
176
  const checkBus = openBus({ dbPath, events: defineEvents({}) });
177
177
  const replies = checkBus.recentEvents({ type: 'config.required.lunacycle.domain.reply' });
178
178
  checkBus.close();
179
179
  expect(replies).toHaveLength(1);
180
180
  expect(replies[0].replyFor).toBe(query.id);
181
- expect(replies[0].payload).toEqual({ value: 'lunacycle.net' });
181
+ expect(replies[0].payload).toEqual({ value: 'example.net' });
182
182
  expect(replies[0].emittedBy).toBe('claude-config-responder');
183
183
  });
184
184
 
@@ -229,7 +229,7 @@ describe('celilo events command handlers', () => {
229
229
  setupBus.close();
230
230
 
231
231
  // A bare word isn't valid JSON — the operator must quote strings.
232
- const res = await handleEventsReply([String(query.id), 'lunacycle.net'], {});
232
+ const res = await handleEventsReply([String(query.id), 'example.net'], {});
233
233
  expect(res.success).toBe(false);
234
234
  if (res.success) throw new Error('expected failure');
235
235
  expect(res.error).toContain('Invalid JSON value');
@@ -439,7 +439,7 @@ export async function handleEventsReply(
439
439
  return {
440
440
  success: false,
441
441
  error: `Usage: celilo events reply <query-event-id> <value-json>
442
- e.g. celilo events reply 42 '"lunacycle.net"'`,
442
+ e.g. celilo events reply 42 '"example.net"'`,
443
443
  };
444
444
  }
445
445
  const queryId = Number(idArg);
@@ -454,7 +454,7 @@ export async function handleEventsReply(
454
454
  return {
455
455
  success: false,
456
456
  error: `Invalid JSON value: ${err instanceof Error ? err.message : String(err)}
457
- Encode the answer as JSON, e.g. '"lunacycle.net"', '8080', '["a","b"]'.`,
457
+ Encode the answer as JSON, e.g. '"example.net"', '8080', '["a","b"]'.`,
458
458
  };
459
459
  }
460
460
 
@@ -122,6 +122,14 @@ export async function handleServiceAddProxmox(
122
122
  validate: validateRequired('Storage'),
123
123
  });
124
124
 
125
+ // VM template name for `requires.system.type: vm` modules. Optional —
126
+ // skip if you only deploy LXC modules. See v2/PROXMOX_VM_TEMPLATE.md.
127
+ const vmTemplateInput = await promptText({
128
+ message: 'VM template name (optional — for VM-type modules):',
129
+ placeholder: 'e.g., ubuntu-2404-cloud-init-9000',
130
+ });
131
+ const vmTemplate = vmTemplateInput?.trim() || undefined;
132
+
125
133
  // Find storage that supports vztmpl content. We do this BEFORE prompting
126
134
  // for a template so the user only ever sees one storage in subsequent
127
135
  // messages, and so the saved volid uses the right storage.
@@ -178,6 +186,7 @@ export async function handleServiceAddProxmox(
178
186
  default_target_node: targetNode,
179
187
  lxc_template: lxcTemplate,
180
188
  storage,
189
+ ...(vmTemplate ? { vm_template: vmTemplate } : {}),
181
190
  },
182
191
  });
183
192
 
@@ -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
+ }
@@ -460,6 +460,7 @@ export async function getCompletions(words: string[], current: number): Promise<
460
460
  'audit',
461
461
  'update',
462
462
  'doctor',
463
+ 'migrate',
463
464
  ];
464
465
  return filterSuggestions(subcommands, args[1] || '');
465
466
  }
package/src/cli/index.ts CHANGED
@@ -89,6 +89,7 @@ import { handleSystemAudit } from './commands/system-audit';
89
89
  import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
90
90
  import { handleSystemDoctor } from './commands/system-doctor';
91
91
  import { handleSystemInit } from './commands/system-init';
92
+ import { handleSystemMigrate } from './commands/system-migrate';
92
93
  import { handleSystemSecretGet } from './commands/system-secret-get';
93
94
  import { handleSystemSecretSet } from './commands/system-secret-set';
94
95
  import { handleSystemUpdate } from './commands/system-update';
@@ -286,7 +287,7 @@ Examples:
286
287
  celilo events tail --type deploy.completed.lunacycle # filter by type
287
288
  celilo events emit deploy.completed.lunacycle '{}' # operator-fired event
288
289
  celilo events tail --type 'config.required.*' # see pending deploy questions
289
- celilo events reply 42 '"lunacycle.net"' # answer query #42 (config)
290
+ celilo events reply 42 '"example.net"' # answer query #42 (config)
290
291
  `;
291
292
  return { success: true, message: helpText.trim() };
292
293
  }
@@ -1090,7 +1091,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1090
1091
  '',
1091
1092
  'Examples:',
1092
1093
  ' celilo hook run namecheap validate_config --debug',
1093
- ' celilo hook run namecheap container_created vps_ip=138.68.140.177',
1094
+ ' celilo hook run namecheap container_created vps_ip=192.0.2.30',
1094
1095
  ].join('\n'),
1095
1096
  };
1096
1097
  }
@@ -1783,6 +1784,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1783
1784
  return handleSystemDoctor(parsed.args, parsed.flags);
1784
1785
  }
1785
1786
 
1787
+ if (parsed.subcommand === 'migrate') {
1788
+ return handleSystemMigrate();
1789
+ }
1790
+
1786
1791
  return {
1787
1792
  success: false,
1788
1793
  error: `Unknown system subcommand: ${parsed.subcommand}\n\nRun "celilo system --help" for usage`,