@ivora/cli 0.0.0-stage → 0.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/LICENSE +202 -0
- package/README.md +120 -2
- package/dist/bin/ivora.js +3 -0
- package/dist/catalog.js +170 -0
- package/dist/cli.js +156 -0
- package/dist/commands/api.js +46 -0
- package/dist/commands/login.js +85 -0
- package/dist/commands/operations.js +25 -0
- package/dist/commands/profiles.js +67 -0
- package/dist/commands/stations.js +23 -0
- package/dist/commands/tariffs.js +82 -0
- package/dist/commands/webhooks.js +23 -0
- package/dist/commands/whoami.js +30 -0
- package/dist/lib/access.js +51 -0
- package/dist/lib/confirm.js +51 -0
- package/dist/lib/http.js +209 -0
- package/dist/lib/output.js +101 -0
- package/dist/lib/profiles.js +195 -0
- package/dist/lib/resource.js +115 -0
- package/dist/lib/secrets.js +151 -0
- package/dist/lib/session.js +58 -0
- package/dist/version.js +9 -0
- package/package.json +56 -4
package/dist/lib/http.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { CliError, ExitCode } from './output.js';
|
|
4
|
+
import { redactBody, redactHeaders } from './secrets.js';
|
|
5
|
+
import { CLI_VERSION } from '../version.js';
|
|
6
|
+
import { validateBaseUrl } from './profiles.js';
|
|
7
|
+
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
8
|
+
/** Path prefixes the CLI never calls. The server refuses them for API keys anyway. */
|
|
9
|
+
export const STAFF_PREFIXES = ['/v1/admin', '/v1/host', '/v1/internal'];
|
|
10
|
+
export class ApiError extends CliError {
|
|
11
|
+
status;
|
|
12
|
+
code;
|
|
13
|
+
body;
|
|
14
|
+
requestId;
|
|
15
|
+
constructor(status, code, message, body, requestId) {
|
|
16
|
+
super(message, status === 401 || status === 403 ? ExitCode.Auth : ExitCode.ApiError);
|
|
17
|
+
this.name = 'ApiError';
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.body = body;
|
|
21
|
+
this.requestId = requestId;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Refuses anything but a relative public /v1 path. The check runs on the pathname the URL
|
|
26
|
+
* parser will actually send (it resolves `%2e%2e`, backslashes and drops `#...`), decoded and
|
|
27
|
+
* with repeated slashes collapsed, so encodings cannot reach a staff prefix.
|
|
28
|
+
*/
|
|
29
|
+
export function assertPublicPath(path) {
|
|
30
|
+
if (!path.startsWith('/v1/') && path !== '/v1') {
|
|
31
|
+
throw new CliError(`Only /v1 paths are supported: ${path}`, ExitCode.Usage);
|
|
32
|
+
}
|
|
33
|
+
if (/[\\#]/.test(path) || /%2f|%5c/i.test(path)) {
|
|
34
|
+
throw new CliError(`Invalid path: ${path}`, ExitCode.Usage);
|
|
35
|
+
}
|
|
36
|
+
const pathname = path.split('?', 1)[0] ?? path;
|
|
37
|
+
let decoded;
|
|
38
|
+
try {
|
|
39
|
+
decoded = decodeURIComponent(pathname);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new CliError(`Invalid path: ${path}`, ExitCode.Usage);
|
|
43
|
+
}
|
|
44
|
+
if (decoded.split('/').some((seg) => seg === '..' || seg === '.')) {
|
|
45
|
+
throw new CliError(`Invalid path: ${path}`, ExitCode.Usage);
|
|
46
|
+
}
|
|
47
|
+
const sent = new URL(pathname, 'https://placeholder.invalid').pathname;
|
|
48
|
+
const lower = decodeURIComponent(sent)
|
|
49
|
+
.replace(/\/{2,}/g, '/')
|
|
50
|
+
.replace(/;.*$/, '')
|
|
51
|
+
.toLowerCase();
|
|
52
|
+
for (const prefix of STAFF_PREFIXES) {
|
|
53
|
+
if (lower === prefix || lower.startsWith(`${prefix}/`)) {
|
|
54
|
+
throw new CliError(`${prefix} is not part of the public API.`, ExitCode.Usage);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Idempotency keys: 8 to 128 printable ASCII characters. */
|
|
59
|
+
export function validateIdempotencyKey(key) {
|
|
60
|
+
if (!/^[\x21-\x7e]{8,128}$/.test(key)) {
|
|
61
|
+
throw new CliError('An idempotency key must be 8-128 printable characters without spaces.', ExitCode.Usage);
|
|
62
|
+
}
|
|
63
|
+
return key;
|
|
64
|
+
}
|
|
65
|
+
/** Seconds to wait from a Retry-After header (delta-seconds or HTTP date), capped. */
|
|
66
|
+
export function parseRetryAfter(value, now = Date.now(), capSeconds = 60) {
|
|
67
|
+
if (!value)
|
|
68
|
+
return 1;
|
|
69
|
+
const trimmed = value.trim();
|
|
70
|
+
const seconds = /^\d+$/.test(trimmed) ? Number(trimmed) : (Date.parse(trimmed) - now) / 1000;
|
|
71
|
+
if (!Number.isFinite(seconds))
|
|
72
|
+
return 1;
|
|
73
|
+
return Math.min(Math.max(seconds, 0), capSeconds);
|
|
74
|
+
}
|
|
75
|
+
export class ApiClient {
|
|
76
|
+
options;
|
|
77
|
+
constructor(options) {
|
|
78
|
+
this.options = {
|
|
79
|
+
fetch: globalThis.fetch,
|
|
80
|
+
maxRetries: 2,
|
|
81
|
+
timeoutMs: 30_000,
|
|
82
|
+
sleep: (ms) => delay(ms),
|
|
83
|
+
...options,
|
|
84
|
+
// https only (plain http just for loopback): the key is never sent in clear text.
|
|
85
|
+
baseUrl: validateBaseUrl(options.baseUrl),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async request(method, path, opts = {}) {
|
|
89
|
+
assertPublicPath(path);
|
|
90
|
+
const url = new URL(this.options.baseUrl + path);
|
|
91
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
92
|
+
if (v !== undefined)
|
|
93
|
+
url.searchParams.set(k, String(v));
|
|
94
|
+
}
|
|
95
|
+
const isWrite = WRITE_METHODS.has(method) && !opts.readOnly;
|
|
96
|
+
const idempotencyKey = isWrite
|
|
97
|
+
? validateIdempotencyKey(opts.idempotencyKey ?? randomUUID())
|
|
98
|
+
: undefined;
|
|
99
|
+
const headers = {
|
|
100
|
+
Accept: 'application/json',
|
|
101
|
+
Authorization: `Bearer ${this.options.apiKey}`,
|
|
102
|
+
'Ivora-Version': this.options.apiVersion,
|
|
103
|
+
'User-Agent': `ivora-cli/${CLI_VERSION} node/${process.versions.node}`,
|
|
104
|
+
};
|
|
105
|
+
if (idempotencyKey)
|
|
106
|
+
headers['Idempotency-Key'] = idempotencyKey;
|
|
107
|
+
const body = opts.body === undefined ? undefined : JSON.stringify(opts.body);
|
|
108
|
+
if (body !== undefined)
|
|
109
|
+
headers['Content-Type'] = 'application/json';
|
|
110
|
+
for (let attempt = 0;; attempt++) {
|
|
111
|
+
const last = attempt >= this.options.maxRetries;
|
|
112
|
+
this.debug(`> ${method} ${url.pathname}${url.search} ${JSON.stringify(redactHeaders(headers))}`);
|
|
113
|
+
if (opts.body !== undefined)
|
|
114
|
+
this.debug(`> body ${JSON.stringify(redactBody(opts.body))}`);
|
|
115
|
+
let res;
|
|
116
|
+
try {
|
|
117
|
+
res = await this.options.fetch(url, {
|
|
118
|
+
method,
|
|
119
|
+
headers,
|
|
120
|
+
body,
|
|
121
|
+
redirect: 'error',
|
|
122
|
+
signal: AbortSignal.timeout(this.options.timeoutMs),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
const timeout = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
|
127
|
+
// Reads are safe to repeat; writes repeat with the same Idempotency-Key.
|
|
128
|
+
if (!last) {
|
|
129
|
+
this.debug(`< ${timeout ? 'timeout' : 'network error'}, retrying`);
|
|
130
|
+
await this.options.sleep(backoffMs(attempt));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
throw new CliError(timeout
|
|
134
|
+
? `Request timed out: ${method} ${path}`
|
|
135
|
+
: `Network error: ${err.message}`, timeout ? ExitCode.Timeout : ExitCode.ApiError);
|
|
136
|
+
}
|
|
137
|
+
const data = await readBody(res);
|
|
138
|
+
this.debug(`< ${res.status} ${JSON.stringify(redactBody(data))}`);
|
|
139
|
+
if (res.ok)
|
|
140
|
+
return { status: res.status, headers: res.headers, data: data, idempotencyKey };
|
|
141
|
+
const code = errorCode(data);
|
|
142
|
+
if (!last && res.status === 429) {
|
|
143
|
+
await this.options.sleep(parseRetryAfter(res.headers.get('retry-after')) * 1000);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!last && res.status === 503 && code === 'outcome_unknown') {
|
|
147
|
+
await this.options.sleep(backoffMs(attempt));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const requestId = errorRequestId(data, res.headers);
|
|
151
|
+
const message = errorMessage(res.status, code, data);
|
|
152
|
+
throw new ApiError(res.status, code, requestId ? `${message} (request id: ${requestId})` : message, data, requestId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
debug(line) {
|
|
156
|
+
this.options.debug?.(line);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function backoffMs(attempt) {
|
|
160
|
+
return Math.min(500 * 2 ** attempt, 8_000);
|
|
161
|
+
}
|
|
162
|
+
async function readBody(res) {
|
|
163
|
+
const text = await res.text();
|
|
164
|
+
if (text === '')
|
|
165
|
+
return undefined;
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(text);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return text;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function errorCode(data) {
|
|
174
|
+
if (data && typeof data === 'object') {
|
|
175
|
+
const obj = data;
|
|
176
|
+
const error = obj.error;
|
|
177
|
+
if (error &&
|
|
178
|
+
typeof error === 'object' &&
|
|
179
|
+
typeof error.code === 'string') {
|
|
180
|
+
return error.code;
|
|
181
|
+
}
|
|
182
|
+
if (typeof obj.code === 'string')
|
|
183
|
+
return obj.code;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
/** The request id from the error body (`request_id`), else the `X-Request-Id` header. */
|
|
188
|
+
function errorRequestId(data, headers) {
|
|
189
|
+
if (data && typeof data === 'object') {
|
|
190
|
+
const id = data.request_id;
|
|
191
|
+
if (typeof id === 'string' && id !== '')
|
|
192
|
+
return id;
|
|
193
|
+
}
|
|
194
|
+
return headers.get('x-request-id') ?? undefined;
|
|
195
|
+
}
|
|
196
|
+
function errorMessage(status, code, data) {
|
|
197
|
+
let detail;
|
|
198
|
+
if (data && typeof data === 'object') {
|
|
199
|
+
const obj = data;
|
|
200
|
+
const error = obj.error;
|
|
201
|
+
const candidate = error?.message ?? obj.message ?? obj.detail;
|
|
202
|
+
if (typeof candidate === 'string')
|
|
203
|
+
detail = candidate;
|
|
204
|
+
}
|
|
205
|
+
else if (typeof data === 'string' && data.length < 300) {
|
|
206
|
+
detail = data;
|
|
207
|
+
}
|
|
208
|
+
return [`HTTP ${status}`, code, detail].filter(Boolean).join(': ');
|
|
209
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { redact } from './secrets.js';
|
|
2
|
+
/** Process exit codes. Scripts may rely on these. */
|
|
3
|
+
export const ExitCode = {
|
|
4
|
+
Ok: 0,
|
|
5
|
+
ApiError: 1,
|
|
6
|
+
Usage: 2,
|
|
7
|
+
ConfirmRefused: 3,
|
|
8
|
+
Timeout: 4,
|
|
9
|
+
Auth: 5,
|
|
10
|
+
};
|
|
11
|
+
/** An error that ends the command with a specific exit code and a user-facing message. */
|
|
12
|
+
export class CliError extends Error {
|
|
13
|
+
exitCode;
|
|
14
|
+
constructor(message, exitCode = ExitCode.ApiError) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'CliError';
|
|
17
|
+
this.exitCode = exitCode;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Where command output goes. Results go to stdout (a table, or JSON with --json); prompts,
|
|
22
|
+
* progress and errors go to stderr, so `--json` output stays machine-readable.
|
|
23
|
+
* Everything written passes through secret redaction.
|
|
24
|
+
*/
|
|
25
|
+
export class Output {
|
|
26
|
+
json;
|
|
27
|
+
color;
|
|
28
|
+
stdout;
|
|
29
|
+
stderr;
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
const env = options.env ?? process.env;
|
|
32
|
+
this.json = options.json ?? false;
|
|
33
|
+
this.stdout = options.stdout ?? process.stdout;
|
|
34
|
+
this.stderr = options.stderr ?? process.stderr;
|
|
35
|
+
this.color = !this.json && this.stdout.isTTY === true && !('NO_COLOR' in env);
|
|
36
|
+
}
|
|
37
|
+
/** Writes a result: JSON in --json mode, otherwise a table (arrays) or key/value lines. */
|
|
38
|
+
result(data, columns) {
|
|
39
|
+
if (this.json) {
|
|
40
|
+
this.stdout.write(redact(JSON.stringify(data, null, 2)) + '\n');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(data)) {
|
|
44
|
+
this.stdout.write(redact(formatTable(data, columns)));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (data !== null && typeof data === 'object') {
|
|
48
|
+
this.stdout.write(redact(formatKeyValues(data)));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
this.stdout.write(redact(String(data)) + '\n');
|
|
52
|
+
}
|
|
53
|
+
/** Writes any value as pretty JSON, in both modes (for arbitrary API responses). */
|
|
54
|
+
rawJson(data) {
|
|
55
|
+
this.stdout.write(redact(JSON.stringify(data ?? null, null, 2)) + '\n');
|
|
56
|
+
}
|
|
57
|
+
/** Writes a human line to stdout (not used in --json mode). */
|
|
58
|
+
line(text) {
|
|
59
|
+
if (!this.json)
|
|
60
|
+
this.stdout.write(redact(text) + '\n');
|
|
61
|
+
}
|
|
62
|
+
/** Writes progress or diagnostics to stderr. */
|
|
63
|
+
info(text) {
|
|
64
|
+
this.stderr.write(redact(text) + '\n');
|
|
65
|
+
}
|
|
66
|
+
error(text) {
|
|
67
|
+
this.stderr.write(redact(`error: ${text}`) + '\n');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** The environment marker shown in prompts and human-mode headers. */
|
|
71
|
+
export function envTag(environment, project) {
|
|
72
|
+
if (environment === 'live')
|
|
73
|
+
return project ? `[LIVE] ${project}` : '[LIVE]';
|
|
74
|
+
return '[TEST]';
|
|
75
|
+
}
|
|
76
|
+
function cell(value) {
|
|
77
|
+
if (value === null || value === undefined)
|
|
78
|
+
return '';
|
|
79
|
+
if (typeof value === 'object')
|
|
80
|
+
return JSON.stringify(value);
|
|
81
|
+
return String(value);
|
|
82
|
+
}
|
|
83
|
+
/** Formats rows as a left-aligned, space-padded table with an upper-case header. */
|
|
84
|
+
export function formatTable(rows, columns) {
|
|
85
|
+
const cols = columns ?? [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
86
|
+
if (cols.length === 0)
|
|
87
|
+
return '';
|
|
88
|
+
const header = cols.map((c) => c.toUpperCase());
|
|
89
|
+
const body = rows.map((row) => cols.map((c) => cell(row[c])));
|
|
90
|
+
const widths = header.map((h, i) => Math.max(h.length, ...body.map((r) => (r[i] ?? '').length)));
|
|
91
|
+
const render = (cells) => cells
|
|
92
|
+
.map((c, i) => (i === cells.length - 1 ? c : c.padEnd(widths[i] ?? 0)))
|
|
93
|
+
.join(' ')
|
|
94
|
+
.trimEnd();
|
|
95
|
+
return [render(header), ...body.map(render)].join('\n') + '\n';
|
|
96
|
+
}
|
|
97
|
+
function formatKeyValues(data) {
|
|
98
|
+
const keys = Object.keys(data);
|
|
99
|
+
const width = Math.max(0, ...keys.map((k) => k.length));
|
|
100
|
+
return keys.map((k) => `${k.padEnd(width)} ${cell(data[k])}`).join('\n') + '\n';
|
|
101
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { CliError, ExitCode } from './output.js';
|
|
5
|
+
export const DEFAULT_PROFILE = 'test';
|
|
6
|
+
/** Production API base, the default for live profiles. */
|
|
7
|
+
export const PRODUCTION_API_BASE = 'https://api.ivoracharge.com';
|
|
8
|
+
/** The environment a key belongs to, from its prefix, or undefined for an unknown format. */
|
|
9
|
+
export function environmentForKey(apiKey) {
|
|
10
|
+
if (apiKey.startsWith('iv_test_'))
|
|
11
|
+
return 'test';
|
|
12
|
+
if (apiKey.startsWith('iv_live_'))
|
|
13
|
+
return 'live';
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
/** Refuses a key that does not belong to the profile's environment. */
|
|
17
|
+
export function assertKeyMatchesProfile(apiKey, profile) {
|
|
18
|
+
const env = environmentForKey(apiKey);
|
|
19
|
+
if (env === undefined) {
|
|
20
|
+
throw new CliError('This does not look like an Ivora API key (iv_test_... or iv_live_...).', ExitCode.Usage);
|
|
21
|
+
}
|
|
22
|
+
if (env !== profile.environment) {
|
|
23
|
+
throw new CliError(`A ${env} key cannot be used with the ${profile.environment} profile "${profile.name}".` +
|
|
24
|
+
(env === 'live' ? ' Use `ivora login --live` for live keys.' : ''), ExitCode.Usage);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Directory of the CLI config: $XDG_CONFIG_HOME/ivora, ~/.config/ivora, or %APPDATA%\ivora. */
|
|
28
|
+
export function configDir(env = process.env, platform = process.platform, home = homedir()) {
|
|
29
|
+
if (env.XDG_CONFIG_HOME)
|
|
30
|
+
return join(env.XDG_CONFIG_HOME, 'ivora');
|
|
31
|
+
if (platform === 'win32' && env.APPDATA)
|
|
32
|
+
return join(env.APPDATA, 'ivora');
|
|
33
|
+
return join(home, '.config', 'ivora');
|
|
34
|
+
}
|
|
35
|
+
export function configPath(env = process.env) {
|
|
36
|
+
return join(configDir(env), 'config.toml');
|
|
37
|
+
}
|
|
38
|
+
const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
39
|
+
export function validateProfileName(name) {
|
|
40
|
+
if (!PROFILE_NAME.test(name)) {
|
|
41
|
+
throw new CliError(`Invalid profile name "${name}": use 1-32 lower-case letters, digits, "-" or "_".`, ExitCode.Usage);
|
|
42
|
+
}
|
|
43
|
+
return name;
|
|
44
|
+
}
|
|
45
|
+
/** The secret-store reference for a profile's key. */
|
|
46
|
+
export function keyRefFor(name) {
|
|
47
|
+
return `profile:${name}`;
|
|
48
|
+
}
|
|
49
|
+
/** Serialises the config as a small TOML subset (string values only). No secrets. */
|
|
50
|
+
export function serializeConfig(config) {
|
|
51
|
+
const q = (v) => JSON.stringify(v);
|
|
52
|
+
const lines = [];
|
|
53
|
+
if (config.current)
|
|
54
|
+
lines.push(`current = ${q(config.current)}`, '');
|
|
55
|
+
for (const name of Object.keys(config.profiles).sort()) {
|
|
56
|
+
const p = config.profiles[name];
|
|
57
|
+
if (!p)
|
|
58
|
+
continue;
|
|
59
|
+
lines.push(`[profiles.${name}]`, `environment = ${q(p.environment)}`);
|
|
60
|
+
if (p.baseUrl)
|
|
61
|
+
lines.push(`base_url = ${q(p.baseUrl)}`);
|
|
62
|
+
if (p.keyRef)
|
|
63
|
+
lines.push(`key_ref = ${q(p.keyRef)}`);
|
|
64
|
+
if (p.tenantId)
|
|
65
|
+
lines.push(`tenant_id = ${q(p.tenantId)}`);
|
|
66
|
+
lines.push('');
|
|
67
|
+
}
|
|
68
|
+
return lines.join('\n');
|
|
69
|
+
}
|
|
70
|
+
/** Parses the subset written by serializeConfig. Unknown keys are ignored. */
|
|
71
|
+
export function parseConfig(text) {
|
|
72
|
+
const config = { profiles: {} };
|
|
73
|
+
let section;
|
|
74
|
+
const sections = new Map();
|
|
75
|
+
for (const [i, raw] of text.split(/\r?\n/).entries()) {
|
|
76
|
+
const line = raw.trim();
|
|
77
|
+
if (line === '' || line.startsWith('#'))
|
|
78
|
+
continue;
|
|
79
|
+
const bad = () => {
|
|
80
|
+
throw new CliError(`Invalid config file at line ${i + 1}.`, ExitCode.Usage);
|
|
81
|
+
};
|
|
82
|
+
const header = /^\[profiles\.([a-z0-9_-]+)\]$/.exec(line);
|
|
83
|
+
if (header) {
|
|
84
|
+
const name = validateProfileName(header[1] ?? '');
|
|
85
|
+
section = {};
|
|
86
|
+
sections.set(name, section);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const kv = /^([a-z_]+)\s*=\s*("(?:[^"\\]|\\.)*")$/.exec(line);
|
|
90
|
+
if (!kv)
|
|
91
|
+
return bad();
|
|
92
|
+
let value;
|
|
93
|
+
try {
|
|
94
|
+
value = JSON.parse(kv[2] ?? '');
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return bad();
|
|
98
|
+
}
|
|
99
|
+
if (section)
|
|
100
|
+
section[kv[1] ?? ''] = value;
|
|
101
|
+
else if (kv[1] === 'current')
|
|
102
|
+
config.current = value;
|
|
103
|
+
}
|
|
104
|
+
for (const [name, f] of sections) {
|
|
105
|
+
if (f.environment !== 'test' && f.environment !== 'live') {
|
|
106
|
+
throw new CliError(`Profile "${name}" has no valid environment.`, ExitCode.Usage);
|
|
107
|
+
}
|
|
108
|
+
config.profiles[name] = {
|
|
109
|
+
name,
|
|
110
|
+
environment: f.environment,
|
|
111
|
+
...(f.base_url ? { baseUrl: f.base_url } : {}),
|
|
112
|
+
...(f.key_ref ? { keyRef: f.key_ref } : {}),
|
|
113
|
+
...(f.tenant_id ? { tenantId: f.tenant_id } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return config;
|
|
117
|
+
}
|
|
118
|
+
export async function loadConfig(env = process.env) {
|
|
119
|
+
let text;
|
|
120
|
+
try {
|
|
121
|
+
text = await readFile(configPath(env), 'utf8');
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (err.code === 'ENOENT')
|
|
125
|
+
return { profiles: {} };
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
return parseConfig(text);
|
|
129
|
+
}
|
|
130
|
+
/** Writes the config atomically with owner-only permissions. */
|
|
131
|
+
export async function saveConfig(config, env = process.env) {
|
|
132
|
+
const dir = configDir(env);
|
|
133
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
134
|
+
const tmp = join(dir, `config.toml.${process.pid}.tmp`);
|
|
135
|
+
await writeFile(tmp, serializeConfig(config), { mode: 0o600 });
|
|
136
|
+
await chmod(tmp, 0o600);
|
|
137
|
+
await rename(tmp, configPath(env));
|
|
138
|
+
}
|
|
139
|
+
/** Selects the profile: --profile, then --live, then the config's current profile, then "test". */
|
|
140
|
+
export function selectProfileName(options, config) {
|
|
141
|
+
if (options.profile)
|
|
142
|
+
return options.profile;
|
|
143
|
+
if (options.live)
|
|
144
|
+
return 'live';
|
|
145
|
+
const current = config.current;
|
|
146
|
+
// A live profile is never picked implicitly, even from a hand-edited config.
|
|
147
|
+
if (current && config.profiles[current]?.environment !== 'live')
|
|
148
|
+
return current;
|
|
149
|
+
return DEFAULT_PROFILE;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The API base URL for a profile. A test profile uses `IVORA_API_BASE`, then its own URL; without
|
|
153
|
+
* either it is a usage error. A live profile ignores `IVORA_API_BASE`, so a leftover test URL never
|
|
154
|
+
* receives a live key: it uses its own stored URL or the production default, and `note` is told
|
|
155
|
+
* when `IVORA_API_BASE` was ignored.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveBaseUrl(profile, env = process.env, note) {
|
|
158
|
+
let candidate;
|
|
159
|
+
if (profile.environment === 'live') {
|
|
160
|
+
candidate = profile.baseUrl ?? PRODUCTION_API_BASE;
|
|
161
|
+
if (env.IVORA_API_BASE) {
|
|
162
|
+
note?.(`IVORA_API_BASE is ignored for the live profile "${profile.name}"; using ${candidate}.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
candidate = env.IVORA_API_BASE ?? profile.baseUrl;
|
|
167
|
+
}
|
|
168
|
+
if (candidate === undefined) {
|
|
169
|
+
throw new CliError(`No API base URL for the ${profile.environment} profile "${profile.name}". Set IVORA_API_BASE.`, ExitCode.Usage);
|
|
170
|
+
}
|
|
171
|
+
return validateBaseUrl(candidate);
|
|
172
|
+
}
|
|
173
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
174
|
+
/** Requires https (plain http only for loopback), no credentials, path or query. */
|
|
175
|
+
export function validateBaseUrl(value) {
|
|
176
|
+
let url;
|
|
177
|
+
try {
|
|
178
|
+
url = new URL(value);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
throw new CliError(`Invalid API base URL: ${value}`, ExitCode.Usage);
|
|
182
|
+
}
|
|
183
|
+
const loopback = LOOPBACK.has(url.hostname);
|
|
184
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
|
185
|
+
throw new CliError('The API base URL must use https.', ExitCode.Usage);
|
|
186
|
+
}
|
|
187
|
+
if (url.username ||
|
|
188
|
+
url.password ||
|
|
189
|
+
url.search ||
|
|
190
|
+
url.hash ||
|
|
191
|
+
(url.pathname !== '/' && url.pathname !== '')) {
|
|
192
|
+
throw new CliError('The API base URL must be an origin only, e.g. https://api.example.com.', ExitCode.Usage);
|
|
193
|
+
}
|
|
194
|
+
return url.origin;
|
|
195
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { findOperation } from '../catalog.js';
|
|
3
|
+
import { accessTenantIds } from './access.js';
|
|
4
|
+
import { CliError, ExitCode } from './output.js';
|
|
5
|
+
import { openSession } from './session.js';
|
|
6
|
+
/**
|
|
7
|
+
* Calls a cataloged operation by its operationId. The path comes from the catalog and the
|
|
8
|
+
* `{param}` values are encoded here, so a command never builds a path by hand.
|
|
9
|
+
*/
|
|
10
|
+
export async function callOperation(s, operationId, params, opts = {}) {
|
|
11
|
+
const op = findOperation(operationId);
|
|
12
|
+
if (!op)
|
|
13
|
+
throw new CliError(`Unknown operation: ${operationId}`, ExitCode.Usage);
|
|
14
|
+
const path = op.path.replace(/\{([^}]+)\}/g, (_m, name) => {
|
|
15
|
+
const value = params[name];
|
|
16
|
+
if (value === undefined || value === '') {
|
|
17
|
+
throw new CliError(`Missing value for ${name}.`, ExitCode.Usage);
|
|
18
|
+
}
|
|
19
|
+
return encodeURIComponent(value);
|
|
20
|
+
});
|
|
21
|
+
return s.client.request(op.method, path, {
|
|
22
|
+
...(opts.query ? { query: opts.query } : {}),
|
|
23
|
+
...(opts.body !== undefined ? { body: opts.body } : {}),
|
|
24
|
+
...(opts.readOnly ? { readOnly: true } : {}),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** The tenant for a command: `--tenant`, else the profile's, else the key's only tenant. */
|
|
28
|
+
export async function resolveTenant(ctx, s, flag) {
|
|
29
|
+
const given = flag ?? s.profile.tenantId;
|
|
30
|
+
if (given !== undefined) {
|
|
31
|
+
if (!/^\d+$/.test(given))
|
|
32
|
+
throw new CliError('A tenant id is a number.', ExitCode.Usage);
|
|
33
|
+
return given;
|
|
34
|
+
}
|
|
35
|
+
const res = await callOperation(s, 'access_and_api_keys_discover_current_csms_access', {});
|
|
36
|
+
const ids = accessTenantIds(res.data ?? {});
|
|
37
|
+
if (ids.length === 1)
|
|
38
|
+
return ids[0];
|
|
39
|
+
throw new CliError(ids.length === 0
|
|
40
|
+
? 'This key has no tenant.'
|
|
41
|
+
: `This key can reach several tenants (${ids.join(', ')}): pass --tenant <id>.`, ExitCode.Usage);
|
|
42
|
+
}
|
|
43
|
+
export function addTenantOption(cmd) {
|
|
44
|
+
return cmd.option('--tenant <id>', 'tenant id (default: the key’s only tenant)');
|
|
45
|
+
}
|
|
46
|
+
export function parseIntOption(name, value) {
|
|
47
|
+
if (value === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (!/^\d+$/.test(value) || Number(value) < 1) {
|
|
50
|
+
throw new CliError(`${name} must be a positive integer.`, ExitCode.Usage);
|
|
51
|
+
}
|
|
52
|
+
return Number(value);
|
|
53
|
+
}
|
|
54
|
+
const MAX_PAGES = 100;
|
|
55
|
+
/** Lists a collection: one page, or every page with `--all`; prints a table or JSON. */
|
|
56
|
+
export async function runList(ctx, spec, opts, filters) {
|
|
57
|
+
const s = await openSession(ctx);
|
|
58
|
+
const tenant = await resolveTenant(ctx, s, opts.tenant);
|
|
59
|
+
const limit = parseIntOption('--limit', opts.limit);
|
|
60
|
+
const cursorOpt = opts[spec.cursorParam];
|
|
61
|
+
let cursor = typeof cursorOpt === 'string' ? cursorOpt : undefined;
|
|
62
|
+
const rows = [];
|
|
63
|
+
// The API's next_cursor, kept with its type (an integer for some collections). Undefined
|
|
64
|
+
// means the listing reached its end.
|
|
65
|
+
let next;
|
|
66
|
+
for (let pages = 0;; pages++) {
|
|
67
|
+
const res = await callOperation(s, spec.operationId, { tenant_id: tenant }, { query: { ...filters, limit, [spec.cursorParam]: cursor } });
|
|
68
|
+
const body = res.data ?? {};
|
|
69
|
+
const pageRows = Array.isArray(body.data) ? body.data : [];
|
|
70
|
+
rows.push(...pageRows);
|
|
71
|
+
next = cursorValue(body.next_cursor);
|
|
72
|
+
// Some collections never return a null cursor: on the last page they echo the cursor that
|
|
73
|
+
// was sent. Treat an empty page, an echoed cursor or a short page as the end.
|
|
74
|
+
const last = next === undefined ||
|
|
75
|
+
pageRows.length === 0 ||
|
|
76
|
+
String(next) === cursor ||
|
|
77
|
+
(limit !== undefined && pageRows.length < limit);
|
|
78
|
+
if (last) {
|
|
79
|
+
next = undefined;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
// Keep `next` when one page was asked for, or when MAX_PAGES cut the listing short.
|
|
83
|
+
if (!opts.all || pages + 1 >= MAX_PAGES)
|
|
84
|
+
break;
|
|
85
|
+
cursor = String(next);
|
|
86
|
+
}
|
|
87
|
+
const out = ctx.out();
|
|
88
|
+
if (out.json) {
|
|
89
|
+
out.result({ data: rows, next_cursor: next ?? null });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
out.result(rows, spec.columns);
|
|
93
|
+
if (next !== undefined)
|
|
94
|
+
out.info(`More results: pass --${spec.cursorParam} ${next} (or --all).`);
|
|
95
|
+
}
|
|
96
|
+
function cursorValue(nc) {
|
|
97
|
+
if (typeof nc === 'number' && Number.isFinite(nc))
|
|
98
|
+
return nc;
|
|
99
|
+
if (typeof nc === 'string' && nc !== '')
|
|
100
|
+
return nc;
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
export function addListOptions(cmd, cursorParam) {
|
|
104
|
+
return addTenantOption(cmd)
|
|
105
|
+
.option('--limit <n>', 'page size')
|
|
106
|
+
.option(`--${cursorParam} <cursor>`, 'continue from the cursor of the previous page')
|
|
107
|
+
.option('--all', 'follow every page');
|
|
108
|
+
}
|
|
109
|
+
/** Fetches one resource by id and prints it. */
|
|
110
|
+
export async function runGet(ctx, operationId, paramName, id, tenantFlag) {
|
|
111
|
+
const s = await openSession(ctx);
|
|
112
|
+
const tenant = await resolveTenant(ctx, s, tenantFlag);
|
|
113
|
+
const res = await callOperation(s, operationId, { tenant_id: tenant, [paramName]: id });
|
|
114
|
+
ctx.out().result(res.data ?? {});
|
|
115
|
+
}
|