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.
package/src/router.js ADDED
@@ -0,0 +1,188 @@
1
+ import { colors, log } from './utils.js';
2
+
3
+ /**
4
+ * Declarative command dispatch.
5
+ *
6
+ * Commands describe themselves - group, usage, flags, sub-actions - so the
7
+ * help screens are generated from the same data the router dispatches on.
8
+ * A command and its documentation cannot drift apart.
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} Command
13
+ * @property {string} group heading it appears under in `elestio --help`
14
+ * @property {string} summary one line, shown in the command list
15
+ * @property {string} [usage] argument shape, without the leading `elestio`
16
+ * @property {object} [actions] sub-action name -> { summary, usage, run }
17
+ * @property {string} [defaultAction] action used when none is given
18
+ * @property {Function} [run] handler for commands without sub-actions
19
+ * @property {string[]} [flags] documented flags, shown in `elestio <cmd> --help`
20
+ */
21
+
22
+ export function findCommand(registry, name) {
23
+ if (registry[name]) return { name, command: registry[name] };
24
+
25
+ // Accept an unambiguous prefix so `elestio clust list` works.
26
+ const matches = Object.keys(registry).filter(key => key.startsWith(name));
27
+ if (matches.length === 1) return { name: matches[0], command: registry[matches[0]] };
28
+
29
+ return { name: null, command: null, ambiguous: matches.length > 1 ? matches : null };
30
+ }
31
+
32
+ export function suggest(registry, name) {
33
+ // Generous enough to catch a transposition ("clsuter" is 3 edits from
34
+ // "clusters"), tight enough not to propose unrelated commands.
35
+ const threshold = Math.max(2, Math.ceil(name.length / 3));
36
+
37
+ const candidates = Object.keys(registry)
38
+ .map(key => ({
39
+ key,
40
+ // A command that contains what was typed is almost always the one meant.
41
+ distance: key.includes(name) || name.includes(key) ? 0 : editDistance(name, key)
42
+ }))
43
+ .filter(c => c.distance <= threshold)
44
+ .sort((a, b) => a.distance - b.distance || a.key.length - b.key.length);
45
+
46
+ return candidates.slice(0, 3).map(c => c.key);
47
+ }
48
+
49
+ function editDistance(a, b) {
50
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
51
+ for (let j = 0; j <= b.length; j++) rows[0][j] = j;
52
+
53
+ for (let i = 1; i <= a.length; i++) {
54
+ for (let j = 1; j <= b.length; j++) {
55
+ rows[i][j] = a[i - 1] === b[j - 1]
56
+ ? rows[i - 1][j - 1]
57
+ : 1 + Math.min(rows[i - 1][j], rows[i][j - 1], rows[i - 1][j - 1]);
58
+ }
59
+ }
60
+ return rows[a.length][b.length];
61
+ }
62
+
63
+ export function renderMainHelp(registry, version) {
64
+ const groups = new Map();
65
+ for (const [name, command] of Object.entries(registry)) {
66
+ if (command.hidden) continue;
67
+ if (!groups.has(command.group)) groups.set(command.group, []);
68
+ groups.get(command.group).push({ name, ...command });
69
+ }
70
+
71
+ const width = Math.max(...Object.keys(registry).map(n => n.length)) + 2;
72
+ const lines = [
73
+ '',
74
+ `${colors.bold}Elestio CLI${colors.reset} v${version}`,
75
+ 'Deploy and manage services on the Elestio DevOps platform.',
76
+ '',
77
+ `${colors.bold}Usage:${colors.reset} elestio <command> [action] [options]`,
78
+ ''
79
+ ];
80
+
81
+ for (const [group, commands] of groups) {
82
+ lines.push(`${colors.bold}${group}${colors.reset}`);
83
+ for (const command of commands) {
84
+ lines.push(` ${colors.cyan}${command.name.padEnd(width)}${colors.reset}${command.summary}`);
85
+ }
86
+ lines.push('');
87
+ }
88
+
89
+ lines.push(
90
+ `${colors.bold}Global Options${colors.reset}`,
91
+ ` ${'--json'.padEnd(width + 2)}Output in JSON format`,
92
+ ` ${'--project <id>'.padEnd(width + 2)}Project ID (overrides the default)`,
93
+ ` ${'--debug'.padEnd(width + 2)}Print full error stack traces`,
94
+ ` ${'--help, -h'.padEnd(width + 2)}Show help`,
95
+ ` ${'--version, -v'.padEnd(width + 2)}Show version`,
96
+ '',
97
+ `Run ${colors.cyan}elestio <command> --help${colors.reset} for a command's actions and flags.`,
98
+ `Use ${colors.cyan}--flag=value${colors.reset} for values that start with a dash.`,
99
+ ''
100
+ );
101
+
102
+ return lines.join('\n');
103
+ }
104
+
105
+ export function renderCommandHelp(name, command) {
106
+ const lines = ['', `${colors.bold}elestio ${command.usage || name}${colors.reset}`, ` ${command.summary}`, ''];
107
+
108
+ if (command.actions) {
109
+ const entries = Object.entries(command.actions).filter(([, a]) => !a.hidden);
110
+ const width = Math.max(...entries.map(([, a]) => (a.usage || '').length), 12) + 2;
111
+
112
+ lines.push(`${colors.bold}Actions${colors.reset}`);
113
+ for (const [actionName, action] of entries) {
114
+ const usage = action.usage || actionName;
115
+ const marker = actionName === command.defaultAction ? `${colors.dim} (default)${colors.reset}` : '';
116
+ lines.push(` ${colors.cyan}${usage.padEnd(width)}${colors.reset}${action.summary}${marker}`);
117
+ }
118
+ lines.push('');
119
+ }
120
+
121
+ if (command.flags?.length) {
122
+ const width = Math.max(...command.flags.map(f => f.name.length)) + 2;
123
+ lines.push(`${colors.bold}Options${colors.reset}`);
124
+ for (const flag of command.flags) {
125
+ lines.push(` ${flag.name.padEnd(width)}${flag.summary}`);
126
+ }
127
+ lines.push('');
128
+ }
129
+
130
+ if (command.examples?.length) {
131
+ lines.push(`${colors.bold}Examples${colors.reset}`);
132
+ for (const example of command.examples) {
133
+ lines.push(` ${colors.dim}# ${example.description}${colors.reset}`);
134
+ lines.push(` ${example.command}`);
135
+ lines.push('');
136
+ }
137
+ }
138
+
139
+ return lines.join('\n');
140
+ }
141
+
142
+ /**
143
+ * Resolves a command and its action, then runs it.
144
+ * Returns an exit code; never throws for user error.
145
+ */
146
+ export async function dispatch(registry, argv, args, context) {
147
+ const requested = args._[0];
148
+ const { name, command, ambiguous } = findCommand(registry, requested);
149
+
150
+ if (ambiguous) {
151
+ log('error', `"${requested}" is ambiguous: ${ambiguous.join(', ')}`);
152
+ return 1;
153
+ }
154
+
155
+ if (!command) {
156
+ log('error', `Unknown command: ${requested}`);
157
+ const suggestions = suggest(registry, requested);
158
+ if (suggestions.length > 0) {
159
+ log('info', `Did you mean: ${suggestions.join(', ')}?`);
160
+ }
161
+ log('info', 'Run "elestio --help" for the full list');
162
+ return 1;
163
+ }
164
+
165
+ if (args.help || args.h) {
166
+ console.log(renderCommandHelp(name, command));
167
+ return 0;
168
+ }
169
+
170
+ if (!command.actions) {
171
+ await command.run({ ...context, args, json: context.json });
172
+ return 0;
173
+ }
174
+
175
+ const requestedAction = args._[1];
176
+ const actionName = requestedAction && command.actions[requestedAction]
177
+ ? requestedAction
178
+ : (!requestedAction ? command.defaultAction : null);
179
+
180
+ if (!actionName) {
181
+ log('error', `Unknown action "${requestedAction}" for "${name}"`);
182
+ console.log(renderCommandHelp(name, command));
183
+ return 1;
184
+ }
185
+
186
+ await command.actions[actionName].run({ ...context, args, json: context.json });
187
+ return 0;
188
+ }
@@ -0,0 +1,188 @@
1
+ import { randomInt } from 'crypto';
2
+
3
+ /**
4
+ * Reading and applying `elestio.yml`, the file every repo under
5
+ * github.com/elestio-examples carries to describe how it must be deployed.
6
+ *
7
+ * The backend parses the YAML for us (`getGitContent` with file="elestio"),
8
+ * so this module only normalises the resulting object and resolves the
9
+ * placeholders the dashboard resolves at pipeline-creation time.
10
+ */
11
+
12
+ const ALPHANUM = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
13
+
14
+ function randomString(length) {
15
+ let out = '';
16
+ for (let i = 0; i < length; i++) out += ALPHANUM[randomInt(ALPHANUM.length)];
17
+ return out;
18
+ }
19
+
20
+ /**
21
+ * Same shape as the dashboard's generated app password: 8-4-8, guaranteed to
22
+ * mix cases and digits so it satisfies the software's own password policies.
23
+ */
24
+ export function generateAppPassword() {
25
+ for (;;) {
26
+ const password = `${randomString(8)}-${randomString(4)}-${randomString(8)}`;
27
+ if (/[a-z]/.test(password) && /[A-Z]/.test(password) && /[0-9]/.test(password)) {
28
+ return password;
29
+ }
30
+ }
31
+ }
32
+
33
+ export function generateShortPassword() {
34
+ return `${randomString(8)}-${randomString(7)}`;
35
+ }
36
+
37
+ /**
38
+ * Placeholders resolved when the pipeline is created.
39
+ *
40
+ * `[CI_CD_DOMAIN]` is deliberately left untouched: the pipeline's CNAME does
41
+ * not exist yet at this point, and the deployment agent substitutes it later.
42
+ * The dashboard behaves the same way.
43
+ */
44
+ export function createSubstitutions(email) {
45
+ return {
46
+ password: generateAppPassword(),
47
+ shortPassword: generateShortPassword(),
48
+ email: email || ''
49
+ };
50
+ }
51
+
52
+ export function substitute(value, subs) {
53
+ if (typeof value !== 'string') return value;
54
+ // `random_password_16` must be replaced first, otherwise `random_password`
55
+ // matches its prefix and leaves a dangling `_16`.
56
+ return value
57
+ .replaceAll('random_password_16', subs.shortPassword)
58
+ .replaceAll('random_password', subs.password)
59
+ .replaceAll('[EMAIL]', subs.email);
60
+ }
61
+
62
+ function substituteDeep(node, subs) {
63
+ if (Array.isArray(node)) return node.map(item => substituteDeep(item, subs));
64
+ if (node && typeof node === 'object') {
65
+ return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, substituteDeep(v, subs)]));
66
+ }
67
+ return substitute(node, subs);
68
+ }
69
+
70
+ /** Keys that mean the repo really does carry an elestio.yml. */
71
+ const CONFIG_KEYS = ['config', 'environments', 'ports', 'exposedPorts', 'lifeCycleConfig', 'copyCommandConfig', 'webUI'];
72
+
73
+ const EMPTY_LIFECYCLE = {
74
+ preInstallCommand: '', postInstallCommand: '',
75
+ preBackupCommand: '', postBackupCommand: '',
76
+ preRestoreCommand: '', postRestoreCommand: '',
77
+ preUpdateCommand: '', postUpdateCommand: '',
78
+ preDeployCommand: '', postDeployCommand: ''
79
+ };
80
+
81
+ /**
82
+ * `environments: [{key, value}]` becomes the newline-separated string the
83
+ * backend expects -- it calls variables.trim(), so an array raises a 500.
84
+ */
85
+ export function environmentsToVariables(environments, subs) {
86
+ if (!Array.isArray(environments)) return '';
87
+
88
+ return environments
89
+ .filter(env => env && env.key !== undefined && env.key !== null && env.key !== '')
90
+ .map(env => `${env.key}=${substitute(String(env.value ?? ''), subs)}`)
91
+ .join('\n');
92
+ }
93
+
94
+ function normalizePort(port) {
95
+ return {
96
+ protocol: String(port.protocol || 'HTTPS').toUpperCase(),
97
+ targetProtocol: String(port.targetProtocol || 'HTTP').toUpperCase(),
98
+ listeningPort: String(port.listeningPort || '443'),
99
+ targetPort: String(port.targetPort || '3000'),
100
+ targetIP: port.targetIP || '172.17.0.1',
101
+ public: port.public !== false,
102
+ path: port.path || '/',
103
+ isAuth: port.isAuth === true,
104
+ login: port.login || '',
105
+ password: port.password || '',
106
+ loginTitle: port.loginTitle || ''
107
+ };
108
+ }
109
+
110
+ function normalizeExposedPort(port) {
111
+ return {
112
+ protocol: String(port.protocol || 'HTTP').toUpperCase(),
113
+ hostPort: String(port.hostPort ?? port.targetPort ?? '3000'),
114
+ containerPort: String(port.containerPort ?? port.targetPort ?? '3000'),
115
+ interface: port.interface || '172.17.0.1'
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Turns a parsed elestio.yml into the pieces a pipeline payload needs.
121
+ *
122
+ * @param {object} raw parsed elestio.yml (may be null for repos without one)
123
+ * @param {object} subs from createSubstitutions()
124
+ */
125
+ export function normalizeElestioConfig(raw, subs) {
126
+ const source = raw && typeof raw === 'object' ? raw : {};
127
+ const config = substituteDeep(source.config || {}, subs);
128
+ const ports = Array.isArray(source.ports) ? substituteDeep(source.ports, subs).map(normalizePort) : [];
129
+ const exposedPorts = Array.isArray(source.exposedPorts)
130
+ ? substituteDeep(source.exposedPorts, subs).map(normalizeExposedPort)
131
+ : [];
132
+
133
+ return {
134
+ // A repo with no elestio.yml still answers 200, with {"status":"ok"} and
135
+ // nothing else -- so presence is decided on the config keys themselves.
136
+ // Treating that reply as a config is what builds a pipeline that runs
137
+ // nothing.
138
+ hasConfig: CONFIG_KEYS.some(key => source[key] !== undefined && source[key] !== null),
139
+ config: {
140
+ runTime: config.runTime || '',
141
+ version: config.version === undefined || config.version === null ? '' : String(config.version),
142
+ framework: config.framework || 'NoFramework',
143
+ buildDir: normalizeDir(config.buildDir, '/'),
144
+ rootDir: normalizeDir(config.rootDir, '/'),
145
+ buildCmd: config.buildCommand || '',
146
+ runCmd: config.runCommand || '',
147
+ installCmd: config.installCommand || '',
148
+ isMonoRepoPackageInRoot: config.isMonoRepoPackageInRoot || false
149
+ },
150
+ ports,
151
+ exposedPorts,
152
+ variables: environmentsToVariables(source.environments, subs),
153
+ lifeCycleCommand: { ...EMPTY_LIFECYCLE, ...substituteDeep(source.lifeCycleConfig || {}, subs) },
154
+ copyCommandConfig: Array.isArray(source.copyCommandConfig) ? substituteDeep(source.copyCommandConfig, subs) : [],
155
+ webUI: Array.isArray(source.webUI) ? substituteDeep(source.webUI, subs) : []
156
+ };
157
+ }
158
+
159
+ function normalizeDir(dir, fallback) {
160
+ if (!dir) return fallback;
161
+ const str = String(dir);
162
+ return str.startsWith('/') ? str : `/${str}`;
163
+ }
164
+
165
+ export { EMPTY_LIFECYCLE };
166
+
167
+ /**
168
+ * Bind mounts whose source is a file that lives in the template repo.
169
+ *
170
+ * Without a repo checkout Docker creates the missing source as a *directory*,
171
+ * and the container then fails to start with "not a directory". n8n hits this
172
+ * on ./n8n-task-runners.json. Directory mounts (./db_data) are fine: Docker
173
+ * creating them empty is exactly what is wanted.
174
+ *
175
+ * A source is treated as a file when its last segment has an extension, which
176
+ * is what tells ./n8n-task-runners.json apart from ./redis_data.
177
+ */
178
+ export function repoFileMounts(compose) {
179
+ if (typeof compose !== 'string') return [];
180
+
181
+ const mounts = new Set();
182
+ for (const match of compose.matchAll(/^\s*-\s+(\.{1,2}\/[^\s:]+):[^\s]+/gm)) {
183
+ const source = match[1];
184
+ const lastSegment = source.slice(source.lastIndexOf('/') + 1);
185
+ if (/\.[A-Za-z0-9]{1,10}$/.test(lastSegment)) mounts.add(source);
186
+ }
187
+ return [...mounts];
188
+ }
package/src/utils.js CHANGED
@@ -36,22 +36,26 @@ export function parseArgs(argv) {
36
36
  while (i < argv.length) {
37
37
  const arg = argv[i];
38
38
 
39
- if (arg.startsWith('--')) {
40
- const key = arg.slice(2);
41
- const next = argv[i + 1];
39
+ // Everything after `--` is positional, never parsed as a flag.
40
+ if (arg === '--') {
41
+ args._.push(...argv.slice(i + 1));
42
+ break;
43
+ }
42
44
 
43
- if (next && !next.startsWith('-')) {
44
- args[key] = next;
45
- i += 2;
46
- } else {
47
- args[key] = true;
48
- i++;
49
- }
50
- } else if (arg.startsWith('-') && arg.length === 2) {
51
- const key = arg.slice(1);
45
+ // `--key=value` is the unambiguous form: it is the only way to pass a value
46
+ // that itself starts with a dash (passwords, negative numbers).
47
+ if (arg.startsWith('--') && arg.includes('=')) {
48
+ const eq = arg.indexOf('=');
49
+ args[arg.slice(2, eq)] = arg.slice(eq + 1);
50
+ i++;
51
+ continue;
52
+ }
53
+
54
+ if (arg.startsWith('--') || (arg.startsWith('-') && arg.length === 2)) {
55
+ const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1);
52
56
  const next = argv[i + 1];
53
57
 
54
- if (next && !next.startsWith('-')) {
58
+ if (next !== undefined && !looksLikeFlag(next)) {
55
59
  args[key] = next;
56
60
  i += 2;
57
61
  } else {
@@ -67,6 +71,14 @@ export function parseArgs(argv) {
67
71
  return args;
68
72
  }
69
73
 
74
+ /**
75
+ * `-p` and `--port` are flags; `-5`, `-p@ssw0rd` and `-` are values.
76
+ * Values that are genuinely ambiguous must use the `--key=value` form.
77
+ */
78
+ function looksLikeFlag(token) {
79
+ return /^--[a-zA-Z]/.test(token) || /^-[a-zA-Z]$/.test(token);
80
+ }
81
+
70
82
  // ── Table formatting ──
71
83
 
72
84
  export function formatTable(data, columns) {
@@ -103,8 +115,8 @@ export function formatBytes(bytes) {
103
115
 
104
116
  export function formatPrice(pricePerHour) {
105
117
  const hourly = parseFloat(pricePerHour);
106
- const monthly = (hourly * 24 * 30).toFixed(2);
107
- return `$${monthly}/mo`;
118
+ if (!Number.isFinite(hourly)) return 'N/A';
119
+ return `$${(hourly * 24 * 30).toFixed(2)}/mo`;
108
120
  }
109
121
 
110
122
  export function validateServerName(name) {