@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.
- package/drizzle/0009_dns_registrations.sql +13 -0
- package/drizzle/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +22 -1
- package/package.json +3 -3
- package/src/ansible/inventory.test.ts +10 -10
- package/src/ansible/validation.test.ts +25 -15
- package/src/api-clients/proxmox.test.ts +211 -1
- package/src/api-clients/proxmox.ts +399 -8
- package/src/cli/command-registry.ts +83 -6
- package/src/cli/commands/backup-delete.ts +10 -7
- package/src/cli/commands/backup-import.ts +11 -8
- package/src/cli/commands/backup-restore.ts +11 -8
- package/src/cli/commands/dns.ts +57 -0
- package/src/cli/commands/events.test.ts +4 -4
- package/src/cli/commands/events.ts +89 -24
- package/src/cli/commands/machine-add.ts +178 -163
- package/src/cli/commands/machine-remove.ts +10 -7
- package/src/cli/commands/module-config.test.ts +78 -0
- package/src/cli/commands/module-config.ts +18 -3
- package/src/cli/commands/module-import.ts +9 -5
- package/src/cli/commands/module-publish.ts +24 -0
- package/src/cli/commands/module-remove.ts +20 -9
- package/src/cli/commands/module-status.ts +15 -0
- package/src/cli/commands/module-upgrade.test.ts +37 -0
- package/src/cli/commands/module-upgrade.ts +26 -6
- package/src/cli/commands/proxmox-node-list.ts +101 -0
- package/src/cli/commands/proxmox-template-selection.ts +16 -15
- package/src/cli/commands/proxmox-vm-template-build.ts +171 -0
- package/src/cli/commands/publish/alpha.test.ts +26 -0
- package/src/cli/commands/publish/alpha.ts +23 -0
- package/src/cli/commands/publish/types.ts +7 -2
- package/src/cli/commands/publish/workspace.ts +11 -1
- package/src/cli/commands/restore.ts +29 -0
- package/src/cli/commands/service-add-digitalocean.ts +120 -109
- package/src/cli/commands/service-add-proxmox.ts +283 -209
- package/src/cli/commands/service-reconfigure.test.ts +115 -0
- package/src/cli/commands/service-reconfigure.ts +252 -129
- package/src/cli/commands/service-remove.ts +19 -13
- package/src/cli/commands/service-verify.ts +9 -10
- package/src/cli/commands/storage-add-local.ts +120 -107
- package/src/cli/commands/storage-add-s3.ts +145 -131
- package/src/cli/commands/storage-remove.ts +11 -8
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-init.ts +119 -128
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +23 -0
- package/src/cli/index.ts +91 -7
- package/src/cli/service-credential.ts +54 -0
- package/src/config/paths.test.ts +61 -48
- 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 +74 -0
- package/src/hooks/capability-loader-firewall.test.ts +3 -3
- package/src/hooks/capability-loader.ts +43 -2
- package/src/hooks/run-named-hook.ts +28 -2
- package/src/hooks/types.ts +2 -1
- package/src/infrastructure/property-extractor.test.ts +15 -0
- package/src/infrastructure/property-extractor.ts +12 -0
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +17 -0
- package/src/manifest/validate.test.ts +53 -0
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-interview.ts +232 -0
- package/src/services/bus-secret-flow.test.ts +2 -2
- package/src/services/celilo-mgmt-hooks.test.ts +3 -2
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/deploy-validation.test.ts +54 -4
- package/src/services/deploy-validation.ts +27 -36
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-provider-backfill.ts +14 -2
- package/src/services/dns-registrations.test.ts +120 -0
- package/src/services/dns-registrations.ts +108 -0
- package/src/services/events-daemon.test.ts +59 -0
- package/src/services/events-daemon.ts +191 -57
- package/src/services/fleet-checks.test.ts +508 -0
- package/src/services/fleet-checks.ts +678 -0
- package/src/services/module-build.test.ts +43 -38
- package/src/services/module-config.ts +12 -0
- package/src/services/module-deploy.ts +7 -6
- 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/services/module-validator/capability-versions.test.ts +1 -1
- package/src/services/placement-reconcile.test.ts +86 -0
- package/src/services/placement-reconcile.ts +108 -0
- package/src/services/programmatic-responder.ts +34 -0
- package/src/services/terminal-responder.ts +113 -0
- package/src/templates/generator.test.ts +92 -12
- package/src/templates/generator.ts +165 -80
- package/src/test-utils/fixtures.test.ts +1 -1
- package/src/test-utils/integration-guard.ts +33 -0
- package/src/types/infrastructure.ts +6 -0
- package/src/variables/computed/computed-integration.test.ts +3 -3
- package/src/variables/computed/computed.test.ts +5 -5
- 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}
|
|
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'),
|
|
@@ -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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
281
|
-
|
|
270
|
+
// Apply configuration (called for its DB side effects).
|
|
271
|
+
initializeSystem(db, overrides);
|
|
282
272
|
|
|
283
|
-
|
|
273
|
+
await celiloOutro('✅ System initialization complete!');
|
|
284
274
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
+
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -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
|
}
|