@celilo/cli 0.5.0-alpha.8 → 0.5.0-alpha.9

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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/api-clients/proxmox.test.ts +78 -0
  3. package/src/api-clients/proxmox.ts +96 -1
  4. package/src/cli/command-registry.ts +32 -3
  5. package/src/cli/commands/backup-delete.ts +10 -7
  6. package/src/cli/commands/backup-import.ts +11 -8
  7. package/src/cli/commands/backup-restore.ts +11 -8
  8. package/src/cli/commands/events.ts +8 -3
  9. package/src/cli/commands/machine-add.ts +178 -163
  10. package/src/cli/commands/machine-remove.ts +10 -7
  11. package/src/cli/commands/module-config.test.ts +78 -0
  12. package/src/cli/commands/module-config.ts +18 -3
  13. package/src/cli/commands/module-import.ts +9 -5
  14. package/src/cli/commands/module-remove.ts +20 -9
  15. package/src/cli/commands/module-status.ts +15 -0
  16. package/src/cli/commands/module-upgrade.ts +10 -6
  17. package/src/cli/commands/proxmox-node-list.ts +101 -0
  18. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  19. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  20. package/src/cli/commands/service-add-proxmox.ts +275 -260
  21. package/src/cli/commands/service-reconfigure.ts +171 -153
  22. package/src/cli/commands/service-remove.ts +19 -13
  23. package/src/cli/commands/service-verify.ts +9 -10
  24. package/src/cli/commands/storage-add-local.ts +120 -107
  25. package/src/cli/commands/storage-add-s3.ts +145 -131
  26. package/src/cli/commands/storage-remove.ts +11 -8
  27. package/src/cli/commands/system-init.ts +119 -128
  28. package/src/cli/completion.ts +15 -0
  29. package/src/cli/index.ts +25 -0
  30. package/src/cli/service-credential.ts +54 -0
  31. package/src/services/bus-interview.ts +232 -0
  32. package/src/services/module-config.ts +12 -0
  33. package/src/services/module-deploy.ts +6 -1
  34. package/src/services/placement-reconcile.test.ts +86 -0
  35. package/src/services/placement-reconcile.ts +108 -0
  36. package/src/services/programmatic-responder.ts +34 -0
  37. package/src/services/terminal-responder.ts +113 -0
  38. package/src/templates/generator.test.ts +30 -0
  39. package/src/templates/generator.ts +86 -31
@@ -3,7 +3,6 @@
3
3
  * Re-run the interactive interview for an existing service's provider configuration
4
4
  */
5
5
 
6
- import * as p from '@clack/prompts';
7
6
  import {
8
7
  type ProxmoxCredentials,
9
8
  type ProxmoxVmTemplate,
@@ -12,14 +11,14 @@ import {
12
11
  listAvailableTemplates,
13
12
  listNodeStorage,
14
13
  } from '../../api-clients/proxmox';
14
+ import { askConfirm, askSelect, askText, withInterviewSession } from '../../services/bus-interview';
15
15
  import {
16
16
  getContainerServiceByServiceId,
17
17
  getServiceCredentials,
18
18
  updateServiceProviderConfig,
19
19
  } from '../../services/container-service';
20
- import { celiloIntro, celiloOutro, promptText } from '../prompts';
20
+ import { celiloIntro, celiloOutro } from '../prompts';
21
21
  import type { CommandResult } from '../types';
22
- import { validateRequired } from '../validators';
23
22
  import {
24
23
  runApplianceDownload,
25
24
  selectUbuntuApplianceFromCatalog,
@@ -116,177 +115,196 @@ export async function handleServiceReconfigure(
116
115
  };
117
116
  }
118
117
 
119
- try {
120
- const service = await getContainerServiceByServiceId(serviceId);
121
- if (!service) {
122
- return { success: false, error: `Service '${serviceId}' not found` };
123
- }
118
+ // Every prompt below is a bus interview (ISS-0127), so reconfigure is
119
+ // drivable headlessly via `celilo events respond --values` / `events reply`.
120
+ // `withInterviewSession` runs a terminal-responder when stdin is a TTY so
121
+ // those questions render locally; on a non-TTY run the headless responder
122
+ // answers instead.
123
+ const scope = `service:${serviceId}`;
124
124
 
125
- if (service.providerName !== 'proxmox') {
126
- return {
127
- success: false,
128
- error: `Service reconfigure is currently only supported for Proxmox services (got: ${service.providerName})`,
129
- };
130
- }
125
+ return withInterviewSession(async () => {
126
+ try {
127
+ const service = await getContainerServiceByServiceId(serviceId);
128
+ if (!service) {
129
+ return { success: false, error: `Service '${serviceId}' not found` };
130
+ }
131
131
 
132
- celiloIntro(`Reconfigure Service: ${service.name}`);
132
+ if (service.providerName !== 'proxmox') {
133
+ return {
134
+ success: false,
135
+ error: `Service reconfigure is currently only supported for Proxmox services (got: ${service.providerName})`,
136
+ };
137
+ }
133
138
 
134
- const credentials = (await getServiceCredentials(service.id)) as ProxmoxCredentials;
135
- const currentConfig = service.providerConfig as unknown as ProxmoxProviderConfig;
139
+ celiloIntro(`Reconfigure Service: ${service.name}`);
136
140
 
137
- // Show current configuration
138
- console.log('Current configuration:');
139
- console.log(` Target node: ${currentConfig.default_target_node}`);
140
- console.log(` Storage: ${currentConfig.storage}`);
141
- console.log(` LXC template: ${currentConfig.lxc_template}`);
142
- console.log(` VM template: ${currentConfig.vm_template ?? '(none)'}`);
143
- console.log('');
141
+ const credentials = (await getServiceCredentials(service.id)) as ProxmoxCredentials;
142
+ const currentConfig = service.providerConfig as unknown as ProxmoxProviderConfig;
144
143
 
145
- // Re-prompt for configurable fields with current values as defaults
146
- const targetNode = await promptText({
147
- message: 'Default target node:',
148
- defaultValue: currentConfig.default_target_node,
149
- placeholder: currentConfig.default_target_node,
150
- validate: validateRequired('Target node'),
151
- });
144
+ // Show current configuration
145
+ console.log('Current configuration:');
146
+ console.log(` Target node: ${currentConfig.default_target_node}`);
147
+ console.log(` Storage: ${currentConfig.storage}`);
148
+ console.log(` LXC template: ${currentConfig.lxc_template}`);
149
+ console.log(` VM template: ${currentConfig.vm_template ?? '(none)'}`);
150
+ console.log('');
152
151
 
153
- const storage = await promptText({
154
- message: 'Default storage:',
155
- defaultValue: currentConfig.storage,
156
- placeholder: currentConfig.storage,
157
- validate: validateRequired('Storage'),
158
- });
152
+ // Re-prompt for configurable fields with current values as defaults
153
+ const targetNode = await askText({
154
+ scope,
155
+ key: 'default_target_node',
156
+ message: 'Default target node',
157
+ defaultValue: currentConfig.default_target_node,
158
+ placeholder: currentConfig.default_target_node,
159
+ required: true,
160
+ });
159
161
 
160
- // Find template storage. Done before catalog selection so the user only
161
- // ever sees the chosen storage in subsequent messages.
162
- console.log('\nFinding storage for templates...');
163
- const storageListResult = await listNodeStorage(credentials, targetNode);
162
+ const storage = await askText({
163
+ scope,
164
+ key: 'storage',
165
+ message: 'Default storage',
166
+ defaultValue: currentConfig.storage,
167
+ placeholder: currentConfig.storage,
168
+ required: true,
169
+ });
164
170
 
165
- let templateStorage = 'local';
166
- if (storageListResult.success) {
167
- const vztmplStorage = storageListResult.data.find(
168
- (s) => s.active && s.enabled && s.content.includes('vztmpl'),
169
- );
170
- if (vztmplStorage) {
171
- templateStorage = vztmplStorage.storage;
172
- console.log(`✓ Using storage '${templateStorage}' for templates`);
173
- }
174
- }
171
+ // Find template storage. Done before catalog selection so the user only
172
+ // ever sees the chosen storage in subsequent messages.
173
+ console.log('\nFinding storage for templates...');
174
+ const storageListResult = await listNodeStorage(credentials, targetNode);
175
175
 
176
- // Pick a template from Proxmox's live catalog. Pre-selects the family
177
- // matching the existing volid (e.g. user has 24.04 -1 cached, mirror has -2).
178
- const selectionResult = await selectUbuntuApplianceFromCatalog(credentials, targetNode, {
179
- currentTemplate: extractTemplateFilename(currentConfig.lxc_template),
180
- });
181
- if (selectionResult.kind === 'cancelled') {
182
- p.cancel('Operation cancelled');
183
- return { success: false, error: 'Cancelled by user' };
184
- }
185
- if (selectionResult.kind === 'error') {
186
- return { success: false, error: selectionResult.message };
187
- }
188
- const templateFilename = selectionResult.choice.template;
189
- const lxcTemplate = buildTemplatePath(templateStorage, templateFilename);
176
+ let templateStorage = 'local';
177
+ if (storageListResult.success) {
178
+ const vztmplStorage = storageListResult.data.find(
179
+ (s) => s.active && s.enabled && s.content.includes('vztmpl'),
180
+ );
181
+ if (vztmplStorage) {
182
+ templateStorage = vztmplStorage.storage;
183
+ console.log(`✓ Using storage '${templateStorage}' for templates`);
184
+ }
185
+ }
190
186
 
191
- // Check if new template exists in storage.
192
- console.log(`\nChecking if template '${templateFilename}' exists...`);
193
- const templatesResult = await listAvailableTemplates(credentials, targetNode, templateStorage);
187
+ // Pick a template from Proxmox's live catalog. Pre-selects the family
188
+ // matching the existing volid (e.g. user has 24.04 -1 cached, mirror has -2).
189
+ // The select + manual-entry fallback inside go through the bus interview
190
+ // too (ISS-0127), scoped to this service.
191
+ const selectionResult = await selectUbuntuApplianceFromCatalog(credentials, targetNode, {
192
+ scope,
193
+ currentTemplate: extractTemplateFilename(currentConfig.lxc_template),
194
+ });
195
+ if (selectionResult.kind === 'cancelled') {
196
+ return { success: false, error: 'Cancelled by user' };
197
+ }
198
+ if (selectionResult.kind === 'error') {
199
+ return { success: false, error: selectionResult.message };
200
+ }
201
+ const templateFilename = selectionResult.choice.template;
202
+ const lxcTemplate = buildTemplatePath(templateStorage, templateFilename);
194
203
 
195
- let templateExists = false;
196
- if (templatesResult.success) {
197
- templateExists = templatesResult.data.some((t) => t.volid.includes(templateFilename));
198
- }
204
+ // Check if new template exists in storage.
205
+ console.log(`\nChecking if template '${templateFilename}' exists...`);
206
+ const templatesResult = await listAvailableTemplates(
207
+ credentials,
208
+ targetNode,
209
+ templateStorage,
210
+ );
199
211
 
200
- if (!templateExists) {
201
- console.log(`✗ Template '${templateFilename}' not found`);
212
+ let templateExists = false;
213
+ if (templatesResult.success) {
214
+ templateExists = templatesResult.data.some((t) => t.volid.includes(templateFilename));
215
+ }
202
216
 
203
- const shouldDownload = await p.confirm({
204
- message: 'Download template now?',
205
- initialValue: true,
206
- });
217
+ if (!templateExists) {
218
+ console.log(`✗ Template '${templateFilename}' not found`);
207
219
 
208
- if (p.isCancel(shouldDownload) || !shouldDownload) {
209
- console.log(
210
- '\nTemplate not downloaded. Service will be updated but may fail verification.',
211
- );
212
- } else {
213
- const outcome = await runApplianceDownload({
214
- credentials,
215
- targetNode,
216
- templateStorage,
217
- templateFilename,
220
+ const shouldDownload = await askConfirm({
221
+ scope,
222
+ key: 'download_template',
223
+ message: 'Download template now?',
224
+ defaultValue: true,
218
225
  });
219
226
 
220
- if (!outcome.ready) {
221
- const detail =
222
- outcome.reason === 'task-failed'
223
- ? `pveam download exited with status: ${outcome.exitStatus ?? 'unknown'}`
224
- : outcome.reason === 'started-failed'
225
- ? `Proxmox rejected the download request: ${outcome.startError ?? 'unknown error'}`
226
- : 'Template download did not complete in time';
227
- return {
228
- success: false,
229
- 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`,
230
- };
227
+ if (!shouldDownload) {
228
+ console.log(
229
+ '\nTemplate not downloaded. Service will be updated but may fail verification.',
230
+ );
231
+ } else {
232
+ const outcome = await runApplianceDownload({
233
+ credentials,
234
+ targetNode,
235
+ templateStorage,
236
+ templateFilename,
237
+ });
238
+
239
+ if (!outcome.ready) {
240
+ const detail =
241
+ outcome.reason === 'task-failed'
242
+ ? `pveam download exited with status: ${outcome.exitStatus ?? 'unknown'}`
243
+ : outcome.reason === 'started-failed'
244
+ ? `Proxmox rejected the download request: ${outcome.startError ?? 'unknown error'}`
245
+ : 'Template download did not complete in time';
246
+ return {
247
+ success: false,
248
+ 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`,
249
+ };
250
+ }
231
251
  }
252
+ } else {
253
+ console.log(`✓ Template '${templateFilename}' found`);
232
254
  }
233
- } else {
234
- console.log(`✓ Template '${templateFilename}' found`);
235
- }
236
255
 
237
- // VM template (for `requires.system.type: vm` modules). Keep the current
238
- // value, swap to another detected template, build a new one, or clear it.
239
- // Without this step a reconfigure would silently drop vm_template (ISS-0128).
240
- let vmTemplate = currentConfig.vm_template;
241
- const existingTemplates = await detectExistingVmTemplates(credentials, targetNode);
242
- const vmChoice = await p.select({
243
- message: 'VM template for VM-type modules:',
244
- options: buildVmTemplateChoices(currentConfig.vm_template, existingTemplates),
245
- });
246
- if (p.isCancel(vmChoice)) {
247
- p.cancel('Operation cancelled');
248
- return { success: false, error: 'Cancelled by user' };
249
- }
250
- if (vmChoice === VM_BUILD) {
251
- vmTemplate = await buildCloudInitTemplate({
252
- credentials,
253
- nodeName: targetNode,
254
- diskStorage: storage,
256
+ // VM template (for `requires.system.type: vm` modules). Keep the current
257
+ // value, swap to another detected template, build a new one, or clear it.
258
+ // Without this step a reconfigure would silently drop vm_template (ISS-0128).
259
+ let vmTemplate = currentConfig.vm_template;
260
+ const existingTemplates = await detectExistingVmTemplates(credentials, targetNode);
261
+ const vmChoice = await askSelect({
262
+ scope,
263
+ key: 'vm_template',
264
+ message: 'VM template for VM-type modules',
265
+ options: buildVmTemplateChoices(currentConfig.vm_template, existingTemplates),
255
266
  });
256
- } else if (vmChoice === VM_CLEAR || vmChoice === VM_SKIP) {
257
- vmTemplate = undefined;
258
- } else if (vmChoice !== VM_KEEP) {
259
- vmTemplate = vmChoice; // an existing template name
260
- }
261
- // VM_KEEP leaves vmTemplate at currentConfig.vm_template.
267
+ if (vmChoice === VM_BUILD) {
268
+ vmTemplate = await buildCloudInitTemplate({
269
+ credentials,
270
+ nodeName: targetNode,
271
+ diskStorage: storage,
272
+ });
273
+ } else if (vmChoice === VM_CLEAR || vmChoice === VM_SKIP) {
274
+ vmTemplate = undefined;
275
+ } else if (vmChoice !== VM_KEEP) {
276
+ vmTemplate = vmChoice; // an existing template name
277
+ }
278
+ // VM_KEEP leaves vmTemplate at currentConfig.vm_template.
262
279
 
263
- // Update service configuration. resolveReconfiguredProviderConfig preserves
264
- // every existing key and carries vm_template forward (ISS-0128 drop guard).
265
- const newConfig = resolveReconfiguredProviderConfig({
266
- default_target_node: targetNode,
267
- lxc_template: lxcTemplate,
268
- storage,
269
- vm_template: vmTemplate,
270
- });
271
- await updateServiceProviderConfig(service.id, newConfig);
280
+ // Update service configuration. resolveReconfiguredProviderConfig preserves
281
+ // every existing key and carries vm_template forward (ISS-0128 drop guard).
282
+ const newConfig = resolveReconfiguredProviderConfig({
283
+ default_target_node: targetNode,
284
+ lxc_template: lxcTemplate,
285
+ storage,
286
+ vm_template: vmTemplate,
287
+ });
288
+ await updateServiceProviderConfig(service.id, newConfig);
272
289
 
273
- celiloOutro(
274
- `Service '${serviceId}' reconfigured successfully!\n\n` +
275
- ` Target node: ${targetNode}\n` +
276
- ` Storage: ${storage}\n` +
277
- ` LXC template: ${lxcTemplate}\n` +
278
- ` VM template: ${vmTemplate ?? '(none)'}\n\n` +
279
- `Next steps:\n - Verify: celilo service verify ${serviceId}\n - Deploy: celilo module deploy <module-id>`,
280
- );
290
+ celiloOutro(
291
+ `Service '${serviceId}' reconfigured successfully!\n\n` +
292
+ ` Target node: ${targetNode}\n` +
293
+ ` Storage: ${storage}\n` +
294
+ ` LXC template: ${lxcTemplate}\n` +
295
+ ` VM template: ${vmTemplate ?? '(none)'}\n\n` +
296
+ `Next steps:\n - Verify: celilo service verify ${serviceId}\n - Deploy: celilo module deploy <module-id>`,
297
+ );
281
298
 
282
- return {
283
- success: true,
284
- message: `Reconfigured service: ${serviceId}`,
285
- };
286
- } catch (error) {
287
- return {
288
- success: false,
289
- error: `Failed to reconfigure service: ${error instanceof Error ? error.message : String(error)}`,
290
- };
291
- }
299
+ return {
300
+ success: true,
301
+ message: `Reconfigured service: ${serviceId}`,
302
+ };
303
+ } catch (error) {
304
+ return {
305
+ success: false,
306
+ error: `Failed to reconfigure service: ${error instanceof Error ? error.message : String(error)}`,
307
+ };
308
+ }
309
+ });
292
310
  }
@@ -3,10 +3,10 @@
3
3
  * Remove a container service
4
4
  */
5
5
 
6
- import * as p from '@clack/prompts';
7
6
  import { eq } from 'drizzle-orm';
8
7
  import { getDb } from '../../db/client';
9
8
  import { moduleInfrastructure } from '../../db/schema';
9
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
10
10
  import {
11
11
  getContainerServiceByServiceId,
12
12
  removeContainerService,
@@ -60,13 +60,16 @@ export async function handleServiceRemove(
60
60
  console.log('');
61
61
 
62
62
  if (!flags.force) {
63
- const confirmed = await p.confirm({
64
- message: 'Remove service and unassign modules?',
65
- initialValue: false,
66
- });
63
+ const confirmed = await withInterviewSession(() =>
64
+ askConfirm({
65
+ scope: `service:${service.serviceId}`,
66
+ key: 'remove_unassign_modules',
67
+ message: 'Remove service and unassign modules?',
68
+ defaultValue: false,
69
+ }),
70
+ );
67
71
 
68
- if (p.isCancel(confirmed) || !confirmed) {
69
- p.cancel('Operation cancelled');
72
+ if (!confirmed) {
70
73
  return { success: false, error: 'Cancelled by user' };
71
74
  }
72
75
  }
@@ -74,13 +77,16 @@ export async function handleServiceRemove(
74
77
 
75
78
  // Confirm deletion
76
79
  if (!flags.force) {
77
- const confirmed = await p.confirm({
78
- message: `Remove service '${service.serviceId}' (${service.name})?`,
79
- initialValue: false,
80
- });
80
+ const confirmed = await withInterviewSession(() =>
81
+ askConfirm({
82
+ scope: `service:${service.serviceId}`,
83
+ key: 'remove',
84
+ message: `Remove service '${service.serviceId}' (${service.name})?`,
85
+ defaultValue: false,
86
+ }),
87
+ );
81
88
 
82
- if (p.isCancel(confirmed) || !confirmed) {
83
- p.cancel('Operation cancelled');
89
+ if (!confirmed) {
84
90
  return { success: false, error: 'Cancelled by user' };
85
91
  }
86
92
  }
@@ -3,8 +3,8 @@
3
3
  * Re-verify a container service connection
4
4
  */
5
5
 
6
- import * as p from '@clack/prompts';
7
6
  import { extractTemplateFilename } from '../../api-clients/proxmox';
7
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
8
8
  import {
9
9
  type ProxmoxCredentials,
10
10
  getContainerServiceByServiceId,
@@ -91,15 +91,14 @@ export async function handleServiceVerify(
91
91
  testResult.message.includes('Template') &&
92
92
  testResult.message.includes('not found')
93
93
  ) {
94
- const shouldDownload = await p.confirm({
95
- message: 'Download missing template now?',
96
- initialValue: true,
97
- });
98
-
99
- if (p.isCancel(shouldDownload)) {
100
- celiloOutro('Verification cancelled');
101
- return { success: false, error: 'Cancelled by user' };
102
- }
94
+ const shouldDownload = await withInterviewSession(() =>
95
+ askConfirm({
96
+ scope: `service:${service.serviceId}`,
97
+ key: 'download_template',
98
+ message: 'Download missing template now?',
99
+ defaultValue: true,
100
+ }),
101
+ );
103
102
 
104
103
  if (shouldDownload) {
105
104
  try {