@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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret handling: redaction of keys, webhook secrets and credentials in anything the CLI
|
|
3
|
+
* prints, and the secret-store interface. Keys are stored in the OS keychain only (macOS Keychain
|
|
4
|
+
* or the freedesktop Secret Service). There is no plain-text file fallback: where no keychain
|
|
5
|
+
* is available, `ivora login` refuses and `IVORA_API_KEY` is the way to supply a key.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { CliError, ExitCode } from './output.js';
|
|
9
|
+
const API_KEY = /\biv_(test|live)_[A-Za-z0-9_-]+/g;
|
|
10
|
+
const WEBHOOK_SECRET = /\bwhsec_[A-Za-z0-9+/=_-]+/g;
|
|
11
|
+
const AUTH_SCHEME = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi;
|
|
12
|
+
export const REDACTED = '[redacted]';
|
|
13
|
+
/** Redacts API keys, webhook secrets and Bearer/Basic credentials in free text. */
|
|
14
|
+
export function redact(text) {
|
|
15
|
+
return text
|
|
16
|
+
.replace(API_KEY, (_m, env) => `iv_${env}_${REDACTED}`)
|
|
17
|
+
.replace(WEBHOOK_SECRET, `whsec_${REDACTED}`)
|
|
18
|
+
.replace(AUTH_SCHEME, (_m, scheme) => `${scheme} ${REDACTED}`);
|
|
19
|
+
}
|
|
20
|
+
const SECRET_HEADERS = new Set([
|
|
21
|
+
'authorization',
|
|
22
|
+
'proxy-authorization',
|
|
23
|
+
'cookie',
|
|
24
|
+
'set-cookie',
|
|
25
|
+
'ivora-signature',
|
|
26
|
+
]);
|
|
27
|
+
/** Returns a copy of the headers with credential headers replaced, for --debug output. */
|
|
28
|
+
export function redactHeaders(headers) {
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
31
|
+
out[name] = SECRET_HEADERS.has(name.toLowerCase()) ? REDACTED : redact(value);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
const SECRET_FIELD = /password|secret|api_?key|authorization/i;
|
|
36
|
+
/** Returns a deep copy of a JSON body with password, secret and key fields replaced. */
|
|
37
|
+
export function redactBody(value) {
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return value.map(redactBody);
|
|
40
|
+
if (value !== null && typeof value === 'object') {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
43
|
+
out[key] = SECRET_FIELD.test(key) ? REDACTED : redactBody(inner);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
return typeof value === 'string' ? redact(value) : value;
|
|
48
|
+
}
|
|
49
|
+
/** A short, non-secret label for a key, e.g. `iv_test_ab12...`. */
|
|
50
|
+
export function displayPrefix(apiKey) {
|
|
51
|
+
const match = /^(iv_(?:test|live)_)(.{0,4})/.exec(apiKey);
|
|
52
|
+
return match ? `${match[1]}${match[2]}...` : '(unrecognised key)';
|
|
53
|
+
}
|
|
54
|
+
/** In-memory store, used by tests and by `IVORA_API_KEY` sessions that persist nothing. */
|
|
55
|
+
export class MemorySecretStore {
|
|
56
|
+
values = new Map();
|
|
57
|
+
async get(ref) {
|
|
58
|
+
return this.values.get(ref);
|
|
59
|
+
}
|
|
60
|
+
async set(ref, secret) {
|
|
61
|
+
this.values.set(ref, secret);
|
|
62
|
+
}
|
|
63
|
+
async delete(ref) {
|
|
64
|
+
return this.values.delete(ref);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const SERVICE = 'ivora-cli';
|
|
68
|
+
const REF = /^[A-Za-z0-9:_-]{1,80}$/;
|
|
69
|
+
const KEYCHAIN_SECRET = /^[A-Za-z0-9_-]{8,200}$/;
|
|
70
|
+
export const defaultExec = (cmd, args, input) => new Promise((resolve) => {
|
|
71
|
+
const child = execFile(cmd, args, { encoding: 'utf8', timeout: 15_000 }, (err, stdout) => {
|
|
72
|
+
if (!err)
|
|
73
|
+
return resolve({ code: 0, stdout });
|
|
74
|
+
const e = err;
|
|
75
|
+
resolve({
|
|
76
|
+
code: typeof e.code === 'number' ? e.code : 1,
|
|
77
|
+
stdout: '',
|
|
78
|
+
missing: e.code === 'ENOENT',
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
child.stdin?.on('error', () => undefined);
|
|
82
|
+
child.stdin?.end(input ?? '');
|
|
83
|
+
});
|
|
84
|
+
/**
|
|
85
|
+
* OS keychain through its command-line tool. The secret travels on stdin only, so it does not
|
|
86
|
+
* show up in the process list. `get` returns undefined for a missing entry.
|
|
87
|
+
*/
|
|
88
|
+
export class KeychainSecretStore {
|
|
89
|
+
platform;
|
|
90
|
+
exec;
|
|
91
|
+
constructor(platform, exec = defaultExec) {
|
|
92
|
+
this.platform = platform;
|
|
93
|
+
this.exec = exec;
|
|
94
|
+
}
|
|
95
|
+
check(ref) {
|
|
96
|
+
if (!REF.test(ref))
|
|
97
|
+
throw new CliError('Invalid secret reference.', ExitCode.Usage);
|
|
98
|
+
}
|
|
99
|
+
async get(ref) {
|
|
100
|
+
this.check(ref);
|
|
101
|
+
const r = this.platform === 'darwin'
|
|
102
|
+
? await this.exec('security', ['find-generic-password', '-s', SERVICE, '-a', ref, '-w'])
|
|
103
|
+
: await this.exec('secret-tool', ['lookup', 'service', SERVICE, 'account', ref]);
|
|
104
|
+
const value = r.stdout.replace(/\r?\n$/, '');
|
|
105
|
+
return r.code === 0 && value !== '' ? value : undefined;
|
|
106
|
+
}
|
|
107
|
+
async set(ref, secret) {
|
|
108
|
+
this.check(ref);
|
|
109
|
+
// Stricter than any real key, so the value is safe in the `security -i` command line.
|
|
110
|
+
if (!KEYCHAIN_SECRET.test(secret)) {
|
|
111
|
+
throw new CliError('The key has an unexpected format.', ExitCode.Usage);
|
|
112
|
+
}
|
|
113
|
+
const r = this.platform === 'darwin'
|
|
114
|
+
? await this.exec('security', ['-i'], `add-generic-password -U -s ${SERVICE} -a ${ref} -w ${secret}\n`)
|
|
115
|
+
: await this.exec('secret-tool', ['store', '--label=Ivora CLI API key', 'service', SERVICE, 'account', ref], secret);
|
|
116
|
+
if (r.code !== 0) {
|
|
117
|
+
throw new CliError('Could not save the key in the OS keychain. Use the IVORA_API_KEY environment variable instead.', ExitCode.ApiError);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async delete(ref) {
|
|
121
|
+
this.check(ref);
|
|
122
|
+
const r = this.platform === 'darwin'
|
|
123
|
+
? await this.exec('security', ['delete-generic-password', '-s', SERVICE, '-a', ref])
|
|
124
|
+
: await this.exec('secret-tool', ['clear', 'service', SERVICE, 'account', ref]);
|
|
125
|
+
return r.code === 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Used where no keychain exists: reads find nothing and writes explain the alternative. */
|
|
129
|
+
export class UnavailableSecretStore {
|
|
130
|
+
async get() {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
async set() {
|
|
134
|
+
throw new CliError('No OS keychain is available on this system, and the CLI never writes keys to disk in ' +
|
|
135
|
+
'plain text. Set the IVORA_API_KEY environment variable instead.', ExitCode.Usage);
|
|
136
|
+
}
|
|
137
|
+
async delete() {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** The keychain store for this platform, or one that refuses writes. */
|
|
142
|
+
export async function openSecretStore(platform = process.platform, exec = defaultExec) {
|
|
143
|
+
if (platform === 'darwin')
|
|
144
|
+
return new KeychainSecretStore('darwin', exec);
|
|
145
|
+
if (platform === 'linux') {
|
|
146
|
+
const probe = await exec('secret-tool', ['--version']);
|
|
147
|
+
if (!probe.missing)
|
|
148
|
+
return new KeychainSecretStore('linux', exec);
|
|
149
|
+
}
|
|
150
|
+
return new UnavailableSecretStore();
|
|
151
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ApiClient } from './http.js';
|
|
2
|
+
import { CliError, ExitCode, Output } from './output.js';
|
|
3
|
+
import { assertKeyMatchesProfile, environmentForKey, loadConfig, resolveBaseUrl, selectProfileName, } from './profiles.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the profile, key and API client for a command. The key comes from `IVORA_API_KEY`
|
|
6
|
+
* or the secret store. Live is an opt-in: a live profile or live key is used only with
|
|
7
|
+
* `--live` or `--profile <live profile>`.
|
|
8
|
+
*/
|
|
9
|
+
export async function openSession(ctx) {
|
|
10
|
+
const g = ctx.globals();
|
|
11
|
+
const config = await loadConfig(ctx.env);
|
|
12
|
+
const name = selectProfileName(g, config);
|
|
13
|
+
const stored = config.profiles[name];
|
|
14
|
+
const envKey = ctx.env.IVORA_API_KEY || undefined;
|
|
15
|
+
let profile;
|
|
16
|
+
let apiKey;
|
|
17
|
+
if (envKey !== undefined) {
|
|
18
|
+
const environment = environmentForKey(envKey);
|
|
19
|
+
if (environment === undefined) {
|
|
20
|
+
throw new CliError('IVORA_API_KEY does not look like an Ivora API key.', ExitCode.Usage);
|
|
21
|
+
}
|
|
22
|
+
// Naming a profile opts in to live only when that profile is stored as live. With no
|
|
23
|
+
// stored profile, a live key from the environment always needs --live.
|
|
24
|
+
if (!stored && environment === 'live' && !g.live) {
|
|
25
|
+
throw new CliError('IVORA_API_KEY holds a live key: pass --live to use it.', ExitCode.Usage);
|
|
26
|
+
}
|
|
27
|
+
profile = stored ?? { name, environment };
|
|
28
|
+
apiKey = envKey;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
if (!stored) {
|
|
32
|
+
throw new CliError(`No profile "${name}". Run \`ivora login${name === 'live' ? ' --live' : ''}\` first.`, ExitCode.Auth);
|
|
33
|
+
}
|
|
34
|
+
profile = stored;
|
|
35
|
+
const key = stored.keyRef ? await (await ctx.secrets()).get(stored.keyRef) : undefined;
|
|
36
|
+
if (key === undefined) {
|
|
37
|
+
throw new CliError(`No stored key for profile "${name}". Run \`ivora login\` again, or set IVORA_API_KEY.`, ExitCode.Auth);
|
|
38
|
+
}
|
|
39
|
+
apiKey = key;
|
|
40
|
+
}
|
|
41
|
+
assertKeyMatchesProfile(apiKey, profile);
|
|
42
|
+
if (g.live && profile.environment !== 'live') {
|
|
43
|
+
throw new CliError(`--live was given but profile "${profile.name}" is a test profile.`, ExitCode.Usage);
|
|
44
|
+
}
|
|
45
|
+
if (profile.environment === 'live' && !g.live && g.profile !== profile.name) {
|
|
46
|
+
throw new CliError('Live access is opt-in: pass --live (or --profile <name> for a live profile).', ExitCode.Usage);
|
|
47
|
+
}
|
|
48
|
+
const baseUrl = resolveBaseUrl(profile, ctx.env, (text) => ctx.out().info(`note: ${text}`));
|
|
49
|
+
const debug = g.debug ? (line) => ctx.out().info(line) : undefined;
|
|
50
|
+
const client = new ApiClient({
|
|
51
|
+
baseUrl,
|
|
52
|
+
apiKey,
|
|
53
|
+
apiVersion: g.apiVersion,
|
|
54
|
+
...(ctx.fetch ? { fetch: ctx.fetch } : {}),
|
|
55
|
+
...(debug ? { debug } : {}),
|
|
56
|
+
});
|
|
57
|
+
return { profile, apiKey, baseUrl, client };
|
|
58
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Version of this CLI. A test keeps it equal to package.json. */
|
|
2
|
+
export const CLI_VERSION = '0.1.0';
|
|
3
|
+
/** The dated /v1 API version this release pins, sent as `Ivora-Version`. */
|
|
4
|
+
export const DEFAULT_API_VERSION = '2026-10-15';
|
|
5
|
+
/** Dated /v1 API versions this release supports, oldest first (`--api-version`). */
|
|
6
|
+
export const SUPPORTED_API_VERSIONS = [DEFAULT_API_VERSION];
|
|
7
|
+
export function isSupportedApiVersion(value) {
|
|
8
|
+
return SUPPORTED_API_VERSIONS.includes(value);
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ivora/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line client for the public Ivora /v1 API",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Ivora",
|
|
7
|
+
"private": false,
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"ivora": "dist/bin/ivora.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.12"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/Ivora-Charge/ivora-cli.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/Ivora-Charge/ivora-cli#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/Ivora-Charge/ivora-cli/issues"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"ivora",
|
|
28
|
+
"ocpp",
|
|
29
|
+
"ev-charging",
|
|
30
|
+
"cli"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"provenance": true
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "node scripts/clean.mjs dist && tsc -p tsconfig.build.json",
|
|
38
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
39
|
+
"test": "node scripts/clean.mjs .test-build && tsc -p tsconfig.test.json && node --test --test-reporter=spec \".test-build/test/**/*.test.js\"",
|
|
40
|
+
"lint": "eslint . && prettier --check .",
|
|
41
|
+
"format": "prettier --write .",
|
|
42
|
+
"check:package": "node scripts/check-package.mjs",
|
|
43
|
+
"prepack": "npm run build",
|
|
44
|
+
"prepublishOnly": "node scripts/guard-publish.mjs"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"commander": "15.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@eslint/js": "10.0.1",
|
|
51
|
+
"@types/node": "22.20.5",
|
|
52
|
+
"eslint": "10.12.0",
|
|
53
|
+
"globals": "17.13.0",
|
|
54
|
+
"prettier": "3.9.10",
|
|
55
|
+
"typescript": "6.0.3",
|
|
56
|
+
"typescript-eslint": "8.71.1"
|
|
57
|
+
}
|
|
58
|
+
}
|