@celilo/cli 0.3.16 → 0.3.18

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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/api-clients/proxmox.ts +77 -45
  3. package/src/cli/command-registry.ts +23 -35
  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-routing.test.ts +52 -0
  7. package/src/cli/commands/module-import.ts +70 -27
  8. package/src/cli/commands/module-publish.test.ts +3 -90
  9. package/src/cli/commands/module-publish.ts +14 -118
  10. package/src/cli/commands/proxmox-template-selection.test.ts +150 -0
  11. package/src/cli/commands/proxmox-template-selection.ts +258 -0
  12. package/src/cli/commands/service-add-proxmox.ts +49 -127
  13. package/src/cli/commands/service-reconfigure.ts +36 -79
  14. package/src/cli/commands/service-verify.ts +20 -79
  15. package/src/cli/commands/{doctor.test.ts → system-doctor.test.ts} +1 -1
  16. package/src/cli/commands/{doctor.ts → system-doctor.ts} +93 -18
  17. package/src/cli/commands/system-update.ts +1 -1
  18. package/src/cli/completion.ts +29 -8
  19. package/src/cli/index.ts +25 -30
  20. package/src/manifest/schema.ts +9 -1
  21. package/src/module/import.ts +4 -2
  22. package/src/registry/client.ts +14 -1
  23. package/src/services/bus-interview.ts +13 -1
  24. package/src/services/bus-secret-flow.test.ts +94 -0
  25. package/src/services/config-interview.ts +66 -6
  26. package/src/services/module-deploy.ts +19 -1
  27. package/src/services/module-validator/capability-versions.test.ts +90 -0
  28. package/src/services/module-validator/capability-versions.ts +115 -0
  29. package/src/services/module-validator/contract-version.test.ts +24 -0
  30. package/src/services/module-validator/contract-version.ts +69 -0
  31. package/src/services/module-validator/git-hygiene.test.ts +141 -0
  32. package/src/services/module-validator/git-hygiene.ts +144 -0
  33. package/src/services/module-validator/index.test.ts +67 -0
  34. package/src/services/module-validator/index.ts +74 -0
  35. package/src/services/module-validator/manifest-schema.ts +42 -0
  36. package/src/services/module-validator/types.ts +43 -0
  37. package/src/services/module-validator/typescript-build.test.ts +58 -0
  38. package/src/services/module-validator/typescript-build.ts +115 -0
  39. package/src/services/module-validator/workspace-deps.test.ts +137 -0
  40. package/src/services/module-validator/workspace-deps.ts +187 -0
  41. package/src/services/terminal-responder.ts +75 -0
  42. package/src/system/prereqs.test.ts +374 -0
  43. package/src/system/prereqs.ts +377 -0
@@ -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
+ }
@@ -7,10 +7,6 @@ import * as p from '@clack/prompts';
7
7
  import {
8
8
  buildProxmoxApiUrl,
9
9
  buildTemplatePath,
10
- buildTemplateUrl,
11
- checkTaskStatus,
12
- downloadTemplate,
13
- extractTemplateFilename,
14
10
  listAvailableTemplates,
15
11
  listNodeStorage,
16
12
  } from '../../api-clients/proxmox';
@@ -20,10 +16,13 @@ import {
20
16
  testConnection as testServiceConnection,
21
17
  updateVerificationStatus,
22
18
  } from '../../services/container-service';
23
- import { FuelGauge } from '../fuel-gauge';
24
19
  import { celiloIntro, celiloOutro, promptPassword, promptText } from '../prompts';
25
20
  import type { CommandResult } from '../types';
26
21
  import { validateRequired } from '../validators';
22
+ import {
23
+ runApplianceDownload,
24
+ selectUbuntuApplianceFromCatalog,
25
+ } from './proxmox-template-selection';
27
26
 
28
27
  /**
29
28
  * Handle service add proxmox command
@@ -123,35 +122,19 @@ export async function handleServiceAddProxmox(
123
122
  validate: validateRequired('Storage'),
124
123
  });
125
124
 
126
- const ubuntuVersion = await p.select({
127
- message: 'Ubuntu LTS version for default template:',
128
- options: [
129
- { value: '24.04', label: 'Ubuntu 24.04 LTS (Noble Numbat)' },
130
- { value: '22.04', label: 'Ubuntu 22.04 LTS (Jammy Jellyfish)', hint: 'Recommended' },
131
- { value: '20.04', label: 'Ubuntu 20.04 LTS (Focal Fossa)' },
132
- ],
133
- initialValue: '22.04',
134
- });
135
-
136
- if (p.isCancel(ubuntuVersion)) {
137
- p.cancel('Operation cancelled');
138
- return { success: false, error: 'Cancelled by user' };
139
- }
140
-
141
- // Find storage that supports vztmpl content
125
+ // Find storage that supports vztmpl content. We do this BEFORE prompting
126
+ // for a template so the user only ever sees one storage in subsequent
127
+ // messages, and so the saved volid uses the right storage.
128
+ const credentials = {
129
+ api_url: apiUrl,
130
+ api_token_id: apiTokenId,
131
+ api_token_secret: apiTokenSecret,
132
+ };
142
133
  console.log('\nFinding storage for templates...');
143
- const storageListResult = await listNodeStorage(
144
- {
145
- api_url: apiUrl,
146
- api_token_id: apiTokenId,
147
- api_token_secret: apiTokenSecret,
148
- },
149
- targetNode,
150
- );
134
+ const storageListResult = await listNodeStorage(credentials, targetNode);
151
135
 
152
- let templateStorage = 'local'; // Default fallback
136
+ let templateStorage = 'local';
153
137
  if (storageListResult.success) {
154
- // Find first active storage that supports 'vztmpl' content
155
138
  const vztmplStorage = storageListResult.data.find(
156
139
  (s) => s.active && s.enabled && s.content.includes('vztmpl'),
157
140
  );
@@ -163,37 +146,34 @@ export async function handleServiceAddProxmox(
163
146
  }
164
147
  }
165
148
 
166
- // Build template path
167
- const lxcTemplate = buildTemplatePath(templateStorage, ubuntuVersion as string);
168
- const templateFilename = extractTemplateFilename(lxcTemplate);
149
+ // Pick a template from Proxmox's live catalog (replaces the old hardcoded
150
+ // version select that built `-1`-revision URLs by hand).
151
+ const selectionResult = await selectUbuntuApplianceFromCatalog(credentials, targetNode);
152
+ if (selectionResult.kind === 'cancelled') {
153
+ p.cancel('Operation cancelled');
154
+ return { success: false, error: 'Cancelled by user' };
155
+ }
156
+ if (selectionResult.kind === 'error') {
157
+ return { success: false, error: selectionResult.message };
158
+ }
159
+ const templateFilename = selectionResult.choice.template;
160
+ const lxcTemplate = buildTemplatePath(templateStorage, templateFilename);
169
161
 
170
- // Check if template exists
162
+ // Check if the template is already cached in the chosen storage.
171
163
  console.log(`\nChecking if template '${templateFilename}' exists...`);
172
- const templatesResult = await listAvailableTemplates(
173
- {
174
- api_url: apiUrl,
175
- api_token_id: apiTokenId,
176
- api_token_secret: apiTokenSecret,
177
- },
178
- targetNode,
179
- templateStorage,
180
- );
164
+ const templatesResult = await listAvailableTemplates(credentials, targetNode, templateStorage);
181
165
 
182
166
  let templateExists = false;
183
167
  if (templatesResult.success) {
184
168
  templateExists = templatesResult.data.some((t) => t.volid.includes(templateFilename));
185
169
  }
186
170
 
187
- // Save the service FIRST so credentials aren't lost if template download fails
171
+ // Save the service FIRST so credentials aren't lost if the download fails.
188
172
  const service = await addContainerService({
189
173
  name,
190
174
  providerName: 'proxmox',
191
175
  zones: zones as unknown as NetworkZone[],
192
- apiCredentials: {
193
- api_url: apiUrl,
194
- api_token_id: apiTokenId,
195
- api_token_secret: apiTokenSecret,
196
- },
176
+ apiCredentials: credentials,
197
177
  providerConfig: {
198
178
  default_target_node: targetNode,
199
179
  lxc_template: lxcTemplate,
@@ -203,7 +183,6 @@ export async function handleServiceAddProxmox(
203
183
 
204
184
  console.log(`✓ Service '${service.serviceId}' saved\n`);
205
185
 
206
- // Now attempt template download (service is already saved)
207
186
  let templateReady = templateExists;
208
187
 
209
188
  if (!templateExists) {
@@ -220,89 +199,32 @@ export async function handleServiceAddProxmox(
220
199
  }
221
200
 
222
201
  if (shouldDownload) {
223
- const templateUrl = buildTemplateUrl(ubuntuVersion as string);
224
-
225
- const downloadResult = await downloadTemplate(
226
- {
227
- api_url: apiUrl,
228
- api_token_id: apiTokenId,
229
- api_token_secret: apiTokenSecret,
230
- },
202
+ const downloadOutcome = await runApplianceDownload({
203
+ credentials,
231
204
  targetNode,
232
205
  templateStorage,
233
- templateUrl,
234
- );
235
-
236
- if (!downloadResult.success) {
237
- console.log(`\n✗ Failed to start download: ${downloadResult.message}`);
238
- } else {
239
- // Wait for download to complete with fuel-gauge progress
240
- const upid = downloadResult.data;
241
- const gauge = new FuelGauge(`Downloading ${templateFilename}`);
242
- gauge.start();
243
-
244
- let downloadComplete = false;
245
- let attempts = 0;
246
- const maxAttempts = 60;
247
-
248
- while (!downloadComplete && attempts < maxAttempts) {
249
- await new Promise((resolve) => setTimeout(resolve, 5000));
250
-
251
- const statusResult = await checkTaskStatus(
252
- {
253
- api_url: apiUrl,
254
- api_token_id: apiTokenId,
255
- api_token_secret: apiTokenSecret,
256
- },
257
- targetNode,
258
- upid,
259
- );
260
-
261
- if (statusResult.success) {
262
- if (statusResult.data.status === 'stopped') {
263
- if (statusResult.data.exitstatus === 'OK') {
264
- downloadComplete = true;
265
- templateReady = true;
266
- gauge.stop(true);
267
- } else {
268
- gauge.stop(false);
269
- console.log(`\n✗ Template download failed: ${statusResult.data.exitstatus}`);
270
- break;
271
- }
272
- } else {
273
- gauge.addOutput(`Status: ${statusResult.data.status} (${attempts * 5}s elapsed)`);
274
- }
275
- } else {
276
- gauge.addOutput(`Waiting for status... (${attempts * 5}s elapsed)`);
277
- }
278
-
279
- attempts++;
280
- }
281
-
282
- if (!downloadComplete && !templateReady) {
283
- gauge.stop(false);
284
- console.log('\n✗ Template download timed out after 5 minutes');
285
- }
286
- }
287
-
288
- if (!templateReady) {
289
- console.log(
290
- '\nTemplate is not available. The service has been saved but needs a template.\n',
291
- );
292
- console.log('Next steps:');
293
- console.log(
294
- ` 1. Try a different version: celilo service reconfigure ${service.serviceId}`,
295
- );
296
- console.log(
297
- ` 2. Download manually: ssh root@${ipAddress} pveam download ${templateStorage} ${templateFilename}`,
298
- );
299
- console.log(` 3. Then verify: celilo service verify ${service.serviceId}`);
300
- }
206
+ templateFilename,
207
+ });
208
+ templateReady = downloadOutcome.ready;
301
209
  } else {
302
210
  console.log(
303
211
  '\nTemplate not downloaded. Service saved but will need a template before deployment.',
304
212
  );
305
213
  }
214
+
215
+ if (!templateReady) {
216
+ console.log(
217
+ '\nTemplate is not available. The service has been saved but needs a template.\n',
218
+ );
219
+ console.log('Next steps:');
220
+ console.log(
221
+ ` 1. Try a different version: celilo service reconfigure ${service.serviceId}`,
222
+ );
223
+ console.log(
224
+ ` 2. Download manually: ssh root@${ipAddress} pveam download ${templateStorage} ${templateFilename}`,
225
+ );
226
+ console.log(` 3. Then verify: celilo service verify ${service.serviceId}`);
227
+ }
306
228
  } else {
307
229
  console.log(`✓ Template '${templateFilename}' found`);
308
230
  }
@@ -5,29 +5,24 @@
5
5
 
6
6
  import * as p from '@clack/prompts';
7
7
  import {
8
+ type ProxmoxCredentials,
8
9
  buildTemplatePath,
9
- buildTemplateUrl,
10
- checkTaskStatus,
11
- downloadTemplate,
12
10
  extractTemplateFilename,
13
11
  listAvailableTemplates,
14
12
  listNodeStorage,
15
13
  } from '../../api-clients/proxmox';
16
14
  import {
17
- getContainerServiceByName,
15
+ getContainerServiceByServiceId,
18
16
  getServiceCredentials,
19
17
  updateServiceProviderConfig,
20
18
  } from '../../services/container-service';
21
- import { FuelGauge } from '../fuel-gauge';
22
19
  import { celiloIntro, celiloOutro, promptText } from '../prompts';
23
20
  import type { CommandResult } from '../types';
24
21
  import { validateRequired } from '../validators';
25
-
26
- interface ProxmoxCredentials {
27
- api_url: string;
28
- api_token_id: string;
29
- api_token_secret: string;
30
- }
22
+ import {
23
+ runApplianceDownload,
24
+ selectUbuntuApplianceFromCatalog,
25
+ } from './proxmox-template-selection';
31
26
 
32
27
  interface ProxmoxProviderConfig {
33
28
  default_target_node: string;
@@ -48,7 +43,7 @@ export async function handleServiceReconfigure(
48
43
  }
49
44
 
50
45
  try {
51
- const service = await getContainerServiceByName(serviceId);
46
+ const service = await getContainerServiceByServiceId(serviceId);
52
47
  if (!service) {
53
48
  return { success: false, error: `Service '${serviceId}' not found` };
54
49
  }
@@ -87,27 +82,8 @@ export async function handleServiceReconfigure(
87
82
  validate: validateRequired('Storage'),
88
83
  });
89
84
 
90
- // Detect current Ubuntu version from template filename
91
- const currentTemplateFile = extractTemplateFilename(currentConfig.lxc_template);
92
- const versionMatch = currentTemplateFile.match(/ubuntu-(\d+\.\d+)-/);
93
- const currentVersion = versionMatch?.[1] || '22.04';
94
-
95
- const ubuntuVersion = await p.select({
96
- message: 'Ubuntu LTS version for default template:',
97
- options: [
98
- { value: '24.04', label: 'Ubuntu 24.04 LTS (Noble Numbat)' },
99
- { value: '22.04', label: 'Ubuntu 22.04 LTS (Jammy Jellyfish)' },
100
- { value: '20.04', label: 'Ubuntu 20.04 LTS (Focal Fossa)' },
101
- ],
102
- initialValue: currentVersion,
103
- });
104
-
105
- if (p.isCancel(ubuntuVersion)) {
106
- p.cancel('Operation cancelled');
107
- return { success: false, error: 'Cancelled by user' };
108
- }
109
-
110
- // Find template storage
85
+ // Find template storage. Done before catalog selection so the user only
86
+ // ever sees the chosen storage in subsequent messages.
111
87
  console.log('\nFinding storage for templates...');
112
88
  const storageListResult = await listNodeStorage(credentials, targetNode);
113
89
 
@@ -122,10 +98,22 @@ export async function handleServiceReconfigure(
122
98
  }
123
99
  }
124
100
 
125
- const lxcTemplate = buildTemplatePath(templateStorage, ubuntuVersion as string);
126
- const templateFilename = extractTemplateFilename(lxcTemplate);
101
+ // Pick a template from Proxmox's live catalog. Pre-selects the family
102
+ // matching the existing volid (e.g. user has 24.04 -1 cached, mirror has -2).
103
+ const selectionResult = await selectUbuntuApplianceFromCatalog(credentials, targetNode, {
104
+ currentTemplate: extractTemplateFilename(currentConfig.lxc_template),
105
+ });
106
+ if (selectionResult.kind === 'cancelled') {
107
+ p.cancel('Operation cancelled');
108
+ return { success: false, error: 'Cancelled by user' };
109
+ }
110
+ if (selectionResult.kind === 'error') {
111
+ return { success: false, error: selectionResult.message };
112
+ }
113
+ const templateFilename = selectionResult.choice.template;
114
+ const lxcTemplate = buildTemplatePath(templateStorage, templateFilename);
127
115
 
128
- // Check if new template exists
116
+ // Check if new template exists in storage.
129
117
  console.log(`\nChecking if template '${templateFilename}' exists...`);
130
118
  const templatesResult = await listAvailableTemplates(credentials, targetNode, templateStorage);
131
119
 
@@ -147,54 +135,23 @@ export async function handleServiceReconfigure(
147
135
  '\nTemplate not downloaded. Service will be updated but may fail verification.',
148
136
  );
149
137
  } else {
150
- const templateUrl = buildTemplateUrl(ubuntuVersion as string);
151
- const downloadResult = await downloadTemplate(
138
+ const outcome = await runApplianceDownload({
152
139
  credentials,
153
140
  targetNode,
154
141
  templateStorage,
155
- templateUrl,
156
- );
157
-
158
- if (!downloadResult.success) {
159
- return {
160
- success: false,
161
- error: `Failed to download template: ${downloadResult.message}\n\nTroubleshooting:\n 1. SSH into your Proxmox host and test: curl -I https://download.proxmox.com\n 2. Try downloading manually: pveam download ${templateStorage} ${templateFilename}\n 3. Check DNS and firewall settings on the Proxmox host`,
162
- };
163
- }
164
-
165
- const upid = downloadResult.data;
166
- const gauge = new FuelGauge(`Downloading ${templateFilename}`);
167
- gauge.start();
168
-
169
- let downloadComplete = false;
170
- let attempts = 0;
171
-
172
- while (!downloadComplete && attempts < 60) {
173
- await new Promise((resolve) => setTimeout(resolve, 5000));
174
- const statusResult = await checkTaskStatus(credentials, targetNode, upid);
175
-
176
- if (statusResult.success && statusResult.data.status === 'stopped') {
177
- if (statusResult.data.exitstatus === 'OK') {
178
- downloadComplete = true;
179
- gauge.stop(true);
180
- } else {
181
- gauge.stop(false);
182
- return {
183
- success: false,
184
- error: `Template download failed: ${statusResult.data.exitstatus}\n\nThis usually means the Proxmox host cannot reach download.proxmox.com.\n\nTroubleshooting:\n 1. Try a different Ubuntu version: celilo service reconfigure ${serviceId}\n 2. Download manually: pveam download ${templateStorage} ${templateFilename}\n 3. Check DNS and firewall settings on the Proxmox host`,
185
- };
186
- }
187
- } else {
188
- gauge.addOutput(`Status: downloading... (${attempts * 5}s elapsed)`);
189
- }
190
- attempts++;
191
- }
192
-
193
- if (!downloadComplete) {
194
- gauge.stop(false);
142
+ templateFilename,
143
+ });
144
+
145
+ if (!outcome.ready) {
146
+ const detail =
147
+ outcome.reason === 'task-failed'
148
+ ? `pveam download exited with status: ${outcome.exitStatus ?? 'unknown'}`
149
+ : outcome.reason === 'started-failed'
150
+ ? `Proxmox rejected the download request: ${outcome.startError ?? 'unknown error'}`
151
+ : 'Template download did not complete in time';
195
152
  return {
196
153
  success: false,
197
- error: 'Template download timed out after 5 minutes.',
154
+ error: `${detail}\n\nTroubleshooting:\n 1. SSH into your Proxmox host and run: pveam update && pveam download ${templateStorage} ${templateFilename}\n 2. Re-try: celilo service reconfigure ${serviceId}\n 3. Check DNS and firewall settings on the Proxmox host`,
198
155
  };
199
156
  }
200
157
  }