@celilo/cli 0.5.0-alpha.8 → 0.5.0

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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/api-clients/proxmox.test.ts +108 -20
  3. package/src/api-clients/proxmox.ts +130 -24
  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/proxmox-vm-template-build.ts +25 -18
  20. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  21. package/src/cli/commands/service-add-proxmox.ts +275 -260
  22. package/src/cli/commands/service-reconfigure.ts +171 -153
  23. package/src/cli/commands/service-remove.ts +19 -13
  24. package/src/cli/commands/service-verify.ts +9 -10
  25. package/src/cli/commands/storage-add-local.ts +120 -107
  26. package/src/cli/commands/storage-add-s3.ts +145 -131
  27. package/src/cli/commands/storage-remove.ts +11 -8
  28. package/src/cli/commands/system-init.ts +119 -128
  29. package/src/cli/completion.ts +15 -0
  30. package/src/cli/index.ts +25 -0
  31. package/src/cli/service-credential.ts +54 -0
  32. package/src/services/bus-interview.ts +232 -0
  33. package/src/services/module-config.ts +12 -0
  34. package/src/services/module-deploy.ts +6 -1
  35. package/src/services/placement-reconcile.test.ts +86 -0
  36. package/src/services/placement-reconcile.ts +108 -0
  37. package/src/services/programmatic-responder.ts +34 -0
  38. package/src/services/proxmox-state-recovery.ts +26 -7
  39. package/src/services/terminal-responder.ts +113 -0
  40. package/src/templates/generator.test.ts +52 -1
  41. package/src/templates/generator.ts +106 -37
@@ -13,7 +13,6 @@ import { cpSync, existsSync, readFileSync, readdirSync } from 'node:fs';
13
13
  import { unlink } from 'node:fs/promises';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join, resolve } from 'node:path';
16
- import * as p from '@clack/prompts';
17
16
  import { eq } from 'drizzle-orm';
18
17
  import { parse as parseYaml } from 'yaml';
19
18
  import { registerModuleCapabilities } from '../../capabilities/registration';
@@ -23,6 +22,7 @@ import { ModuleManifestSchema } from '../../manifest/schema';
23
22
  import type { ModuleManifest } from '../../manifest/schema';
24
23
  import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
25
24
  import { RegistryClient } from '../../registry/client';
25
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
26
26
  import { getFlag } from '../parser';
27
27
  import { log } from '../prompts';
28
28
  import type { CommandResult } from '../types';
@@ -508,11 +508,15 @@ async function runRegistrySweep(
508
508
  log.message('Each breaking update will be applied only on explicit confirmation.\n');
509
509
 
510
510
  for (const plan of breaking) {
511
- const proceed = await p.confirm({
512
- message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`,
513
- initialValue: false,
514
- });
515
- if (p.isCancel(proceed) || !proceed) {
511
+ const proceed = await withInterviewSession(() =>
512
+ askConfirm({
513
+ scope: `module-upgrade:${plan.moduleId}`,
514
+ key: 'apply_breaking',
515
+ message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} ${plan.targetVersion})?`,
516
+ defaultValue: false,
517
+ }),
518
+ );
519
+ if (!proceed) {
516
520
  skippedBreaking++;
517
521
  continue;
518
522
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `celilo proxmox node list` — live per-node capacity from the Proxmox cluster
3
+ * (ISS-0060, Phase 1 of v2/PROXMOX_CAPACITY_AND_LIFECYCLE.md).
4
+ *
5
+ * Reads reality from the Proxmox API via `ProxmoxClient`, never a cached DB
6
+ * value — the foundation for capacity-aware placement (ISS-0061) and
7
+ * reconcile-on-read (the rest of ISS-0060 / ISS-0090).
8
+ */
9
+
10
+ import { ProxmoxClient, type ProxmoxCredentials } from '../../api-clients/proxmox';
11
+ import { getServiceCredentials, listContainerServices } from '../../services/container-service';
12
+ import { celiloIntro } from '../prompts';
13
+ import type { CommandResult } from '../types';
14
+
15
+ function formatUptime(sec: number): string {
16
+ if (sec <= 0) return '—';
17
+ const days = Math.floor(sec / 86400);
18
+ const hours = Math.floor((sec % 86400) / 3600);
19
+ return days > 0 ? `${days}d${hours}h` : `${hours}h`;
20
+ }
21
+
22
+ /**
23
+ * Resolve which Proxmox service to introspect: an explicit service-id arg, else
24
+ * the sole Proxmox service. Returns an error message when the choice is
25
+ * ambiguous or the named service doesn't exist.
26
+ */
27
+ function resolveProxmoxService(
28
+ services: Awaited<ReturnType<typeof listContainerServices>>,
29
+ requested: string | undefined,
30
+ ): { service: (typeof services)[number] } | { error: string } {
31
+ const proxmox = services.filter((s) => s.providerName === 'proxmox');
32
+ if (proxmox.length === 0) {
33
+ return {
34
+ error: 'No Proxmox container service configured. Add one: celilo service add proxmox',
35
+ };
36
+ }
37
+ if (requested) {
38
+ const match = proxmox.find((s) => s.serviceId === requested);
39
+ if (!match) {
40
+ return {
41
+ error: `No Proxmox service '${requested}'. Known: ${proxmox.map((s) => s.serviceId).join(', ')}`,
42
+ };
43
+ }
44
+ return { service: match };
45
+ }
46
+ if (proxmox.length > 1) {
47
+ return {
48
+ error: `Multiple Proxmox services — specify one: celilo proxmox node list <service-id>\n ${proxmox
49
+ .map((s) => s.serviceId)
50
+ .join('\n ')}`,
51
+ };
52
+ }
53
+ return { service: proxmox[0] };
54
+ }
55
+
56
+ export async function handleProxmoxNodeList(
57
+ args: string[],
58
+ _flags: Record<string, boolean | string> = {},
59
+ ): Promise<CommandResult> {
60
+ celiloIntro('Proxmox nodes');
61
+
62
+ const resolved = resolveProxmoxService(await listContainerServices(), args[0]);
63
+ if ('error' in resolved) {
64
+ console.log(`✗ ${resolved.error}`);
65
+ return { success: false, error: resolved.error };
66
+ }
67
+ const { service } = resolved;
68
+
69
+ const creds = (await getServiceCredentials(service.id)) as ProxmoxCredentials;
70
+ const result = await new ProxmoxClient(creds).nodeCapacities();
71
+ if (!result.success) {
72
+ console.log(`✗ Could not reach Proxmox (${service.serviceId}): ${result.message}`);
73
+ return { success: false, error: result.message };
74
+ }
75
+
76
+ if (result.data.length === 0) {
77
+ console.log('No nodes reported by the cluster.');
78
+ return { success: true, message: 'No nodes' };
79
+ }
80
+
81
+ console.log(`Service: ${service.serviceId} (${service.name})\n`);
82
+ console.log(
83
+ 'NODE STATUS RAM free/total CPU DISK free/total UPTIME',
84
+ );
85
+ console.log(
86
+ '─────────────────────────────────────────────────────────────────────────────────────',
87
+ );
88
+ for (const n of result.data) {
89
+ const status = n.online ? 'online' : 'OFFLINE';
90
+ const ram = `${(n.memFreeMb / 1024).toFixed(1)}/${(n.memTotalMb / 1024).toFixed(1)} GB`;
91
+ const cpu = `${n.cpuCores} cores ${n.cpuUsedPct}%`;
92
+ const disk = `${n.diskFreeGb}/${n.diskTotalGb} GB`;
93
+ console.log(
94
+ `${n.node.padEnd(11)} ${status.padEnd(9)} ${ram.padEnd(19)} ${cpu.padEnd(15)} ${disk.padEnd(19)} ${formatUptime(
95
+ n.uptimeSec,
96
+ )}`,
97
+ );
98
+ }
99
+ console.log('');
100
+ return { success: true, message: `${result.data.length} node(s)` };
101
+ }
@@ -10,7 +10,6 @@
10
10
  * means the canonical filename always matches what `pveam download` accepts.
11
11
  */
12
12
 
13
- import * as p from '@clack/prompts';
14
13
  import {
15
14
  type ProxmoxAppliance,
16
15
  type ProxmoxCredentials,
@@ -18,9 +17,8 @@ import {
18
17
  downloadAppliance,
19
18
  listAvailableAppliances,
20
19
  } from '../../api-clients/proxmox';
20
+ import { askSelect, askText } from '../../services/bus-interview';
21
21
  import { FuelGauge } from '../fuel-gauge';
22
- import { promptText } from '../prompts';
23
- import { validateRequired } from '../validators';
24
22
 
25
23
  export interface UbuntuTemplateChoice {
26
24
  /** Canonical filename, e.g. "ubuntu-24.04-standard_24.04-2_amd64.tar.zst" */
@@ -103,7 +101,7 @@ export function pickInitialTemplate(
103
101
  export async function selectUbuntuApplianceFromCatalog(
104
102
  credentials: ProxmoxCredentials,
105
103
  nodeName: string,
106
- opts: { currentTemplate?: string } = {},
104
+ opts: { scope: string; currentTemplate?: string },
107
105
  ): Promise<TemplateSelectionResult> {
108
106
  console.log('\nFetching Proxmox template catalog...');
109
107
  const result = await listAvailableAppliances(credentials, nodeName);
@@ -115,13 +113,17 @@ export async function selectUbuntuApplianceFromCatalog(
115
113
  ' After saving, run `pveam download <storage> <template>` on the Proxmox host before deploying.',
116
114
  );
117
115
 
118
- const manual = await promptText({
119
- message: 'Template filename:',
116
+ // Manual fallback over the bus (ISS-0127) — drivable headlessly by
117
+ // answering `interview.required.<scope>.lxc_template_manual`.
118
+ const manual = await askText({
119
+ scope: opts.scope,
120
+ key: 'lxc_template_manual',
121
+ message: 'Template filename',
120
122
  placeholder: 'ubuntu-24.04-standard_24.04-2_amd64.tar.zst',
121
- validate: validateRequired('Template filename'),
123
+ required: true,
122
124
  });
123
125
 
124
- if (typeof manual !== 'string' || manual.trim() === '') {
126
+ if (manual.trim() === '') {
125
127
  return { kind: 'cancelled' };
126
128
  }
127
129
 
@@ -140,8 +142,11 @@ export async function selectUbuntuApplianceFromCatalog(
140
142
 
141
143
  const initialValue = pickInitialTemplate(options, opts.currentTemplate);
142
144
 
143
- const selection = await p.select<string>({
144
- message: 'Default Ubuntu template:',
145
+ // Template choice over the bus (ISS-0127), keyed `lxc_template` within scope.
146
+ const selection = await askSelect({
147
+ scope: opts.scope,
148
+ key: 'lxc_template',
149
+ message: 'Default Ubuntu template',
145
150
  options: options.map((e) => ({
146
151
  value: e.appliance.template,
147
152
  label: `Ubuntu ${e.major}.${String(e.minor).padStart(2, '0')}${e.isLts ? ' LTS' : ''}`,
@@ -149,13 +154,9 @@ export async function selectUbuntuApplianceFromCatalog(
149
154
  ? `revision ${e.appliance.version}`
150
155
  : `revision ${e.appliance.version} · non-LTS`,
151
156
  })),
152
- initialValue,
157
+ defaultValue: initialValue,
153
158
  });
154
159
 
155
- if (p.isCancel(selection)) {
156
- return { kind: 'cancelled' };
157
- }
158
-
159
160
  return { kind: 'selected', choice: { template: selection, source: 'catalog' } };
160
161
  }
161
162
 
@@ -25,7 +25,7 @@ import {
25
25
  deleteVm,
26
26
  downloadCloudImage,
27
27
  findFreeTemplateVmid,
28
- findIsoStorage,
28
+ findImportStorage,
29
29
  listVmTemplates,
30
30
  pollTaskUntilDone,
31
31
  } from '../../api-clients/proxmox';
@@ -33,7 +33,12 @@ import { FuelGauge } from '../fuel-gauge';
33
33
 
34
34
  const UBUNTU_2404_AMD64_URL =
35
35
  'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img';
36
- const UBUNTU_2404_AMD64_FILENAME = 'noble-server-cloudimg-amd64.img';
36
+ // Stored under the storage's import/ namespace, so the filename must end in an
37
+ // extension PVE accepts for `import` content (UPLOAD_IMPORT_EXT_RE_1 =
38
+ // ova|qcow2|raw|vmdk) — a `.img` is rejected with "invalid filename or wrong
39
+ // extension". Ubuntu's cloud image is qcow2 format despite the upstream `.img`
40
+ // URL, so we download it under a `.qcow2` name. The URL above is unchanged.
41
+ const UBUNTU_2404_AMD64_FILENAME = 'noble-server-cloudimg-amd64.qcow2';
37
42
  const DEFAULT_TEMPLATE_NAME = 'ubuntu-2404-cloudinit';
38
43
 
39
44
  export interface VmTemplateBuildParams {
@@ -69,21 +74,23 @@ export async function buildCloudInitTemplate(params: VmTemplateBuildParams): Pro
69
74
  let vmid: number | null = null;
70
75
 
71
76
  try {
72
- // Step 1: find ISO storage
73
- gauge.addOutput('Finding ISO-capable storage on the node…');
74
- const isoStorageResult = await findIsoStorage(credentials, nodeName);
75
- if (!isoStorageResult.success) {
76
- throw new Error(`Could not list node storage: ${isoStorageResult.message}`);
77
+ // Step 1: find import-capable storage. The cloud image is consumed via the
78
+ // VM-create `import-from=`, which requires an `import`/`images` source — a
79
+ // `.img` in the `iso/` namespace is rejected (`has wrong type 'iso'`).
80
+ gauge.addOutput('Finding import-capable storage on the node…');
81
+ const importStorageResult = await findImportStorage(credentials, nodeName);
82
+ if (!importStorageResult.success) {
83
+ throw new Error(`Could not list node storage: ${importStorageResult.message}`);
77
84
  }
78
- const isoStorage = isoStorageResult.data;
79
- if (!isoStorage) {
85
+ const importStorage = importStorageResult.data;
86
+ if (!importStorage) {
80
87
  throw new Error(
81
- 'No ISO-capable storage found on the node. ' +
82
- "Enable 'iso' content on a storage (typically 'local') in the Proxmox UI: " +
88
+ 'No import-capable storage found on the node. ' +
89
+ "Enable 'import' content on a storage (typically 'local') in the Proxmox UI: " +
83
90
  'Datacenter → Storage → Edit → Content.',
84
91
  );
85
92
  }
86
- gauge.addOutput(`ISO storage: ${isoStorage}`);
93
+ gauge.addOutput(`Import storage: ${importStorage}`);
87
94
 
88
95
  // Step 2: find a free template VMID
89
96
  gauge.addOutput('Finding a free template VMID (>= 9000)…');
@@ -92,21 +99,21 @@ export async function buildCloudInitTemplate(params: VmTemplateBuildParams): Pro
92
99
  vmid = vmidResult.data;
93
100
  gauge.addOutput(`Using VMID ${vmid}`);
94
101
 
95
- // Step 3: download cloud image to ISO storage
102
+ // Step 3: download cloud image to import storage
96
103
  gauge.addOutput(
97
- `Downloading Ubuntu 24.04 cloud image to '${isoStorage}' (~600 MB, may take several minutes)…`,
104
+ `Downloading Ubuntu 24.04 cloud image to '${importStorage}' (~600 MB, may take several minutes)…`,
98
105
  );
99
106
  const downloadResult = await downloadCloudImage(
100
107
  credentials,
101
108
  nodeName,
102
- isoStorage,
109
+ importStorage,
103
110
  UBUNTU_2404_AMD64_URL,
104
111
  UBUNTU_2404_AMD64_FILENAME,
105
112
  );
106
113
  if (!downloadResult.success) {
107
114
  if (downloadResult.message.includes('403') || downloadResult.message.includes('not found')) {
108
115
  throw new Error(
109
- `Proxmox storage download-url endpoint rejected the request. This endpoint requires Proxmox VE 7.2+. Error: ${downloadResult.message}`,
116
+ `Proxmox storage download-url endpoint rejected the request. Downloading to 'import' content requires Proxmox VE 8.2+. Error: ${downloadResult.message}`,
110
117
  );
111
118
  }
112
119
  throw new Error(`Cloud image download failed: ${downloadResult.message}`);
@@ -116,14 +123,14 @@ export async function buildCloudInitTemplate(params: VmTemplateBuildParams): Pro
116
123
  gauge.addOutput('Cloud image downloaded');
117
124
 
118
125
  // Step 4: create VM from cloud image
119
- const isoVolid = buildCloudImageVolid(isoStorage, UBUNTU_2404_AMD64_FILENAME);
126
+ const imageVolid = buildCloudImageVolid(importStorage, UBUNTU_2404_AMD64_FILENAME);
120
127
  gauge.addOutput(`Creating VM ${vmid} '${DEFAULT_TEMPLATE_NAME}' (importing disk)…`);
121
128
  const createResult = await createVmFromCloudImage({
122
129
  credentials,
123
130
  nodeName,
124
131
  vmid,
125
132
  name: DEFAULT_TEMPLATE_NAME,
126
- isoVolid,
133
+ imageVolid,
127
134
  diskStorage,
128
135
  });
129
136
  if (!createResult.success) {
@@ -3,16 +3,16 @@
3
3
  * Configure a Digital Ocean VPS service
4
4
  */
5
5
 
6
- import * as p from '@clack/prompts';
7
6
  import type { NetworkZone } from '../../db/schema';
7
+ import { askMultiselect, askText, withInterviewSession } from '../../services/bus-interview';
8
8
  import {
9
9
  addContainerService,
10
10
  testConnection as testServiceConnection,
11
11
  updateVerificationStatus,
12
12
  } from '../../services/container-service';
13
- import { celiloIntro, celiloOutro, promptPassword, promptText } from '../prompts';
13
+ import { celiloIntro, celiloOutro } from '../prompts';
14
+ import { resolveServiceCredential } from '../service-credential';
14
15
  import type { CommandResult } from '../types';
15
- import { validateRequired } from '../validators';
16
16
 
17
17
  /**
18
18
  * Handle service add digitalocean command
@@ -22,112 +22,123 @@ import { validateRequired } from '../validators';
22
22
  */
23
23
  export async function handleServiceAddDigitalOcean(
24
24
  _args: string[],
25
- _flags: Record<string, boolean | string> = {},
25
+ flags: Record<string, boolean | string> = {},
26
26
  ): Promise<CommandResult> {
27
- try {
28
- celiloIntro('Add Digital Ocean VPS Service');
29
-
30
- // Prompt for service configuration
31
- const name = await promptText({
32
- message: 'Human-readable name:',
33
- defaultValue: 'Digital Ocean VPS',
34
- placeholder: 'Digital Ocean VPS',
35
- validate: validateRequired('Service name'),
36
- });
37
-
38
- // Prompt for zones (multi-select)
39
- const zones = await p.multiselect<NetworkZone>({
40
- message: 'Zones this service can provision to:',
41
- options: [
42
- { value: 'internal' as NetworkZone, label: 'internal' },
43
- { value: 'dmz' as NetworkZone, label: 'dmz' },
44
- { value: 'app' as NetworkZone, label: 'app' },
45
- { value: 'secure' as NetworkZone, label: 'secure' },
46
- { value: 'external' as NetworkZone, label: 'external' },
47
- ],
48
- required: true,
49
- initialValues: ['external' as NetworkZone], // Digital Ocean is typically for external-facing services
50
- });
51
-
52
- if (p.isCancel(zones)) {
53
- p.cancel('Operation cancelled');
54
- return { success: false, error: 'Cancelled by user' };
27
+ // Non-secret prompts route through the bus interview (ISS-0127); the DO API
28
+ // token is a service credential that travels by flag/env only (D7).
29
+ const scope = 'service-add:digitalocean';
30
+
31
+ return withInterviewSession(async () => {
32
+ try {
33
+ celiloIntro('Add Digital Ocean VPS Service');
34
+
35
+ const name = await askText({
36
+ scope,
37
+ key: 'name',
38
+ message: 'Human-readable name',
39
+ defaultValue: 'Digital Ocean VPS',
40
+ placeholder: 'Digital Ocean VPS',
41
+ required: true,
42
+ });
43
+
44
+ const zones = await askMultiselect({
45
+ scope,
46
+ key: 'zones',
47
+ message: 'Zones this service can provision to',
48
+ options: [
49
+ { value: 'internal', label: 'internal' },
50
+ { value: 'dmz', label: 'dmz' },
51
+ { value: 'app', label: 'app' },
52
+ { value: 'secure', label: 'secure' },
53
+ { value: 'external', label: 'external' },
54
+ ],
55
+ required: true,
56
+ });
57
+
58
+ console.log('\nDigital Ocean Configuration');
59
+ console.log('──────────────────────────');
60
+
61
+ // API token is a service credential — flag/env only (D7).
62
+ const apiToken = await resolveServiceCredential({
63
+ field: 'API token (Personal Access Token)',
64
+ flag: 'api-token',
65
+ envVar: 'DIGITALOCEAN_API_TOKEN',
66
+ flagValue: flags['api-token'],
67
+ });
68
+
69
+ const defaultRegion = await askText({
70
+ scope,
71
+ key: 'default_region',
72
+ message: 'Default region',
73
+ defaultValue: 'nyc3',
74
+ placeholder: 'nyc3',
75
+ required: true,
76
+ });
77
+
78
+ const defaultSize = await askText({
79
+ scope,
80
+ key: 'default_size',
81
+ message: 'Default droplet size',
82
+ defaultValue: 's-1vcpu-1gb',
83
+ placeholder: 's-1vcpu-1gb',
84
+ required: true,
85
+ });
86
+
87
+ const defaultImage = await askText({
88
+ scope,
89
+ key: 'default_image',
90
+ message: 'Default image',
91
+ defaultValue: 'ubuntu-22-04-x64',
92
+ placeholder: 'ubuntu-22-04-x64',
93
+ required: true,
94
+ });
95
+
96
+ // Store the service first so we can test it
97
+ const service = await addContainerService({
98
+ name,
99
+ providerName: 'digitalocean',
100
+ zones: zones as unknown as NetworkZone[],
101
+ apiCredentials: {
102
+ api_token: apiToken,
103
+ },
104
+ providerConfig: {
105
+ default_region: defaultRegion,
106
+ default_size: defaultSize,
107
+ default_image: defaultImage,
108
+ },
109
+ });
110
+
111
+ // Test connection
112
+ console.log('\nTesting connection...');
113
+ const testResult = await testServiceConnection(service);
114
+ await updateVerificationStatus(service.id, testResult);
115
+
116
+ if (!testResult.success) {
117
+ console.log(`✗ Connection test failed: ${testResult.message}`);
118
+ console.log(
119
+ '\nService saved but not verified. The service will not be used until verification succeeds.',
120
+ );
121
+
122
+ celiloOutro(
123
+ `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`,
124
+ );
125
+ } else {
126
+ console.log(`✓ ${testResult.message}`);
127
+
128
+ celiloOutro(
129
+ `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`,
130
+ );
131
+ }
132
+
133
+ return {
134
+ success: true,
135
+ message: `Added Digital Ocean service: ${service.id}`,
136
+ };
137
+ } catch (error) {
138
+ return {
139
+ success: false,
140
+ error: `Failed to add Digital Ocean service: ${error instanceof Error ? error.message : String(error)}`,
141
+ };
55
142
  }
56
-
57
- console.log('\nDigital Ocean Configuration');
58
- console.log('──────────────────────────');
59
-
60
- const apiToken = await promptPassword({
61
- message: 'API Token (Personal Access Token):',
62
- validate: validateRequired('API token'),
63
- });
64
-
65
- const defaultRegion = await promptText({
66
- message: 'Default region:',
67
- defaultValue: 'nyc3',
68
- placeholder: 'nyc3',
69
- validate: validateRequired('Region'),
70
- });
71
-
72
- const defaultSize = await promptText({
73
- message: 'Default droplet size:',
74
- defaultValue: 's-1vcpu-1gb',
75
- placeholder: 's-1vcpu-1gb',
76
- validate: validateRequired('Droplet size'),
77
- });
78
-
79
- const defaultImage = await promptText({
80
- message: 'Default image:',
81
- defaultValue: 'ubuntu-22-04-x64',
82
- placeholder: 'ubuntu-22-04-x64',
83
- validate: validateRequired('Image'),
84
- });
85
-
86
- // Store the service first so we can test it
87
- const service = await addContainerService({
88
- name,
89
- providerName: 'digitalocean',
90
- zones: zones as NetworkZone[],
91
- apiCredentials: {
92
- api_token: apiToken,
93
- },
94
- providerConfig: {
95
- default_region: defaultRegion,
96
- default_size: defaultSize,
97
- default_image: defaultImage,
98
- },
99
- });
100
-
101
- // Test connection
102
- console.log('\nTesting connection...');
103
- const testResult = await testServiceConnection(service);
104
- await updateVerificationStatus(service.id, testResult);
105
-
106
- if (!testResult.success) {
107
- console.log(`✗ Connection test failed: ${testResult.message}`);
108
- console.log(
109
- '\nService saved but not verified. The service will not be used until verification succeeds.',
110
- );
111
-
112
- celiloOutro(
113
- `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`,
114
- );
115
- } else {
116
- console.log(`✓ ${testResult.message}`);
117
-
118
- celiloOutro(
119
- `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`,
120
- );
121
- }
122
-
123
- return {
124
- success: true,
125
- message: `Added Digital Ocean service: ${service.id}`,
126
- };
127
- } catch (error) {
128
- return {
129
- success: false,
130
- error: `Failed to add Digital Ocean service: ${error instanceof Error ? error.message : String(error)}`,
131
- };
132
- }
143
+ });
133
144
  }