elestio 1.0.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.
@@ -0,0 +1,132 @@
1
+ import { apiRequest } from '../api.js';
2
+ import { loadConfig } from '../config.js';
3
+ import { log, colors, outputJson } from '../utils.js';
4
+ import { getServiceDetails } from './services.js';
5
+
6
+ export async function getCredentials(vmID, projectId = null, json = false) {
7
+ const config = loadConfig();
8
+ const pid = projectId || config.defaultProject;
9
+ if (!pid) throw new Error('Project ID required');
10
+
11
+ const serviceInfo = await getServiceDetails(vmID, pid);
12
+ if (!serviceInfo) throw new Error('Failed to get service details');
13
+
14
+ const targetPort = serviceInfo.adminInternalPort || 8080;
15
+ const srvPort = serviceInfo.adminExternalPort || 443;
16
+
17
+ const response = await apiRequest('/api/servers/getAppCredentials', 'POST', {
18
+ vmID: String(vmID), targetPort, srvPort,
19
+ projectID: String(pid), appID: 'CloudVM',
20
+ isServerDeleted: false, mode: 'dbAdmin'
21
+ });
22
+
23
+ if (!response.url) throw new Error(response.message || 'Failed to get credentials');
24
+
25
+ if (json) {
26
+ outputJson({
27
+ service: { name: serviceInfo.displayName, type: serviceInfo.serverType, ip: serviceInfo.ipv4 },
28
+ credentials: { url: response.url, user: response.user, password: response.password },
29
+ database: serviceInfo.managedDBPort ? {
30
+ host: serviceInfo.cname, port: serviceInfo.managedDBPort
31
+ } : null
32
+ });
33
+ return response;
34
+ }
35
+
36
+ console.log(`\n${colors.bold}Service Info${colors.reset}\n`);
37
+ console.log(` Name: ${serviceInfo.displayName}`);
38
+ console.log(` Type: ${serviceInfo.serverType} (${serviceInfo.cores} CPU / ${serviceInfo.ramGB} GB RAM)`);
39
+ console.log(` Status: ${serviceInfo.status}`);
40
+ console.log(` IP: ${serviceInfo.ipv4}`);
41
+ console.log(`\n${colors.bold}App Credentials${colors.reset}\n`);
42
+ console.log(` URL: ${colors.cyan}${response.url}${colors.reset}`);
43
+ console.log(` User: ${response.user || 'N/A'}`);
44
+ console.log(` Password: ${response.password || 'N/A'}`);
45
+
46
+ if (serviceInfo.managedDBPort) {
47
+ console.log(`\n${colors.bold}Database Connection${colors.reset}`);
48
+ console.log(` Host: ${serviceInfo.cname}`);
49
+ console.log(` Port: ${serviceInfo.managedDBPort}`);
50
+ }
51
+ console.log('');
52
+ return response;
53
+ }
54
+
55
+ export async function getSSH(vmID, projectId = null, json = false) {
56
+ const config = loadConfig();
57
+ const pid = projectId || config.defaultProject;
58
+ if (!pid) throw new Error('Project ID required');
59
+
60
+ const response = await apiRequest('/api/servers/startSSHDirect', 'POST', {
61
+ vmID: String(vmID), projectID: String(pid), path: '/root/'
62
+ });
63
+
64
+ if (!response.url) throw new Error(response.message || 'Failed to get SSH access');
65
+
66
+ if (json) { outputJson({ url: response.url }); return response; }
67
+
68
+ console.log(`\n${colors.bold}SSH Access${colors.reset}\n`);
69
+ console.log(` Web Terminal: ${colors.cyan}${response.url}${colors.reset}`);
70
+ console.log('');
71
+ return response;
72
+ }
73
+
74
+ export async function getSSHDirect(vmID, json = false) {
75
+ const response = await apiRequest('/api/servers/startSSHDirect', 'POST', { vmID: String(vmID) });
76
+ if (response.status !== 'OK' && !response.ip) throw new Error(response.message || 'Failed to get SSH info');
77
+
78
+ if (json) {
79
+ outputJson({ host: response.ip || response.host, port: response.port || 22, user: response.user || 'root' });
80
+ return response;
81
+ }
82
+
83
+ console.log(`\n${colors.bold}Direct SSH${colors.reset}\n`);
84
+ console.log(` Host: ${response.ip || response.host || 'N/A'}`);
85
+ console.log(` Port: ${response.port || 22}`);
86
+ console.log(` User: ${response.user || 'root'}`);
87
+ console.log(`\n ${colors.dim}ssh ${response.user || 'root'}@${response.ip || response.host} -p ${response.port || 22}${colors.reset}`);
88
+ console.log('');
89
+ return response;
90
+ }
91
+
92
+ export async function getVSCode(vmID, projectId = null, json = false) {
93
+ const config = loadConfig();
94
+ const pid = projectId || config.defaultProject;
95
+ if (!pid) throw new Error('Project ID required');
96
+
97
+ const response = await apiRequest('/api/servers/startVSCode', 'POST', {
98
+ vmID: String(vmID), projectID: String(pid)
99
+ });
100
+
101
+ if (!response.url) throw new Error(response.message || 'Failed to get VSCode access');
102
+
103
+ if (json) { outputJson({ url: response.url, user: response.user, password: response.password }); return response; }
104
+
105
+ console.log(`\n${colors.bold}VSCode Web Access${colors.reset}\n`);
106
+ console.log(` URL: ${colors.cyan}${response.url}${colors.reset}`);
107
+ console.log(` User: ${response.user || 'N/A'}`);
108
+ console.log(` Password: ${response.password || 'N/A'}`);
109
+ console.log('');
110
+ return response;
111
+ }
112
+
113
+ export async function getFileExplorer(vmID, projectId = null, json = false) {
114
+ const config = loadConfig();
115
+ const pid = projectId || config.defaultProject;
116
+ if (!pid) throw new Error('Project ID required');
117
+
118
+ const response = await apiRequest('/api/servers/startFileExplorer', 'POST', {
119
+ vmID: String(vmID), projectID: String(pid)
120
+ });
121
+
122
+ if (!response.url) throw new Error(response.message || 'Failed to get File Explorer access');
123
+
124
+ if (json) { outputJson({ url: response.url, user: response.user, password: response.password }); return response; }
125
+
126
+ console.log(`\n${colors.bold}File Explorer Access${colors.reset}\n`);
127
+ console.log(` URL: ${colors.cyan}${response.url}${colors.reset}`);
128
+ console.log(` User: ${response.user || 'N/A'}`);
129
+ console.log(` Password: ${response.password || 'N/A'}`);
130
+ console.log('');
131
+ return response;
132
+ }
@@ -0,0 +1,427 @@
1
+ import { apiRequest } from '../api.js';
2
+ import { loadConfig } from '../config.js';
3
+ import { log, colors, formatTable, outputJson } from '../utils.js';
4
+ import { getServiceDetails } from './services.js';
5
+ import { filterSizes } from './templates.js';
6
+ import dns from 'dns/promises';
7
+
8
+ export async function doAction(vmID, action, additionalParams = {}) {
9
+ const response = await apiRequest('/api/servers/DoActionOnServer', 'POST', {
10
+ vmID: String(vmID), action, ...additionalParams
11
+ });
12
+
13
+ if (Array.isArray(response)) return { data: response, status: 'OK' };
14
+ if (response.status === 'KO' || response.status === 'error') {
15
+ throw new Error(response.message || `Action "${action}" failed`);
16
+ }
17
+ return response;
18
+ }
19
+
20
+ // ── Power Management ──
21
+
22
+ export async function reboot(vmID) {
23
+ log('info', `Rebooting VM ${vmID}...`);
24
+ const result = await doAction(vmID, 'reboot');
25
+ log('success', 'Reboot initiated');
26
+ return result;
27
+ }
28
+
29
+ export async function reset(vmID) {
30
+ log('info', `Hard resetting VM ${vmID}...`);
31
+ const result = await doAction(vmID, 'reset');
32
+ log('success', 'Hard reset initiated');
33
+ return result;
34
+ }
35
+
36
+ const MANAGED_DB_TEMPLATES = ['postgresql', 'mysql', 'mariadb', 'mongodb', 'redis', 'memcached', 'keydb', 'clickhouse', 'couchdb', 'elasticsearch', 'opensearch', 'meilisearch', 'typesense', 'ferretdb'];
37
+
38
+ export async function shutdown(vmID, options = {}) {
39
+ try {
40
+ const service = await getServiceDetails(vmID, options.project);
41
+ const templateName = (service.templateName || service.displayName || '').toLowerCase();
42
+ if (MANAGED_DB_TEMPLATES.some(db => templateName.includes(db))) {
43
+ throw new Error(`Cannot shutdown managed database "${service.templateName || service.displayName}". Use "reboot" instead.`);
44
+ }
45
+ } catch (e) {
46
+ if (e.message?.includes('Cannot shutdown managed database')) throw e;
47
+ }
48
+
49
+ log('info', `Shutting down VM ${vmID}...`);
50
+ const result = await doAction(vmID, 'shutdown');
51
+ log('success', 'Shutdown initiated');
52
+ return result;
53
+ }
54
+
55
+ export async function poweroff(vmID) {
56
+ log('info', `Forcing power off VM ${vmID}...`);
57
+ const result = await doAction(vmID, 'powerOff');
58
+ log('success', 'Power off initiated');
59
+ return result;
60
+ }
61
+
62
+ export async function poweron(vmID) {
63
+ log('info', `Powering on VM ${vmID}...`);
64
+ const result = await doAction(vmID, 'powerOn');
65
+ log('success', 'Power on initiated');
66
+ return result;
67
+ }
68
+
69
+ export async function restartStack(vmID) {
70
+ log('info', `Restarting Docker stack on ${vmID}...`);
71
+ const result = await doAction(vmID, 'restartAppStack');
72
+ log('success', 'Docker stack restart initiated');
73
+ return result;
74
+ }
75
+
76
+ // ── Termination Protection ──
77
+
78
+ export async function lock(vmID) {
79
+ log('info', `Enabling termination protection on ${vmID}...`);
80
+ const result = await doAction(vmID, 'lock');
81
+ log('success', 'Termination protection enabled');
82
+ return result;
83
+ }
84
+
85
+ export async function unlock(vmID) {
86
+ log('info', `Disabling termination protection on ${vmID}...`);
87
+ const result = await doAction(vmID, 'unlock');
88
+ log('success', 'Termination protection disabled');
89
+ return result;
90
+ }
91
+
92
+ // ── Firewall ──
93
+
94
+ export async function getFirewallRules(vmID, json = false) {
95
+ const result = await doAction(vmID, 'getFirewallRules');
96
+ const rules = result.data?.rules || result.rules || [];
97
+
98
+ if (json) { outputJson(rules); return rules; }
99
+
100
+ if (rules.length === 0) { log('info', 'No firewall rules configured'); return rules; }
101
+
102
+ const columns = [
103
+ { key: 'type', label: 'Type' },
104
+ { key: 'port', label: 'Port' },
105
+ { key: 'protocol', label: 'Protocol' },
106
+ { key: 'targets', label: 'Targets' }
107
+ ];
108
+
109
+ const data = rules.map(r => ({ ...r, targets: Array.isArray(r.targets) ? r.targets.join(', ') : r.targets }));
110
+ console.log(`\n${colors.bold}Firewall Rules${colors.reset}\n`);
111
+ console.log(formatTable(data, columns));
112
+ console.log('');
113
+ return rules;
114
+ }
115
+
116
+ async function mergeFirewallRules(vmID, newRules) {
117
+ let existingRules = [];
118
+ try {
119
+ const result = await doAction(vmID, 'getFirewallRules');
120
+ existingRules = result.data?.rules || result.rules || [];
121
+ } catch { /* no existing */ }
122
+
123
+ if (existingRules.length === 0) return newRules;
124
+
125
+ const ruleMap = new Map();
126
+ for (const rule of existingRules) ruleMap.set(`${rule.type}|${rule.port}|${rule.protocol}`, rule);
127
+ for (const rule of newRules) ruleMap.set(`${rule.type}|${rule.port}|${rule.protocol}`, rule);
128
+ return Array.from(ruleMap.values());
129
+ }
130
+
131
+ export async function enableFirewall(vmID, rules) {
132
+ if (!rules || !Array.isArray(rules)) {
133
+ throw new Error('Rules array required. Example: [{"type":"INPUT","port":"22","protocol":"tcp","targets":["0.0.0.0/0"]}]');
134
+ }
135
+
136
+ let existingRules = [];
137
+ try {
138
+ const result = await doAction(vmID, 'getFirewallRules');
139
+ existingRules = result.data?.rules || result.rules || [];
140
+ } catch { /* not active */ }
141
+
142
+ if (existingRules.length > 0) {
143
+ const mergedRules = await mergeFirewallRules(vmID, rules);
144
+ log('info', `Updating firewall on ${vmID}...`);
145
+ const result = await doAction(vmID, 'updateFirewall', { rules: mergedRules });
146
+ log('success', 'Firewall updated');
147
+ return result;
148
+ }
149
+
150
+ log('info', `Enabling firewall on ${vmID}...`);
151
+ try {
152
+ const result = await doAction(vmID, 'enableFirewall', { rules });
153
+ log('success', 'Firewall enabled');
154
+ return result;
155
+ } catch {
156
+ const result = await doAction(vmID, 'updateFirewall', { rules });
157
+ log('success', 'Firewall enabled');
158
+ return result;
159
+ }
160
+ }
161
+
162
+ export async function updateFirewall(vmID, rules) {
163
+ if (!rules || !Array.isArray(rules)) throw new Error('Rules array required');
164
+ const mergedRules = await mergeFirewallRules(vmID, rules);
165
+ log('info', `Updating firewall on ${vmID}...`);
166
+ const result = await doAction(vmID, 'updateFirewall', { rules: mergedRules });
167
+ log('success', 'Firewall updated');
168
+ return result;
169
+ }
170
+
171
+ export async function disableFirewall(vmID) {
172
+ log('info', `Disabling firewall on ${vmID}...`);
173
+ const result = await doAction(vmID, 'disableFirewall');
174
+ log('success', 'Firewall disabled');
175
+ return result;
176
+ }
177
+
178
+ // ── SSL / Custom Domains ──
179
+
180
+ export async function listSslDomains(vmID, json = false) {
181
+ const config = loadConfig();
182
+ const pid = config.defaultProject;
183
+ if (!pid) throw new Error('Project ID required');
184
+
185
+ const response = await apiRequest('/api/domains/getDomains', 'POST', { projectID: Number(pid) });
186
+ const domains = response.data || [];
187
+
188
+ if (json) { outputJson(domains); return domains; }
189
+
190
+ if (domains.length === 0) { log('info', 'No custom domains configured'); return domains; }
191
+
192
+ console.log(`\n${colors.bold}SSL Domains${colors.reset}\n`);
193
+ domains.forEach(d => console.log(` ${typeof (d.domain || d.name || d) === 'string' ? (d.domain || d.name || d) : JSON.stringify(d)}`));
194
+ console.log('');
195
+ return domains;
196
+ }
197
+
198
+ export async function addSslDomain(vmID, domain) {
199
+ if (!domain) throw new Error('Domain required');
200
+
201
+ try {
202
+ const addresses = await dns.resolve4(domain);
203
+ log('info', `Domain ${domain} resolves to: ${addresses.join(', ')}`);
204
+ } catch (e) {
205
+ if (['ENODATA', 'ENOTFOUND', 'SERVFAIL'].includes(e.code)) {
206
+ throw new Error(`Domain "${domain}" does not resolve. Add an A record pointing to your service IP first.`);
207
+ }
208
+ log('warn', `Could not verify DNS for ${domain}: ${e.code || e.message}`);
209
+ }
210
+
211
+ log('info', `Adding SSL domain ${domain} to ${vmID}...`);
212
+ const result = await doAction(vmID, 'SSLDomainsAdd', { domain });
213
+ log('success', `Domain ${domain} added with auto-SSL`);
214
+ return result;
215
+ }
216
+
217
+ export async function removeSslDomain(vmID, domain) {
218
+ if (!domain) throw new Error('Domain required');
219
+ log('info', `Removing SSL domain ${domain} from ${vmID}...`);
220
+ const result = await doAction(vmID, 'SSLDomainsRemove', { domain });
221
+ log('success', `Domain ${domain} removed`);
222
+ return result;
223
+ }
224
+
225
+ // ── SSH Keys ──
226
+
227
+ export async function listSshKeys(vmID, json = false) {
228
+ const result = await doAction(vmID, 'SSHPubKeysList');
229
+ const keys = Array.isArray(result.data) ? result.data : (result.data?.keys || result.keys || []);
230
+
231
+ if (json) { outputJson(keys); return keys; }
232
+ if (keys.length === 0) { log('info', 'No SSH keys configured'); return keys; }
233
+
234
+ console.log(`\n${colors.bold}SSH Keys${colors.reset}\n`);
235
+ keys.forEach(k => console.log(` ${colors.cyan}${k.name}${colors.reset}: ${k.key ? k.key.slice(0, 50) + '...' : 'N/A'}`));
236
+ console.log('');
237
+ return keys;
238
+ }
239
+
240
+ export async function addSshKey(vmID, name, key) {
241
+ if (!name || !key) throw new Error('Both name and key are required');
242
+ log('info', `Adding SSH key "${name}" to ${vmID}...`);
243
+ const result = await doAction(vmID, 'SSHPubKeysAdd', { name, key });
244
+ log('success', `SSH key "${name}" added`);
245
+ return result;
246
+ }
247
+
248
+ export async function removeSshKey(vmID, name) {
249
+ if (!name) throw new Error('Key name required');
250
+ log('info', `Removing SSH key "${name}" from ${vmID}...`);
251
+ const result = await doAction(vmID, 'SSHPubKeysRemove', { deleteParams: name });
252
+ log('success', `SSH key "${name}" removed`);
253
+ return result;
254
+ }
255
+
256
+ // ── Auto-Updates ──
257
+
258
+ export async function enableSystemAutoUpdate(vmID, options = {}) {
259
+ const { day = 0, hour = 5, minute = 0, securityOnly = true } = options;
260
+ log('info', `Enabling OS auto-updates on ${vmID}...`);
261
+ const result = await doAction(vmID, 'systemAutoUpdateEnable', {
262
+ systemAutoUpdateRebootDayOfWeek: String(day), systemAutoUpdateRebootHour: String(hour),
263
+ systemAutoUpdateRebootMinute: String(minute), systemAutoUpdateSecurityPatchesOnly: securityOnly
264
+ });
265
+ log('success', `OS auto-updates enabled (Day ${day}, ${hour}:${String(minute).padStart(2, '0')})`);
266
+ return result;
267
+ }
268
+
269
+ export async function disableSystemAutoUpdate(vmID) {
270
+ log('info', `Disabling OS auto-updates on ${vmID}...`);
271
+ const result = await doAction(vmID, 'systemAutoUpdateDisable');
272
+ log('success', 'OS auto-updates disabled');
273
+ return result;
274
+ }
275
+
276
+ export async function runSystemUpdate(vmID) {
277
+ log('info', `Running OS update on ${vmID}...`);
278
+ const result = await doAction(vmID, 'systemAutoUpdateNow');
279
+ log('success', 'OS update initiated');
280
+ return result;
281
+ }
282
+
283
+ export async function enableAppAutoUpdate(vmID, options = {}) {
284
+ const { day = 0, hour = 3, minute = 0 } = options;
285
+ log('info', `Enabling app auto-updates on ${vmID}...`);
286
+ const result = await doAction(vmID, 'appAutoUpdateEnable', {
287
+ appAutoUpdateDayOfWeek: String(day), appAutoUpdateHour: String(hour),
288
+ appAutoUpdateMinute: String(minute).padStart(2, '0')
289
+ });
290
+ log('success', `App auto-updates enabled (Day ${day}, ${hour}:${String(minute).padStart(2, '0')})`);
291
+ return result;
292
+ }
293
+
294
+ export async function disableAppAutoUpdate(vmID) {
295
+ log('info', `Disabling app auto-updates on ${vmID}...`);
296
+ const result = await doAction(vmID, 'appAutoUpdateDisable');
297
+ log('success', 'App auto-updates disabled');
298
+ return result;
299
+ }
300
+
301
+ export async function runAppUpdate(vmID) {
302
+ log('info', `Running app update on ${vmID}...`);
303
+ const result = await doAction(vmID, 'appAutoUpdateNow');
304
+ log('success', 'App update initiated');
305
+ return result;
306
+ }
307
+
308
+ export async function changeVersion(vmID, versionTag) {
309
+ if (!versionTag) throw new Error('Version tag required');
310
+ log('info', `Changing version to ${versionTag} on ${vmID}...`);
311
+ const result = await doAction(vmID, 'softwareChangeSelectedVersion', { versionTag });
312
+ log('success', `Version changed to ${versionTag}`);
313
+ return result;
314
+ }
315
+
316
+ // ── Alerts ──
317
+
318
+ export async function getAlerts(vmID, json = false) {
319
+ const result = await doAction(vmID, 'getAlertsRules');
320
+ const rules = result.data?.rules || result.rules || {};
321
+ if (json) { outputJson(rules); return rules; }
322
+ console.log(`\n${colors.bold}Alert Rules${colors.reset}\n`);
323
+ console.log(JSON.stringify(rules, null, 2));
324
+ console.log('');
325
+ return rules;
326
+ }
327
+
328
+ export async function enableAlerts(vmID, rules, cycleSeconds = 60) {
329
+ if (!rules) throw new Error('Rules configuration required');
330
+ const rulesStr = typeof rules === 'string' ? rules : JSON.stringify(rules);
331
+ log('info', `Updating alerts on ${vmID}...`);
332
+ const result = await doAction(vmID, 'updateAlerts', { monitCycleInSeconds: Number(cycleSeconds), rules: rulesStr });
333
+ log('success', 'Alerts updated');
334
+ return result;
335
+ }
336
+
337
+ export async function disableAlerts(vmID) {
338
+ log('info', `Disabling alerts on ${vmID}...`);
339
+ const result = await doAction(vmID, 'disableAlerts');
340
+ log('success', 'Alerts disabled');
341
+ return result;
342
+ }
343
+
344
+ // ── Resize ──
345
+
346
+ const DOWNGRADE_SUPPORTED_PROVIDERS = ['netcup', 'aws', 'azure', 'scaleway'];
347
+
348
+ function parseSizeSpec(sizeName) {
349
+ const cpuMatch = sizeName.match(/(\d+)C/i);
350
+ const ramMatch = sizeName.match(/(\d+)G/i);
351
+ return { cpu: cpuMatch ? parseInt(cpuMatch[1]) : 0, ram: ramMatch ? parseInt(ramMatch[1]) : 0 };
352
+ }
353
+
354
+ function isDowngrade(currentType, newType) {
355
+ const current = parseSizeSpec(currentType);
356
+ const newSpec = parseSizeSpec(newType);
357
+ if (current.cpu === 0 || newSpec.cpu === 0) return false;
358
+ return newSpec.cpu < current.cpu || newSpec.ram < current.ram;
359
+ }
360
+
361
+ async function validateSizeForProvider(newType, providerName, region, currentType) {
362
+ const availableSizes = await filterSizes(providerName);
363
+ const regionSizes = availableSizes.filter(s => s.regionID?.toLowerCase() === region?.toLowerCase());
364
+ const allProviderSizes = regionSizes.length > 0 ? regionSizes : availableSizes;
365
+
366
+ const exactMatch = allProviderSizes.find(s => s.title?.toLowerCase() === newType.toLowerCase());
367
+ if (exactMatch) return exactMatch.title;
368
+
369
+ const baseName = newType.toUpperCase();
370
+ const candidates = allProviderSizes.filter(s => s.title?.toUpperCase().startsWith(baseName));
371
+
372
+ if (candidates.length === 1) {
373
+ log('warn', `Size "${newType}" auto-corrected to "${candidates[0].title}"`);
374
+ return candidates[0].title;
375
+ }
376
+
377
+ if (candidates.length > 1) {
378
+ if (currentType) {
379
+ const currentSuffix = currentType.replace(/^.*?(\d+G)/, '').toUpperCase();
380
+ if (currentSuffix) {
381
+ const sameFamilyMatch = candidates.find(s => s.title?.toUpperCase().endsWith(currentSuffix));
382
+ if (sameFamilyMatch) {
383
+ log('warn', `Size auto-corrected to "${sameFamilyMatch.title}"`);
384
+ return sameFamilyMatch.title;
385
+ }
386
+ }
387
+ }
388
+ const options = candidates.map(s => s.title).join(', ');
389
+ throw new Error(`Multiple sizes match "${newType}": ${options}`);
390
+ }
391
+
392
+ const uniqueSizes = [...new Map(allProviderSizes.map(s => [s.title, s])).values()];
393
+ const sizeList = uniqueSizes.map(s => s.title).join(', ');
394
+ throw new Error(`Size "${newType}" not available for ${providerName}/${region}. Available: ${sizeList}`);
395
+ }
396
+
397
+ export async function resizeServer(vmID, newType, options = {}) {
398
+ if (!newType) throw new Error('New server type required (e.g., LARGE-4C-8G)');
399
+
400
+ log('info', 'Checking current service configuration...');
401
+ const service = await getServiceDetails(vmID, options.project);
402
+ const providerName = service.provider || service.providerName || options.provider || 'netcup';
403
+ const region = service.datacenter || options.region || 'nbg';
404
+ const currentType = service.serverType || 'unknown';
405
+
406
+ const validatedType = await validateSizeForProvider(newType, providerName, region, currentType);
407
+
408
+ if (currentType === validatedType) {
409
+ log('warn', `Service is already ${validatedType}`);
410
+ return;
411
+ }
412
+
413
+ if (isDowngrade(currentType, validatedType)) {
414
+ if (!DOWNGRADE_SUPPORTED_PROVIDERS.includes(providerName.toLowerCase())) {
415
+ throw new Error(`Downgrade not supported on ${providerName}. Supported: ${DOWNGRADE_SUPPORTED_PROVIDERS.join(', ')}`);
416
+ }
417
+ log('warn', `Downgrade detected (${currentType} -> ${validatedType})`);
418
+ }
419
+
420
+ log('info', `Resizing VM ${vmID}: ${currentType} -> ${validatedType}...`);
421
+ const result = await doAction(vmID, 'changeType', {
422
+ newType: validatedType, region, providerName, upgradeCPURAMOnly: options.cpuRamOnly !== false
423
+ });
424
+
425
+ log('success', `VM ${vmID} resize initiated`);
426
+ return result;
427
+ }