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/README.md +242 -4
- package/package.json +9 -2
- package/src/api.js +59 -21
- package/src/cli.js +16 -813
- package/src/commands/access.js +26 -13
- package/src/commands/auth.js +1 -1
- package/src/commands/cicd-template.js +363 -0
- package/src/commands/cicd.js +40 -15
- package/src/commands/clusters.js +337 -0
- package/src/commands/services.js +38 -26
- package/src/config.js +22 -2
- package/src/constants.js +76 -0
- package/src/deployment.js +33 -0
- package/src/payloads/pipeline.js +191 -0
- package/src/payloads/server.js +130 -0
- package/src/registry.js +663 -0
- package/src/router.js +188 -0
- package/src/templates/elestio-config.js +188 -0
- package/src/utils.js +27 -15
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
return `$${
|
|
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) {
|