@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
@@ -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
  */
@@ -558,12 +653,11 @@ export async function listAvailableTemplates(
558
653
  }
559
654
 
560
655
  /**
561
- * Make an authenticated POST request to the Proxmox API.
562
- * Shares connection/auth handling with makeProxmoxRequest; the only differences
563
- * are the verb and the form-encoded body.
656
+ * Make an authenticated form-encoded request (POST or PUT) to the Proxmox API.
564
657
  */
565
- async function makeProxmoxPost<T>(
658
+ async function makeProxmoxFormRequest<T>(
566
659
  credentials: ProxmoxCredentials,
660
+ method: 'POST' | 'PUT',
567
661
  path: string,
568
662
  params: Record<string, string>,
569
663
  ): Promise<ProxmoxResult<T>> {
@@ -576,7 +670,7 @@ async function makeProxmoxPost<T>(
576
670
  const postData = new URLSearchParams(params).toString();
577
671
 
578
672
  if (process.env.DEBUG) {
579
- console.log(`[Proxmox] POST: ${fullUrl}`);
673
+ console.log(`[Proxmox] ${method}: ${fullUrl}`);
580
674
  console.log(`[Proxmox] Body: ${postData}`);
581
675
  }
582
676
 
@@ -587,7 +681,7 @@ async function makeProxmoxPost<T>(
587
681
  hostname: url.hostname,
588
682
  port: url.port || 443,
589
683
  path: url.pathname,
590
- method: 'POST',
684
+ method,
591
685
  headers: {
592
686
  Authorization: authHeader,
593
687
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -605,7 +699,7 @@ async function makeProxmoxPost<T>(
605
699
 
606
700
  if (statusCode < 200 || statusCode >= 300) {
607
701
  if (process.env.DEBUG || statusCode >= 400) {
608
- console.error(`[Proxmox] POST ${path} failed (${statusCode}): ${body}`);
702
+ console.error(`[Proxmox] ${method} ${path} failed (${statusCode}): ${body}`);
609
703
  }
610
704
  resolve({
611
705
  success: false,
@@ -649,6 +743,14 @@ async function makeProxmoxPost<T>(
649
743
  });
650
744
  }
651
745
 
746
+ async function makeProxmoxPost<T>(
747
+ credentials: ProxmoxCredentials,
748
+ path: string,
749
+ params: Record<string, string>,
750
+ ): Promise<ProxmoxResult<T>> {
751
+ return makeProxmoxFormRequest(credentials, 'POST', path, params);
752
+ }
753
+
652
754
  /**
653
755
  * Entry from Proxmox's appliance catalog (`pveam available`). The `template`
654
756
  * field is the canonical filename (revision included) that should be passed to
@@ -742,3 +844,292 @@ export function buildTemplatePath(storageName: string, templateFilename: string)
742
844
  export function buildProxmoxApiUrl(ipAddress: string, port = 8006): string {
743
845
  return `https://${ipAddress}:${port}/api2/json`;
744
846
  }
847
+
848
+ // ── VM template build API ─────────────────────────────────────────────────────
849
+ // Used by proxmox-vm-template-build.ts to build a cloud-init template via the
850
+ // Proxmox API (no SSH to the node required).
851
+ //
852
+ // Version requirements (the build needs the HIGHER of the two — i.e. PVE 8.0+):
853
+ // - the storage `download-url` endpoint (downloadCloudImage) → PVE 7.2+
854
+ // - `import-from=` on a disk in the VM-create call → PVE 8.0+
855
+ // On PVE 7.x the create call rejects `import-from`; the older two-step
856
+ // `qm importdisk` flow would be needed there (not implemented — 8.x is current).
857
+ // @psbanka - 2026-06: revisit only if a 7.x node must be supported.
858
+
859
+ export interface ProxmoxVmTemplate {
860
+ vmid: number;
861
+ name: string;
862
+ }
863
+
864
+ /** Raw `/nodes/{node}/qemu` guest row (only the fields we read). */
865
+ interface ProxmoxQemuGuestRow {
866
+ vmid: number;
867
+ name: string;
868
+ template?: number;
869
+ }
870
+
871
+ /**
872
+ * Extract VM templates (guests with `template === 1`) from a `/nodes/{node}/qemu`
873
+ * listing. Pure filtering logic, split from the network call for testability
874
+ * (Rule 10) — mirrors findNodeForVmid.
875
+ */
876
+ export function filterVmTemplates(guests: ProxmoxQemuGuestRow[]): ProxmoxVmTemplate[] {
877
+ return guests.filter((vm) => vm.template === 1).map((vm) => ({ vmid: vm.vmid, name: vm.name }));
878
+ }
879
+
880
+ /** List existing VM templates on a node. */
881
+ export async function listVmTemplates(
882
+ credentials: ProxmoxCredentials,
883
+ nodeName: string,
884
+ ): Promise<ProxmoxResult<ProxmoxVmTemplate[]>> {
885
+ const result = await makeProxmoxRequest<ProxmoxQemuGuestRow[]>(
886
+ credentials,
887
+ `/nodes/${nodeName}/qemu`,
888
+ );
889
+ if (!result.success) return result;
890
+ return { success: true, data: filterVmTemplates(result.data) };
891
+ }
892
+
893
+ /** Storage row shape (subset) used when picking import-capable storage. */
894
+ type ProxmoxStorageRow = { storage: string; content: string; active: number; enabled: number };
895
+
896
+ /**
897
+ * Pick the first active, enabled storage that accepts `import` content. Pure
898
+ * selection logic, split from the network call for testability (Rule 10).
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.)
907
+ */
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;
911
+ }
912
+
913
+ /**
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.
917
+ */
918
+ export async function findImportStorage(
919
+ credentials: ProxmoxCredentials,
920
+ nodeName: string,
921
+ ): Promise<ProxmoxResult<string | null>> {
922
+ const result = await listNodeStorage(credentials, nodeName);
923
+ if (!result.success) return result;
924
+ return { success: true, data: selectImportStorage(result.data) };
925
+ }
926
+
927
+ /**
928
+ * Pick the first free VMID >= minVmid given the set of in-use VMIDs. Pure
929
+ * selection logic, split from the network call for testability (Rule 10).
930
+ * Template VMIDs must stay outside celilo's IPAM range (200+) so they can
931
+ * never collide with deployed systems — hence minVmid defaults to 9000.
932
+ */
933
+ export function selectFreeVmid(usedVmids: Iterable<number>, minVmid = 9000): number {
934
+ const used = new Set(usedVmids);
935
+ let candidate = minVmid;
936
+ while (used.has(candidate)) {
937
+ candidate++;
938
+ }
939
+ return candidate;
940
+ }
941
+
942
+ /**
943
+ * Find a free VMID >= minVmid (default 9000) across the cluster.
944
+ * Template VMIDs must stay outside celilo's IPAM range (200+) so they can
945
+ * never collide with deployed systems.
946
+ */
947
+ export async function findFreeTemplateVmid(
948
+ credentials: ProxmoxCredentials,
949
+ _nodeName: string,
950
+ minVmid = 9000,
951
+ ): Promise<ProxmoxResult<number>> {
952
+ const result = await makeProxmoxRequest<Array<{ vmid?: number }>>(
953
+ credentials,
954
+ '/cluster/resources',
955
+ );
956
+ if (!result.success) return result;
957
+ const usedVmids = result.data
958
+ .map((r) => r.vmid)
959
+ .filter((vmid): vmid is number => typeof vmid === 'number');
960
+ return { success: true, data: selectFreeVmid(usedVmids, minVmid) };
961
+ }
962
+
963
+ /**
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.
968
+ */
969
+ export async function downloadCloudImage(
970
+ credentials: ProxmoxCredentials,
971
+ nodeName: string,
972
+ importStorage: string,
973
+ url: string,
974
+ filename: string,
975
+ ): Promise<ProxmoxResult<string>> {
976
+ return makeProxmoxPost<string>(
977
+ credentials,
978
+ `/nodes/${nodeName}/storage/${importStorage}/download-url`,
979
+ { url, filename, content: 'import' },
980
+ );
981
+ }
982
+
983
+ /**
984
+ * Poll a task UPID until it reaches `stopped` status. Resolves with the
985
+ * exitstatus string (typically `'OK'` on success). Throws on timeout or a
986
+ * non-OK exitstatus.
987
+ *
988
+ * An empty/missing UPID means the API call completed synchronously (some PVE
989
+ * endpoints — e.g. converting a diskless VM to a template — return `null` data
990
+ * instead of a worker UPID). There is nothing to poll, so treat it as an
991
+ * immediate success rather than encoding the empty string and 404-ing on the
992
+ * task-status lookup.
993
+ */
994
+ export async function pollTaskUntilDone(
995
+ credentials: ProxmoxCredentials,
996
+ nodeName: string,
997
+ upid: string,
998
+ timeoutMs = 600_000,
999
+ ): Promise<string> {
1000
+ if (!upid) return 'OK';
1001
+ const deadline = Date.now() + timeoutMs;
1002
+ while (Date.now() < deadline) {
1003
+ await new Promise<void>((r) => setTimeout(r, 3_000));
1004
+ const status = await checkTaskStatus(credentials, nodeName, upid);
1005
+ if (!status.success) throw new Error(`Task poll failed: ${status.message}`);
1006
+ if (status.data.status === 'stopped') {
1007
+ const exit = status.data.exitstatus ?? 'unknown';
1008
+ if (exit !== 'OK') throw new Error(`Task ${upid} failed with exitstatus: ${exit}`);
1009
+ return exit;
1010
+ }
1011
+ }
1012
+ throw new Error(`Task ${upid} timed out after ${timeoutMs / 1000}s`);
1013
+ }
1014
+
1015
+ /**
1016
+ * Build the volid for a cloud image downloaded to `import` storage. Pure path
1017
+ * construction, split out for testability (Rule 10) — mirrors buildTemplatePath.
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).
1021
+ */
1022
+ export function buildCloudImageVolid(importStorage: string, filename: string): string {
1023
+ return `${importStorage}:import/${filename}`;
1024
+ }
1025
+
1026
+ export interface CreateVmFromCloudImageParams {
1027
+ credentials: ProxmoxCredentials;
1028
+ nodeName: string;
1029
+ vmid: number;
1030
+ name: string;
1031
+ /** Volume ID of the downloaded cloud image, e.g. `local:import/noble-server-cloudimg-amd64.img` */
1032
+ imageVolid: string;
1033
+ /** Storage for the imported VM disk and the cloud-init drive */
1034
+ diskStorage: string;
1035
+ }
1036
+
1037
+ /**
1038
+ * Create a VM and import the cloud image as its boot disk in one API call.
1039
+ * Also attaches the cloud-init CDROM drive so terraform can inject credentials
1040
+ * at clone time. Returns a UPID — poll with pollTaskUntilDone.
1041
+ *
1042
+ * `import-from=` in the disk spec requires PVE 8.0+ (see the version note at the
1043
+ * top of this section). The `net0` bridge is hardcoded to `vmbr0` (the Proxmox
1044
+ * default); it only governs the template's own NIC, which terraform overrides
1045
+ * with `$system:network.bridge` at clone time — so it never reaches a deployed
1046
+ * guest. @psbanka - 2026-06: parameterize if a non-vmbr0 node ever blocks the
1047
+ * template build itself.
1048
+ */
1049
+ export async function createVmFromCloudImage(
1050
+ params: CreateVmFromCloudImageParams,
1051
+ ): Promise<ProxmoxResult<string>> {
1052
+ const { credentials, nodeName, vmid, name, imageVolid, diskStorage } = params;
1053
+ return makeProxmoxPost<string>(credentials, `/nodes/${nodeName}/qemu`, {
1054
+ vmid: String(vmid),
1055
+ name,
1056
+ memory: '2048',
1057
+ cores: '2',
1058
+ scsihw: 'virtio-scsi-pci',
1059
+ net0: 'virtio,bridge=vmbr0',
1060
+ agent: 'enabled=1',
1061
+ serial0: 'socket',
1062
+ vga: 'serial0',
1063
+ boot: 'order=scsi0',
1064
+ scsi0: `${diskStorage}:0,import-from=${imageVolid}`,
1065
+ ide2: `${diskStorage}:cloudinit`,
1066
+ });
1067
+ }
1068
+
1069
+ /**
1070
+ * Convert an existing VM to a template. Returns a UPID — the VM must be
1071
+ * powered off. Poll with pollTaskUntilDone.
1072
+ */
1073
+ export async function convertVmToTemplate(
1074
+ credentials: ProxmoxCredentials,
1075
+ nodeName: string,
1076
+ vmid: number,
1077
+ ): Promise<ProxmoxResult<string>> {
1078
+ return makeProxmoxPost<string>(credentials, `/nodes/${nodeName}/qemu/${vmid}/template`, {});
1079
+ }
1080
+
1081
+ /**
1082
+ * Delete a VM (cleanup on build failure). Returns a UPID.
1083
+ * Passes `purge=1` so storage volumes are also removed.
1084
+ */
1085
+ export async function deleteVm(
1086
+ credentials: ProxmoxCredentials,
1087
+ nodeName: string,
1088
+ vmid: number,
1089
+ ): Promise<ProxmoxResult<string>> {
1090
+ return new Promise((resolve) => {
1091
+ try {
1092
+ const { api_url, api_token_id, api_token_secret } = credentials;
1093
+ const authHeader = `PVEAPIToken=${api_token_id}=${api_token_secret}`;
1094
+ const fullUrl = `${api_url}/nodes/${nodeName}/qemu/${vmid}`;
1095
+ const url = new URL(fullUrl);
1096
+ const agent = new https.Agent({ rejectUnauthorized: false });
1097
+ const req = https.request(
1098
+ {
1099
+ hostname: url.hostname,
1100
+ port: url.port || 443,
1101
+ path: `${url.pathname}?purge=1`,
1102
+ method: 'DELETE',
1103
+ headers: { Authorization: authHeader },
1104
+ agent,
1105
+ },
1106
+ (res) => {
1107
+ let body = '';
1108
+ res.on('data', (chunk) => {
1109
+ body += chunk;
1110
+ });
1111
+ res.on('end', () => {
1112
+ const statusCode = res.statusCode || 0;
1113
+ if (statusCode < 200 || statusCode >= 300) {
1114
+ resolve({
1115
+ success: false,
1116
+ message: `Delete failed with status ${statusCode}: ${body}`,
1117
+ });
1118
+ return;
1119
+ }
1120
+ try {
1121
+ const data = JSON.parse(body) as ProxmoxApiResponse<string>;
1122
+ resolve({ success: true, data: data.data });
1123
+ } catch {
1124
+ resolve({ success: true, data: '' });
1125
+ }
1126
+ });
1127
+ },
1128
+ );
1129
+ req.on('error', (error) => resolve({ success: false, message: error.message }));
1130
+ req.end();
1131
+ } catch (error) {
1132
+ resolve({ success: false, message: error instanceof Error ? error.message : String(error) });
1133
+ }
1134
+ });
1135
+ }
@@ -106,6 +106,11 @@ export const COMMANDS: CommandDef[] = [
106
106
  ],
107
107
  },
108
108
  { name: 'list-subscribers', description: 'List persistent bus subscribers' },
109
+ {
110
+ name: 'resync-subscriptions',
111
+ description:
112
+ "Rebuild subscribers from deployed modules' manifests (after a restore/migration)",
113
+ },
109
114
  {
110
115
  name: 'list-pending',
111
116
  description: 'List pending deliveries',
@@ -227,7 +232,7 @@ export const COMMANDS: CommandDef[] = [
227
232
  },
228
233
  {
229
234
  name: 'install-daemon',
230
- description: 'Write a systemd/launchd user unit for the dispatcher',
235
+ description: 'Write a systemd/launchd unit for the dispatcher',
231
236
  flags: [
232
237
  {
233
238
  name: 'celilo-path',
@@ -237,15 +242,47 @@ export const COMMANDS: CommandDef[] = [
237
242
  },
238
243
  { name: 'poll-ms', description: 'Polling interval in ms', takesValue: true },
239
244
  { name: 'concurrency', description: 'Max parallel handlers', takesValue: true },
245
+ {
246
+ name: 'system',
247
+ description:
248
+ 'System scope: /etc/systemd/system unit or /Library/LaunchDaemons plist, run as the celilo state-dir owner (management-plane shape)',
249
+ takesValue: false,
250
+ },
251
+ {
252
+ name: 'print',
253
+ description:
254
+ 'Render the unit to stdout without writing it (used by the celilo-mgmt role)',
255
+ takesValue: false,
256
+ },
240
257
  ],
241
258
  },
242
259
  {
243
260
  name: 'uninstall-daemon',
244
261
  description: 'Remove the installed supervisor unit',
262
+ flags: [{ name: 'system', description: 'Target the system-scope unit', takesValue: false }],
245
263
  },
246
264
  {
247
265
  name: 'show-daemon',
248
266
  description: 'Print the currently installed unit file',
267
+ flags: [{ name: 'system', description: 'Target the system-scope unit', takesValue: false }],
268
+ },
269
+ ],
270
+ },
271
+ {
272
+ name: 'dns',
273
+ description: 'View DNS bookkeeping (registrations ledger)',
274
+ subcommands: [
275
+ {
276
+ name: 'registrations',
277
+ description: 'List the DNS registration ledger',
278
+ flags: [
279
+ {
280
+ name: 'provider',
281
+ description: 'Only show registrations owned by one provider module',
282
+ takesValue: true,
283
+ valueHint: 'module_ids',
284
+ },
285
+ ],
249
286
  },
250
287
  ],
251
288
  },
@@ -629,8 +666,16 @@ export const COMMANDS: CommandDef[] = [
629
666
  description: 'Add Proxmox container service',
630
667
  flags: [
631
668
  { name: 'api-url', description: 'Proxmox API URL', takesValue: true },
632
- { name: 'token-id', description: 'API token ID', takesValue: true },
633
- { 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
+ },
634
679
  { name: 'node', description: 'Target node', takesValue: true },
635
680
  {
636
681
  name: 'zone',
@@ -645,7 +690,11 @@ export const COMMANDS: CommandDef[] = [
645
690
  name: 'digitalocean',
646
691
  description: 'Add DigitalOcean container service',
647
692
  flags: [
648
- { 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
+ },
649
698
  { name: 'region', description: 'Region', takesValue: true },
650
699
  {
651
700
  name: 'zone',
@@ -919,14 +968,25 @@ export const COMMANDS: CommandDef[] = [
919
968
  },
920
969
  ],
921
970
  },
971
+ {
972
+ name: 'migrate',
973
+ description: 'Apply pending database migrations (idempotent; safe to re-run)',
974
+ },
922
975
  {
923
976
  name: 'doctor',
924
- description: 'Diagnose system prerequisites and @celilo/* version drift',
977
+ description:
978
+ 'Diagnose system prerequisites, @celilo/* version drift, and (on a management plane) fleet-runtime drift',
925
979
  flags: [
926
980
  {
927
981
  name: 'fix',
928
982
  description:
929
- 'Repair drift by `bun link`-ing each drifted @celilo/* package from the workspace',
983
+ 'Repair the auto-fixable findings: `bun link` drifted @celilo/* packages and resync bus subscribers',
984
+ takesValue: false,
985
+ },
986
+ {
987
+ name: 'fleet',
988
+ description:
989
+ 'Force the fleet-runtime section (dispatcher, subscribers, capability chains) even without a celilo DB',
930
990
  takesValue: false,
931
991
  },
932
992
  ],
@@ -1009,6 +1069,23 @@ export const COMMANDS: CommandDef[] = [
1009
1069
  },
1010
1070
  ],
1011
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
+ },
1012
1089
  {
1013
1090
  name: 'subscribers',
1014
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
  }