@celilo/cli 0.5.0-alpha.8 → 0.5.0

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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/api-clients/proxmox.test.ts +108 -20
  3. package/src/api-clients/proxmox.ts +130 -24
  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/proxmox-vm-template-build.ts +25 -18
  20. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  21. package/src/cli/commands/service-add-proxmox.ts +275 -260
  22. package/src/cli/commands/service-reconfigure.ts +171 -153
  23. package/src/cli/commands/service-remove.ts +19 -13
  24. package/src/cli/commands/service-verify.ts +9 -10
  25. package/src/cli/commands/storage-add-local.ts +120 -107
  26. package/src/cli/commands/storage-add-s3.ts +145 -131
  27. package/src/cli/commands/storage-remove.ts +11 -8
  28. package/src/cli/commands/system-init.ts +119 -128
  29. package/src/cli/completion.ts +15 -0
  30. package/src/cli/index.ts +25 -0
  31. package/src/cli/service-credential.ts +54 -0
  32. package/src/services/bus-interview.ts +232 -0
  33. package/src/services/module-config.ts +12 -0
  34. package/src/services/module-deploy.ts +6 -1
  35. package/src/services/placement-reconcile.test.ts +86 -0
  36. package/src/services/placement-reconcile.ts +108 -0
  37. package/src/services/programmatic-responder.ts +34 -0
  38. package/src/services/proxmox-state-recovery.ts +26 -7
  39. package/src/services/terminal-responder.ts +113 -0
  40. package/src/templates/generator.test.ts +52 -1
  41. package/src/templates/generator.ts +106 -37
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",
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
- selectIsoStorage,
9
+ selectImportStorage,
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 = [
@@ -59,38 +137,48 @@ describe('filterVmTemplates — extract VM templates from a /nodes/{node}/qemu l
59
137
  });
60
138
  });
61
139
 
62
- describe('selectIsoStorage — pick a storage that accepts iso content', () => {
63
- test('returns the first active, enabled storage whose content includes iso', () => {
140
+ describe('selectImportStorage — pick a storage that accepts import content', () => {
141
+ test('returns the first active, enabled storage whose content includes import', () => {
64
142
  const storages = [
65
143
  { storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 },
66
- { storage: 'local', content: 'iso,vztmpl,backup', active: 1, enabled: 1 },
144
+ { storage: 'local', content: 'import,vztmpl,backup', active: 1, enabled: 1 },
145
+ ];
146
+ expect(selectImportStorage(storages)).toBe('local');
147
+ });
148
+
149
+ test('does NOT pick an iso-only storage (import-from rejects iso content)', () => {
150
+ // Regression guard: the cloud image must land in the import namespace, not
151
+ // iso/, or the VM-create `import-from=` fails with `has wrong type 'iso'`.
152
+ const storages = [
153
+ { storage: 'iso-only', content: 'iso,vztmpl,backup', active: 1, enabled: 1 },
154
+ { storage: 'data', content: 'import,images', active: 1, enabled: 1 },
67
155
  ];
68
- expect(selectIsoStorage(storages)).toBe('local');
156
+ expect(selectImportStorage(storages)).toBe('data');
69
157
  });
70
158
 
71
- test('skips an iso-capable storage that is inactive', () => {
159
+ test('skips an import-capable storage that is inactive', () => {
72
160
  const storages = [
73
- { storage: 'cold', content: 'iso', active: 0, enabled: 1 },
74
- { storage: 'local', content: 'iso', active: 1, enabled: 1 },
161
+ { storage: 'cold', content: 'import', active: 0, enabled: 1 },
162
+ { storage: 'local', content: 'import', active: 1, enabled: 1 },
75
163
  ];
76
- expect(selectIsoStorage(storages)).toBe('local');
164
+ expect(selectImportStorage(storages)).toBe('local');
77
165
  });
78
166
 
79
- test('skips an iso-capable storage that is disabled', () => {
167
+ test('skips an import-capable storage that is disabled', () => {
80
168
  const storages = [
81
- { storage: 'off', content: 'iso', active: 1, enabled: 0 },
82
- { storage: 'local', content: 'iso', active: 1, enabled: 1 },
169
+ { storage: 'off', content: 'import', active: 1, enabled: 0 },
170
+ { storage: 'local', content: 'import', active: 1, enabled: 1 },
83
171
  ];
84
- expect(selectIsoStorage(storages)).toBe('local');
172
+ expect(selectImportStorage(storages)).toBe('local');
85
173
  });
86
174
 
87
- test('returns null when no storage accepts iso content', () => {
175
+ test('returns null when no storage accepts import content', () => {
88
176
  const storages = [{ storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 }];
89
- expect(selectIsoStorage(storages)).toBeNull();
177
+ expect(selectImportStorage(storages)).toBeNull();
90
178
  });
91
179
 
92
180
  test('returns null for an empty storage list', () => {
93
- expect(selectIsoStorage([])).toBeNull();
181
+ expect(selectImportStorage([])).toBeNull();
94
182
  });
95
183
  });
96
184
 
@@ -122,15 +210,15 @@ describe('selectFreeVmid — first free VMID >= minVmid (template hygiene)', ()
122
210
  });
123
211
  });
124
212
 
125
- describe('buildCloudImageVolid — volid for a cloud image on iso storage', () => {
126
- test('places the filename under the storage iso/ namespace', () => {
213
+ describe('buildCloudImageVolid — volid for a cloud image on import storage', () => {
214
+ test('places the filename under the storage import/ namespace', () => {
127
215
  expect(buildCloudImageVolid('local', 'noble-server-cloudimg-amd64.img')).toBe(
128
- 'local:iso/noble-server-cloudimg-amd64.img',
216
+ 'local:import/noble-server-cloudimg-amd64.img',
129
217
  );
130
218
  });
131
219
 
132
220
  test('works with an arbitrary storage name', () => {
133
- expect(buildCloudImageVolid('nas-iso', 'jammy.img')).toBe('nas-iso:iso/jammy.img');
221
+ expect(buildCloudImageVolid('nas', 'jammy.img')).toBe('nas:import/jammy.img');
134
222
  });
135
223
  });
136
224
 
@@ -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
  */
@@ -795,30 +890,38 @@ export async function listVmTemplates(
795
890
  return { success: true, data: filterVmTemplates(result.data) };
796
891
  }
797
892
 
798
- /** Storage row shape (subset) used when picking ISO-capable storage. */
893
+ /** Storage row shape (subset) used when picking import-capable storage. */
799
894
  type ProxmoxStorageRow = { storage: string; content: string; active: number; enabled: number };
800
895
 
801
896
  /**
802
- * Pick the first active, enabled storage that accepts `iso` content. Pure
897
+ * Pick the first active, enabled storage that accepts `import` content. Pure
803
898
  * selection logic, split from the network call for testability (Rule 10).
804
899
  * Returns the storage name, or null when none qualifies.
900
+ *
901
+ * Must be `import`, NOT `iso`: the cloud image is consumed via the VM-create
902
+ * `import-from=` parameter, which Proxmox only accepts from a source on a
903
+ * storage whose content type is `images` or `import`. A `.img` downloaded to
904
+ * the `iso/` namespace is rejected with `has wrong type 'iso'`. The
905
+ * `download-url` endpoint accepts `content=import` on PVE 8.2+, so we download
906
+ * straight into the import namespace. (PVE 8.2+; node fleet is 9.x.)
805
907
  */
806
- export function selectIsoStorage(storages: ProxmoxStorageRow[]): string | null {
807
- const iso = storages.find((s) => s.active && s.enabled && s.content.includes('iso'));
808
- return iso?.storage ?? null;
908
+ export function selectImportStorage(storages: ProxmoxStorageRow[]): string | null {
909
+ const match = storages.find((s) => s.active && s.enabled && s.content.includes('import'));
910
+ return match?.storage ?? null;
809
911
  }
810
912
 
811
913
  /**
812
- * Find a storage on the node that accepts `iso` content (for downloading cloud
813
- * images). Returns the storage name, or null if none found.
914
+ * Find a storage on the node that accepts `import` content (for downloading
915
+ * cloud images that will be imported as a VM disk). Returns the storage name,
916
+ * or null if none found.
814
917
  */
815
- export async function findIsoStorage(
918
+ export async function findImportStorage(
816
919
  credentials: ProxmoxCredentials,
817
920
  nodeName: string,
818
921
  ): Promise<ProxmoxResult<string | null>> {
819
922
  const result = await listNodeStorage(credentials, nodeName);
820
923
  if (!result.success) return result;
821
- return { success: true, data: selectIsoStorage(result.data) };
924
+ return { success: true, data: selectImportStorage(result.data) };
822
925
  }
823
926
 
824
927
  /**
@@ -858,20 +961,22 @@ export async function findFreeTemplateVmid(
858
961
  }
859
962
 
860
963
  /**
861
- * Download a cloud image URL to ISO storage on the node. Returns a UPID for
862
- * status polling. Requires PVE 7.2+ (`download-url` endpoint).
964
+ * Download a cloud image URL to `import` storage on the node. Returns a UPID
965
+ * for status polling. Requires PVE 8.2+ (`download-url` with `content=import`).
966
+ * The image MUST land in the import namespace (not iso) so the VM-create
967
+ * `import-from=` accepts it — see selectImportStorage.
863
968
  */
864
969
  export async function downloadCloudImage(
865
970
  credentials: ProxmoxCredentials,
866
971
  nodeName: string,
867
- isoStorage: string,
972
+ importStorage: string,
868
973
  url: string,
869
974
  filename: string,
870
975
  ): Promise<ProxmoxResult<string>> {
871
976
  return makeProxmoxPost<string>(
872
977
  credentials,
873
- `/nodes/${nodeName}/storage/${isoStorage}/download-url`,
874
- { url, filename, content: 'iso' },
978
+ `/nodes/${nodeName}/storage/${importStorage}/download-url`,
979
+ { url, filename, content: 'import' },
875
980
  );
876
981
  }
877
982
 
@@ -908,13 +1013,14 @@ export async function pollTaskUntilDone(
908
1013
  }
909
1014
 
910
1015
  /**
911
- * Build the volid for a cloud image downloaded to ISO storage. Pure path
1016
+ * Build the volid for a cloud image downloaded to `import` storage. Pure path
912
1017
  * construction, split out for testability (Rule 10) — mirrors buildTemplatePath.
913
- * Proxmox's `download-url` with `content=iso` stores the `.img` under the
914
- * storage's `iso/` namespace, so the volid is `<storage>:iso/<filename>`.
1018
+ * Proxmox's `download-url` with `content=import` stores the `.img` under the
1019
+ * storage's `import/` namespace, so the volid is `<storage>:import/<filename>`.
1020
+ * This is the namespace the VM-create `import-from=` accepts (iso/ is rejected).
915
1021
  */
916
- export function buildCloudImageVolid(isoStorage: string, filename: string): string {
917
- return `${isoStorage}:iso/${filename}`;
1022
+ export function buildCloudImageVolid(importStorage: string, filename: string): string {
1023
+ return `${importStorage}:import/${filename}`;
918
1024
  }
919
1025
 
920
1026
  export interface CreateVmFromCloudImageParams {
@@ -922,8 +1028,8 @@ export interface CreateVmFromCloudImageParams {
922
1028
  nodeName: string;
923
1029
  vmid: number;
924
1030
  name: string;
925
- /** Volume ID of the downloaded cloud image, e.g. `local:iso/noble-server-cloudimg-amd64.img` */
926
- isoVolid: string;
1031
+ /** Volume ID of the downloaded cloud image, e.g. `local:import/noble-server-cloudimg-amd64.img` */
1032
+ imageVolid: string;
927
1033
  /** Storage for the imported VM disk and the cloud-init drive */
928
1034
  diskStorage: string;
929
1035
  }
@@ -943,7 +1049,7 @@ export interface CreateVmFromCloudImageParams {
943
1049
  export async function createVmFromCloudImage(
944
1050
  params: CreateVmFromCloudImageParams,
945
1051
  ): Promise<ProxmoxResult<string>> {
946
- const { credentials, nodeName, vmid, name, isoVolid, diskStorage } = params;
1052
+ const { credentials, nodeName, vmid, name, imageVolid, diskStorage } = params;
947
1053
  return makeProxmoxPost<string>(credentials, `/nodes/${nodeName}/qemu`, {
948
1054
  vmid: String(vmid),
949
1055
  name,
@@ -955,7 +1061,7 @@ export async function createVmFromCloudImage(
955
1061
  serial0: 'socket',
956
1062
  vga: 'serial0',
957
1063
  boot: 'order=scsi0',
958
- scsi0: `${diskStorage}:0,import-from=${isoVolid}`,
1064
+ scsi0: `${diskStorage}:0,import-from=${imageVolid}`,
959
1065
  ide2: `${diskStorage}:cloudinit`,
960
1066
  });
961
1067
  }
@@ -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
  }