@celilo/cli 0.3.16 → 0.3.17

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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/api-clients/proxmox.ts +77 -45
  3. package/src/cli/command-registry.ts +12 -12
  4. package/src/cli/commands/completion.ts +12 -11
  5. package/src/cli/commands/module-check.ts +158 -0
  6. package/src/cli/commands/module-import.ts +5 -5
  7. package/src/cli/commands/module-publish.test.ts +3 -90
  8. package/src/cli/commands/module-publish.ts +14 -118
  9. package/src/cli/commands/proxmox-template-selection.test.ts +150 -0
  10. package/src/cli/commands/proxmox-template-selection.ts +258 -0
  11. package/src/cli/commands/service-add-proxmox.ts +49 -127
  12. package/src/cli/commands/service-reconfigure.ts +36 -79
  13. package/src/cli/commands/service-verify.ts +20 -79
  14. package/src/cli/commands/{doctor.test.ts → system-doctor.test.ts} +1 -1
  15. package/src/cli/commands/{doctor.ts → system-doctor.ts} +93 -18
  16. package/src/cli/completion.ts +29 -2
  17. package/src/cli/index.ts +16 -7
  18. package/src/module/import.ts +4 -2
  19. package/src/registry/client.ts +14 -1
  20. package/src/services/module-deploy.ts +19 -1
  21. package/src/services/module-validator/capability-versions.test.ts +90 -0
  22. package/src/services/module-validator/capability-versions.ts +115 -0
  23. package/src/services/module-validator/contract-version.test.ts +24 -0
  24. package/src/services/module-validator/contract-version.ts +69 -0
  25. package/src/services/module-validator/git-hygiene.test.ts +141 -0
  26. package/src/services/module-validator/git-hygiene.ts +144 -0
  27. package/src/services/module-validator/index.test.ts +67 -0
  28. package/src/services/module-validator/index.ts +74 -0
  29. package/src/services/module-validator/manifest-schema.ts +42 -0
  30. package/src/services/module-validator/types.ts +43 -0
  31. package/src/services/module-validator/typescript-build.test.ts +58 -0
  32. package/src/services/module-validator/typescript-build.ts +115 -0
  33. package/src/services/module-validator/workspace-deps.test.ts +137 -0
  34. package/src/services/module-validator/workspace-deps.ts +187 -0
  35. package/src/system/prereqs.test.ts +374 -0
  36. package/src/system/prereqs.ts +377 -0
@@ -35,16 +35,10 @@
35
35
  * timestamp / CLI version / optional --message.
36
36
  */
37
37
 
38
- import { spawnSync } from 'node:child_process';
39
38
  import { rm } from 'node:fs/promises';
40
39
  import { readFile } from 'node:fs/promises';
41
40
  import { tmpdir } from 'node:os';
42
41
  import { join, resolve } from 'node:path';
43
- import {
44
- CAPABILITY_CONTRACT_VERSIONS,
45
- type KnownCapabilityName,
46
- compareProviderToRuntime,
47
- } from '@celilo/capabilities';
48
42
  import { parse as parseYaml } from 'yaml';
49
43
  import { buildModule } from '../../module/packaging/build';
50
44
  import {
@@ -55,122 +49,18 @@ import {
55
49
  } from '../../module/packaging/release-metadata';
56
50
  import { RegistryClient } from '../../registry/client';
57
51
  import { CrossModuleDataManager } from '../../services/cross-module-data-manager';
52
+ import {
53
+ type ManifestForCapabilityCheck,
54
+ validateCapabilityVersions,
55
+ } from '../../services/module-validator/capability-versions';
56
+ import { checkModuleStale } from '../../services/module-validator/git-hygiene';
58
57
  import { getFlag, hasFlag } from '../parser';
59
58
  import type { CommandResult } from '../types';
60
59
 
61
- interface ManifestCapabilityClaim {
62
- name: string;
63
- version: string;
64
- }
65
-
66
- interface ManifestForPublish {
60
+ interface ManifestForPublish extends ManifestForCapabilityCheck {
67
61
  id: string;
68
62
  version: string;
69
- provides?: { capabilities?: ManifestCapabilityClaim[] };
70
- requires?: { capabilities?: ManifestCapabilityClaim[] };
71
- }
72
-
73
- function isKnownCapability(name: string): name is KnownCapabilityName {
74
- return name in CAPABILITY_CONTRACT_VERSIONS;
75
- }
76
-
77
- /**
78
- * Strict-publish: every `provides[X].version` must match the framework's
79
- * runtime registry; every `requires[X].version` must be at most the
80
- * framework's runtime version (consumers can require older minors of
81
- * still-supported majors).
82
- *
83
- * Exported for testing.
84
- */
85
- export function validateCapabilityVersions(manifest: ManifestForPublish): string[] {
86
- const errors: string[] = [];
87
-
88
- for (const p of manifest.provides?.capabilities ?? []) {
89
- if (!isKnownCapability(p.name)) continue;
90
- const runtime = CAPABILITY_CONTRACT_VERSIONS[p.name];
91
- const r = compareProviderToRuntime(p.version, runtime);
92
- if (!r.compatible) {
93
- errors.push(
94
- `provides[${p.name}].version is ${p.version} but framework registry is ${runtime} ` +
95
- `(${r.reason}). Update the manifest, then retry.`,
96
- );
97
- }
98
- }
99
-
100
- for (const need of manifest.requires?.capabilities ?? []) {
101
- if (!isKnownCapability(need.name)) continue;
102
- const runtime = CAPABILITY_CONTRACT_VERSIONS[need.name];
103
- // Treat the runtime as the de-facto provider for publish-time validation:
104
- // if a consumer's manifest claims it requires a contract newer than the
105
- // framework even has registered, no in-tree provider can satisfy it.
106
- const r = compareProviderToRuntime(need.version, runtime);
107
- if (!r.compatible && r.reason === 'major_mismatch_higher') {
108
- errors.push(
109
- `requires[${need.name}].version is ${need.version} but framework registry is ${runtime}. Bump the framework before publishing, or lower the manifest version.`,
110
- );
111
- }
112
- }
113
-
114
- return errors;
115
- }
116
-
117
- /**
118
- * Look up the SHA of the last commit touching the given pathspecs. Returns
119
- * null on any git failure (no repo, never-committed file, etc.) — callers
120
- * treat that as "can't determine, skip the check."
121
- */
122
- function lastCommitTouching(pathspec: string[]): string | null {
123
- const r = spawnSync('git', ['log', '-1', '--format=%H', '--', ...pathspec], {
124
- stdio: ['ignore', 'pipe', 'ignore'],
125
- encoding: 'utf-8',
126
- });
127
- if (r.status !== 0) return null;
128
- const sha = r.stdout.trim();
129
- return sha || null;
130
- }
131
-
132
- function isAncestor(maybeAncestor: string, descendant: string): boolean {
133
- const r = spawnSync('git', ['merge-base', '--is-ancestor', maybeAncestor, descendant], {
134
- stdio: 'ignore',
135
- });
136
- return r.status === 0;
137
- }
138
-
139
- export interface StalenessIssue {
140
- moduleDir: string;
141
- lastSrcCommit: string;
142
- lastManifestCommit: string;
143
- }
144
-
145
- /**
146
- * Detect "I edited module src but forgot to bump (or touch) manifest.yml."
147
- *
148
- * Returns null when the manifest is the most recently-touched file in the
149
- * dir (or when neither side has any commit history — e.g. brand-new module
150
- * not yet committed). Returns a StalenessIssue when src has commits AFTER
151
- * the last manifest.yml change — the operator must bump the manifest
152
- * (semver change → reset +N to 1) or just touch it (release-only change →
153
- * auto-bump +N), then re-publish.
154
- *
155
- * Exported for testing.
156
- */
157
- export function checkModuleStale(moduleDir: string): StalenessIssue | null {
158
- const manifestPath = join(moduleDir, 'manifest.yml');
159
- const lastManifest = lastCommitTouching([manifestPath]);
160
- // Excluding manifest.yml from the "src" pathspec is the whole point — we
161
- // want to know if anything ELSE in the dir moved past it. node_modules and
162
- // common build outputs are gitignored already, but be explicit defensively.
163
- const lastSrc = lastCommitTouching([
164
- moduleDir,
165
- `:(exclude)${manifestPath}`,
166
- `:(exclude)${moduleDir}/node_modules`,
167
- `:(exclude)${moduleDir}/dist`,
168
- ]);
169
- if (!lastManifest || !lastSrc) return null;
170
- if (lastSrc === lastManifest) return null; // same commit changed both
171
- if (!isAncestor(lastManifest, lastSrc)) return null; // manifest moved last
172
-
173
- return { moduleDir, lastSrcCommit: lastSrc, lastManifestCommit: lastManifest };
63
+ description?: string;
174
64
  }
175
65
 
176
66
  /**
@@ -378,7 +268,13 @@ export async function publishOneModule(
378
268
  const client = new RegistryClient(opts.registryUrl || undefined);
379
269
  try {
380
270
  console.log(`Publishing ${name}@${version} to ${client.baseUrl}...`);
381
- await client.publish({ name, version, netappPath: buildResult.packagePath, token: opts.token });
271
+ await client.publish({
272
+ name,
273
+ version,
274
+ netappPath: buildResult.packagePath,
275
+ token: opts.token,
276
+ description: manifest.description?.trim() || undefined,
277
+ });
382
278
  } catch (err) {
383
279
  return {
384
280
  moduleDir,
@@ -0,0 +1,150 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ProxmoxAppliance } from '../../api-clients/proxmox';
3
+ import { buildUbuntuOptions, pickInitialTemplate } from './proxmox-template-selection';
4
+
5
+ // Snapshot of `pveam available --section system` filtered to Ubuntu, taken
6
+ // 2026-05-08. The point of these tests is to lock in the catalog-driven
7
+ // behavior that replaced the hardcoded `-1` revision URL.
8
+ const catalog: ProxmoxAppliance[] = [
9
+ {
10
+ template: 'ubuntu-22.04-standard_22.04-1_amd64.tar.zst',
11
+ package: 'ubuntu-22.04-standard',
12
+ version: '22.04-1',
13
+ section: 'system',
14
+ os: 'ubuntu',
15
+ type: 'lxc',
16
+ },
17
+ {
18
+ template: 'ubuntu-24.04-standard_24.04-2_amd64.tar.zst',
19
+ package: 'ubuntu-24.04-standard',
20
+ version: '24.04-2',
21
+ section: 'system',
22
+ os: 'ubuntu',
23
+ type: 'lxc',
24
+ },
25
+ {
26
+ template: 'ubuntu-24.10-standard_24.10-1_amd64.tar.zst',
27
+ package: 'ubuntu-24.10-standard',
28
+ version: '24.10-1',
29
+ section: 'system',
30
+ os: 'ubuntu',
31
+ type: 'lxc',
32
+ },
33
+ {
34
+ template: 'ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst',
35
+ package: 'ubuntu-25.04-standard',
36
+ version: '25.04-1.1',
37
+ section: 'system',
38
+ os: 'ubuntu',
39
+ type: 'lxc',
40
+ },
41
+ {
42
+ template: 'ubuntu-26.04-standard_26.04-1_amd64.tar.zst',
43
+ package: 'ubuntu-26.04-standard',
44
+ version: '26.04-1',
45
+ section: 'system',
46
+ os: 'ubuntu',
47
+ type: 'lxc',
48
+ },
49
+ ];
50
+
51
+ describe('buildUbuntuOptions', () => {
52
+ test('returns Ubuntu options sorted newest-first', () => {
53
+ const options = buildUbuntuOptions(catalog);
54
+ expect(options.map((o) => o.appliance.package)).toEqual([
55
+ 'ubuntu-26.04-standard',
56
+ 'ubuntu-25.04-standard',
57
+ 'ubuntu-24.10-standard',
58
+ 'ubuntu-24.04-standard',
59
+ 'ubuntu-22.04-standard',
60
+ ]);
61
+ });
62
+
63
+ test('flags LTS releases correctly', () => {
64
+ const options = buildUbuntuOptions(catalog);
65
+ const ltsByPackage = Object.fromEntries(options.map((o) => [o.appliance.package, o.isLts]));
66
+ expect(ltsByPackage).toEqual({
67
+ 'ubuntu-22.04-standard': true,
68
+ 'ubuntu-24.04-standard': true,
69
+ 'ubuntu-24.10-standard': false,
70
+ 'ubuntu-25.04-standard': false,
71
+ 'ubuntu-26.04-standard': true,
72
+ });
73
+ });
74
+
75
+ test('drops non-Ubuntu and non-system entries', () => {
76
+ const mixed: ProxmoxAppliance[] = [
77
+ ...catalog,
78
+ {
79
+ template: 'debian-12-standard_12.7-1_amd64.tar.zst',
80
+ package: 'debian-12-standard',
81
+ version: '12.7-1',
82
+ section: 'system',
83
+ os: 'debian',
84
+ },
85
+ {
86
+ template: 'ubuntu-24.04-special_24.04-1_amd64.tar.zst',
87
+ package: 'ubuntu-24.04-standard',
88
+ version: '24.04-1',
89
+ section: 'turnkeylinux',
90
+ os: 'ubuntu',
91
+ },
92
+ ];
93
+ const options = buildUbuntuOptions(mixed);
94
+ expect(options).toHaveLength(catalog.length);
95
+ expect(options.every((o) => (o.appliance.section ?? 'system') === 'system')).toBe(true);
96
+ });
97
+
98
+ test('returns empty array when nothing matches', () => {
99
+ expect(buildUbuntuOptions([])).toEqual([]);
100
+ });
101
+ });
102
+
103
+ describe('pickInitialTemplate', () => {
104
+ const options = buildUbuntuOptions(catalog);
105
+
106
+ test('defaults to newest LTS when no current template given', () => {
107
+ expect(pickInitialTemplate(options)).toBe('ubuntu-26.04-standard_26.04-1_amd64.tar.zst');
108
+ });
109
+
110
+ test('exact match wins when revision still on mirror', () => {
111
+ expect(pickInitialTemplate(options, 'ubuntu-22.04-standard_22.04-1_amd64.tar.zst')).toBe(
112
+ 'ubuntu-22.04-standard_22.04-1_amd64.tar.zst',
113
+ );
114
+ });
115
+
116
+ test('falls back to family match when revision has been refreshed', () => {
117
+ // The case that motivated the refactor: user has stale -1 cached, mirror has -2.
118
+ expect(pickInitialTemplate(options, 'ubuntu-24.04-standard_24.04-1_amd64.tar.zst')).toBe(
119
+ 'ubuntu-24.04-standard_24.04-2_amd64.tar.zst',
120
+ );
121
+ });
122
+
123
+ test('falls through to newest LTS when current does not match any family', () => {
124
+ expect(pickInitialTemplate(options, 'ubuntu-18.04-standard_18.04-1_amd64.tar.zst')).toBe(
125
+ 'ubuntu-26.04-standard_26.04-1_amd64.tar.zst',
126
+ );
127
+ });
128
+
129
+ test('returns undefined for empty options', () => {
130
+ expect(pickInitialTemplate([])).toBeUndefined();
131
+ });
132
+
133
+ test('falls back to newest entry when no LTS in catalog', () => {
134
+ const noLts = buildUbuntuOptions([
135
+ {
136
+ template: 'ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst',
137
+ package: 'ubuntu-25.04-standard',
138
+ version: '25.04-1.1',
139
+ section: 'system',
140
+ },
141
+ {
142
+ template: 'ubuntu-24.10-standard_24.10-1_amd64.tar.zst',
143
+ package: 'ubuntu-24.10-standard',
144
+ version: '24.10-1',
145
+ section: 'system',
146
+ },
147
+ ]);
148
+ expect(pickInitialTemplate(noLts)).toBe('ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst');
149
+ });
150
+ });
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Proxmox template selection — shared between `service add proxmox` and
3
+ * `service reconfigure`. Queries Proxmox's live appliance catalog
4
+ * (`pveam available`) and prompts the user to pick an Ubuntu LXC template by
5
+ * canonical name.
6
+ *
7
+ * Why this exists: Proxmox bumps template revisions over time
8
+ * (ubuntu-24.04 went from -1 to -2, 25.04 ships as -1.1) and an older home-lab
9
+ * deployment guide naming `-1` produces 404s on the mirror. Using the catalog
10
+ * means the canonical filename always matches what `pveam download` accepts.
11
+ */
12
+
13
+ import * as p from '@clack/prompts';
14
+ import {
15
+ type ProxmoxAppliance,
16
+ type ProxmoxCredentials,
17
+ checkTaskStatus,
18
+ downloadAppliance,
19
+ listAvailableAppliances,
20
+ } from '../../api-clients/proxmox';
21
+ import { FuelGauge } from '../fuel-gauge';
22
+ import { promptText } from '../prompts';
23
+ import { validateRequired } from '../validators';
24
+
25
+ export interface UbuntuTemplateChoice {
26
+ /** Canonical filename, e.g. "ubuntu-24.04-standard_24.04-2_amd64.tar.zst" */
27
+ template: string;
28
+ /**
29
+ * 'catalog' = filename came from Proxmox's aplinfo, so it's known good for
30
+ * downloadAppliance. 'manual' = user typed it because aplinfo was unavailable;
31
+ * caller should expect they'll need to `pveam download` themselves.
32
+ */
33
+ source: 'catalog' | 'manual';
34
+ }
35
+
36
+ export type TemplateSelectionResult =
37
+ | { kind: 'selected'; choice: UbuntuTemplateChoice }
38
+ | { kind: 'cancelled' }
39
+ | { kind: 'error'; message: string };
40
+
41
+ interface ParsedUbuntu {
42
+ appliance: ProxmoxAppliance;
43
+ major: number;
44
+ minor: number;
45
+ isLts: boolean;
46
+ }
47
+
48
+ function parseUbuntuPackage(appliance: ProxmoxAppliance): ParsedUbuntu | null {
49
+ const match = appliance.package.match(/^ubuntu-(\d+)\.(\d+)-standard$/);
50
+ if (!match) return null;
51
+ const major = Number(match[1]);
52
+ const minor = Number(match[2]);
53
+ // Ubuntu LTS = even major, .04 minor (20.04, 22.04, 24.04, 26.04, ...).
54
+ const isLts = minor === 4 && major % 2 === 0;
55
+ return { appliance, major, minor, isLts };
56
+ }
57
+
58
+ function compareDescending(a: ParsedUbuntu, b: ParsedUbuntu): number {
59
+ if (a.major !== b.major) return b.major - a.major;
60
+ return b.minor - a.minor;
61
+ }
62
+
63
+ /**
64
+ * Build the option list used by the select prompt and tests.
65
+ * Exported so tests can assert on filtering/sorting without mocking @clack.
66
+ */
67
+ export function buildUbuntuOptions(appliances: ProxmoxAppliance[]): ParsedUbuntu[] {
68
+ return appliances
69
+ .map(parseUbuntuPackage)
70
+ .filter((entry): entry is ParsedUbuntu => entry !== null)
71
+ .filter((entry) => (entry.appliance.section ?? 'system') === 'system')
72
+ .sort(compareDescending);
73
+ }
74
+
75
+ /**
76
+ * Pre-select rule:
77
+ * 1. Exact match on currentTemplate (reconfigure with current revision still on mirror).
78
+ * 2. Family match on currentTemplate package (e.g. user has 24.04 -1 cached, mirror has -2).
79
+ * 3. Newest LTS in the catalog.
80
+ * 4. Newest entry overall.
81
+ */
82
+ export function pickInitialTemplate(
83
+ options: ParsedUbuntu[],
84
+ currentTemplate?: string,
85
+ ): string | undefined {
86
+ if (options.length === 0) return undefined;
87
+
88
+ if (currentTemplate) {
89
+ const exact = options.find((e) => e.appliance.template === currentTemplate);
90
+ if (exact) return exact.appliance.template;
91
+
92
+ const familyName = currentTemplate.match(/^(ubuntu-\d+\.\d+-standard)/)?.[1];
93
+ if (familyName) {
94
+ const family = options.find((e) => e.appliance.package === familyName);
95
+ if (family) return family.appliance.template;
96
+ }
97
+ }
98
+
99
+ const newestLts = options.find((e) => e.isLts);
100
+ return (newestLts ?? options[0]).appliance.template;
101
+ }
102
+
103
+ export async function selectUbuntuApplianceFromCatalog(
104
+ credentials: ProxmoxCredentials,
105
+ nodeName: string,
106
+ opts: { currentTemplate?: string } = {},
107
+ ): Promise<TemplateSelectionResult> {
108
+ console.log('\nFetching Proxmox template catalog...');
109
+ const result = await listAvailableAppliances(credentials, nodeName);
110
+
111
+ if (!result.success) {
112
+ console.log(`⚠ Could not fetch template catalog from Proxmox: ${result.message}`);
113
+ console.log(' You can still configure the service by entering a template filename manually.');
114
+ console.log(
115
+ ' After saving, run `pveam download <storage> <template>` on the Proxmox host before deploying.',
116
+ );
117
+
118
+ const manual = await promptText({
119
+ message: 'Template filename:',
120
+ placeholder: 'ubuntu-24.04-standard_24.04-2_amd64.tar.zst',
121
+ validate: validateRequired('Template filename'),
122
+ });
123
+
124
+ if (typeof manual !== 'string' || manual.trim() === '') {
125
+ return { kind: 'cancelled' };
126
+ }
127
+
128
+ return { kind: 'selected', choice: { template: manual.trim(), source: 'manual' } };
129
+ }
130
+
131
+ const options = buildUbuntuOptions(result.data);
132
+
133
+ if (options.length === 0) {
134
+ return {
135
+ kind: 'error',
136
+ message:
137
+ 'Proxmox aplinfo returned no Ubuntu standard templates. Run `pveam update` on the Proxmox host and re-try.',
138
+ };
139
+ }
140
+
141
+ const initialValue = pickInitialTemplate(options, opts.currentTemplate);
142
+
143
+ const selection = await p.select<string>({
144
+ message: 'Default Ubuntu template:',
145
+ options: options.map((e) => ({
146
+ value: e.appliance.template,
147
+ label: `Ubuntu ${e.major}.${String(e.minor).padStart(2, '0')}${e.isLts ? ' LTS' : ''}`,
148
+ hint: e.isLts
149
+ ? `revision ${e.appliance.version}`
150
+ : `revision ${e.appliance.version} · non-LTS`,
151
+ })),
152
+ initialValue,
153
+ });
154
+
155
+ if (p.isCancel(selection)) {
156
+ return { kind: 'cancelled' };
157
+ }
158
+
159
+ return { kind: 'selected', choice: { template: selection, source: 'catalog' } };
160
+ }
161
+
162
+ export interface ApplianceDownloadOutcome {
163
+ /** True iff the template ended up available in the chosen storage. */
164
+ ready: boolean;
165
+ /**
166
+ * One of:
167
+ * 'started-failed' — POST to aplinfo failed (Proxmox rejected the request).
168
+ * 'task-failed' — Download started but pveam exited non-OK (typically exit 8 = HTTP error).
169
+ * 'timeout' — Polling exhausted the budget without seeing 'stopped'.
170
+ * 'ok' — pveam exited 0 and template should now exist in storage.
171
+ */
172
+ reason: 'started-failed' | 'task-failed' | 'timeout' | 'ok';
173
+ /** The pveam exit status when reason='task-failed', or null otherwise. */
174
+ exitStatus?: string;
175
+ /** The error message from Proxmox when reason='started-failed'. */
176
+ startError?: string;
177
+ }
178
+
179
+ export interface RunApplianceDownloadInput {
180
+ credentials: ProxmoxCredentials;
181
+ targetNode: string;
182
+ templateStorage: string;
183
+ templateFilename: string;
184
+ /** Polling budget in 5s ticks; defaults to 60 (= 5 minutes). */
185
+ maxAttempts?: number;
186
+ /** Override the default 5000ms tick — primarily to keep tests fast. */
187
+ pollIntervalMs?: number;
188
+ }
189
+
190
+ /**
191
+ * Trigger a `pveam download` on the Proxmox host and poll until it finishes.
192
+ * Drives a FuelGauge for user-visible progress. Distinguishes real failures
193
+ * (Proxmox rejected the start, or pveam exited non-OK) from timeout, so the
194
+ * caller can render an accurate next-steps message instead of always blaming
195
+ * "5-minute timeout" for any non-success path (the bug that motivated this
196
+ * refactor).
197
+ */
198
+ export async function runApplianceDownload(
199
+ input: RunApplianceDownloadInput,
200
+ ): Promise<ApplianceDownloadOutcome> {
201
+ const {
202
+ credentials,
203
+ targetNode,
204
+ templateStorage,
205
+ templateFilename,
206
+ maxAttempts = 60,
207
+ pollIntervalMs = 5000,
208
+ } = input;
209
+
210
+ const startResult = await downloadAppliance(
211
+ credentials,
212
+ targetNode,
213
+ templateStorage,
214
+ templateFilename,
215
+ );
216
+
217
+ if (!startResult.success) {
218
+ console.log(`\n✗ Failed to start download: ${startResult.message}`);
219
+ return { ready: false, reason: 'started-failed', startError: startResult.message };
220
+ }
221
+
222
+ const upid = startResult.data;
223
+ const gauge = new FuelGauge(`Downloading ${templateFilename}`);
224
+ gauge.start();
225
+
226
+ let attempts = 0;
227
+ while (attempts < maxAttempts) {
228
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
229
+ attempts++;
230
+
231
+ const statusResult = await checkTaskStatus(credentials, targetNode, upid);
232
+ if (!statusResult.success) {
233
+ gauge.addOutput(`Waiting for status... (${attempts * 5}s elapsed)`);
234
+ continue;
235
+ }
236
+
237
+ if (statusResult.data.status !== 'stopped') {
238
+ gauge.addOutput(`Status: ${statusResult.data.status} (${attempts * 5}s elapsed)`);
239
+ continue;
240
+ }
241
+
242
+ const exit = statusResult.data.exitstatus;
243
+ if (exit === 'OK') {
244
+ gauge.stop(true);
245
+ return { ready: true, reason: 'ok' };
246
+ }
247
+
248
+ gauge.stop(false);
249
+ console.log(`\n✗ pveam download failed: ${exit ?? 'unknown exit status'}`);
250
+ return { ready: false, reason: 'task-failed', exitStatus: exit ?? undefined };
251
+ }
252
+
253
+ gauge.stop(false);
254
+ console.log(
255
+ `\n✗ Template download did not complete within ${(maxAttempts * pollIntervalMs) / 1000}s`,
256
+ );
257
+ return { ready: false, reason: 'timeout' };
258
+ }