@celilo/cli 0.5.0-alpha.8 → 0.5.0-alpha.9

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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/api-clients/proxmox.test.ts +78 -0
  3. package/src/api-clients/proxmox.ts +96 -1
  4. package/src/cli/command-registry.ts +32 -3
  5. package/src/cli/commands/backup-delete.ts +10 -7
  6. package/src/cli/commands/backup-import.ts +11 -8
  7. package/src/cli/commands/backup-restore.ts +11 -8
  8. package/src/cli/commands/events.ts +8 -3
  9. package/src/cli/commands/machine-add.ts +178 -163
  10. package/src/cli/commands/machine-remove.ts +10 -7
  11. package/src/cli/commands/module-config.test.ts +78 -0
  12. package/src/cli/commands/module-config.ts +18 -3
  13. package/src/cli/commands/module-import.ts +9 -5
  14. package/src/cli/commands/module-remove.ts +20 -9
  15. package/src/cli/commands/module-status.ts +15 -0
  16. package/src/cli/commands/module-upgrade.ts +10 -6
  17. package/src/cli/commands/proxmox-node-list.ts +101 -0
  18. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  19. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  20. package/src/cli/commands/service-add-proxmox.ts +275 -260
  21. package/src/cli/commands/service-reconfigure.ts +171 -153
  22. package/src/cli/commands/service-remove.ts +19 -13
  23. package/src/cli/commands/service-verify.ts +9 -10
  24. package/src/cli/commands/storage-add-local.ts +120 -107
  25. package/src/cli/commands/storage-add-s3.ts +145 -131
  26. package/src/cli/commands/storage-remove.ts +11 -8
  27. package/src/cli/commands/system-init.ts +119 -128
  28. package/src/cli/completion.ts +15 -0
  29. package/src/cli/index.ts +25 -0
  30. package/src/cli/service-credential.ts +54 -0
  31. package/src/services/bus-interview.ts +232 -0
  32. package/src/services/module-config.ts +12 -0
  33. package/src/services/module-deploy.ts +6 -1
  34. package/src/services/placement-reconcile.test.ts +86 -0
  35. package/src/services/placement-reconcile.ts +108 -0
  36. package/src/services/programmatic-responder.ts +34 -0
  37. package/src/services/terminal-responder.ts +113 -0
  38. package/src/templates/generator.test.ts +30 -0
  39. package/src/templates/generator.ts +86 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.5.0-alpha.8",
3
+ "version": "0.5.0-alpha.9",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import {
3
+ type ProxmoxClusterResource,
3
4
  buildCloudImageVolid,
4
5
  filterVmTemplates,
5
6
  findNodeForVmid,
6
7
  pollTaskUntilDone,
7
8
  selectFreeVmid,
8
9
  selectIsoStorage,
10
+ summarizeNodeCapacities,
9
11
  } from './proxmox';
10
12
 
11
13
  describe('findNodeForVmid (ISS-0090 — Proxmox is source of truth for current location)', () => {
@@ -36,6 +38,82 @@ describe('findNodeForVmid (ISS-0090 — Proxmox is source of truth for current l
36
38
  });
37
39
  });
38
40
 
41
+ describe('summarizeNodeCapacities (ISS-0060 — per-node capacity reconciled from Proxmox)', () => {
42
+ const GB = 1024 * 1024 * 1024;
43
+ const resources: ProxmoxClusterResource[] = [
44
+ {
45
+ type: 'node',
46
+ node: 'node3',
47
+ status: 'online',
48
+ maxmem: 16 * GB,
49
+ mem: 4 * GB,
50
+ maxcpu: 8,
51
+ cpu: 0.25,
52
+ maxdisk: 200 * GB,
53
+ disk: 50 * GB,
54
+ uptime: 3600,
55
+ },
56
+ {
57
+ type: 'node',
58
+ node: 'node2',
59
+ status: 'offline',
60
+ maxmem: 8 * GB,
61
+ mem: 7 * GB,
62
+ maxcpu: 4,
63
+ cpu: 0.9,
64
+ maxdisk: 100 * GB,
65
+ disk: 90 * GB,
66
+ uptime: 0,
67
+ },
68
+ // Guest + storage rows must be ignored.
69
+ { type: 'lxc', vmid: 200, node: 'node2', status: 'running' },
70
+ { type: 'storage', node: 'node3', status: 'available' },
71
+ ];
72
+
73
+ test('derives free RAM/CPU/disk and online state from node rows only', () => {
74
+ const caps = summarizeNodeCapacities(resources);
75
+ expect(caps).toHaveLength(2); // guest + storage rows dropped
76
+ // Sorted by node name → node2 first.
77
+ expect(caps[0]).toEqual({
78
+ node: 'node2',
79
+ online: false,
80
+ memTotalMb: 8192,
81
+ memFreeMb: 1024,
82
+ cpuCores: 4,
83
+ cpuUsedPct: 90,
84
+ diskTotalGb: 100,
85
+ diskFreeGb: 10,
86
+ uptimeSec: 0,
87
+ });
88
+ expect(caps[1]).toMatchObject({
89
+ node: 'node3',
90
+ online: true,
91
+ memFreeMb: 12288,
92
+ cpuUsedPct: 25,
93
+ diskFreeGb: 150,
94
+ });
95
+ });
96
+
97
+ test('tolerates missing fields (treats absent capacity as 0, offline unless status==online)', () => {
98
+ const caps = summarizeNodeCapacities([{ type: 'node', node: 'bare' }]);
99
+ expect(caps[0]).toEqual({
100
+ node: 'bare',
101
+ online: false,
102
+ memTotalMb: 0,
103
+ memFreeMb: 0,
104
+ cpuCores: 0,
105
+ cpuUsedPct: 0,
106
+ diskTotalGb: 0,
107
+ diskFreeGb: 0,
108
+ uptimeSec: 0,
109
+ });
110
+ });
111
+
112
+ test('empty inventory → no nodes', () => {
113
+ expect(summarizeNodeCapacities([])).toEqual([]);
114
+ });
115
+ });
116
+
39
117
  describe('filterVmTemplates — extract VM templates from a /nodes/{node}/qemu listing', () => {
40
118
  test('keeps only guests flagged template===1 and projects to {vmid, name}', () => {
41
119
  const guests = [
@@ -28,7 +28,7 @@ interface ProxmoxError {
28
28
  details?: Record<string, unknown>;
29
29
  }
30
30
 
31
- type ProxmoxResult<T> = { success: true; data: T } | ProxmoxError;
31
+ export type ProxmoxResult<T> = { success: true; data: T } | ProxmoxError;
32
32
 
33
33
  /**
34
34
  * Make an authenticated API request to Proxmox
@@ -543,6 +543,101 @@ export function findNodeForVmid(
543
543
  return match?.node ?? null;
544
544
  }
545
545
 
546
+ /**
547
+ * One row from `GET /cluster/resources`. The list is heterogeneous — `type`
548
+ * discriminates node / storage / guest rows. For `type: 'node'`, `status` is
549
+ * 'online'/'offline' and the mem/cpu/disk fields describe the node's capacity;
550
+ * for guests it's 'running'/'stopped' and `vmid` is set.
551
+ */
552
+ export interface ProxmoxClusterResource {
553
+ type: string;
554
+ node?: string;
555
+ status?: string;
556
+ vmid?: number;
557
+ maxmem?: number; // bytes (node total RAM)
558
+ mem?: number; // bytes (node RAM in use)
559
+ maxcpu?: number; // cores
560
+ cpu?: number; // load fraction 0..1
561
+ maxdisk?: number; // bytes (node total disk on the relevant storage)
562
+ disk?: number; // bytes (disk in use)
563
+ uptime?: number; // seconds
564
+ }
565
+
566
+ /** Per-node capacity reconciled from Proxmox (reality, never a cached DB value). */
567
+ export interface ProxmoxNodeCapacity {
568
+ node: string;
569
+ online: boolean;
570
+ memTotalMb: number;
571
+ memFreeMb: number;
572
+ cpuCores: number;
573
+ cpuUsedPct: number; // 0..100
574
+ diskTotalGb: number;
575
+ diskFreeGb: number;
576
+ uptimeSec: number;
577
+ }
578
+
579
+ const BYTES_PER_MB = 1024 * 1024;
580
+ const BYTES_PER_GB = 1024 * 1024 * 1024;
581
+
582
+ /**
583
+ * Summarize per-node capacity from a `/cluster/resources` list. Pure (Rule 10) —
584
+ * split from the network call for unit testing. Only `type: 'node'` rows count;
585
+ * guest and storage rows are ignored. Sorted by node name for stable output.
586
+ */
587
+ export function summarizeNodeCapacities(
588
+ resources: ProxmoxClusterResource[],
589
+ ): ProxmoxNodeCapacity[] {
590
+ return resources
591
+ .filter((r): r is ProxmoxClusterResource & { node: string } => r.type === 'node' && !!r.node)
592
+ .map((r) => {
593
+ const maxmem = r.maxmem ?? 0;
594
+ const mem = r.mem ?? 0;
595
+ const maxdisk = r.maxdisk ?? 0;
596
+ const disk = r.disk ?? 0;
597
+ return {
598
+ node: r.node,
599
+ online: r.status === 'online',
600
+ memTotalMb: Math.round(maxmem / BYTES_PER_MB),
601
+ memFreeMb: Math.round((maxmem - mem) / BYTES_PER_MB),
602
+ cpuCores: r.maxcpu ?? 0,
603
+ cpuUsedPct: Math.round((r.cpu ?? 0) * 100),
604
+ diskTotalGb: Math.round(maxdisk / BYTES_PER_GB),
605
+ diskFreeGb: Math.round((maxdisk - disk) / BYTES_PER_GB),
606
+ uptimeSec: r.uptime ?? 0,
607
+ };
608
+ })
609
+ .sort((a, b) => a.node.localeCompare(b.node));
610
+ }
611
+
612
+ /**
613
+ * Cohesive Proxmox introspection client (ISS-0060). Wraps the credentials so
614
+ * callers don't thread them through every call, and centralizes the live reads
615
+ * that let celilo treat Proxmox — not a cached DB row — as the source of truth
616
+ * for where containers live and how much room each node has.
617
+ */
618
+ export class ProxmoxClient {
619
+ constructor(private readonly credentials: ProxmoxCredentials) {}
620
+
621
+ /** Raw cluster resource inventory (node + guest + storage rows). */
622
+ async clusterResources(): Promise<ProxmoxResult<ProxmoxClusterResource[]>> {
623
+ return makeProxmoxRequest<ProxmoxClusterResource[]>(this.credentials, '/cluster/resources');
624
+ }
625
+
626
+ /** Live per-node capacity (RAM/CPU/disk/online), reconciled from Proxmox. */
627
+ async nodeCapacities(): Promise<ProxmoxResult<ProxmoxNodeCapacity[]>> {
628
+ const result = await this.clusterResources();
629
+ if (!result.success) return result;
630
+ return { success: true, data: summarizeNodeCapacities(result.data) };
631
+ }
632
+
633
+ /** The node a VMID currently lives on, or null if it isn't created yet. */
634
+ async nodeForVmid(vmid: number): Promise<ProxmoxResult<string | null>> {
635
+ const result = await this.clusterResources();
636
+ if (!result.success) return result;
637
+ return { success: true, data: findNodeForVmid(result.data, vmid) };
638
+ }
639
+ }
640
+
546
641
  /**
547
642
  * List available LXC templates in storage
548
643
  */
@@ -666,8 +666,16 @@ export const COMMANDS: CommandDef[] = [
666
666
  description: 'Add Proxmox container service',
667
667
  flags: [
668
668
  { name: 'api-url', description: 'Proxmox API URL', takesValue: true },
669
- { name: 'token-id', description: 'API token ID', takesValue: true },
670
- { name: 'token-secret', description: 'API token secret', takesValue: true },
669
+ {
670
+ name: 'api-token-id',
671
+ description: 'API token ID (or $PROXMOX_API_TOKEN_ID)',
672
+ takesValue: true,
673
+ },
674
+ {
675
+ name: 'api-token-secret',
676
+ description: 'API token secret (or $PROXMOX_API_TOKEN_SECRET)',
677
+ takesValue: true,
678
+ },
671
679
  { name: 'node', description: 'Target node', takesValue: true },
672
680
  {
673
681
  name: 'zone',
@@ -682,7 +690,11 @@ export const COMMANDS: CommandDef[] = [
682
690
  name: 'digitalocean',
683
691
  description: 'Add DigitalOcean container service',
684
692
  flags: [
685
- { name: 'api-token', description: 'DigitalOcean API token', takesValue: true },
693
+ {
694
+ name: 'api-token',
695
+ description: 'DigitalOcean API token (or $DIGITALOCEAN_API_TOKEN)',
696
+ takesValue: true,
697
+ },
686
698
  { name: 'region', description: 'Region', takesValue: true },
687
699
  {
688
700
  name: 'zone',
@@ -1057,6 +1069,23 @@ export const COMMANDS: CommandDef[] = [
1057
1069
  },
1058
1070
  ],
1059
1071
  },
1072
+ {
1073
+ name: 'proxmox',
1074
+ description: 'Proxmox cluster introspection (nodes, capacity)',
1075
+ subcommands: [
1076
+ {
1077
+ name: 'node',
1078
+ description: 'Proxmox node operations',
1079
+ subcommands: [
1080
+ {
1081
+ name: 'list',
1082
+ description: 'List cluster nodes with live capacity (RAM/CPU/disk)',
1083
+ args: [{ name: 'service-id', description: 'Proxmox service (optional if only one)' }],
1084
+ },
1085
+ ],
1086
+ },
1087
+ ],
1088
+ },
1060
1089
  {
1061
1090
  name: 'subscribers',
1062
1091
  description: 'Manage build-bus subscribers (cross-machine publish-event delivery)',
@@ -3,9 +3,9 @@
3
3
  * Delete a specific backup entry and its storage file.
4
4
  */
5
5
 
6
- import * as p from '@clack/prompts';
7
6
  import { deleteBackupRecord, formatSize, getBackup } from '../../services/backup-metadata';
8
7
  import { createStorageProvider, getBackupStorage } from '../../services/backup-storage';
8
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
9
9
  import { celiloIntro, celiloOutro } from '../prompts';
10
10
  import type { CommandResult } from '../types';
11
11
 
@@ -40,13 +40,16 @@ export async function handleBackupDelete(
40
40
  console.log(` → ${storage?.storageId ?? '?'}: ${backup.storagePath}\n`);
41
41
 
42
42
  if (!flags.force) {
43
- const confirmed = await p.confirm({
44
- message: 'Delete this backup?',
45
- initialValue: false,
46
- });
43
+ const confirmed = await withInterviewSession(() =>
44
+ askConfirm({
45
+ scope: `backup:${backupId}`,
46
+ key: 'delete',
47
+ message: 'Delete this backup?',
48
+ defaultValue: false,
49
+ }),
50
+ );
47
51
 
48
- if (p.isCancel(confirmed) || !confirmed) {
49
- p.cancel('Operation cancelled');
52
+ if (!confirmed) {
50
53
  return { success: false, error: 'Cancelled by user' };
51
54
  }
52
55
  }
@@ -5,8 +5,8 @@
5
5
 
6
6
  import { existsSync, statSync } from 'node:fs';
7
7
  import { resolve } from 'node:path';
8
- import * as p from '@clack/prompts';
9
8
  import { formatSize } from '../../services/backup-metadata';
9
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
10
10
  import { celiloIntro, celiloOutro } from '../prompts';
11
11
  import type { CommandResult } from '../types';
12
12
 
@@ -54,13 +54,16 @@ export async function handleBackupImport(
54
54
 
55
55
  // Confirm unless --yes
56
56
  if (!flags.yes) {
57
- const confirmed = await p.confirm({
58
- message: `Import this file as a backup for ${moduleId}?`,
59
- initialValue: true,
60
- });
61
-
62
- if (p.isCancel(confirmed) || !confirmed) {
63
- p.cancel('Import cancelled');
57
+ const confirmed = await withInterviewSession(() =>
58
+ askConfirm({
59
+ scope: `backup-import:${moduleId}`,
60
+ key: 'import',
61
+ message: `Import this file as a backup for ${moduleId}?`,
62
+ defaultValue: true,
63
+ }),
64
+ );
65
+
66
+ if (!confirmed) {
64
67
  return { success: false, error: 'Cancelled by user' };
65
68
  }
66
69
  }
@@ -3,10 +3,10 @@
3
3
  * Restores from a backup archive, executing the module's on_restore hook.
4
4
  */
5
5
 
6
- import * as p from '@clack/prompts';
7
6
  import { formatSize, getBackup } from '../../services/backup-metadata';
8
7
  import { restoreModuleBackup, restoreSystemStateBackup } from '../../services/backup-restore';
9
8
  import { getBackupStorage } from '../../services/backup-storage';
9
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
10
10
  import { celiloIntro, celiloOutro } from '../prompts';
11
11
  import type { CommandResult } from '../types';
12
12
 
@@ -66,13 +66,16 @@ export async function handleBackupRestore(
66
66
 
67
67
  // Confirm unless --yes
68
68
  if (!flags.yes) {
69
- const confirmed = await p.confirm({
70
- message: 'Proceed with restore?',
71
- initialValue: false,
72
- });
73
-
74
- if (p.isCancel(confirmed) || !confirmed) {
75
- p.cancel('Restore cancelled');
69
+ const confirmed = await withInterviewSession(() =>
70
+ askConfirm({
71
+ scope: `backup-restore:${backupId}`,
72
+ key: 'proceed',
73
+ message: 'Proceed with restore?',
74
+ defaultValue: false,
75
+ }),
76
+ );
77
+
78
+ if (!confirmed) {
76
79
  return { success: false, error: 'Cancelled by user' };
77
80
  }
78
81
  }
@@ -389,7 +389,7 @@ export async function handleEventsRepair(): Promise<CommandResult> {
389
389
  }
390
390
  }
391
391
 
392
- const INTERVIEW_FAMILIES = ['config', 'secret', 'ensure', 'aspect'] as const;
392
+ const INTERVIEW_FAMILIES = ['config', 'secret', 'ensure', 'aspect', 'interview'] as const;
393
393
  type InterviewFamily = (typeof INTERVIEW_FAMILIES)[number];
394
394
 
395
395
  /** Classify a query event type into its interview family, or null if it isn't one. */
@@ -398,6 +398,7 @@ function interviewFamily(type: string): InterviewFamily | null {
398
398
  if (type.startsWith('secret.required.')) return 'secret';
399
399
  if (type.startsWith('ensure.required.')) return 'ensure';
400
400
  if (type.startsWith('aspect.required.')) return 'aspect';
401
+ if (type.startsWith('interview.required.')) return 'interview';
401
402
  return null;
402
403
  }
403
404
 
@@ -428,6 +429,10 @@ function interviewFamily(type: string): InterviewFamily | null {
428
429
  * base-module aspect, 'false' refuses it (ISS-0027).
429
430
  * Replies { consented }; the deploy records the
430
431
  * approval/denial.
432
+ * - interview.required.<scope>.<key> → value is the answer itself, shaped per
433
+ * the question's kind ('"node3"', 'true',
434
+ * '["a","b"]'); replies { value }. The generic
435
+ * operator-command interview family (ISS-0127).
431
436
  */
432
437
  export async function handleEventsReply(
433
438
  args: string[],
@@ -476,7 +481,7 @@ export async function handleEventsReply(
476
481
  return {
477
482
  success: false,
478
483
  error: `Event ${queryId} is type '${query.type}', not an interview query.
479
- Expected one of: config.required.* / secret.required.* / ensure.required.* / aspect.required.*`,
484
+ Expected one of: config.required.* / secret.required.* / ensure.required.* / aspect.required.* / interview.required.*`,
480
485
  };
481
486
  }
482
487
 
@@ -496,7 +501,7 @@ export async function handleEventsReply(
496
501
  });
497
502
  }
498
503
 
499
- if (family === 'config') {
504
+ if (family === 'config' || family === 'interview') {
500
505
  bus.emitRaw(`${query.type}.reply`, { value }, { replyFor: queryId, emittedBy });
501
506
  return jsonResult({ ok: true, status: 'replied', queryId, type: query.type, family, value });
502
507
  }