@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.11

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 (101) hide show
  1. package/drizzle/0009_dns_registrations.sql +13 -0
  2. package/drizzle/0010_dns_internal_records.sql +12 -0
  3. package/drizzle/0011_backups_name.sql +1 -0
  4. package/drizzle/meta/_journal.json +22 -1
  5. package/package.json +3 -3
  6. package/src/ansible/inventory.test.ts +10 -10
  7. package/src/ansible/validation.test.ts +25 -15
  8. package/src/api-clients/proxmox.test.ts +211 -1
  9. package/src/api-clients/proxmox.ts +399 -8
  10. package/src/cli/command-registry.ts +83 -6
  11. package/src/cli/commands/backup-delete.ts +10 -7
  12. package/src/cli/commands/backup-import.ts +11 -8
  13. package/src/cli/commands/backup-restore.ts +11 -8
  14. package/src/cli/commands/dns.ts +57 -0
  15. package/src/cli/commands/events.test.ts +4 -4
  16. package/src/cli/commands/events.ts +89 -24
  17. package/src/cli/commands/machine-add.ts +178 -163
  18. package/src/cli/commands/machine-remove.ts +10 -7
  19. package/src/cli/commands/module-config.test.ts +78 -0
  20. package/src/cli/commands/module-config.ts +18 -3
  21. package/src/cli/commands/module-import.ts +9 -5
  22. package/src/cli/commands/module-publish.ts +24 -0
  23. package/src/cli/commands/module-remove.ts +20 -9
  24. package/src/cli/commands/module-status.ts +15 -0
  25. package/src/cli/commands/module-upgrade.test.ts +37 -0
  26. package/src/cli/commands/module-upgrade.ts +26 -6
  27. package/src/cli/commands/proxmox-node-list.ts +101 -0
  28. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  29. package/src/cli/commands/proxmox-vm-template-build.ts +171 -0
  30. package/src/cli/commands/publish/alpha.test.ts +26 -0
  31. package/src/cli/commands/publish/alpha.ts +23 -0
  32. package/src/cli/commands/publish/types.ts +7 -2
  33. package/src/cli/commands/publish/workspace.ts +11 -1
  34. package/src/cli/commands/restore.ts +29 -0
  35. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  36. package/src/cli/commands/service-add-proxmox.ts +283 -209
  37. package/src/cli/commands/service-reconfigure.test.ts +115 -0
  38. package/src/cli/commands/service-reconfigure.ts +252 -129
  39. package/src/cli/commands/service-remove.ts +19 -13
  40. package/src/cli/commands/service-verify.ts +9 -10
  41. package/src/cli/commands/storage-add-local.ts +120 -107
  42. package/src/cli/commands/storage-add-s3.ts +145 -131
  43. package/src/cli/commands/storage-remove.ts +11 -8
  44. package/src/cli/commands/system-doctor.ts +135 -40
  45. package/src/cli/commands/system-init.ts +119 -128
  46. package/src/cli/commands/system-migrate.test.ts +40 -0
  47. package/src/cli/commands/system-migrate.ts +65 -0
  48. package/src/cli/completion.ts +23 -0
  49. package/src/cli/index.ts +91 -7
  50. package/src/cli/service-credential.ts +54 -0
  51. package/src/config/paths.test.ts +61 -48
  52. package/src/db/client.ts +15 -146
  53. package/src/db/migrate.ts +14 -6
  54. package/src/db/schema-introspection.ts +88 -0
  55. package/src/db/schema.ts +74 -0
  56. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  57. package/src/hooks/capability-loader.ts +43 -2
  58. package/src/hooks/run-named-hook.ts +28 -2
  59. package/src/hooks/types.ts +2 -1
  60. package/src/infrastructure/property-extractor.test.ts +15 -0
  61. package/src/infrastructure/property-extractor.ts +12 -0
  62. package/src/manifest/contracts/v1.ts +16 -0
  63. package/src/manifest/schema.ts +17 -0
  64. package/src/manifest/validate.test.ts +53 -0
  65. package/src/services/bus-interview.test.ts +2 -2
  66. package/src/services/bus-interview.ts +232 -0
  67. package/src/services/bus-secret-flow.test.ts +2 -2
  68. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  69. package/src/services/deploy-preflight.ts +25 -0
  70. package/src/services/deploy-validation.test.ts +54 -4
  71. package/src/services/deploy-validation.ts +27 -36
  72. package/src/services/dns-internal-records.test.ts +126 -0
  73. package/src/services/dns-internal-records.ts +119 -0
  74. package/src/services/dns-provider-backfill.test.ts +2 -2
  75. package/src/services/dns-provider-backfill.ts +14 -2
  76. package/src/services/dns-registrations.test.ts +120 -0
  77. package/src/services/dns-registrations.ts +108 -0
  78. package/src/services/events-daemon.test.ts +59 -0
  79. package/src/services/events-daemon.ts +191 -57
  80. package/src/services/fleet-checks.test.ts +508 -0
  81. package/src/services/fleet-checks.ts +678 -0
  82. package/src/services/module-build.test.ts +43 -38
  83. package/src/services/module-config.ts +12 -0
  84. package/src/services/module-deploy.ts +7 -6
  85. package/src/services/module-subscriptions.test.ts +88 -0
  86. package/src/services/module-subscriptions.ts +50 -1
  87. package/src/services/module-validator/bundled-deps.test.ts +55 -0
  88. package/src/services/module-validator/bundled-deps.ts +115 -0
  89. package/src/services/module-validator/capability-versions.test.ts +1 -1
  90. package/src/services/placement-reconcile.test.ts +86 -0
  91. package/src/services/placement-reconcile.ts +108 -0
  92. package/src/services/programmatic-responder.ts +34 -0
  93. package/src/services/terminal-responder.ts +113 -0
  94. package/src/templates/generator.test.ts +92 -12
  95. package/src/templates/generator.ts +165 -80
  96. package/src/test-utils/fixtures.test.ts +1 -1
  97. package/src/test-utils/integration-guard.ts +33 -0
  98. package/src/types/infrastructure.ts +6 -0
  99. package/src/variables/computed/computed-integration.test.ts +3 -3
  100. package/src/variables/computed/computed.test.ts +5 -5
  101. package/src/variables/declarative-derivation.test.ts +6 -6
@@ -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'),
@@ -5,8 +5,8 @@
5
5
  * or applies defaults for non-interactive use.
6
6
  */
7
7
 
8
- import * as p from '@clack/prompts';
9
8
  import { getDb } from '../../db/client';
9
+ import { askConfirm, askSelect, askText, withInterviewSession } from '../../services/bus-interview';
10
10
  import {
11
11
  autoDetectSSHKeys,
12
12
  getDefaultConfiguration,
@@ -14,9 +14,8 @@ import {
14
14
  isSystemInitialized,
15
15
  loadExistingConfiguration,
16
16
  } from '../../services/system-init';
17
- import { celiloIntro, celiloOutro, promptConfirm, promptText } from '../prompts';
17
+ import { celiloIntro, celiloOutro } from '../prompts';
18
18
  import type { CommandResult } from '../types';
19
- import { validateRequired } from '../validators';
20
19
 
21
20
  /**
22
21
  * Parse key=value pairs from positional arguments
@@ -163,139 +162,131 @@ async function initInteractive(cliOverrides: Record<string, string> = {}): Promi
163
162
  // Helper: skip prompt if value already provided via CLI
164
163
  const has = (key: string) => key in cliOverrides;
165
164
 
166
- await celiloIntro('🎛️ Welcome to Celilo System Setup');
167
-
168
- // Network and DNS addressing are intentionally NOT prompted here.
169
- // Network topology is no longer owned by `system init`
170
- // (v2/NETWORK_CONFIG_TO_FIREWALL.md): the `internal` zone and DNS are
171
- // discovered when celilo-mgmt is deployed, and `dmz`/`app`/`secure`
172
- // come from a firewall module. The only thing left to capture
173
- // interactively is the SSH key celilo uses to reach managed machines.
174
-
175
- // SSH Key skip if provided via CLI
176
- if (!has('ssh.public_key')) {
177
- const detectedKeys = autoDetectSSHKeys();
178
- const existingSshKey = defaults['ssh.public_key'];
179
-
180
- // Fresh box, no key in ~/.ssh and no previously-saved key — point the
181
- // user at ssh-keygen rather than making them paste at a blank prompt.
182
- // They can also skip this branch by passing ssh.public_key=... on the CLI.
183
- if (detectedKeys.length === 0 && !existingSshKey) {
184
- p.cancel('No SSH key found in ~/.ssh/');
185
- console.log();
186
- console.log('Celilo uses an SSH key to reach managed machines');
187
- console.log('(Proxmox hosts, VPS, Raspberry Pi, etc.). Generate one:');
188
- console.log();
189
- console.log(' ssh-keygen -t ed25519 -C "<your-email>"');
190
- console.log();
191
- console.log('Then re-run: celilo system init');
192
- console.log();
193
- console.log('Or, to paste a key from elsewhere without generating one:');
194
- console.log(' celilo system init ssh.public_key="ssh-ed25519 AAAA..."');
195
- return { success: false, error: 'No SSH key available — run ssh-keygen first' };
196
- }
197
-
198
- let keySelected = false;
199
-
200
- if (detectedKeys.length === 1) {
201
- // Single key: confirm
202
- const key = detectedKeys[0];
203
- const keyPreview = `${key.keyType} ...${key.content.slice(-20)}`;
204
- const useDetected = await promptConfirm({
205
- message: `Auto-detected SSH key (${key.filename}: ${keyPreview}) - Use it?`,
206
- initialValue: true,
207
- });
208
-
209
- if (useDetected) {
210
- overrides['ssh.public_key'] = key.content;
211
- keySelected = true;
212
- }
213
- } else if (detectedKeys.length > 1) {
214
- // Multiple keys: selection menu
215
- const PASTE_OPTION = '__paste__';
216
- const selected = await p.select({
217
- message: `Found ${detectedKeys.length} SSH keys in ~/.ssh/`,
218
- options: [
219
- ...detectedKeys.map((key) => ({
220
- value: key.content,
221
- label: key.filename,
222
- hint: `${key.keyType} ...${key.content.slice(-20)}`,
223
- })),
224
- { value: PASTE_OPTION, label: 'Paste a different key' },
225
- ],
226
- });
227
-
228
- if (p.isCancel(selected)) {
229
- p.cancel('Operation cancelled');
230
- return { success: false, error: 'Cancelled by user' };
165
+ // Every prompt below is a bus interview (ISS-0127), so `system init` is
166
+ // drivable headlessly via `celilo events respond --values` / `events reply`.
167
+ // `withInterviewSession` renders bus questions locally when stdin is a TTY.
168
+ const scope = 'system-init';
169
+
170
+ return withInterviewSession(async () => {
171
+ await celiloIntro('🎛️ Welcome to Celilo System Setup');
172
+
173
+ // Network and DNS addressing are intentionally NOT prompted here.
174
+ // Network topology is no longer owned by `system init`
175
+ // (v2/NETWORK_CONFIG_TO_FIREWALL.md): the `internal` zone and DNS are
176
+ // discovered when celilo-mgmt is deployed, and `dmz`/`app`/`secure`
177
+ // come from a firewall module. The only thing left to capture
178
+ // interactively is the SSH key celilo uses to reach managed machines.
179
+
180
+ // SSH Key skip if provided via CLI
181
+ if (!has('ssh.public_key')) {
182
+ const detectedKeys = autoDetectSSHKeys();
183
+ const existingSshKey = defaults['ssh.public_key'];
184
+
185
+ // Fresh box, no key in ~/.ssh and no previously-saved key — point the
186
+ // user at ssh-keygen rather than making them paste at a blank prompt.
187
+ // They can also skip this branch by passing ssh.public_key=... on the CLI.
188
+ if (detectedKeys.length === 0 && !existingSshKey) {
189
+ console.log('No SSH key found in ~/.ssh/');
190
+ console.log();
191
+ console.log('Celilo uses an SSH key to reach managed machines');
192
+ console.log('(Proxmox hosts, VPS, Raspberry Pi, etc.). Generate one:');
193
+ console.log();
194
+ console.log(' ssh-keygen -t ed25519 -C "<your-email>"');
195
+ console.log();
196
+ console.log('Then re-run: celilo system init');
197
+ console.log();
198
+ console.log('Or, to paste a key from elsewhere without generating one:');
199
+ console.log(' celilo system init ssh.public_key="ssh-ed25519 AAAA..."');
200
+ return { success: false, error: 'No SSH key available — run ssh-keygen first' };
231
201
  }
232
202
 
233
- if (selected !== PASTE_OPTION) {
234
- overrides['ssh.public_key'] = selected as string;
235
- keySelected = true;
203
+ let keySelected = false;
204
+
205
+ if (detectedKeys.length === 1) {
206
+ // Single key: confirm
207
+ const key = detectedKeys[0];
208
+ const keyPreview = `${key.keyType} ...${key.content.slice(-20)}`;
209
+ const useDetected = await askConfirm({
210
+ scope,
211
+ key: 'ssh_use_detected',
212
+ message: `Auto-detected SSH key (${key.filename}: ${keyPreview}) - Use it?`,
213
+ defaultValue: true,
214
+ });
215
+
216
+ if (useDetected) {
217
+ overrides['ssh.public_key'] = key.content;
218
+ keySelected = true;
219
+ }
220
+ } else if (detectedKeys.length > 1) {
221
+ // Multiple keys: selection menu
222
+ const PASTE_OPTION = '__paste__';
223
+ const selected = await askSelect({
224
+ scope,
225
+ key: 'ssh_key_choice',
226
+ message: `Found ${detectedKeys.length} SSH keys in ~/.ssh/`,
227
+ options: [
228
+ ...detectedKeys.map((key) => ({
229
+ value: key.content,
230
+ label: key.filename,
231
+ hint: `${key.keyType} ...${key.content.slice(-20)}`,
232
+ })),
233
+ { value: PASTE_OPTION, label: 'Paste a different key' },
234
+ ],
235
+ });
236
+
237
+ if (selected !== PASTE_OPTION) {
238
+ overrides['ssh.public_key'] = selected;
239
+ keySelected = true;
240
+ }
236
241
  }
237
- }
238
242
 
239
- // Manual prompt if no key selected from detection
240
- if (!keySelected) {
241
- overrides['ssh.public_key'] = await promptText({
242
- message: existingSshKey
243
- ? 'SSH public key (press Enter to keep existing)'
244
- : 'SSH public key',
245
- defaultValue: String(existingSshKey || ''),
246
- // String(undefined) === 'undefined' (truthy), so guard explicitly —
247
- // otherwise the prompt shows "undefined" as the placeholder text.
248
- placeholder: existingSshKey ? String(existingSshKey) : 'ssh-ed25519 AAAA...',
249
- validate: (value) => {
250
- if (existingSshKey && !value) {
251
- return;
252
- }
253
- if (!value) {
254
- return 'SSH public key is required';
255
- }
256
- },
257
- });
258
- if (!overrides['ssh.public_key'] && existingSshKey) {
259
- overrides['ssh.public_key'] = existingSshKey;
260
- }
261
- } else if (existingSshKey) {
262
- overrides['ssh.public_key'] = await promptText({
263
- message: 'SSH public key (press Enter to keep existing)',
264
- defaultValue: String(existingSshKey),
265
- placeholder: String(existingSshKey),
266
- validate: () => undefined,
267
- });
268
- if (!overrides['ssh.public_key']) {
269
- overrides['ssh.public_key'] = existingSshKey;
243
+ // Manual prompt if no key selected from detection
244
+ if (!keySelected) {
245
+ const entered = await askText({
246
+ scope,
247
+ key: 'ssh_public_key',
248
+ message: existingSshKey
249
+ ? 'SSH public key (press Enter to keep existing)'
250
+ : 'SSH public key',
251
+ defaultValue: existingSshKey ? String(existingSshKey) : undefined,
252
+ // String(undefined) === 'undefined' (truthy), so guard explicitly —
253
+ // otherwise the prompt shows "undefined" as the placeholder text.
254
+ placeholder: existingSshKey ? String(existingSshKey) : 'ssh-ed25519 AAAA...',
255
+ required: !existingSshKey,
256
+ });
257
+ overrides['ssh.public_key'] = entered || existingSshKey;
258
+ } else if (existingSshKey) {
259
+ const entered = await askText({
260
+ scope,
261
+ key: 'ssh_public_key',
262
+ message: 'SSH public key (press Enter to keep existing)',
263
+ defaultValue: String(existingSshKey),
264
+ placeholder: String(existingSshKey),
265
+ });
266
+ overrides['ssh.public_key'] = entered || existingSshKey;
270
267
  }
271
- } else {
272
- overrides['ssh.public_key'] = await promptText({
273
- message: 'SSH public key',
274
- placeholder: 'ssh-ed25519 AAAA...',
275
- validate: validateRequired('SSH public key'),
276
- });
277
268
  }
278
- }
279
269
 
280
- // Apply configuration (called for its DB side effects).
281
- initializeSystem(db, overrides);
270
+ // Apply configuration (called for its DB side effects).
271
+ initializeSystem(db, overrides);
282
272
 
283
- await celiloOutro('✅ System initialization complete!');
273
+ await celiloOutro('✅ System initialization complete!');
284
274
 
285
- console.log('\nNext steps:');
286
- console.log(' 1. Configure infrastructure:');
287
- console.log(' Container services:');
288
- console.log(' - celilo service add proxmox');
289
- console.log(' - celilo service add digitalocean');
290
- console.log(' Existing hardware:');
291
- console.log(' - celilo machine add');
292
- console.log('');
293
- console.log(' 2. Import a module: celilo module import <path>');
294
- console.log(' 3. Configure module: celilo module config set <module-id> <key> <value>');
295
- console.log(' 4. Generate infra: celilo module generate <module-id>');
275
+ console.log('\nNext steps:');
276
+ console.log(' 1. Configure infrastructure:');
277
+ console.log(' Container services:');
278
+ console.log(' - celilo service add proxmox');
279
+ console.log(' - celilo service add digitalocean');
280
+ console.log(' Existing hardware:');
281
+ console.log(' - celilo machine add');
282
+ console.log('');
283
+ console.log(' 2. Import a module: celilo module import <path>');
284
+ console.log(' 3. Configure module: celilo module config set <module-id> <key> <value>');
285
+ console.log(' 4. Generate infra: celilo module generate <module-id>');
296
286
 
297
- return {
298
- success: true,
299
- message: 'System initialized successfully',
300
- };
287
+ return {
288
+ success: true,
289
+ message: 'System initialized successfully',
290
+ };
291
+ });
301
292
  }
@@ -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
+ }
@@ -31,6 +31,7 @@ export async function getCompletions(words: string[], current: number): Promise<
31
31
  'audit',
32
32
  'backup',
33
33
  'capability',
34
+ 'dns',
34
35
  'completion',
35
36
  'events',
36
37
  'help',
@@ -39,6 +40,7 @@ export async function getCompletions(words: string[], current: number): Promise<
39
40
  'machine',
40
41
  'module',
41
42
  'package',
43
+ 'proxmox',
42
44
  'publish',
43
45
  'restore',
44
46
  'service',
@@ -53,6 +55,11 @@ export async function getCompletions(words: string[], current: number): Promise<
53
55
 
54
56
  const command = args[0];
55
57
 
58
+ // DNS subcommands
59
+ if (command === 'dns' && currentIndex === 1) {
60
+ return filterSuggestions(['registrations'], args[1] || '');
61
+ }
62
+
56
63
  // Capability subcommands
57
64
  if (command === 'capability' && currentIndex === 1) {
58
65
  const subcommands = ['list', 'info'];
@@ -76,6 +83,7 @@ export async function getCompletions(words: string[], current: number): Promise<
76
83
  'status',
77
84
  'tail',
78
85
  'list-subscribers',
86
+ 'resync-subscriptions',
79
87
  'list-pending',
80
88
  'drain',
81
89
  'run',
@@ -237,6 +245,20 @@ export async function getCompletions(words: string[], current: number): Promise<
237
245
  }
238
246
 
239
247
  // Service subcommands
248
+ // Proxmox subcommands
249
+ if (command === 'proxmox' && currentIndex === 1) {
250
+ return filterSuggestions(['node'], args[1] || '');
251
+ }
252
+ if (command === 'proxmox' && args[1] === 'node' && currentIndex === 2) {
253
+ return filterSuggestions(['list'], args[2] || '');
254
+ }
255
+ // proxmox node list <service-id> — proxmox services only
256
+ if (command === 'proxmox' && args[1] === 'node' && args[2] === 'list' && currentIndex === 3) {
257
+ const services = await listContainerServices();
258
+ const serviceIds = services.filter((s) => s.providerName === 'proxmox').map((s) => s.serviceId);
259
+ return filterSuggestions(serviceIds, args[3] || '');
260
+ }
261
+
240
262
  if (command === 'service' && currentIndex === 1) {
241
263
  const subcommands = ['add', 'list', 'verify', 'reconfigure', 'remove', 'config'];
242
264
  return filterSuggestions(subcommands, args[1] || '');
@@ -453,6 +475,7 @@ export async function getCompletions(words: string[], current: number): Promise<
453
475
  'audit',
454
476
  'update',
455
477
  'doctor',
478
+ 'migrate',
456
479
  ];
457
480
  return filterSuggestions(subcommands, args[1] || '');
458
481
  }