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.
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/bin/elestio.js +5 -0
- package/package.json +42 -0
- package/src/api.js +105 -0
- package/src/cli.js +828 -0
- package/src/commands/access.js +132 -0
- package/src/commands/actions.js +427 -0
- package/src/commands/auth.js +146 -0
- package/src/commands/backups.js +215 -0
- package/src/commands/billing.js +106 -0
- package/src/commands/cicd.js +403 -0
- package/src/commands/projects.js +145 -0
- package/src/commands/services.js +294 -0
- package/src/commands/templates.js +188 -0
- package/src/commands/volumes.js +117 -0
- package/src/config.js +84 -0
- package/src/utils.js +162 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { apiRequest } from '../api.js';
|
|
2
|
+
import { loadConfig } from '../config.js';
|
|
3
|
+
import { findTemplate } from './templates.js';
|
|
4
|
+
import { formatTable, formatService, formatPrice, colors, log, sleep, validateServerName, outputJson } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
async function listProjectsRaw() {
|
|
7
|
+
const response = await apiRequest('/api/projects/getList');
|
|
8
|
+
if (response.status !== 'OK') return [];
|
|
9
|
+
return response.data?.projects || [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function findServiceAcrossProjects(vmID) {
|
|
13
|
+
const projects = await listProjectsRaw();
|
|
14
|
+
for (const project of projects) {
|
|
15
|
+
const pid = project.projectID;
|
|
16
|
+
try {
|
|
17
|
+
const services = await listServicesRaw(pid);
|
|
18
|
+
const svc = services.find(s => String(s.vmID) === String(vmID));
|
|
19
|
+
if (svc) return { service: svc, projectId: pid, projectName: project.project_name };
|
|
20
|
+
} catch { /* skip */ }
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function listServicesRaw(projectId = null) {
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
const pid = projectId || config.defaultProject;
|
|
28
|
+
if (!pid) throw new Error('Project ID required. Use --project or set default with: elestio config --set-default-project <id>');
|
|
29
|
+
|
|
30
|
+
const response = await apiRequest('/api/servers/getServices', 'POST', {
|
|
31
|
+
appid: 'Cloudxx',
|
|
32
|
+
projectId: String(pid),
|
|
33
|
+
isActiveService: 'true'
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return response.servers || response.data?.services || [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function listServices(projectId = null, json = false) {
|
|
40
|
+
const config = loadConfig();
|
|
41
|
+
const pid = projectId || config.defaultProject;
|
|
42
|
+
if (!pid) throw new Error('Project ID required. Use --project or set default with: elestio config --set-default-project <id>');
|
|
43
|
+
|
|
44
|
+
const response = await apiRequest('/api/servers/getServices', 'POST', {
|
|
45
|
+
appid: 'Cloudxx',
|
|
46
|
+
projectId: String(pid),
|
|
47
|
+
isActiveService: 'true'
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (response.status === 'KO' || response.code === 'AccessDenied') {
|
|
51
|
+
throw new Error(response.message || 'Access denied.');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const services = response.servers || response.data?.services || [];
|
|
55
|
+
|
|
56
|
+
if (json) {
|
|
57
|
+
outputJson(services);
|
|
58
|
+
return services;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (services.length === 0) {
|
|
62
|
+
log('info', `No services in project ${pid}`);
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const columns = [
|
|
67
|
+
{ key: 'displayName', label: 'Name' },
|
|
68
|
+
{ key: 'templateName', label: 'Software' },
|
|
69
|
+
{ key: 'status', label: 'Status' },
|
|
70
|
+
{ key: 'deploymentStatus', label: 'Deploy' },
|
|
71
|
+
{ key: 'vmID', label: 'vmID' },
|
|
72
|
+
{ key: 'ipv4', label: 'IP' }
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
console.log(`\n${colors.bold}Services in project ${pid} (${services.length})${colors.reset}\n`);
|
|
76
|
+
console.log(formatTable(services, columns));
|
|
77
|
+
console.log('');
|
|
78
|
+
return services;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function getServiceDetails(vmID, projectId = null) {
|
|
82
|
+
const config = loadConfig();
|
|
83
|
+
const pid = projectId || config.defaultProject;
|
|
84
|
+
if (!pid) throw new Error('Project ID required');
|
|
85
|
+
|
|
86
|
+
const response = await apiRequest('/api/servers/getServerDetails', 'POST', {
|
|
87
|
+
vmID: String(vmID),
|
|
88
|
+
projectID: String(pid)
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (response.serviceInfos && response.serviceInfos.length > 0) return response.serviceInfos[0];
|
|
92
|
+
if (response.status === 'OK' && response.data) return response.data;
|
|
93
|
+
if (response.status === 'KO' || response.message) throw new Error(response.message || 'Failed to get service details');
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function getServiceByVmId(vmID, projectId = null) {
|
|
98
|
+
const services = await listServicesRaw(projectId);
|
|
99
|
+
return services.find(s => String(s.vmID) === String(vmID));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function getServerIdFromVmId(vmID, projectId = null) {
|
|
103
|
+
const service = await getServiceByVmId(vmID, projectId);
|
|
104
|
+
if (!service) throw new Error(`Service with vmID ${vmID} not found`);
|
|
105
|
+
return service.id;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function deployService(templateNameOrId, options = {}) {
|
|
109
|
+
const config = loadConfig();
|
|
110
|
+
const template = await findTemplate(templateNameOrId);
|
|
111
|
+
if (!template) throw new Error(`Template "${templateNameOrId}" not found. Use: elestio templates search <name>`);
|
|
112
|
+
|
|
113
|
+
const projectId = options.project || config.defaultProject;
|
|
114
|
+
if (!projectId) throw new Error('Project ID required. Use --project or set default');
|
|
115
|
+
|
|
116
|
+
const serverName = options.name || `${template.title.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Date.now().toString(36)}`;
|
|
117
|
+
const serverType = options.size || config.defaults?.serverType || 'MEDIUM-2C-4G';
|
|
118
|
+
const datacenter = options.region || config.defaults?.datacenter || 'nbg';
|
|
119
|
+
const support = options.support || config.defaults?.support || 'level1';
|
|
120
|
+
const adminEmail = options.email || config.email;
|
|
121
|
+
const provider = options.provider || config.defaults?.provider || 'netcup';
|
|
122
|
+
const version = options.version || template.dockerhub_default_tag || 'latest';
|
|
123
|
+
|
|
124
|
+
const nameValidation = validateServerName(serverName);
|
|
125
|
+
if (!nameValidation.valid) throw new Error(nameValidation.error);
|
|
126
|
+
|
|
127
|
+
const isCicd = template.title?.toLowerCase().includes('ci-cd') || templateNameOrId?.toLowerCase() === 'cicd';
|
|
128
|
+
const serviceType = isCicd ? 'CICD' : 'Service';
|
|
129
|
+
|
|
130
|
+
if (options.dryRun) {
|
|
131
|
+
const preview = {
|
|
132
|
+
template: template.title, templateId: template.id, version,
|
|
133
|
+
projectId, serverName, provider, datacenter, serverType, support, adminEmail, serviceType
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (options.json) {
|
|
137
|
+
outputJson({ dryRun: true, ...preview });
|
|
138
|
+
return preview;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log(`\n${colors.bold}Deployment Preview (--dry-run)${colors.reset}\n`);
|
|
142
|
+
console.log(` Software: ${colors.cyan}${template.title}${colors.reset} (ID: ${template.id})`);
|
|
143
|
+
console.log(` Version: ${version}`);
|
|
144
|
+
console.log(` Project: ${projectId}`);
|
|
145
|
+
console.log(` Name: ${serverName}`);
|
|
146
|
+
console.log(` Provider: ${provider}`);
|
|
147
|
+
console.log(` Region: ${datacenter}`);
|
|
148
|
+
console.log(` Size: ${serverType}`);
|
|
149
|
+
console.log(` Support: ${support}`);
|
|
150
|
+
console.log(` Admin: ${adminEmail}`);
|
|
151
|
+
console.log('');
|
|
152
|
+
log('info', 'To deploy, run the same command without --dry-run');
|
|
153
|
+
return preview;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
log('info', `Deploying ${template.title} (ID: ${template.id})`);
|
|
157
|
+
log('info', ` Project: ${projectId} | Name: ${serverName}`);
|
|
158
|
+
log('info', ` Provider: ${provider} | Size: ${serverType} @ ${datacenter}`);
|
|
159
|
+
|
|
160
|
+
const payload = {
|
|
161
|
+
templateID: String(template.id), serverType, datacenter,
|
|
162
|
+
providerName: provider, serverName, appid: 'Cloudxx',
|
|
163
|
+
data: 'data', support, projectId: String(projectId),
|
|
164
|
+
version, adminEmail, deploymentServiceType: 'normal', serviceType
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (isCicd) {
|
|
168
|
+
payload.cicdPayload = { pipelineName: options.pipelineName || serverName };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const response = await apiRequest('/api/servers/createServer', 'POST', payload);
|
|
172
|
+
|
|
173
|
+
if (!response.providerServerID && !response.action) {
|
|
174
|
+
throw new Error(response.message || 'Failed to create service');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
log('success', `Deployment started! Provider Server ID: ${response.providerServerID}`);
|
|
178
|
+
|
|
179
|
+
if (options.wait !== false) {
|
|
180
|
+
log('info', 'Waiting for deployment to complete...');
|
|
181
|
+
return await waitForDeployment(response.providerServerID, projectId, options.timeout);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return response;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function waitForDeployment(vmID, projectId, timeoutMs = 600000) {
|
|
188
|
+
const start = Date.now();
|
|
189
|
+
let lastStatus = '';
|
|
190
|
+
|
|
191
|
+
while (Date.now() - start < timeoutMs) {
|
|
192
|
+
const services = await listServicesRaw(projectId);
|
|
193
|
+
const svc = services.find(s =>
|
|
194
|
+
String(s.vmID) === String(vmID) || String(s.providerServerID) === String(vmID)
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
if (!svc) { await sleep(10000); continue; }
|
|
198
|
+
|
|
199
|
+
if (svc.deploymentStatus !== lastStatus) {
|
|
200
|
+
lastStatus = svc.deploymentStatus;
|
|
201
|
+
log('info', `Status: ${svc.deploymentStatus}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (svc.deploymentStatus === 'Deployed' && svc.status === 'running') {
|
|
205
|
+
log('success', 'Deployment complete!');
|
|
206
|
+
console.log('\n' + formatService(svc) + '\n');
|
|
207
|
+
return svc;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await sleep(15000);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
throw new Error(`Deployment timed out after ${timeoutMs / 1000}s`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function deleteService(vmID, options = {}) {
|
|
217
|
+
if (!options.force) throw new Error('Deleting a service requires --force flag');
|
|
218
|
+
|
|
219
|
+
const config = loadConfig();
|
|
220
|
+
const projectId = options.project || config.defaultProject;
|
|
221
|
+
if (!projectId) throw new Error('Project ID required');
|
|
222
|
+
|
|
223
|
+
const response = await apiRequest('/api/servers/deleteServer', 'POST', {
|
|
224
|
+
vmID: String(vmID),
|
|
225
|
+
projectID: String(projectId),
|
|
226
|
+
isDeleteServiceWithBackup: options.withBackups ? 'true' : 'false'
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (response.status !== 'OK' && !response.action) {
|
|
230
|
+
throw new Error(response.message || 'Failed to delete service');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
log('success', `Service ${vmID} deletion initiated`);
|
|
234
|
+
return response;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function moveService(vmIDOrServerID, targetProjectId, sourceProjectId = null) {
|
|
238
|
+
const config = loadConfig();
|
|
239
|
+
const sourcePid = sourceProjectId || config.defaultProject;
|
|
240
|
+
|
|
241
|
+
let serverID = vmIDOrServerID;
|
|
242
|
+
let foundInProject = null;
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const service = await getServiceByVmId(vmIDOrServerID, sourcePid);
|
|
246
|
+
if (service?.id) { serverID = service.id; foundInProject = sourcePid; }
|
|
247
|
+
} catch { /* not found */ }
|
|
248
|
+
|
|
249
|
+
if (!foundInProject) {
|
|
250
|
+
log('info', `Searching across all projects...`);
|
|
251
|
+
const found = await findServiceAcrossProjects(vmIDOrServerID);
|
|
252
|
+
if (found) {
|
|
253
|
+
serverID = found.service.id;
|
|
254
|
+
log('info', `Found in project "${found.projectName}" (${found.projectId})`);
|
|
255
|
+
} else {
|
|
256
|
+
throw new Error(`Service ${vmIDOrServerID} not found in any project`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const response = await apiRequest('/api/servers/moveService', 'PUT', {
|
|
261
|
+
serviceId: String(serverID),
|
|
262
|
+
projectId: String(targetProjectId)
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (response.status !== 'OK') throw new Error(response.message || 'Failed to move service');
|
|
266
|
+
|
|
267
|
+
log('success', `Service moved to project ${targetProjectId}`);
|
|
268
|
+
return response;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function showService(vmID, projectId = null, json = false) {
|
|
272
|
+
const config = loadConfig();
|
|
273
|
+
const pid = projectId || config.defaultProject;
|
|
274
|
+
|
|
275
|
+
const details = await getServiceDetails(vmID, pid);
|
|
276
|
+
if (!details) throw new Error(`Service with vmID ${vmID} not found`);
|
|
277
|
+
|
|
278
|
+
if (json) {
|
|
279
|
+
outputJson(details);
|
|
280
|
+
return details;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
console.log('\n' + formatService(details));
|
|
284
|
+
console.log(`\n${colors.bold}Extended Details${colors.reset}`);
|
|
285
|
+
console.log(` Server ID: ${details.id || 'N/A'}`);
|
|
286
|
+
console.log(` Firewall: ${details.isFirewallActivated ? 'enabled' : 'disabled'}`);
|
|
287
|
+
console.log(` Alerts: ${details.isAlertsActivated ? 'enabled' : 'disabled'}`);
|
|
288
|
+
console.log(` Remote Backup: ${details.remoteBackupsActivated ? 'enabled' : 'disabled'}`);
|
|
289
|
+
console.log(` System Auto-Update: ${details.system_AutoUpdate_Enabled ? 'enabled' : 'disabled'}`);
|
|
290
|
+
console.log(` App Auto-Update: ${details.app_AutoUpdate_Enabled ? 'enabled' : 'disabled'}`);
|
|
291
|
+
console.log(` Price/Hour: $${details.pricePerHour || 'N/A'}`);
|
|
292
|
+
console.log('');
|
|
293
|
+
return details;
|
|
294
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { apiRequestNoAuth } from '../api.js';
|
|
2
|
+
import { formatTable, colors, truncate, outputJson } from '../utils.js';
|
|
3
|
+
|
|
4
|
+
let templatesCache = null;
|
|
5
|
+
let sizesCache = null;
|
|
6
|
+
|
|
7
|
+
export async function getTemplates() {
|
|
8
|
+
if (templatesCache) return templatesCache;
|
|
9
|
+
const response = await apiRequestNoAuth('/api/servers/getTemplates');
|
|
10
|
+
templatesCache = response.instances || [];
|
|
11
|
+
return templatesCache;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function searchTemplates(query, category = null) {
|
|
15
|
+
const templates = await getTemplates();
|
|
16
|
+
const q = query?.toLowerCase() || '';
|
|
17
|
+
|
|
18
|
+
return templates.filter(t => {
|
|
19
|
+
const matchesQuery = !q ||
|
|
20
|
+
t.title?.toLowerCase().includes(q) ||
|
|
21
|
+
t.description?.toLowerCase().includes(q);
|
|
22
|
+
const matchesCategory = !category ||
|
|
23
|
+
t.category?.toLowerCase().includes(category.toLowerCase());
|
|
24
|
+
return matchesQuery && matchesCategory;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TEMPLATE_ALIASES = {
|
|
29
|
+
'cicd': 'CI-CD-Target',
|
|
30
|
+
'ci-cd': 'CI-CD-Target',
|
|
31
|
+
'postgres': 'PostgreSQL',
|
|
32
|
+
'pg': 'PostgreSQL',
|
|
33
|
+
'mysql': 'MySQL',
|
|
34
|
+
'mariadb': 'MariaDB',
|
|
35
|
+
'mongo': 'MongoDB',
|
|
36
|
+
'mongodb': 'MongoDB',
|
|
37
|
+
'elastic': 'Elasticsearch',
|
|
38
|
+
'elasticsearch': 'Elasticsearch',
|
|
39
|
+
'wp': 'Wordpress',
|
|
40
|
+
'wordpress': 'Wordpress',
|
|
41
|
+
'k8s': 'K3S',
|
|
42
|
+
'kubernetes': 'K3S'
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export async function findTemplate(nameOrId) {
|
|
46
|
+
const templates = await getTemplates();
|
|
47
|
+
|
|
48
|
+
const byId = templates.find(t => String(t.id) === String(nameOrId));
|
|
49
|
+
if (byId) return byId;
|
|
50
|
+
|
|
51
|
+
const aliasedName = TEMPLATE_ALIASES[nameOrId?.toLowerCase()];
|
|
52
|
+
if (aliasedName) {
|
|
53
|
+
const byAlias = templates.find(t => t.title?.toLowerCase() === aliasedName.toLowerCase());
|
|
54
|
+
if (byAlias) return byAlias;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const byTitle = templates.find(t => t.title?.toLowerCase() === nameOrId?.toLowerCase());
|
|
58
|
+
if (byTitle) return byTitle;
|
|
59
|
+
|
|
60
|
+
return templates.find(t => t.title?.toLowerCase().includes(nameOrId?.toLowerCase()));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function listTemplates(category = null, json = false) {
|
|
64
|
+
const templates = category
|
|
65
|
+
? await searchTemplates(null, category)
|
|
66
|
+
: await getTemplates();
|
|
67
|
+
|
|
68
|
+
if (json) {
|
|
69
|
+
outputJson(templates.map(t => ({ id: t.id, title: t.title, category: t.category, version: t.version || 'latest' })));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const columns = [
|
|
74
|
+
{ key: 'id', label: 'ID' },
|
|
75
|
+
{ key: 'title', label: 'Name' },
|
|
76
|
+
{ key: 'category', label: 'Category' },
|
|
77
|
+
{ key: 'version', label: 'Version' }
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const data = templates.map(t => ({
|
|
81
|
+
id: t.id,
|
|
82
|
+
title: t.title,
|
|
83
|
+
category: truncate(t.category, 25),
|
|
84
|
+
version: t.version || 'latest'
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
console.log(`\n${colors.bold}Templates (${templates.length})${colors.reset}\n`);
|
|
88
|
+
console.log(formatTable(data, columns));
|
|
89
|
+
console.log('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function getServerSizes() {
|
|
93
|
+
if (sizesCache) return sizesCache;
|
|
94
|
+
const response = await apiRequestNoAuth('/api/servers/getServerSizes');
|
|
95
|
+
sizesCache = response.instances || [];
|
|
96
|
+
return sizesCache;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function filterSizes(provider = null, country = null) {
|
|
100
|
+
const sizes = await getServerSizes();
|
|
101
|
+
return sizes.filter(s => {
|
|
102
|
+
const matchesProvider = !provider || s.providerName?.toLowerCase() === provider.toLowerCase();
|
|
103
|
+
const matchesCountry = !country ||
|
|
104
|
+
s.Country?.toLowerCase().includes(country.toLowerCase()) ||
|
|
105
|
+
s.CountryCode?.toLowerCase() === country.toLowerCase();
|
|
106
|
+
return matchesProvider && matchesCountry;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function validateCombo(provider, datacenter, serverType) {
|
|
111
|
+
const sizes = await getServerSizes();
|
|
112
|
+
return sizes.find(s =>
|
|
113
|
+
s.providerName?.toLowerCase() === provider?.toLowerCase() &&
|
|
114
|
+
s.regionID?.toLowerCase() === datacenter?.toLowerCase() &&
|
|
115
|
+
s.title?.toLowerCase() === serverType?.toLowerCase()
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function listSizes(provider = null, country = null, json = false) {
|
|
120
|
+
const sizes = await filterSizes(provider, country);
|
|
121
|
+
|
|
122
|
+
if (json) {
|
|
123
|
+
outputJson(sizes.map(s => ({
|
|
124
|
+
provider: s.providerName,
|
|
125
|
+
region: s.regionID,
|
|
126
|
+
location: `${s.City}, ${s.CountryCode}`,
|
|
127
|
+
size: s.title,
|
|
128
|
+
cpu: s.vCPU,
|
|
129
|
+
ramGB: s.ramGB,
|
|
130
|
+
storageGB: s.storageSizeGB,
|
|
131
|
+
pricePerHour: s.pricePerHour,
|
|
132
|
+
priceMonthly: (parseFloat(s.pricePerHour) * 24 * 30).toFixed(0)
|
|
133
|
+
})));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const byProvider = {};
|
|
138
|
+
sizes.forEach(s => {
|
|
139
|
+
if (!byProvider[s.providerName]) byProvider[s.providerName] = [];
|
|
140
|
+
byProvider[s.providerName].push(s);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
console.log(`\n${colors.bold}Server Sizes (${sizes.length} options)${colors.reset}\n`);
|
|
144
|
+
|
|
145
|
+
Object.entries(byProvider).forEach(([providerName, providerSizes]) => {
|
|
146
|
+
console.log(`${colors.cyan}${providerName}${colors.reset}`);
|
|
147
|
+
|
|
148
|
+
const columns = [
|
|
149
|
+
{ key: 'regionID', label: 'Region' },
|
|
150
|
+
{ key: 'location', label: 'Location' },
|
|
151
|
+
{ key: 'title', label: 'Size' },
|
|
152
|
+
{ key: 'vCPU', label: 'CPU' },
|
|
153
|
+
{ key: 'ramGB', label: 'RAM' },
|
|
154
|
+
{ key: 'storage', label: 'Storage' },
|
|
155
|
+
{ key: 'price', label: 'Price/mo' }
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
const data = providerSizes.slice(0, 20).map(s => ({
|
|
159
|
+
regionID: s.regionID,
|
|
160
|
+
location: `${s.City}, ${s.CountryCode}`,
|
|
161
|
+
title: s.title,
|
|
162
|
+
vCPU: s.vCPU,
|
|
163
|
+
ramGB: s.ramGB + 'GB',
|
|
164
|
+
storage: s.storageSizeGB + 'GB ' + (s.storageType || ''),
|
|
165
|
+
price: '$' + (parseFloat(s.pricePerHour) * 24 * 30).toFixed(0)
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
console.log(formatTable(data, columns));
|
|
169
|
+
if (providerSizes.length > 20) {
|
|
170
|
+
console.log(` ... and ${providerSizes.length - 20} more`);
|
|
171
|
+
}
|
|
172
|
+
console.log('');
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function listCategories(json = false) {
|
|
177
|
+
const templates = await getTemplates();
|
|
178
|
+
const categories = [...new Set(templates.map(t => t.category))].filter(Boolean).sort();
|
|
179
|
+
|
|
180
|
+
if (json) {
|
|
181
|
+
outputJson(categories);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
console.log(`\n${colors.bold}Template Categories${colors.reset}\n`);
|
|
186
|
+
categories.forEach(c => console.log(` ${c}`));
|
|
187
|
+
console.log('');
|
|
188
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { apiRequest } from '../api.js';
|
|
2
|
+
import { loadConfig } from '../config.js';
|
|
3
|
+
import { doAction } from './actions.js';
|
|
4
|
+
import { log, colors, formatTable, outputJson } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
export async function listVolumes(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 response = await apiRequest('/api/volumes/getVolumes', 'POST', { projectID: String(pid) });
|
|
12
|
+
if (response.status !== 'OK') throw new Error(response.message || 'Failed to list volumes');
|
|
13
|
+
|
|
14
|
+
const volumes = response.data?.volumes || [];
|
|
15
|
+
|
|
16
|
+
if (json) { outputJson(volumes); return volumes; }
|
|
17
|
+
if (volumes.length === 0) { log('info', `No volumes in project ${pid}`); return []; }
|
|
18
|
+
|
|
19
|
+
const columns = [
|
|
20
|
+
{ key: 'id', label: 'ID' },
|
|
21
|
+
{ key: 'name', label: 'Name' },
|
|
22
|
+
{ key: 'size', label: 'Size' },
|
|
23
|
+
{ key: 'provider', label: 'Provider' },
|
|
24
|
+
{ key: 'region', label: 'Region' },
|
|
25
|
+
{ key: 'attached', label: 'Attached' }
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const data = volumes.map(v => ({
|
|
29
|
+
id: v.volumeID || v.id,
|
|
30
|
+
name: v.volumeName || v.name,
|
|
31
|
+
size: (v.volume || v.size) + 'GB',
|
|
32
|
+
provider: v.providerName || v.provider,
|
|
33
|
+
region: v.datacenter || v.region,
|
|
34
|
+
attached: v.serverID ? 'Yes' : 'No'
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
console.log(`\n${colors.bold}Volumes (${volumes.length})${colors.reset}\n`);
|
|
38
|
+
console.log(formatTable(data, columns));
|
|
39
|
+
console.log('');
|
|
40
|
+
return volumes;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function createVolume(options = {}) {
|
|
44
|
+
const config = loadConfig();
|
|
45
|
+
const { name, size = 10, provider = config.defaults?.provider || 'hetzner', datacenter = config.defaults?.datacenter || 'fsn1', projectId = config.defaultProject, serverId = null, storageType = 'NVME' } = options;
|
|
46
|
+
|
|
47
|
+
if (!name) throw new Error('Volume name required');
|
|
48
|
+
if (!projectId) throw new Error('Project ID required');
|
|
49
|
+
|
|
50
|
+
const price = (size * 0.05).toFixed(2);
|
|
51
|
+
log('info', `Creating volume "${name}" (${size}GB ${storageType} @ ${provider}/${datacenter})...`);
|
|
52
|
+
|
|
53
|
+
const body = {
|
|
54
|
+
projectID: String(projectId), providerName: provider, datacenter,
|
|
55
|
+
volumeName: name, price, isMoveData: false, volume: size, blockStorageType: storageType
|
|
56
|
+
};
|
|
57
|
+
if (serverId) body.selectedServerID = String(serverId);
|
|
58
|
+
|
|
59
|
+
const response = await apiRequest('/api/volumes/createVolume', 'POST', body);
|
|
60
|
+
if (response.status !== 'OK' && !response.volumeID) throw new Error(response.message || 'Failed to create volume');
|
|
61
|
+
|
|
62
|
+
log('success', `Volume "${name}" created`);
|
|
63
|
+
return response;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function getServiceVolumes(vmID, json = false) {
|
|
67
|
+
const result = await doAction(vmID, 'getServiceVolume');
|
|
68
|
+
const volumes = Array.isArray(result.data) ? result.data : (result.data?.volumes || result.volumes || []);
|
|
69
|
+
|
|
70
|
+
if (json) { outputJson(volumes); return volumes; }
|
|
71
|
+
if (volumes.length === 0) { log('info', 'No volumes attached'); return []; }
|
|
72
|
+
|
|
73
|
+
console.log(`\n${colors.bold}Attached Volumes${colors.reset}\n`);
|
|
74
|
+
volumes.forEach(v => console.log(` ${v.volumeID || v.id}: ${v.volumeName || v.name} (${v.volumeSizeInGB || v.volume || v.size || 'N/A'}GB)`));
|
|
75
|
+
console.log('');
|
|
76
|
+
return volumes;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function createServiceVolume(vmID, options = {}) {
|
|
80
|
+
const { name, size = 10, storageType = 'NVME' } = options;
|
|
81
|
+
if (!name) throw new Error('Volume name required');
|
|
82
|
+
|
|
83
|
+
log('info', `Creating and attaching volume "${name}" to ${vmID}...`);
|
|
84
|
+
const result = await doAction(vmID, 'createServiceVolume', { volumeName: name, volume: size, blockStorageType: storageType, isMoveData: false });
|
|
85
|
+
log('success', `Volume "${name}" created and attached`);
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function resizeVolume(vmID, volumeID, newSize) {
|
|
90
|
+
if (!newSize || newSize < 10) throw new Error('New size must be at least 10GB');
|
|
91
|
+
log('info', `Resizing volume ${volumeID} to ${newSize}GB...`);
|
|
92
|
+
const result = await doAction(vmID, 'resizeServiceVolume', { volumeID: String(volumeID), volume: newSize, volumeName: '', isMoveData: false, currentSize: 0 });
|
|
93
|
+
log('success', `Volume resized to ${newSize}GB`);
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function detachVolume(vmID, volumeID, options = {}) {
|
|
98
|
+
const { keepVolume = true } = options;
|
|
99
|
+
log('info', `Detaching volume ${volumeID} from ${vmID}...`);
|
|
100
|
+
const result = await doAction(vmID, 'detachServiceVolume', { volumeID: String(volumeID), isKeepVolume: keepVolume, isMoveData: false });
|
|
101
|
+
log('success', `Volume detached${keepVolume ? '' : ' and deleted'}`);
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function deleteServiceVolume(vmID, volumeID) {
|
|
106
|
+
log('info', `Deleting volume ${volumeID}...`);
|
|
107
|
+
const result = await doAction(vmID, 'deleteServiceVolume', { volumeID: String(volumeID) });
|
|
108
|
+
log('success', 'Volume deleted');
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function setVolumeProtection(vmID, volumeID, enabled = true) {
|
|
113
|
+
log('info', `${enabled ? 'Enabling' : 'Disabling'} protection for volume ${volumeID}...`);
|
|
114
|
+
const result = await doAction(vmID, 'manageServiceVolumeProtection', { volumeID: String(volumeID), isVolumeProtection: enabled });
|
|
115
|
+
log('success', `Volume protection ${enabled ? 'enabled' : 'disabled'}`);
|
|
116
|
+
return result;
|
|
117
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const ELESTIO_DIR = path.join(os.homedir(), '.elestio');
|
|
6
|
+
const CREDENTIALS_PATH = path.join(ELESTIO_DIR, 'credentials');
|
|
7
|
+
const CONFIG_PATH = path.join(ELESTIO_DIR, 'config.json');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG = {
|
|
10
|
+
jwt: null,
|
|
11
|
+
jwtExpiry: null,
|
|
12
|
+
defaultProject: null,
|
|
13
|
+
defaults: {
|
|
14
|
+
provider: 'netcup',
|
|
15
|
+
datacenter: 'nbg',
|
|
16
|
+
serverType: 'MEDIUM-2C-4G',
|
|
17
|
+
support: 'level1'
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function ensureDir() {
|
|
22
|
+
if (!fs.existsSync(ELESTIO_DIR)) {
|
|
23
|
+
fs.mkdirSync(ELESTIO_DIR, { mode: 0o700, recursive: true });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Credentials (email + apiToken) ──
|
|
28
|
+
|
|
29
|
+
export function getCredentials() {
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(CREDENTIALS_PATH)) {
|
|
32
|
+
const data = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf-8'));
|
|
33
|
+
return {
|
|
34
|
+
email: data.email || null,
|
|
35
|
+
apiToken: data.apiToken || null
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// ignore
|
|
40
|
+
}
|
|
41
|
+
return { email: null, apiToken: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function saveCredentials(email, apiToken) {
|
|
45
|
+
ensureDir();
|
|
46
|
+
fs.writeFileSync(
|
|
47
|
+
CREDENTIALS_PATH,
|
|
48
|
+
JSON.stringify({ email, apiToken }, null, 2),
|
|
49
|
+
{ mode: 0o600 }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Config (jwt, defaults, defaultProject) ──
|
|
54
|
+
|
|
55
|
+
export function loadConfig() {
|
|
56
|
+
let config = { ...DEFAULT_CONFIG };
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
60
|
+
const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
61
|
+
config = { ...DEFAULT_CONFIG, ...data };
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// ignore
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Merge credentials into config for convenience
|
|
68
|
+
const creds = getCredentials();
|
|
69
|
+
config.email = creds.email;
|
|
70
|
+
config.apiToken = creds.apiToken;
|
|
71
|
+
|
|
72
|
+
return config;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function saveConfig(config) {
|
|
76
|
+
ensureDir();
|
|
77
|
+
// Never persist credentials in config.json
|
|
78
|
+
const { email, apiToken, ...safeConfig } = config;
|
|
79
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(safeConfig, null, 2));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Exports ──
|
|
83
|
+
|
|
84
|
+
export { ELESTIO_DIR, CREDENTIALS_PATH, CONFIG_PATH };
|