elestio 1.0.2 → 1.1.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.
@@ -1,6 +1,6 @@
1
1
  import { apiRequest } from '../api.js';
2
2
  import { loadConfig } from '../config.js';
3
- import { log, colors, outputJson } from '../utils.js';
3
+ import { colors, outputJson } from '../utils.js';
4
4
  import { getServiceDetails } from './services.js';
5
5
 
6
6
  export async function getCredentials(vmID, projectId = null, json = false) {
@@ -71,22 +71,35 @@ export async function getSSH(vmID, projectId = null, json = false) {
71
71
  return response;
72
72
  }
73
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');
74
+ /**
75
+ * `--direct` prints the details for connecting with a local ssh client.
76
+ *
77
+ * These come from the service record: /api/servers/startSSHDirect returns a
78
+ * browser terminal URL, not host/port/user, so it cannot answer this.
79
+ */
80
+ export async function getSSHDirect(vmID, projectId = null, json = false) {
81
+ const config = loadConfig();
82
+ const pid = projectId || config.defaultProject;
83
+ if (!pid) throw new Error('Project ID required');
77
84
 
78
- if (json) {
79
- outputJson({ host: response.ip || response.host, port: response.port || 22, user: response.user || 'root' });
80
- return response;
81
- }
85
+ const service = await getServiceDetails(vmID, pid);
86
+ if (!service) throw new Error(`Service with vmID ${vmID} not found`);
87
+
88
+ const host = service.ipv4 || service.cname;
89
+ if (!host) throw new Error(`Service ${vmID} has no IP yet; it may still be deploying`);
90
+
91
+ const details = { host, port: 22, user: 'root', command: `ssh root@${host}` };
92
+
93
+ if (json) { outputJson(details); return details; }
82
94
 
83
95
  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}`);
96
+ console.log(` Host: ${host}`);
97
+ console.log(` Port: 22`);
98
+ console.log(` User: root`);
99
+ console.log(`\n ${colors.cyan}${details.command}${colors.reset}`);
100
+ console.log(`\n ${colors.dim}Add your key first: elestio ssh-keys add ${vmID} --name <n> --key "$(awk '{print $1" "$2}' ~/.ssh/id_ed25519.pub)"${colors.reset}`);
88
101
  console.log('');
89
- return response;
102
+ return details;
90
103
  }
91
104
 
92
105
  export async function getVSCode(vmID, projectId = null, json = false) {
@@ -1,4 +1,4 @@
1
- import { loadConfig, saveConfig, saveCredentials, getCredentials, CREDENTIALS_PATH } from '../config.js';
1
+ import { loadConfig, saveConfig, saveCredentials, getCredentials } from '../config.js';
2
2
  import { authenticate } from '../api.js';
3
3
  import { log, colors, outputJson } from '../utils.js';
4
4
 
@@ -0,0 +1,363 @@
1
+ import { apiRequest } from '../api.js';
2
+ import { loadConfig } from '../config.js';
3
+ import { log, colors, outputJson, formatTable } from '../utils.js';
4
+ import { getTemplates } from './templates.js';
5
+ import { createSubstitutions, normalizeElestioConfig, repoFileMounts } from '../templates/elestio-config.js';
6
+ import { buildGitPipelinePayload, buildComposePipelinePayload } from '../payloads/pipeline.js';
7
+ import { TEMPLATE_REPO_OWNER, TEMPLATE_REPO_HOST } from '../constants.js';
8
+
9
+ /**
10
+ * `elestio cicd deploy-template` - deploy catalog software as a CI/CD pipeline.
11
+ *
12
+ * Every catalog entry has a matching repo under github.com/elestio-examples
13
+ * carrying an elestio.yml that describes how to run it: ports, environment
14
+ * variables, lifecycle hooks. Creating a pipeline without reading that file
15
+ * produces a pipeline that starts nothing, which is why this is a dedicated
16
+ * command rather than a flag on `cicd create`.
17
+ *
18
+ * Two routes:
19
+ * git - generate the template repo into the user's own Git account, then
20
+ * point the pipeline at it. Keeps the lifecycle scripts.
21
+ * compose - inline the template's docker-compose.yml. No Git account needed,
22
+ * but lifecycle scripts are lost because there is no checkout.
23
+ */
24
+
25
+ function templateRepoUrl(repoName) {
26
+ return `${TEMPLATE_REPO_HOST}/${TEMPLATE_REPO_OWNER}/${repoName}`;
27
+ }
28
+
29
+ /**
30
+ * Repos under elestio-examples are named after the catalog title, lowercased
31
+ * and hyphenated -- "N8N" -> n8n, "InvenTree" -> inventree. GitHub resolves
32
+ * repository names case-insensitively, so the casing does not have to match.
33
+ *
34
+ * Do not derive this from dockerhub_image: that is the upstream image
35
+ * ("n8nio/n8n"), not the template repo.
36
+ */
37
+ export function templateRepoName(template) {
38
+ return String(template.title)
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, '-')
41
+ .replace(/^-+|-+$/g, '');
42
+ }
43
+
44
+ async function fetchGitContent(url, branch, file, projectId, authID = null) {
45
+ const response = await apiRequest('/api/cicd/getGitContent', 'POST', {
46
+ url, branch, file,
47
+ unAuthorized: !authID,
48
+ authID: authID || '',
49
+ projectID: String(projectId),
50
+ pipelineID: null,
51
+ contentPath: ''
52
+ });
53
+
54
+ if (response && response.status === 'KO') return null;
55
+ return response;
56
+ }
57
+
58
+ /**
59
+ * Reads elestio.yml from a template repo. The backend parses the YAML, so the
60
+ * CLI stays dependency-free.
61
+ */
62
+ export async function fetchElestioConfig(repoUrl, branch, projectId, email, authID = null) {
63
+ const raw = await fetchGitContent(repoUrl, branch, 'elestio', projectId, authID);
64
+ const subs = createSubstitutions(email);
65
+ return { config: normalizeElestioConfig(raw, subs), subs, raw };
66
+ }
67
+
68
+ export async function fetchCompose(repoUrl, branch, projectId, authID = null) {
69
+ const response = await fetchGitContent(repoUrl, branch, 'docker-compose', projectId, authID);
70
+ const compose = response?.data?.dockerCompose;
71
+ if (!compose) {
72
+ throw new Error(`No docker-compose file found in ${repoUrl} on branch "${branch}"`);
73
+ }
74
+ return compose;
75
+ }
76
+
77
+ async function resolveCicdTarget(vmID, projectId) {
78
+ const response = await apiRequest('/api/cicd/getCICDServices', 'POST', { projectID: String(projectId) });
79
+ const services = Array.isArray(response) ? response : (response.data?.services || []);
80
+ const target = services.find(s => String(s.providerServerID) === String(vmID) || String(s.vmID) === String(vmID));
81
+
82
+ if (!target) {
83
+ const available = services.map(s => s.providerServerID || s.vmID).join(', ') || 'none';
84
+ throw new Error(
85
+ `CI/CD target ${vmID} not found in project ${projectId}. Available targets: ${available}. ` +
86
+ 'Create one with: elestio deploy CI-CD-Target'
87
+ );
88
+ }
89
+
90
+ return {
91
+ displayName: target.displayName || target.name,
92
+ id: target.id || target.serverID,
93
+ serverName: target.serverName || '',
94
+ vmID: String(target.providerServerID || target.vmID),
95
+ vmProvider: target.vmProvider || target.provider || '',
96
+ vmRegion: target.vmRegion || target.datacenter || '',
97
+ levelName: target.levelName || 'Elestio-services',
98
+ projectID: String(projectId)
99
+ };
100
+ }
101
+
102
+ async function findGitAuth(gitType, projectId) {
103
+ const response = await apiRequest('/api/cicd/getCICDUserAuthAccount', 'POST', {});
104
+ const accounts = response.data?.accounts || response.data || response.accounts || [];
105
+ if (!Array.isArray(accounts)) return null;
106
+ const match = accounts.find(a => (a.externalProviderName || a.gitType) === gitType);
107
+ return match ? String(match.id || match.authID) : null;
108
+ }
109
+
110
+ /**
111
+ * Generates the template repo into the user's Git account.
112
+ * GitHub uses the template-generate API; GitLab imports the repo by URL.
113
+ *
114
+ * NOTE: /api/cicd/createRepoByTemplate currently returns 404 in production -
115
+ * the controller exists in the backend but is not registered in apiconfig.json,
116
+ * which is the route whitelist. Until that is fixed the Git route cannot work,
117
+ * so --no-git is the default and this path reports the gap precisely rather
118
+ * than surfacing a bare 404.
119
+ */
120
+ async function generateRepoFromTemplate({ gitType, authID, owner, repoName, templateName, isPrivate, isNonOrg }) {
121
+ let response;
122
+
123
+ try {
124
+ response = await apiRequest('/api/cicd/createRepoByTemplate', 'POST', {
125
+ ownerName: owner,
126
+ repoName,
127
+ templateName,
128
+ gitType,
129
+ authID: String(authID),
130
+ isPrivate: String(!!isPrivate),
131
+ isNonOrg: !!isNonOrg
132
+ });
133
+ } catch (err) {
134
+ if (/non-JSON response \(HTTP 404\)|HTTP 404/.test(err.message)) {
135
+ throw new Error(
136
+ 'The Elestio API does not currently expose /api/cicd/createRepoByTemplate (404), ' +
137
+ 'so the Git route is unavailable. Deploy with --no-git instead; the software will run, ' +
138
+ 'but any lifecycle scripts it declares will be skipped.'
139
+ );
140
+ }
141
+ throw err;
142
+ }
143
+
144
+ if (response?.status === 'KO') {
145
+ throw new Error(response.message || response.details?.message || `Could not create "${repoName}" in ${owner}`);
146
+ }
147
+ return response;
148
+ }
149
+
150
+ export async function listDeployableTemplates(query, json = false) {
151
+ const templates = await getTemplates();
152
+ const q = (query || '').toLowerCase();
153
+ const matches = templates
154
+ .filter(t => !q || t.title?.toLowerCase().includes(q) || String(t.id) === q)
155
+ .map(t => ({ id: t.id, title: t.title, category: t.category, repo: templateRepoName(t) }));
156
+
157
+ if (json) { outputJson(matches); return matches; }
158
+
159
+ console.log(`\n${colors.bold}Catalog software deployable as a pipeline (${matches.length})${colors.reset}\n`);
160
+ console.log(formatTable(matches.slice(0, 60), [
161
+ { key: 'id', label: 'ID' },
162
+ { key: 'title', label: 'Software' },
163
+ { key: 'category', label: 'Category' },
164
+ { key: 'repo', label: 'Template repo' }
165
+ ]));
166
+ if (matches.length > 60) console.log(`\n ... and ${matches.length - 60} more. Narrow with: elestio cicd templates <query>`);
167
+ console.log('');
168
+ return matches;
169
+ }
170
+
171
+ /**
172
+ * @param {string} nameOrId catalog template, by name or ID
173
+ * @param {object} options see cli.js for the flag mapping
174
+ */
175
+ export async function deployTemplate(nameOrId, options = {}) {
176
+ const config = loadConfig();
177
+ const projectId = options.project || config.defaultProject;
178
+ if (!projectId) throw new Error('Project ID required. Use --project or set a default.');
179
+ if (!options.target) throw new Error('A CI/CD target is required (--target <vmID>). List them with: elestio cicd targets');
180
+
181
+ const templates = await getTemplates();
182
+ const template = templates.find(t =>
183
+ String(t.id) === String(nameOrId) || t.title?.toLowerCase() === String(nameOrId).toLowerCase()
184
+ ) || templates.find(t => t.title?.toLowerCase().includes(String(nameOrId).toLowerCase()));
185
+
186
+ if (!template) {
187
+ throw new Error(`Template "${nameOrId}" not found. Search with: elestio cicd templates ${nameOrId}`);
188
+ }
189
+
190
+ const repoName = options.repoName || templateRepoName(template);
191
+ const pipelineName = options.name || repoName;
192
+ const branch = options.branch || 'main';
193
+ const sourceRepoUrl = templateRepoUrl(repoName);
194
+ // Opt-in, not opt-out: the Git route depends on an endpoint the API does not
195
+ // currently expose (see generateRepoFromTemplate).
196
+ const useGit = options.git === true;
197
+
198
+ log('info', `Resolving CI/CD target ${options.target}...`);
199
+ const target = await resolveCicdTarget(options.target, projectId);
200
+ log('success', `Target: ${target.displayName} (${target.vmID})`);
201
+
202
+ log('info', `Reading elestio.yml from ${TEMPLATE_REPO_OWNER}/${repoName}...`);
203
+ const { config: elestioConfig, subs } = await fetchElestioConfig(sourceRepoUrl, branch, projectId, config.email);
204
+
205
+ if (!elestioConfig.hasConfig) {
206
+ throw new Error(
207
+ `No elestio.yml found at ${sourceRepoUrl} (branch "${branch}"). ` +
208
+ `"${template.title}" may not be publishable as a pipeline; deploy it as a managed service instead: elestio deploy ${template.id}`
209
+ );
210
+ }
211
+ log('success', `Config loaded: runtime "${elestioConfig.config.runTime}", ${elestioConfig.variables ? elestioConfig.variables.split('\n').length : 0} env vars, ${elestioConfig.ports.length} port(s)`);
212
+
213
+ const payload = useGit
214
+ ? await buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig })
215
+ : await buildComposeRoute({ projectId, target, repoName, pipelineName, branch, sourceRepoUrl, elestioConfig, force: options.force });
216
+
217
+ if (options.dryRun) {
218
+ if (options.json) { outputJson({ dryRun: true, route: useGit ? 'git' : 'compose', payload }); return payload; }
219
+ printPlan({ template, repoName, pipelineName, target, elestioConfig, useGit, branch });
220
+ log('info', 'To deploy, run the same command without --dry-run');
221
+ return payload;
222
+ }
223
+
224
+ log('info', 'Creating pipeline...');
225
+ const response = await apiRequest('/api/cicd/createCiCdExistServer', 'POST', payload);
226
+
227
+ if (response.status !== 'OK' && !response.providerServerID && !response.pipelineID) {
228
+ throw new Error(response.message || JSON.stringify(response));
229
+ }
230
+
231
+ log('success', `Pipeline "${pipelineName}" created on ${target.displayName}`);
232
+
233
+ if (options.json) {
234
+ outputJson({ status: 'OK', pipelineName, target: target.vmID, route: useGit ? 'git' : 'compose', webUI: elestioConfig.webUI, response });
235
+ return response;
236
+ }
237
+
238
+ printAccess(elestioConfig, subs, target);
239
+ return response;
240
+ }
241
+
242
+ async function buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig }) {
243
+ const gitType = (options.gitType || 'GITHUB').toUpperCase();
244
+
245
+ let authID = options.authId ? String(options.authId) : await findGitAuth(gitType, projectId);
246
+ if (!authID) {
247
+ throw new Error(
248
+ `No ${gitType} account connected. Connect one in the dashboard (CI/CD > Git accounts), pass --auth-id, ` +
249
+ 'or deploy without Git using --no-git (lifecycle scripts will be skipped).'
250
+ );
251
+ }
252
+
253
+ const owner = options.owner;
254
+ if (!owner) {
255
+ throw new Error('The Git account or organisation to create the repo in is required (--owner <user-or-org>)');
256
+ }
257
+
258
+ log('info', `Creating ${owner}/${repoName} from ${TEMPLATE_REPO_OWNER}/${repoName}...`);
259
+ const repo = await generateRepoFromTemplate({
260
+ gitType, authID, owner, repoName,
261
+ templateName: repoName,
262
+ isPrivate: !!options.private,
263
+ isNonOrg: !!options.nonOrg
264
+ });
265
+ log('success', `Repo created: ${owner}/${repoName}`);
266
+
267
+ return buildGitPipelinePayload({
268
+ target,
269
+ projectId,
270
+ pipelineName,
271
+ gitType,
272
+ repo: `${owner}/${repoName}`,
273
+ branch,
274
+ repoID: repo?.id ?? repo?.content?.id ?? '',
275
+ elestioConfig,
276
+ authID,
277
+ isPublicGitRepo: !options.private,
278
+ isNeedToCreateRepo: true,
279
+ appType: 'docker',
280
+ overrides: collectOverrides(options)
281
+ });
282
+ }
283
+
284
+ async function buildComposeRoute({ projectId, target, repoName, pipelineName, branch, sourceRepoUrl, elestioConfig, force }) {
285
+ log('info', `Fetching docker-compose from ${TEMPLATE_REPO_OWNER}/${repoName}...`);
286
+ const compose = await fetchCompose(sourceRepoUrl, branch, projectId);
287
+ log('success', 'Compose file loaded');
288
+
289
+ // Checked before anything is created: without these files the containers
290
+ // fail ~40s into the build with an opaque "not a directory" from runc.
291
+ const fileMounts = repoFileMounts(compose);
292
+ if (fileMounts.length > 0 && !force) {
293
+ throw new Error(
294
+ `"${repoName}" bind-mounts ${fileMounts.length} file(s) that live in the template repo ` +
295
+ `(${fileMounts.join(', ')}). The compose route has no checkout, so Docker creates each one as an ` +
296
+ 'empty directory and the container fails to start. Use the Git route (--owner <git-user>) for this ' +
297
+ 'software, or --force to deploy anyway.'
298
+ );
299
+ }
300
+
301
+ if (hasLifecycleHooks(elestioConfig)) {
302
+ log('warn', `"${repoName}" defines lifecycle scripts that need a repo checkout; they are skipped on the compose route.`);
303
+ }
304
+
305
+ return buildComposePipelinePayload({
306
+ target, projectId, pipelineName, compose, elestioConfig
307
+ });
308
+ }
309
+
310
+ function hasLifecycleHooks(elestioConfig) {
311
+ return Object.values(elestioConfig.lifeCycleCommand).some(v => v && v !== '');
312
+ }
313
+
314
+ function collectOverrides(options) {
315
+ const overrides = {};
316
+ if (options.buildCmd !== undefined) overrides.buildCmd = options.buildCmd;
317
+ if (options.runCmd !== undefined) overrides.runCmd = options.runCmd;
318
+ if (options.installCmd !== undefined) overrides.installCmd = options.installCmd;
319
+ if (options.buildDir !== undefined) overrides.buildDir = options.buildDir;
320
+ if (options.variables !== undefined) overrides.variables = options.variables;
321
+ return overrides;
322
+ }
323
+
324
+ function printPlan({ template, repoName, pipelineName, target, elestioConfig, useGit, branch }) {
325
+ console.log(`\n${colors.bold}Pipeline Preview (--dry-run)${colors.reset}\n`);
326
+ console.log(` Software: ${colors.cyan}${template.title}${colors.reset} (ID: ${template.id})`);
327
+ console.log(` Template: ${TEMPLATE_REPO_OWNER}/${repoName} @ ${branch}`);
328
+ console.log(` Pipeline: ${pipelineName}`);
329
+ console.log(` Target: ${target.displayName} (${target.vmID})`);
330
+ console.log(` Route: ${useGit ? 'git (repo generated in your account, lifecycle scripts kept)' : 'compose (inlined, lifecycle scripts skipped)'}`);
331
+ console.log(` Runtime: ${elestioConfig.config.runTime || 'N/A'}`);
332
+ console.log(` Ports: ${elestioConfig.ports.map(p => `${p.listeningPort}->${p.targetPort}`).join(', ') || 'default'}`);
333
+ console.log(` Env vars: ${elestioConfig.variables ? elestioConfig.variables.split('\n').map(v => v.split('=')[0]).join(', ') : 'none'}`);
334
+ if (hasLifecycleHooks(elestioConfig)) {
335
+ const hooks = Object.entries(elestioConfig.lifeCycleCommand).filter(([, v]) => v).map(([k]) => k);
336
+ console.log(` Lifecycle: ${hooks.join(', ')}`);
337
+ }
338
+ console.log('');
339
+ }
340
+
341
+ function printAccess(elestioConfig, subs, target) {
342
+ const vars = Object.fromEntries(
343
+ (elestioConfig.variables || '').split('\n').filter(Boolean).map(line => {
344
+ const eq = line.indexOf('=');
345
+ return [line.slice(0, eq), line.slice(eq + 1)];
346
+ })
347
+ );
348
+
349
+ if (elestioConfig.webUI.length > 0) {
350
+ console.log(`\n${colors.bold}Access${colors.reset}\n`);
351
+ for (const ui of elestioConfig.webUI) {
352
+ // webUI references env vars by name, e.g. password: "[ADMIN_PASSWORD]"
353
+ const resolve = (v) => String(v || '').replace(/\[([A-Z0-9_]+)\]/g, (m, key) => vars[key] ?? m);
354
+ console.log(` ${colors.bold}${ui.label || 'Web UI'}${colors.reset}`);
355
+ console.log(` URL: ${colors.cyan}${resolve(ui.url)}${colors.reset}`);
356
+ if (ui.login) console.log(` User: ${resolve(ui.login)}`);
357
+ if (ui.password) console.log(` Password: ${resolve(ui.password)}`);
358
+ }
359
+ console.log(`\n ${colors.dim}[CI_CD_DOMAIN] resolves once the pipeline has deployed.${colors.reset}`);
360
+ }
361
+
362
+ console.log(`\n Follow the build: ${colors.cyan}elestio cicd pipelines ${target.vmID}${colors.reset}\n`);
363
+ }
@@ -1,11 +1,7 @@
1
1
  import { apiRequest } from '../api.js';
2
2
  import { loadConfig } from '../config.js';
3
- import { log, colors, formatTable, sleep, outputJson } from '../utils.js';
4
- import { getServiceDetails } from './services.js';
3
+ import { log, colors, formatTable, outputJson } from '../utils.js';
5
4
  import fs from 'fs';
6
- import path from 'path';
7
- import os from 'os';
8
- import { execSync } from 'child_process';
9
5
 
10
6
  // ── List / Details ──
11
7
 
@@ -147,13 +143,29 @@ export async function getPipelineLogs(vmID, pipelineID, projectId) {
147
143
 
148
144
  export async function getPipelineHistory(vmID, pipelineID, projectId, json = false) {
149
145
  const result = await doActionOnPipeline(vmID, pipelineID, 'getHistory', {}, projectId);
150
- const history = result.data?.history || result.history || [];
146
+ // The action answers {data: [...]}, not the {data: {history: [...]}} shape
147
+ // the other endpoints use, so history never rendered.
148
+ const history = Array.isArray(result.data) ? result.data : (result.data?.history || result.history || []);
151
149
  if (json) { outputJson(history); return history; }
152
150
  if (history.length === 0) { log('info', 'No build history'); return []; }
153
151
 
152
+ const rows = history.map(h => ({
153
+ status: h.status || 'N/A',
154
+ action: h.action || 'N/A',
155
+ duration: h.duration !== undefined ? `${h.duration}s` : 'N/A',
156
+ startTime: h.startTime ? new Date(h.startTime).toISOString().replace('T', ' ').slice(0, 19) : 'N/A',
157
+ log: h.logID || h.filepath || h.file || 'N/A'
158
+ }));
159
+
154
160
  console.log(`\n${colors.bold}Build History${colors.reset}\n`);
155
- history.forEach(h => console.log(` ${h.filepath || h.file}: ${h.status || 'N/A'}`));
156
- console.log('');
161
+ console.log(formatTable(rows, [
162
+ { key: 'status', label: 'Status' },
163
+ { key: 'action', label: 'Action' },
164
+ { key: 'duration', label: 'Duration' },
165
+ { key: 'startTime', label: 'Started' },
166
+ { key: 'log', label: 'Log file' }
167
+ ]));
168
+ console.log(`\n Read one with: ${colors.cyan}elestio cicd pipeline-log ${vmID} --pipeline ${pipelineID} --file <log file>${colors.reset}\n`);
157
169
  return history;
158
170
  }
159
171
 
@@ -203,7 +215,13 @@ export async function removePipelineDomain(vmID, pipelineID, domain, projectId)
203
215
  export async function createPipeline(configFile) {
204
216
  if (!configFile || !fs.existsSync(configFile)) throw new Error(`Config file not found: ${configFile}`);
205
217
 
206
- const pipelineConfig = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
218
+ const fileConfig = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
219
+ // The backend runs variables.trim(), so it must be a string, never an array.
220
+ const pipelineConfig = {
221
+ ...fileConfig,
222
+ variables: typeof fileConfig.variables === 'string' ? fileConfig.variables : ''
223
+ };
224
+
207
225
  log('info', 'Creating pipeline...');
208
226
  const response = await apiRequest('/api/cicd/createCiCdExistServer', 'POST', pipelineConfig);
209
227
 
@@ -338,7 +356,7 @@ export async function autoCreatePipeline(options = {}) {
338
356
  targetIP: '172.17.0.1', path: '/', isAuth: false,
339
357
  login: '', password: '', loginTitle: ''
340
358
  }],
341
- variables: options.variables || '',
359
+ variables: typeof options.variables === 'string' ? options.variables : '',
342
360
  isPublicGitRepo: repoData ? !repoData.private : false,
343
361
  exposedPorts: [{ protocol: 'HTTP', hostPort: preset.containerPort, containerPort: preset.containerPort, interface: '172.17.0.1' }],
344
362
  gitVolumeConfig: [{}],
@@ -455,12 +473,14 @@ export function generatePipelineTemplate(mode = 'docker') {
455
473
 
456
474
  // ── Docker Registries ──
457
475
 
458
- export async function addDockerRegistry(projectId, identityName, username, password, url) {
476
+ export async function addDockerRegistry(projectId, identityName, username, password, url, registryType = 'docker.io', repoId = '', gitlabUrl = '') {
459
477
  const config = loadConfig();
460
478
  const pid = projectId || config.defaultProject;
461
479
 
462
- const response = await apiRequest('/api/cicd/addDockerRegistry', 'POST', { projectID: String(pid), identityName, username, password, url });
463
- if (response.status !== 'OK') throw new Error(response.message || 'Failed');
480
+ const payload = { projectID: String(pid), identityName, username, password, url, registryType, repoID: repoId, gitlabUrl };
481
+ const response = await apiRequest('/api/cicd/addDockerRegistry', 'POST', payload);
482
+ if (response.status === 'KO') throw new Error(response.message || 'Failed');
483
+ if (!response.id) throw new Error(response.message || 'Failed');
464
484
 
465
485
  log('success', `Docker registry "${identityName}" added`);
466
486
  return response;
@@ -471,9 +491,14 @@ export async function getDockerRegistries(projectId = null, json = false) {
471
491
  const pid = projectId || config.defaultProject;
472
492
 
473
493
  const response = await apiRequest('/api/cicd/getDockerRegistry', 'GET', { projectID: String(pid) });
474
- if (response.status !== 'OK') throw new Error(response.message || 'Failed');
494
+ // This endpoint answers with a bare array, not the usual {status, data}
495
+ // envelope -- and an empty project gets a bare []. Demanding the envelope
496
+ // turned "no registries" into an error.
497
+ if (!Array.isArray(response) && response.status === 'KO') {
498
+ throw new Error(response.message || 'Failed to list Docker registries');
499
+ }
475
500
 
476
- const registries = response.data?.registries || [];
501
+ const registries = Array.isArray(response) ? response : (response.data?.registries || []);
477
502
  if (json) { outputJson(registries); return registries; }
478
503
  if (registries.length === 0) { log('info', 'No Docker registries'); return []; }
479
504