@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
@@ -3,7 +3,6 @@
3
3
  * Configure a Proxmox container service
4
4
  */
5
5
 
6
- import * as p from '@clack/prompts';
7
6
  import {
8
7
  buildProxmoxApiUrl,
9
8
  buildTemplatePath,
@@ -11,18 +10,31 @@ import {
11
10
  listNodeStorage,
12
11
  } from '../../api-clients/proxmox';
13
12
  import type { NetworkZone } from '../../db/schema';
13
+ import {
14
+ askConfirm,
15
+ askMultiselect,
16
+ askSelect,
17
+ askText,
18
+ withInterviewSession,
19
+ } from '../../services/bus-interview';
14
20
  import {
15
21
  addContainerService,
16
22
  testConnection as testServiceConnection,
17
23
  updateVerificationStatus,
18
24
  } from '../../services/container-service';
19
- import { celiloIntro, celiloOutro, promptPassword, promptText } from '../prompts';
25
+ import { celiloIntro, celiloOutro } from '../prompts';
26
+ import { resolveServiceCredential } from '../service-credential';
20
27
  import type { CommandResult } from '../types';
21
- import { validateRequired } from '../validators';
22
28
  import {
23
29
  runApplianceDownload,
24
30
  selectUbuntuApplianceFromCatalog,
25
31
  } from './proxmox-template-selection';
32
+ import { buildCloudInitTemplate, detectExistingVmTemplates } from './proxmox-vm-template-build';
33
+
34
+ // Sentinel VM-template choices returned by the select. Any other value is an
35
+ // existing template name.
36
+ const VM_BUILD = '__build__';
37
+ const VM_SKIP = '__skip__';
26
38
 
27
39
  /**
28
40
  * Handle service add proxmox command
@@ -32,233 +44,295 @@ import {
32
44
  */
33
45
  export async function handleServiceAddProxmox(
34
46
  _args: string[],
35
- _flags: Record<string, boolean | string> = {},
47
+ flags: Record<string, boolean | string> = {},
36
48
  ): Promise<CommandResult> {
37
- try {
38
- celiloIntro('Add Proxmox Container Service');
39
-
40
- // Prompt for service configuration
41
- const name = await promptText({
42
- message: 'Human-readable name:',
43
- defaultValue: 'Proxmox Home Lab',
44
- placeholder: 'Proxmox Home Lab',
45
- validate: validateRequired('Service name'),
46
- });
47
-
48
- // Prompt for zones (multi-select)
49
- const zones = await p.multiselect<NetworkZone>({
50
- message: 'Zones this service can provision to:',
51
- options: [
52
- { value: 'internal' as NetworkZone, label: 'internal' },
53
- { value: 'dmz' as NetworkZone, label: 'dmz' },
54
- { value: 'app' as NetworkZone, label: 'app' },
55
- { value: 'secure' as NetworkZone, label: 'secure' },
56
- { value: 'external' as NetworkZone, label: 'external' },
57
- ],
58
- required: true,
59
- initialValues: ['internal', 'dmz', 'app', 'secure'] as NetworkZone[], // Common defaults
60
- });
61
-
62
- if (p.isCancel(zones)) {
63
- p.cancel('Operation cancelled');
64
- return { success: false, error: 'Cancelled by user' };
65
- }
49
+ // Non-secret prompts route through the bus interview (ISS-0127); the API
50
+ // token id + secret are service credentials that travel by flag/env only
51
+ // (D7). `withInterviewSession` renders bus questions locally on a TTY.
52
+ const scope = 'service-add:proxmox';
53
+
54
+ return withInterviewSession(async () => {
55
+ try {
56
+ celiloIntro('Add Proxmox Container Service');
57
+
58
+ const name = await askText({
59
+ scope,
60
+ key: 'name',
61
+ message: 'Human-readable name',
62
+ defaultValue: 'Proxmox Home Lab',
63
+ placeholder: 'Proxmox Home Lab',
64
+ required: true,
65
+ });
66
+
67
+ const zones = await askMultiselect({
68
+ scope,
69
+ key: 'zones',
70
+ message: 'Zones this service can provision to',
71
+ options: [
72
+ { value: 'internal', label: 'internal' },
73
+ { value: 'dmz', label: 'dmz' },
74
+ { value: 'app', label: 'app' },
75
+ { value: 'secure', label: 'secure' },
76
+ { value: 'external', label: 'external' },
77
+ ],
78
+ required: true,
79
+ });
80
+
81
+ console.log('\nProxmox Configuration');
82
+ console.log('─────────────────────');
83
+
84
+ const ipAddress = await askText({
85
+ scope,
86
+ key: 'ip_address',
87
+ message: 'Proxmox IP address',
88
+ placeholder: 'e.g., 192.168.1.100 or proxmox.local',
89
+ required: true,
90
+ });
91
+
92
+ const port = await askText({
93
+ scope,
94
+ key: 'port',
95
+ message: 'Proxmox API port',
96
+ defaultValue: '8006',
97
+ placeholder: '8006',
98
+ required: true,
99
+ type: 'integer',
100
+ pattern: '^[0-9]+$',
101
+ });
102
+
103
+ // Build the full API URL
104
+ const apiUrl = buildProxmoxApiUrl(ipAddress, Number(port));
105
+
106
+ // API token ID + secret are service credentials — flag/env only (D7).
107
+ const apiTokenId = await resolveServiceCredential({
108
+ field: 'API token ID',
109
+ flag: 'api-token-id',
110
+ envVar: 'PROXMOX_API_TOKEN_ID',
111
+ flagValue: flags['api-token-id'],
112
+ });
113
+
114
+ const apiTokenSecret = await resolveServiceCredential({
115
+ field: 'API token secret',
116
+ flag: 'api-token-secret',
117
+ envVar: 'PROXMOX_API_TOKEN_SECRET',
118
+ flagValue: flags['api-token-secret'],
119
+ });
66
120
 
67
- console.log('\nProxmox Configuration');
68
- console.log('─────────────────────');
69
-
70
- const ipAddress = await promptText({
71
- message: 'Proxmox IP address:',
72
- placeholder: 'e.g., 192.168.1.100 or proxmox.local',
73
- validate: validateRequired('IP address'),
74
- });
75
-
76
- const port = await promptText({
77
- message: 'Proxmox API port:',
78
- defaultValue: '8006',
79
- placeholder: '8006',
80
- validate: (value): string | Error | undefined => {
81
- // validateRequired returns undefined for valid inputs (including undefined with defaults)
82
- const requiredError = validateRequired('Port')(value);
83
- if (requiredError !== undefined) return requiredError;
84
-
85
- // If value is undefined, default will be used - that's valid
86
- if (value === undefined) return undefined;
87
-
88
- const portNum = Number(value);
89
- if (Number.isNaN(portNum) || portNum < 1 || portNum > 65535) {
90
- return 'Port must be a number between 1 and 65535';
121
+ const targetNode = await askText({
122
+ scope,
123
+ key: 'default_target_node',
124
+ message: 'Default target node',
125
+ defaultValue: 'pve',
126
+ placeholder: 'pve',
127
+ required: true,
128
+ });
129
+
130
+ const storage = await askText({
131
+ scope,
132
+ key: 'storage',
133
+ message: 'Default storage',
134
+ defaultValue: 'local-lvm',
135
+ placeholder: 'local-lvm',
136
+ required: true,
137
+ });
138
+
139
+ // Build credentials here needed for VM template detection below and
140
+ // for LXC template catalog lookup further down.
141
+ const credentials = {
142
+ api_url: apiUrl,
143
+ api_token_id: apiTokenId,
144
+ api_token_secret: apiTokenSecret,
145
+ };
146
+
147
+ // VM template for `requires.system.type: vm` modules.
148
+ // Auto-detect existing templates on the node; offer to build if none found.
149
+ // See v2/PROXMOX_VM_TEMPLATE.md.
150
+ let vmTemplate: string | undefined;
151
+ const existingTemplates = await detectExistingVmTemplates(credentials, targetNode);
152
+ if (existingTemplates.length > 0) {
153
+ const templateChoice = await askSelect({
154
+ scope,
155
+ key: 'vm_template',
156
+ message: 'VM template for VM-type modules',
157
+ options: [
158
+ ...existingTemplates.map((t) => ({
159
+ value: t.name,
160
+ label: `${t.name} (VMID ${t.vmid})`,
161
+ })),
162
+ { value: VM_BUILD, label: 'Build a new Ubuntu 24.04 cloud-init template now' },
163
+ { value: VM_SKIP, label: 'Skip — I only need LXC modules' },
164
+ ],
165
+ });
166
+ if (templateChoice === VM_BUILD) {
167
+ vmTemplate = await buildCloudInitTemplate({
168
+ credentials,
169
+ nodeName: targetNode,
170
+ diskStorage: storage,
171
+ });
172
+ } else if (templateChoice !== VM_SKIP) {
173
+ vmTemplate = templateChoice;
91
174
  }
92
- return undefined;
93
- },
94
- });
95
-
96
- // Build the full API URL
97
- const apiUrl = buildProxmoxApiUrl(ipAddress, Number(port));
98
-
99
- const apiTokenId = await promptText({
100
- message: 'API Token ID:',
101
- defaultValue: 'root@pam!celilo',
102
- placeholder: 'root@pam!celilo',
103
- validate: validateRequired('API token ID'),
104
- });
105
-
106
- const apiTokenSecret = await promptPassword({
107
- message: 'API Token Secret:',
108
- validate: validateRequired('API token secret'),
109
- });
110
-
111
- const targetNode = await promptText({
112
- message: 'Default target node:',
113
- defaultValue: 'pve',
114
- placeholder: 'pve',
115
- validate: validateRequired('Target node'),
116
- });
117
-
118
- const storage = await promptText({
119
- message: 'Default storage:',
120
- defaultValue: 'local-lvm',
121
- placeholder: 'local-lvm',
122
- validate: validateRequired('Storage'),
123
- });
124
-
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
- };
133
- console.log('\nFinding storage for templates...');
134
- const storageListResult = await listNodeStorage(credentials, targetNode);
135
-
136
- let templateStorage = 'local';
137
- if (storageListResult.success) {
138
- const vztmplStorage = storageListResult.data.find(
139
- (s) => s.active && s.enabled && s.content.includes('vztmpl'),
140
- );
141
- if (vztmplStorage) {
142
- templateStorage = vztmplStorage.storage;
143
- console.log(`✓ Using storage '${templateStorage}' for templates`);
144
175
  } else {
145
- console.log(`⚠ No storage found with 'vztmpl' support, using '${templateStorage}'`);
176
+ const shouldBuild = await askConfirm({
177
+ scope,
178
+ key: 'vm_template_build',
179
+ message:
180
+ 'No VM templates found. Build a Ubuntu 24.04 cloud-init template now? (~2–10 min)',
181
+ defaultValue: false,
182
+ });
183
+ if (shouldBuild) {
184
+ vmTemplate = await buildCloudInitTemplate({
185
+ credentials,
186
+ nodeName: targetNode,
187
+ diskStorage: storage,
188
+ });
189
+ } else {
190
+ const manualEntry = await askText({
191
+ scope,
192
+ key: 'vm_template_manual',
193
+ message: 'VM template name (optional — leave blank to skip)',
194
+ placeholder: 'e.g., ubuntu-2404-cloudinit',
195
+ });
196
+ vmTemplate = manualEntry.trim() || undefined;
197
+ }
146
198
  }
147
- }
148
-
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);
161
199
 
162
- // Check if the template is already cached in the chosen storage.
163
- console.log(`\nChecking if template '${templateFilename}' exists...`);
164
- const templatesResult = await listAvailableTemplates(credentials, targetNode, templateStorage);
200
+ // Find storage that supports vztmpl content. We do this BEFORE prompting
201
+ // for a template so the user only ever sees one storage in subsequent
202
+ // messages, and so the saved volid uses the right storage.
203
+ console.log('\nFinding storage for templates...');
204
+ const storageListResult = await listNodeStorage(credentials, targetNode);
165
205
 
166
- let templateExists = false;
167
- if (templatesResult.success) {
168
- templateExists = templatesResult.data.some((t) => t.volid.includes(templateFilename));
169
- }
206
+ let templateStorage = 'local';
207
+ if (storageListResult.success) {
208
+ const vztmplStorage = storageListResult.data.find(
209
+ (s) => s.active && s.enabled && s.content.includes('vztmpl'),
210
+ );
211
+ if (vztmplStorage) {
212
+ templateStorage = vztmplStorage.storage;
213
+ console.log(`✓ Using storage '${templateStorage}' for templates`);
214
+ } else {
215
+ console.log(`⚠ No storage found with 'vztmpl' support, using '${templateStorage}'`);
216
+ }
217
+ }
170
218
 
171
- // Save the service FIRST so credentials aren't lost if the download fails.
172
- const service = await addContainerService({
173
- name,
174
- providerName: 'proxmox',
175
- zones: zones as unknown as NetworkZone[],
176
- apiCredentials: credentials,
177
- providerConfig: {
178
- default_target_node: targetNode,
179
- lxc_template: lxcTemplate,
180
- storage,
181
- },
182
- });
183
-
184
- console.log(`✓ Service '${service.serviceId}' saved\n`);
185
-
186
- let templateReady = templateExists;
187
-
188
- if (!templateExists) {
189
- console.log(`✗ Template '${templateFilename}' not found in storage '${templateStorage}'`);
190
-
191
- const shouldDownload = await p.confirm({
192
- message: 'Download template now?',
193
- initialValue: true,
219
+ // Pick a template from Proxmox's live catalog (replaces the old hardcoded
220
+ // version select that built `-1`-revision URLs by hand).
221
+ const selectionResult = await selectUbuntuApplianceFromCatalog(credentials, targetNode, {
222
+ scope,
194
223
  });
195
-
196
- if (p.isCancel(shouldDownload)) {
197
- p.cancel('Operation cancelled');
224
+ if (selectionResult.kind === 'cancelled') {
198
225
  return { success: false, error: 'Cancelled by user' };
199
226
  }
227
+ if (selectionResult.kind === 'error') {
228
+ return { success: false, error: selectionResult.message };
229
+ }
230
+ const templateFilename = selectionResult.choice.template;
231
+ const lxcTemplate = buildTemplatePath(templateStorage, templateFilename);
232
+
233
+ // Check if the template is already cached in the chosen storage.
234
+ console.log(`\nChecking if template '${templateFilename}' exists...`);
235
+ const templatesResult = await listAvailableTemplates(
236
+ credentials,
237
+ targetNode,
238
+ templateStorage,
239
+ );
240
+
241
+ let templateExists = false;
242
+ if (templatesResult.success) {
243
+ templateExists = templatesResult.data.some((t) => t.volid.includes(templateFilename));
244
+ }
245
+
246
+ // Save the service FIRST so credentials aren't lost if the download fails.
247
+ const service = await addContainerService({
248
+ name,
249
+ providerName: 'proxmox',
250
+ zones: zones as unknown as NetworkZone[],
251
+ apiCredentials: credentials,
252
+ providerConfig: {
253
+ default_target_node: targetNode,
254
+ lxc_template: lxcTemplate,
255
+ storage,
256
+ ...(vmTemplate ? { vm_template: vmTemplate } : {}),
257
+ },
258
+ });
200
259
 
201
- if (shouldDownload) {
202
- const downloadOutcome = await runApplianceDownload({
203
- credentials,
204
- targetNode,
205
- templateStorage,
206
- templateFilename,
260
+ console.log(`✓ Service '${service.serviceId}' saved\n`);
261
+
262
+ let templateReady = templateExists;
263
+
264
+ if (!templateExists) {
265
+ console.log(`✗ Template '${templateFilename}' not found in storage '${templateStorage}'`);
266
+
267
+ const shouldDownload = await askConfirm({
268
+ scope,
269
+ key: 'download_template',
270
+ message: 'Download template now?',
271
+ defaultValue: true,
207
272
  });
208
- templateReady = downloadOutcome.ready;
273
+
274
+ if (shouldDownload) {
275
+ const downloadOutcome = await runApplianceDownload({
276
+ credentials,
277
+ targetNode,
278
+ templateStorage,
279
+ templateFilename,
280
+ });
281
+ templateReady = downloadOutcome.ready;
282
+ } else {
283
+ console.log(
284
+ '\nTemplate not downloaded. Service saved but will need a template before deployment.',
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
+ }
209
301
  } else {
210
- console.log(
211
- '\nTemplate not downloaded. Service saved but will need a template before deployment.',
212
- );
302
+ console.log(`✓ Template '${templateFilename}' found`);
213
303
  }
214
304
 
215
- if (!templateReady) {
305
+ // Test connection
306
+ console.log('\nTesting connection...');
307
+ const testResult = await testServiceConnection(service);
308
+ await updateVerificationStatus(service.id, testResult);
309
+
310
+ if (!testResult.success) {
311
+ console.log(`✗ Connection test failed: ${testResult.message}`);
216
312
  console.log(
217
- '\nTemplate is not available. The service has been saved but needs a template.\n',
313
+ '\nService saved but not verified. The service will not be used until verification succeeds.',
218
314
  );
219
- console.log('Next steps:');
220
- console.log(
221
- ` 1. Try a different version: celilo service reconfigure ${service.serviceId}`,
315
+
316
+ celiloOutro(
317
+ `Service '${service.serviceId}' (${name}) added but not verified.\n\nNext steps:\n - Fix the connection issue\n - Re-verify: celilo service verify ${service.serviceId}\n - Check status: celilo service list`,
222
318
  );
223
- console.log(
224
- ` 2. Download manually: ssh root@${ipAddress} pveam download ${templateStorage} ${templateFilename}`,
319
+ } else {
320
+ console.log(`✓ ${testResult.message}`);
321
+
322
+ celiloOutro(
323
+ `Service '${service.serviceId}' (${name}) added and verified successfully!\n\nService ID: ${service.serviceId}\n\nNext steps:\n - Generate module: celilo module generate <module-id>\n - List services: celilo service list`,
225
324
  );
226
- console.log(` 3. Then verify: celilo service verify ${service.serviceId}`);
227
325
  }
228
- } else {
229
- console.log(`✓ Template '${templateFilename}' found`);
230
- }
231
326
 
232
- // Test connection
233
- console.log('\nTesting connection...');
234
- const testResult = await testServiceConnection(service);
235
- await updateVerificationStatus(service.id, testResult);
236
-
237
- if (!testResult.success) {
238
- console.log(`✗ Connection test failed: ${testResult.message}`);
239
- console.log(
240
- '\nService saved but not verified. The service will not be used until verification succeeds.',
241
- );
242
-
243
- celiloOutro(
244
- `Service '${service.serviceId}' (${name}) added but not verified.\n\nNext steps:\n - Fix the connection issue\n - Re-verify: celilo service verify ${service.serviceId}\n - Check status: celilo service list`,
245
- );
246
- } else {
247
- console.log(`✓ ${testResult.message}`);
248
-
249
- celiloOutro(
250
- `Service '${service.serviceId}' (${name}) added and verified successfully!\n\nService ID: ${service.serviceId}\n\nNext steps:\n - Generate module: celilo module generate <module-id>\n - List services: celilo service list`,
251
- );
327
+ return {
328
+ success: true,
329
+ message: `Added Proxmox service: ${service.id}`,
330
+ };
331
+ } catch (error) {
332
+ return {
333
+ success: false,
334
+ error: `Failed to add Proxmox service: ${error instanceof Error ? error.message : String(error)}`,
335
+ };
252
336
  }
253
-
254
- return {
255
- success: true,
256
- message: `Added Proxmox service: ${service.id}`,
257
- };
258
- } catch (error) {
259
- return {
260
- success: false,
261
- error: `Failed to add Proxmox service: ${error instanceof Error ? error.message : String(error)}`,
262
- };
263
- }
337
+ });
264
338
  }
@@ -0,0 +1,115 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ type ProxmoxProviderConfig,
4
+ buildVmTemplateChoices,
5
+ resolveReconfiguredProviderConfig,
6
+ } from './service-reconfigure';
7
+
8
+ describe('buildVmTemplateChoices — VM-template reconfigure menu', () => {
9
+ const templates = [
10
+ { vmid: 9000, name: 'ubuntu-2404-cloudinit' },
11
+ { vmid: 9001, name: 'ubuntu-2204-cloudinit' },
12
+ ];
13
+
14
+ test('current set + detected templates: keep first, others (current deduped), build, clear', () => {
15
+ const choices = buildVmTemplateChoices('ubuntu-2404-cloudinit', templates);
16
+ expect(choices.map((c) => c.value)).toEqual([
17
+ '__keep__',
18
+ 'ubuntu-2204-cloudinit', // the other detected template — current is not re-listed
19
+ '__build__',
20
+ '__clear__',
21
+ ]);
22
+ expect(choices[0].label).toBe('Keep current (ubuntu-2404-cloudinit)');
23
+ });
24
+
25
+ test('current unset: no keep/clear; detected templates, build, then skip', () => {
26
+ const choices = buildVmTemplateChoices(undefined, templates);
27
+ expect(choices.map((c) => c.value)).toEqual([
28
+ 'ubuntu-2404-cloudinit',
29
+ 'ubuntu-2204-cloudinit',
30
+ '__build__',
31
+ '__skip__',
32
+ ]);
33
+ });
34
+
35
+ test('current set, no templates detected: keep, build, clear', () => {
36
+ const choices = buildVmTemplateChoices('ubuntu-2404-cloudinit', []);
37
+ expect(choices.map((c) => c.value)).toEqual(['__keep__', '__build__', '__clear__']);
38
+ });
39
+
40
+ test('current unset, no templates detected: build, skip', () => {
41
+ const choices = buildVmTemplateChoices(undefined, []);
42
+ expect(choices.map((c) => c.value)).toEqual(['__build__', '__skip__']);
43
+ });
44
+
45
+ test('a detected template carries its VMID in the label', () => {
46
+ const choices = buildVmTemplateChoices(undefined, [{ vmid: 9002, name: 'debian-12' }]);
47
+ expect(choices[0]).toEqual({ value: 'debian-12', label: 'debian-12 (VMID 9002)' });
48
+ });
49
+ });
50
+
51
+ describe('resolveReconfiguredProviderConfig — drop-bug guard (ISS-0128)', () => {
52
+ const current: ProxmoxProviderConfig = {
53
+ default_target_node: 'pve',
54
+ lxc_template: 'local:vztmpl/ubuntu-24.04.tar.zst',
55
+ storage: 'local-lvm',
56
+ vm_template: 'ubuntu-2404-cloudinit',
57
+ };
58
+
59
+ test('keeping the VM template (edit echoes current) preserves it while applying other edits', () => {
60
+ const result = resolveReconfiguredProviderConfig({
61
+ default_target_node: 'pve2',
62
+ lxc_template: 'local:vztmpl/ubuntu-24.04-2.tar.zst',
63
+ storage: 'fast-lvm',
64
+ vm_template: 'ubuntu-2404-cloudinit', // VM_KEEP resolves to the current value
65
+ });
66
+ expect(result).toEqual({
67
+ default_target_node: 'pve2',
68
+ lxc_template: 'local:vztmpl/ubuntu-24.04-2.tar.zst',
69
+ storage: 'fast-lvm',
70
+ vm_template: 'ubuntu-2404-cloudinit',
71
+ });
72
+ });
73
+
74
+ test('the pre-fix behavior would have dropped vm_template — guard against regressing', () => {
75
+ // Reconfiguring only the LXC fields must NOT lose the VM template: the
76
+ // handler threads currentConfig.vm_template through as the edit value.
77
+ const result = resolveReconfiguredProviderConfig({
78
+ default_target_node: current.default_target_node,
79
+ lxc_template: 'local:vztmpl/new.tar.zst',
80
+ storage: current.storage,
81
+ vm_template: current.vm_template,
82
+ });
83
+ expect(result.vm_template).toBe('ubuntu-2404-cloudinit');
84
+ });
85
+
86
+ test('clearing (undefined edit) removes the field entirely, not sets it undefined', () => {
87
+ const result = resolveReconfiguredProviderConfig({
88
+ default_target_node: 'pve',
89
+ lxc_template: current.lxc_template,
90
+ storage: current.storage,
91
+ vm_template: undefined,
92
+ });
93
+ expect('vm_template' in result).toBe(false);
94
+ });
95
+
96
+ test('setting a new template overwrites the old', () => {
97
+ const result = resolveReconfiguredProviderConfig({
98
+ default_target_node: 'pve',
99
+ lxc_template: current.lxc_template,
100
+ storage: current.storage,
101
+ vm_template: 'debian-12-cloudinit',
102
+ });
103
+ expect(result.vm_template).toBe('debian-12-cloudinit');
104
+ });
105
+
106
+ test('no VM template edit leaves it absent', () => {
107
+ const result = resolveReconfiguredProviderConfig({
108
+ default_target_node: 'pve',
109
+ lxc_template: 'local:vztmpl/x.tar.zst',
110
+ storage: 'local-lvm',
111
+ vm_template: undefined,
112
+ });
113
+ expect('vm_template' in result).toBe(false);
114
+ });
115
+ });