@ivora/cli 0.0.0-placeholder → 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 +121 -1
- 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 +54 -3
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { findOperation, getOperationIds, pathParams } from '../catalog.js';
|
|
3
|
+
import { CliError, ExitCode } from '../lib/output.js';
|
|
4
|
+
import { openSession } from '../lib/session.js';
|
|
5
|
+
import { callOperation, resolveTenant } from '../lib/resource.js';
|
|
6
|
+
function keyValues(flag, values) {
|
|
7
|
+
const result = {};
|
|
8
|
+
for (const v of values) {
|
|
9
|
+
const i = v.indexOf('=');
|
|
10
|
+
if (i < 1)
|
|
11
|
+
throw new CliError(`${flag} expects name=value, got "${v}".`, ExitCode.Usage);
|
|
12
|
+
result[v.slice(0, i)] = v.slice(i + 1);
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
const collect = (value, previous) => [...previous, value];
|
|
17
|
+
export function apiCommand(ctx) {
|
|
18
|
+
return new Command('api')
|
|
19
|
+
.description('call a read-only (GET) API operation by operationId')
|
|
20
|
+
.argument('[operation-id]', 'an operationId from the catalog (omit to list them)')
|
|
21
|
+
.option('--path <name=value>', 'path parameter (repeatable)', collect, [])
|
|
22
|
+
.option('--query <name=value>', 'query parameter (repeatable)', collect, [])
|
|
23
|
+
.option('--tenant <id>', 'value for {tenant_id} (default: the key’s only tenant)')
|
|
24
|
+
.action(async (operationId, opts) => {
|
|
25
|
+
if (operationId === undefined) {
|
|
26
|
+
ctx.out().result(getOperationIds().map((id) => ({ operation_id: id })));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const op = findOperation(operationId);
|
|
30
|
+
if (!op) {
|
|
31
|
+
throw new CliError(`Unknown operation "${operationId}". Run \`ivora api\` to list them.`, ExitCode.Usage);
|
|
32
|
+
}
|
|
33
|
+
if (op.method !== 'GET') {
|
|
34
|
+
throw new CliError(`${operationId} is a ${op.method} operation. \`ivora api\` only calls GET operations for now.`, ExitCode.Usage);
|
|
35
|
+
}
|
|
36
|
+
const params = keyValues('--path', opts.path);
|
|
37
|
+
const query = keyValues('--query', opts.query);
|
|
38
|
+
const s = await openSession(ctx);
|
|
39
|
+
if (pathParams(op.path).includes('tenant_id') && params.tenant_id === undefined) {
|
|
40
|
+
params.tenant_id = await resolveTenant(ctx, s, opts.tenant);
|
|
41
|
+
}
|
|
42
|
+
const res = await callOperation(s, operationId, params, { query });
|
|
43
|
+
// Responses are arbitrary shapes: print JSON in both modes.
|
|
44
|
+
ctx.out().rawJson(res.data);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Command, Option } from 'commander';
|
|
2
|
+
import { CliError, ExitCode } from '../lib/output.js';
|
|
3
|
+
import { ApiClient } from '../lib/http.js';
|
|
4
|
+
import { DEFAULT_PROFILE, assertKeyMatchesProfile, keyRefFor, loadConfig, resolveBaseUrl, saveConfig, validateBaseUrl, validateProfileName, } from '../lib/profiles.js';
|
|
5
|
+
import { accessEnvironment, defaultTenant } from '../lib/access.js';
|
|
6
|
+
import { displayPrefix } from '../lib/secrets.js';
|
|
7
|
+
async function readAll(stream) {
|
|
8
|
+
let text = '';
|
|
9
|
+
stream.setEncoding?.('utf8');
|
|
10
|
+
for await (const chunk of stream)
|
|
11
|
+
text += String(chunk);
|
|
12
|
+
return text;
|
|
13
|
+
}
|
|
14
|
+
/** The key comes from IVORA_API_KEY, stdin (piped) or a hidden prompt. Never from argv. */
|
|
15
|
+
async function obtainKey(ctx) {
|
|
16
|
+
const fromEnv = ctx.env.IVORA_API_KEY?.trim();
|
|
17
|
+
if (fromEnv)
|
|
18
|
+
return fromEnv;
|
|
19
|
+
const raw = ctx.stdin.isTTY
|
|
20
|
+
? await ctx.readSecret('API key: ')
|
|
21
|
+
: ((await readAll(ctx.stdin)).split(/\r?\n/, 1)[0] ?? '');
|
|
22
|
+
const key = raw.trim();
|
|
23
|
+
if (!key)
|
|
24
|
+
throw new CliError('No API key given (pipe it on stdin or type it).', ExitCode.Usage);
|
|
25
|
+
return key;
|
|
26
|
+
}
|
|
27
|
+
export function loginCommand(ctx) {
|
|
28
|
+
return new Command('login')
|
|
29
|
+
.description('verify an API key and save it to a profile (key from stdin, prompt or IVORA_API_KEY)')
|
|
30
|
+
.addOption(new Option('--base-url <url>', 'API origin to store with the profile'))
|
|
31
|
+
.action(async (opts) => {
|
|
32
|
+
const g = ctx.globals();
|
|
33
|
+
const config = await loadConfig(ctx.env);
|
|
34
|
+
const name = validateProfileName(g.profile ?? (g.live ? 'live' : DEFAULT_PROFILE));
|
|
35
|
+
const existing = config.profiles[name];
|
|
36
|
+
const environment = g.live ? 'live' : (existing?.environment ?? 'test');
|
|
37
|
+
if (existing && existing.environment !== environment) {
|
|
38
|
+
throw new CliError(`Profile "${name}" is a ${existing.environment} profile; pick another name.`, ExitCode.Usage);
|
|
39
|
+
}
|
|
40
|
+
if (environment === 'live' && !g.live) {
|
|
41
|
+
throw new CliError('Logging in to a live profile needs --live.', ExitCode.Usage);
|
|
42
|
+
}
|
|
43
|
+
const profile = {
|
|
44
|
+
...(existing ?? { name, environment }),
|
|
45
|
+
name,
|
|
46
|
+
environment,
|
|
47
|
+
...(opts.baseUrl ? { baseUrl: validateBaseUrl(opts.baseUrl) } : {}),
|
|
48
|
+
keyRef: keyRefFor(name),
|
|
49
|
+
};
|
|
50
|
+
const apiKey = await obtainKey(ctx);
|
|
51
|
+
assertKeyMatchesProfile(apiKey, profile);
|
|
52
|
+
const baseUrl = resolveBaseUrl(profile, ctx.env, (text) => ctx.out().info(`note: ${text}`));
|
|
53
|
+
// Verify before saving anything.
|
|
54
|
+
const client = new ApiClient({
|
|
55
|
+
baseUrl,
|
|
56
|
+
apiKey,
|
|
57
|
+
apiVersion: g.apiVersion,
|
|
58
|
+
...(ctx.fetch ? { fetch: ctx.fetch } : {}),
|
|
59
|
+
});
|
|
60
|
+
const access = (await client.request('GET', '/v1/access')).data ?? {};
|
|
61
|
+
const serverEnvironment = accessEnvironment(access);
|
|
62
|
+
if (serverEnvironment && serverEnvironment !== environment) {
|
|
63
|
+
throw new CliError(`The server says this is a ${serverEnvironment} key, not a ${environment} one. Nothing was saved.`, ExitCode.Usage);
|
|
64
|
+
}
|
|
65
|
+
const tenantId = defaultTenant(access, (text) => ctx.out().info(`note: ${text}`));
|
|
66
|
+
if (tenantId)
|
|
67
|
+
profile.tenantId = tenantId;
|
|
68
|
+
else
|
|
69
|
+
delete profile.tenantId;
|
|
70
|
+
await (await ctx.secrets()).set(profile.keyRef, apiKey);
|
|
71
|
+
config.profiles[name] = profile;
|
|
72
|
+
// Only a test profile may become the default; live always needs --live.
|
|
73
|
+
if (environment === 'test')
|
|
74
|
+
config.current ??= name;
|
|
75
|
+
await saveConfig(config, ctx.env);
|
|
76
|
+
const out = ctx.out();
|
|
77
|
+
out.result({
|
|
78
|
+
profile: name,
|
|
79
|
+
environment,
|
|
80
|
+
key: displayPrefix(apiKey),
|
|
81
|
+
tenant_id: profile.tenantId,
|
|
82
|
+
api_base: baseUrl,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { addListOptions, addTenantOption, runGet, runList } from '../lib/resource.js';
|
|
3
|
+
export function operationsCommand(ctx) {
|
|
4
|
+
const cmd = new Command('operations').description('read charger operations (never re-dispatches)');
|
|
5
|
+
addListOptions(cmd.command('list'), 'before')
|
|
6
|
+
.description('list operations, newest first')
|
|
7
|
+
.option('--status <status>', 'filter by status')
|
|
8
|
+
.option('--kind <kind>', 'filter by kind')
|
|
9
|
+
.option('--actor <actor>', 'filter by actor')
|
|
10
|
+
.option('--station-id <id>', 'filter by station')
|
|
11
|
+
.action((opts) => runList(ctx, {
|
|
12
|
+
operationId: 'charging_and_operations_list_the_project_s_operations',
|
|
13
|
+
cursorParam: 'before',
|
|
14
|
+
columns: ['id', 'kind', 'status', 'actor', 'station_id', 'created_at'],
|
|
15
|
+
}, opts, {
|
|
16
|
+
status: opts.status,
|
|
17
|
+
kind: opts.kind,
|
|
18
|
+
actor: opts.actor,
|
|
19
|
+
station_id: opts.stationId,
|
|
20
|
+
}));
|
|
21
|
+
addTenantOption(cmd.command('get').argument('<operation-id>'))
|
|
22
|
+
.description('show one operation')
|
|
23
|
+
.action((id, opts) => runGet(ctx, 'charging_and_operations_inspect_an_operation_without_redispatching_it', 'operation_id', id, opts.tenant));
|
|
24
|
+
return cmd;
|
|
25
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { CliError, ExitCode } from '../lib/output.js';
|
|
3
|
+
import { confirmAction } from '../lib/confirm.js';
|
|
4
|
+
import { loadConfig, saveConfig, validateProfileName } from '../lib/profiles.js';
|
|
5
|
+
export function profilesCommand(ctx) {
|
|
6
|
+
const cmd = new Command('profiles').description('list, select and remove profiles');
|
|
7
|
+
cmd
|
|
8
|
+
.command('list', { isDefault: true })
|
|
9
|
+
.description('list profiles (no keys are shown)')
|
|
10
|
+
.action(async () => {
|
|
11
|
+
const config = await loadConfig(ctx.env);
|
|
12
|
+
const rows = Object.values(config.profiles).map((p) => ({
|
|
13
|
+
name: p.name,
|
|
14
|
+
environment: p.environment,
|
|
15
|
+
current: config.current === p.name ? '*' : '',
|
|
16
|
+
tenant_id: p.tenantId ?? '',
|
|
17
|
+
base_url: p.baseUrl ?? '',
|
|
18
|
+
}));
|
|
19
|
+
const out = ctx.out();
|
|
20
|
+
if (out.json)
|
|
21
|
+
out.result(rows);
|
|
22
|
+
else if (rows.length === 0)
|
|
23
|
+
out.info('No profiles. Run `ivora login`.');
|
|
24
|
+
else
|
|
25
|
+
out.result(rows, ['name', 'environment', 'current', 'tenant_id', 'base_url']);
|
|
26
|
+
});
|
|
27
|
+
cmd
|
|
28
|
+
.command('use <name>')
|
|
29
|
+
.description('set the default profile (test profiles only; live always needs --live)')
|
|
30
|
+
.action(async (rawName) => {
|
|
31
|
+
const name = validateProfileName(rawName);
|
|
32
|
+
const config = await loadConfig(ctx.env);
|
|
33
|
+
const profile = config.profiles[name];
|
|
34
|
+
if (!profile)
|
|
35
|
+
throw new CliError(`No profile "${name}".`, ExitCode.Usage);
|
|
36
|
+
if (profile.environment === 'live') {
|
|
37
|
+
throw new CliError('A live profile cannot be the default. Pass --live on each command that should use it.', ExitCode.Usage);
|
|
38
|
+
}
|
|
39
|
+
config.current = name;
|
|
40
|
+
await saveConfig(config, ctx.env);
|
|
41
|
+
ctx.out().line(`Default profile: ${name}`);
|
|
42
|
+
});
|
|
43
|
+
cmd
|
|
44
|
+
.command('remove <name>')
|
|
45
|
+
.description('remove a profile and its stored key')
|
|
46
|
+
.action(async (rawName) => {
|
|
47
|
+
const name = validateProfileName(rawName);
|
|
48
|
+
const config = await loadConfig(ctx.env);
|
|
49
|
+
const profile = config.profiles[name];
|
|
50
|
+
if (!profile)
|
|
51
|
+
throw new CliError(`No profile "${name}".`, ExitCode.Usage);
|
|
52
|
+
await confirmAction(ctx.prompter, {
|
|
53
|
+
environment: profile.environment,
|
|
54
|
+
summary: `Remove profile ${name} and its stored key`,
|
|
55
|
+
write: true,
|
|
56
|
+
yes: ctx.globals().yes,
|
|
57
|
+
});
|
|
58
|
+
if (profile.keyRef)
|
|
59
|
+
await (await ctx.secrets()).delete(profile.keyRef);
|
|
60
|
+
config.profiles = Object.fromEntries(Object.entries(config.profiles).filter(([key]) => key !== name));
|
|
61
|
+
if (config.current === name)
|
|
62
|
+
delete config.current;
|
|
63
|
+
await saveConfig(config, ctx.env);
|
|
64
|
+
ctx.out().line(`Removed profile ${name}`);
|
|
65
|
+
});
|
|
66
|
+
return cmd;
|
|
67
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { addListOptions, addTenantOption, runGet, runList } from '../lib/resource.js';
|
|
3
|
+
export function stationsCommand(ctx) {
|
|
4
|
+
const cmd = new Command('stations').description('read charging stations');
|
|
5
|
+
addListOptions(cmd.command('list'), 'after')
|
|
6
|
+
.description('list stations')
|
|
7
|
+
.option('--name <name>', 'filter by name')
|
|
8
|
+
.option('--charger-id <id>', 'filter by OCPP charger id')
|
|
9
|
+
.option('--external-reference <ref>', 'filter by your external reference')
|
|
10
|
+
.action((opts) => runList(ctx, {
|
|
11
|
+
operationId: 'stations_list_tenant_charging_stations',
|
|
12
|
+
cursorParam: 'after',
|
|
13
|
+
columns: ['id', 'name', 'charger_id', 'online', 'last_seen'],
|
|
14
|
+
}, opts, {
|
|
15
|
+
name: opts.name,
|
|
16
|
+
charger_id: opts.chargerId,
|
|
17
|
+
external_reference: opts.externalReference,
|
|
18
|
+
}));
|
|
19
|
+
addTenantOption(cmd.command('get').argument('<station-id>'))
|
|
20
|
+
.description('show one station')
|
|
21
|
+
.action((id, opts) => runGet(ctx, 'stations_read_station_and_connector_state', 'station_id', id, opts.tenant));
|
|
22
|
+
return cmd;
|
|
23
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { CliError, ExitCode } from '../lib/output.js';
|
|
4
|
+
import { openSession } from '../lib/session.js';
|
|
5
|
+
import { addListOptions, addTenantOption, callOperation, parseIntOption, resolveTenant, runGet, runList, } from '../lib/resource.js';
|
|
6
|
+
/** Reads JSON from a file path, or from stdin for `-`. */
|
|
7
|
+
async function readJsonInput(ctx, what, source) {
|
|
8
|
+
let text;
|
|
9
|
+
try {
|
|
10
|
+
if (source === '-') {
|
|
11
|
+
const chunks = [];
|
|
12
|
+
for await (const c of ctx.stdin)
|
|
13
|
+
chunks.push(Buffer.from(c));
|
|
14
|
+
text = Buffer.concat(chunks).toString('utf8');
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
text = await readFile(source, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
return JSON.parse(text);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
throw new CliError(`Cannot read ${what} JSON from ${source}: ${err.message}`, ExitCode.Usage);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function tariffsCommand(ctx) {
|
|
26
|
+
const cmd = new Command('tariffs').description('read tariffs; quote and simulate prices');
|
|
27
|
+
addListOptions(cmd.command('list'), 'after')
|
|
28
|
+
.description('list tariffs')
|
|
29
|
+
.option('--external-reference <ref>', 'filter by your external reference')
|
|
30
|
+
.option('--include-archived', 'include archived tariffs')
|
|
31
|
+
.action((opts) => runList(ctx, {
|
|
32
|
+
operationId: 'tariffs_and_bills_read_tenant_billing_tariffs',
|
|
33
|
+
cursorParam: 'after',
|
|
34
|
+
columns: ['id', 'name', 'currency', 'external_reference'],
|
|
35
|
+
}, opts, {
|
|
36
|
+
external_reference: opts.externalReference,
|
|
37
|
+
include_archived: opts.includeArchived ? true : undefined,
|
|
38
|
+
}));
|
|
39
|
+
addTenantOption(cmd.command('get').argument('<tariff-id>'))
|
|
40
|
+
.description('show one tariff')
|
|
41
|
+
.action((id, opts) => runGet(ctx, 'tariffs_and_bills_read_one_tariff', 'tariff_id', id, opts.tenant));
|
|
42
|
+
addTenantOption(cmd.command('quote').argument('<tariff-id>'))
|
|
43
|
+
.description('price a session under a stored tariff (read-only)')
|
|
44
|
+
.requiredOption('--session <file>', 'session profile JSON file, or - for stdin')
|
|
45
|
+
.option('--location-id <id>', 'apply this location’s tax lines')
|
|
46
|
+
.action(async (id, opts) => {
|
|
47
|
+
const session = await readJsonInput(ctx, 'session', opts.session);
|
|
48
|
+
const locationId = parseIntOption('--location-id', opts.locationId);
|
|
49
|
+
const s = await openSession(ctx);
|
|
50
|
+
const tenant = await resolveTenant(ctx, s, opts.tenant);
|
|
51
|
+
const res = await callOperation(s, 'tariffs_and_bills_quote_a_session_s_cost_under_a_stored_tariff', { tenant_id: tenant, tariff_id: id }, {
|
|
52
|
+
body: { session, ...(locationId !== undefined ? { location_id: locationId } : {}) },
|
|
53
|
+
readOnly: true,
|
|
54
|
+
});
|
|
55
|
+
ctx.out().result(res.data ?? {});
|
|
56
|
+
});
|
|
57
|
+
addTenantOption(cmd.command('simulate'))
|
|
58
|
+
.description('price a session under a draft tariff that is not stored (read-only)')
|
|
59
|
+
.requiredOption('--tariff <file>', 'tariff definition JSON file, or - for stdin')
|
|
60
|
+
.requiredOption('--session <file>', 'session profile JSON file')
|
|
61
|
+
.option('--location-id <id>', 'apply this location’s tax lines')
|
|
62
|
+
.action(async (opts) => {
|
|
63
|
+
if (opts.tariff === '-' && opts.session === '-') {
|
|
64
|
+
throw new CliError('Only one of --tariff and --session can read stdin.', ExitCode.Usage);
|
|
65
|
+
}
|
|
66
|
+
const tariff = await readJsonInput(ctx, 'tariff', opts.tariff);
|
|
67
|
+
const session = await readJsonInput(ctx, 'session', opts.session);
|
|
68
|
+
const locationId = parseIntOption('--location-id', opts.locationId);
|
|
69
|
+
const s = await openSession(ctx);
|
|
70
|
+
const tenant = await resolveTenant(ctx, s, opts.tenant);
|
|
71
|
+
const res = await callOperation(s, 'tariffs_and_bills_simulate_a_tariff_draft_against_a_session', { tenant_id: tenant }, {
|
|
72
|
+
body: {
|
|
73
|
+
tariff,
|
|
74
|
+
session,
|
|
75
|
+
...(locationId !== undefined ? { location_id: locationId } : {}),
|
|
76
|
+
},
|
|
77
|
+
readOnly: true,
|
|
78
|
+
});
|
|
79
|
+
ctx.out().result(res.data ?? {});
|
|
80
|
+
});
|
|
81
|
+
return cmd;
|
|
82
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { addTenantOption, callOperation, resolveTenant, runGet } from '../lib/resource.js';
|
|
3
|
+
import { openSession } from '../lib/session.js';
|
|
4
|
+
export function webhooksCommand(ctx) {
|
|
5
|
+
const cmd = new Command('webhooks').description('read webhook endpoints');
|
|
6
|
+
addTenantOption(cmd.command('list'))
|
|
7
|
+
.description('list webhook endpoints')
|
|
8
|
+
.action(async (opts) => {
|
|
9
|
+
const s = await openSession(ctx);
|
|
10
|
+
const tenant = await resolveTenant(ctx, s, opts.tenant);
|
|
11
|
+
const res = await callOperation(s, 'webhooks_and_events_list_webhook_endpoints', { tenant_id: tenant });
|
|
12
|
+
const out = ctx.out();
|
|
13
|
+
const rows = (res.data?.data ?? []);
|
|
14
|
+
if (out.json)
|
|
15
|
+
out.result({ data: rows });
|
|
16
|
+
else
|
|
17
|
+
out.result(rows, ['id', 'url', 'status', 'events']);
|
|
18
|
+
});
|
|
19
|
+
addTenantOption(cmd.command('get').argument('<webhook-id>'))
|
|
20
|
+
.description('show one webhook endpoint')
|
|
21
|
+
.action((id, opts) => runGet(ctx, 'webhooks_and_events_read_one_webhook_endpoint', 'webhook_id', id, opts.tenant));
|
|
22
|
+
return cmd;
|
|
23
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { accessEnvironment, accessKeyInfo, accessTenantIds } from '../lib/access.js';
|
|
3
|
+
import { envTag } from '../lib/output.js';
|
|
4
|
+
import { openSession } from '../lib/session.js';
|
|
5
|
+
export function whoamiCommand(ctx) {
|
|
6
|
+
return new Command('whoami')
|
|
7
|
+
.description('show the profile, environment, tenant and scopes of the current key')
|
|
8
|
+
.action(async () => {
|
|
9
|
+
const s = await openSession(ctx);
|
|
10
|
+
const access = (await s.client.request('GET', '/v1/access')).data ?? {};
|
|
11
|
+
const caps = (await s.client.request('GET', '/v1/capabilities'))
|
|
12
|
+
.data;
|
|
13
|
+
const tenants = accessTenantIds(access);
|
|
14
|
+
const key = accessKeyInfo(access);
|
|
15
|
+
const result = {
|
|
16
|
+
profile: s.profile.name,
|
|
17
|
+
environment: accessEnvironment(access) ?? s.profile.environment,
|
|
18
|
+
api_base: s.baseUrl,
|
|
19
|
+
tenant_id: tenants.length === 1 ? tenants[0] : undefined,
|
|
20
|
+
tenants,
|
|
21
|
+
scopes: access.scopes ?? [],
|
|
22
|
+
...(key ? { key } : {}),
|
|
23
|
+
capabilities: caps ?? {},
|
|
24
|
+
};
|
|
25
|
+
const out = ctx.out();
|
|
26
|
+
if (!out.json)
|
|
27
|
+
out.line(envTag(s.profile.environment));
|
|
28
|
+
out.result(result);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { CliError, ExitCode } from './output.js';
|
|
2
|
+
/**
|
|
3
|
+
* The profile environment for the server's `environment` value: "production" is a live key,
|
|
4
|
+
* "test" a test key. Undefined when the server does not say; an unknown value is refused, so a
|
|
5
|
+
* key is never saved for the wrong environment.
|
|
6
|
+
*/
|
|
7
|
+
export function accessEnvironment(access) {
|
|
8
|
+
if (access.environment === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
if (access.environment === 'production')
|
|
11
|
+
return 'live';
|
|
12
|
+
if (access.environment === 'test')
|
|
13
|
+
return 'test';
|
|
14
|
+
throw new CliError(`The server reported an unknown environment ("${String(access.environment)}").`, ExitCode.Usage);
|
|
15
|
+
}
|
|
16
|
+
/** The tenant ids the key can reach, as strings. */
|
|
17
|
+
export function accessTenantIds(access) {
|
|
18
|
+
if (!Array.isArray(access.tenants))
|
|
19
|
+
return [];
|
|
20
|
+
return access.tenants
|
|
21
|
+
.map((t) => t?.id)
|
|
22
|
+
.filter((id) => typeof id === 'number' && Number.isSafeInteger(id))
|
|
23
|
+
.map(String);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The tenant to remember for a key: the only one it can reach. With none or several there is no
|
|
27
|
+
* default, and `note` explains why.
|
|
28
|
+
*/
|
|
29
|
+
export function defaultTenant(access, note) {
|
|
30
|
+
const ids = accessTenantIds(access);
|
|
31
|
+
if (ids.length === 1)
|
|
32
|
+
return ids[0];
|
|
33
|
+
if (ids.length === 0)
|
|
34
|
+
note('this key has no tenant, so no default tenant is set.');
|
|
35
|
+
else
|
|
36
|
+
note(`this key can reach ${ids.length} tenants (${ids.join(', ')}); no default tenant is set.`);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
/** The key details for display, when the server sends them. */
|
|
40
|
+
export function accessKeyInfo(access) {
|
|
41
|
+
const key = access.key;
|
|
42
|
+
if (!key || typeof key !== 'object')
|
|
43
|
+
return undefined;
|
|
44
|
+
const info = {};
|
|
45
|
+
for (const field of ['id', 'prefix', 'label', 'created_at', 'expires_at']) {
|
|
46
|
+
const value = key[field];
|
|
47
|
+
if (typeof value === 'string' || typeof value === 'number')
|
|
48
|
+
info[field] = String(value);
|
|
49
|
+
}
|
|
50
|
+
return Object.keys(info).length > 0 ? info : undefined;
|
|
51
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { CliError, ExitCode, envTag } from './output.js';
|
|
3
|
+
/** Prompts on stderr and reads stdin, so stdout stays clean for results. */
|
|
4
|
+
export function ttyPrompter(input = process.stdin, output = process.stderr) {
|
|
5
|
+
return {
|
|
6
|
+
interactive: input.isTTY === true && output.isTTY === true,
|
|
7
|
+
async ask(question) {
|
|
8
|
+
const rl = createInterface({ input, output, terminal: true });
|
|
9
|
+
try {
|
|
10
|
+
return await rl.question(question);
|
|
11
|
+
}
|
|
12
|
+
finally {
|
|
13
|
+
rl.close();
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function needsConfirmation(req) {
|
|
19
|
+
if (!req.write)
|
|
20
|
+
return false;
|
|
21
|
+
return req.dangerous === true || req.environment === 'live';
|
|
22
|
+
}
|
|
23
|
+
/** Resolves when the action may proceed; throws a CliError (exit 3) when it is refused. */
|
|
24
|
+
export async function confirmAction(prompter, req) {
|
|
25
|
+
if (!needsConfirmation(req) || req.yes)
|
|
26
|
+
return;
|
|
27
|
+
if (!prompter.interactive) {
|
|
28
|
+
throw new CliError(`${req.summary}: confirmation needed. Pass --yes to run non-interactively.`, ExitCode.ConfirmRefused);
|
|
29
|
+
}
|
|
30
|
+
const answer = await prompter.ask(`${envTag(req.environment, req.project)} ${req.summary}. Continue? [y/N] `);
|
|
31
|
+
if (!/^(y|yes)$/i.test(answer.trim())) {
|
|
32
|
+
throw new CliError('Cancelled.', ExitCode.ConfirmRefused);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Type-the-name confirmation. Returns the confirmed name, which the caller sends to the API
|
|
37
|
+
* (the server checks it again). Non-interactive runs must pass --confirm-name.
|
|
38
|
+
*/
|
|
39
|
+
export async function confirmName(prompter, req) {
|
|
40
|
+
let typed = req.confirmName;
|
|
41
|
+
if (typed === undefined) {
|
|
42
|
+
if (!prompter.interactive) {
|
|
43
|
+
throw new CliError(`Pass --confirm-name "<name>" to confirm ${req.what} non-interactively.`, ExitCode.ConfirmRefused);
|
|
44
|
+
}
|
|
45
|
+
typed = await prompter.ask(`${envTag(req.environment, req.project)} Type "${req.expected}" to confirm ${req.what}: `);
|
|
46
|
+
}
|
|
47
|
+
if (typed.trim() !== req.expected) {
|
|
48
|
+
throw new CliError('The name did not match. Nothing was changed.', ExitCode.ConfirmRefused);
|
|
49
|
+
}
|
|
50
|
+
return req.expected;
|
|
51
|
+
}
|