@commercelayer/agent-cli 0.8.1 → 1.0.0-dev.1
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 +98 -215
- package/dist/argv.js +227 -0
- package/dist/bin.js +15 -178
- package/dist/builtins/auth.js +169 -0
- package/dist/builtins/extensions.js +107 -0
- package/dist/builtins/index.js +8 -0
- package/dist/builtins/loops.js +215 -0
- package/dist/builtins/sandbox.js +186 -0
- package/dist/config.js +238 -0
- package/dist/confirm.js +56 -0
- package/dist/engine.js +205 -0
- package/dist/errors.js +146 -0
- package/dist/help.js +81 -0
- package/dist/http.js +117 -0
- package/dist/inputs.js +112 -0
- package/dist/login.js +136 -0
- package/dist/resources.js +42 -0
- package/dist/schema.js +182 -0
- package/dist/schemas.js +72 -0
- package/dist/secrets.js +204 -0
- package/dist/version.js +4 -8
- package/package.json +4 -9
- package/dist/api/client.js +0 -190
- package/dist/api/server.js +0 -26
- package/dist/api/stub/db.js +0 -37
- package/dist/api/stub/http.js +0 -18
- package/dist/api/stub/index.js +0 -52
- package/dist/api/stub/locale.js +0 -52
- package/dist/api/stub/management.js +0 -409
- package/dist/api/stub/messages.js +0 -126
- package/dist/api/stub/orders.js +0 -625
- package/dist/api/stub/search.js +0 -78
- package/dist/api/stub/shopping.js +0 -673
- package/dist/api/stub/theme.js +0 -197
- package/dist/api/stub/ucp.js +0 -377
- package/dist/api/transport.js +0 -57
- package/dist/api/types.js +0 -4
- package/dist/assets.js +0 -100
- package/dist/catalog.js +0 -372
- package/dist/check.js +0 -187
- package/dist/commands.js +0 -1241
- package/dist/cursor.js +0 -31
- package/dist/files.js +0 -98
- package/dist/inbox/cursor.js +0 -11
- package/dist/inbox/fake.js +0 -95
- package/dist/inbox/imap.js +0 -139
- package/dist/inbox/mailbox.js +0 -25
- package/dist/inbox/message.js +0 -88
- package/dist/inspect.js +0 -237
- package/dist/languages.js +0 -49
- package/dist/links.js +0 -118
- package/dist/locales.js +0 -43
- package/dist/ops.js +0 -190
- package/dist/orderRequest.js +0 -141
- package/dist/output.js +0 -39
- package/dist/products.js +0 -151
- package/dist/promotions.js +0 -291
- package/dist/store.js +0 -379
- package/dist/storefront.js +0 -285
- package/dist/ucp/buy.js +0 -90
- package/dist/ucp/check.js +0 -362
- package/dist/ucp/client.js +0 -129
- package/dist/ucp/images.js +0 -62
- package/dist/ucp/platform.js +0 -90
- package/dist/ucp/shop.js +0 -357
- package/dist/ucp/signing.js +0 -67
- package/dist/web.js +0 -316
- package/dist/website.js +0 -390
package/dist/help.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Help, as JSON in the envelope like everything else. It follows the conversational rule: it says
|
|
2
|
+
// what a command does and what it returns, never that a human should run something. The agent
|
|
3
|
+
// reads it and offers to do the work.
|
|
4
|
+
import { toFlag } from './argv.js';
|
|
5
|
+
import { EXIT } from './errors.js';
|
|
6
|
+
export const USAGE = 'c11r <noun> <verb> [id] [--field value ...]';
|
|
7
|
+
export const GLOBAL_FLAG_HELP = {
|
|
8
|
+
'--json': 'Output as JSON. The default, and the only format: one object on stdout, { data, state, warnings, next } or { error, state }.',
|
|
9
|
+
'--dry-run': 'On a write, the API checks everything and says what would change, and changes nothing. Reads ignore it.',
|
|
10
|
+
'--yes': 'Confirms a destructive verb in advance. Without it a destructive verb asks in the terminal, or, with no terminal, stops with confirmation_required.',
|
|
11
|
+
'--org <slug>': 'The organization to act on, by its stored token. Without it: C11R_ORG, then the folder\'s .c11r.json, then the only stored organization.',
|
|
12
|
+
'--live': 'Acts on live data, on a claimed organization. Without it every command works in test mode.',
|
|
13
|
+
'--help': 'Describes the noun or verb it follows, from its schema.',
|
|
14
|
+
};
|
|
15
|
+
export const EXIT_CODE_HELP = {
|
|
16
|
+
[EXIT.ok]: 'done',
|
|
17
|
+
[EXIT.unexpected]: 'unexpected failure, a bug in c11r',
|
|
18
|
+
[EXIT.usage]: 'the command is not valid: unknown noun, verb or flag, a bad value, or no organization named',
|
|
19
|
+
[EXIT.confirmation]: 'a destructive verb needs confirmation (--yes)',
|
|
20
|
+
[EXIT.requirement]: 'the organization needs another state or plan, has expired, or reached a limit',
|
|
21
|
+
[EXIT.not_found]: 'no such resource',
|
|
22
|
+
[EXIT.rejected]: 'the API or Commerce Layer refused the request as it is',
|
|
23
|
+
[EXIT.unavailable]: 'the API did not answer, is busy, or answered with an error of its own',
|
|
24
|
+
[EXIT.auth]: 'a wrong or revoked token, or no token stored for the organization named',
|
|
25
|
+
};
|
|
26
|
+
export function indexHelp(builtins, api) {
|
|
27
|
+
return {
|
|
28
|
+
usage: USAGE,
|
|
29
|
+
description: 'c11r is the Commerce Layer CLI for coding agents. Every answer, errors included, is one JSON object on stdout.',
|
|
30
|
+
nouns: [...builtins, ...(api ?? [])].sort((a, b) => (a.noun < b.noun ? -1 : 1)),
|
|
31
|
+
standard_verbs: 'list, get, create, update, delete. Nouns with a state machine add their own verbs.',
|
|
32
|
+
flags: 'Fields in kebab or snake case (--price-list, --price_list). On list, --<field> <value> filters by equality and --<field>.<op> <value> by another operator (--created-at.gte 2026-11-01; in takes values separated by commas). A dotted flag sets part of an object (--customer.email). An array takes a JSON array, values separated by commas, or the flag repeated; an array of objects takes key=value pairs (--items sku=COFFEE,quantity=2). --data takes the fields as one JSON object, or @ and a file name.',
|
|
33
|
+
global_flags: GLOBAL_FLAG_HELP,
|
|
34
|
+
exit_codes: EXIT_CODE_HELP,
|
|
35
|
+
more: 'c11r <noun> --help describes a noun and its verbs; c11r <noun> <verb> --help a verb and its flags; c11r schema <noun> returns the full schema.',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function nounHelp(s) {
|
|
39
|
+
return {
|
|
40
|
+
noun: s.noun,
|
|
41
|
+
description: s.description,
|
|
42
|
+
...(s.skill ? { skill: s.skill } : {}),
|
|
43
|
+
...(s.id?.accepts.length ? { id: `The id, or ${s.id.accepts.filter((a) => a !== 'id').join(' or ')}${s.id.example ? `, as in ${s.id.example}` : ''}.` } : {}),
|
|
44
|
+
verbs: Object.values(s.verbs).map((v) => ({ verb: v.name, usage: usageOf(s.noun, v), description: v.description, ...(v.destructive ? { destructive: true } : {}), ...(v.loopable ? { loopable: true } : {}), ...(v.refused ? { served: false, refused_by: v.refused.by } : {}) })),
|
|
45
|
+
...(s.waiting ? { waiting: s.waiting } : {}),
|
|
46
|
+
...(s.loop ? { loop: s.loop } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function verbHelp(s, v) {
|
|
50
|
+
return {
|
|
51
|
+
usage: usageOf(s.noun, v),
|
|
52
|
+
description: v.description,
|
|
53
|
+
state: v.state,
|
|
54
|
+
...(v.plan ? { plan: v.plan } : {}),
|
|
55
|
+
...(v.refused ? { served: false, refused_by: v.refused.by } : {}),
|
|
56
|
+
...(v.destructive ? { destructive: true, confirmation: 'Asks for confirmation unless --yes; with no terminal and no --yes it stops with confirmation_required.' } : {}),
|
|
57
|
+
...(v.loopable ? { loopable: 'A loop policy may allow this verb.' } : {}),
|
|
58
|
+
...(v.live ? {} : { live: 'Not in live mode.' }),
|
|
59
|
+
flags: Object.entries(v.flags).map(([key, f]) => flagHelp(key, f)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function flagHelp(key, f) {
|
|
63
|
+
return {
|
|
64
|
+
flag: toFlag(key),
|
|
65
|
+
type: f.type === 'array' ? `array of ${f.items ?? 'string'}` : f.type === 'ref' ? `${f.noun ?? 'resource'} id` : f.type === 'money' ? 'amount in major units' : f.type,
|
|
66
|
+
...(f.required || (f.input?.kind === 'secret' && f.input.required) ? { required: true } : {}),
|
|
67
|
+
...(f.input ? { reads: f.input.kind === 'secret' ? `${f.input.target}, a secret: never a flag value, never printed` : `${f.input.target}, a JSON document` } : {}),
|
|
68
|
+
...(f.values ? { values: f.values } : {}),
|
|
69
|
+
...(f.filter ? { filter: f.filter.op } : {}),
|
|
70
|
+
...(f.description ? { description: f.description } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function usageOf(noun, v) {
|
|
74
|
+
const needed = (f) => f.required || (f.input?.kind === 'secret' && f.input.required);
|
|
75
|
+
const required = Object.entries(v.flags)
|
|
76
|
+
.filter(([, f]) => needed(f))
|
|
77
|
+
.map(([k, f]) => `${toFlag(k)} <${f.input ? 'file' : f.type === 'enum' ? f.values?.join('|') : f.type}>`);
|
|
78
|
+
const optional = Object.entries(v.flags).some(([k, f]) => !needed(f) && !v.idAlternative.includes(k));
|
|
79
|
+
const id = v.id ? (v.idAlternative.length ? `<id | ${v.idAlternative.map((k) => `${toFlag(k)} <${v.flags[k].type}>`).join(' ')}>` : '<id>') : '';
|
|
80
|
+
return [`c11r ${noun} ${v.name}`, id, ...required, optional ? '[--field value ...]' : ''].filter(Boolean).join(' ');
|
|
81
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Requests to the c11r API (contract sections 1, 2, 3 and 8). C11R_API_URL points the CLI at
|
|
2
|
+
// another API, such as wrangler dev on http://localhost:8787/v1 (a URL without /v1 gets it).
|
|
3
|
+
// Every answer is the envelope { data, state, warnings, next }; every error { error, state }.
|
|
4
|
+
//
|
|
5
|
+
// Query flags: --live sends mode=live on every request, --dry-run sends dry_run=true on writes,
|
|
6
|
+
// and a confirmed destructive verb sends confirm=true (the form that works on every verb).
|
|
7
|
+
//
|
|
8
|
+
// Retries, as the PM ruled: rate_limited means nothing was processed, so reads and writes are
|
|
9
|
+
// retried twice with a short backoff. commercelayer_busy and commercelayer_unavailable are
|
|
10
|
+
// retried for reads only: a write that may have reached Commerce Layer fails with its next step.
|
|
11
|
+
// The exception is a write with an Idempotency-Key (sandbox create): the API answers the same key
|
|
12
|
+
// with the same result, so it is repeated with that key after any 5xx or a lost connection too.
|
|
13
|
+
import { CliError, errorBody, fail, kindOfStatus } from './errors.js';
|
|
14
|
+
import { USER_AGENT } from './version.js';
|
|
15
|
+
export const DEFAULT_API_URL = 'https://api.commercelayer.ai/v1';
|
|
16
|
+
export function apiUrl() {
|
|
17
|
+
const raw = (process.env.C11R_API_URL || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
18
|
+
return /\/v\d+$/.test(raw) ? raw : `${raw}/v1`;
|
|
19
|
+
}
|
|
20
|
+
/** An answer of the CLI's own, in the API's shape. state null: the CLI cannot tell it. */
|
|
21
|
+
export const envelope = (data, extra = {}) => ({ data, state: null, warnings: [], next: [], ...extra });
|
|
22
|
+
const RETRY_ALWAYS = new Set(['rate_limited']);
|
|
23
|
+
const RETRY_READS = new Set(['commercelayer_busy', 'commercelayer_unavailable']);
|
|
24
|
+
const TRIES = 3;
|
|
25
|
+
export function urlOf(req, ctx) {
|
|
26
|
+
const q = new URLSearchParams(req.query ?? {});
|
|
27
|
+
if (ctx.live)
|
|
28
|
+
q.set('mode', 'live');
|
|
29
|
+
if (req.dryRun && req.method !== 'GET')
|
|
30
|
+
q.set('dry_run', 'true');
|
|
31
|
+
if (req.confirm)
|
|
32
|
+
q.set('confirm', 'true');
|
|
33
|
+
const qs = q.toString();
|
|
34
|
+
return `${apiUrl()}${req.path}${qs ? `?${qs}` : ''}`;
|
|
35
|
+
}
|
|
36
|
+
/** Sends the request: the envelope, or the API's error as a CliError. */
|
|
37
|
+
export async function request(req, ctx) {
|
|
38
|
+
for (let attempt = 1;; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
return await once(req, ctx);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
const safe = req.method === 'GET' || req.idempotencyKey !== undefined;
|
|
44
|
+
const retryable = e instanceof CliError && (RETRY_ALWAYS.has(e.code) || (safe && (RETRY_READS.has(e.code) || (req.idempotencyKey !== undefined && (e.code === 'api_unreachable' || e.kind === 'unavailable')))));
|
|
45
|
+
if (!retryable || attempt >= TRIES)
|
|
46
|
+
throw e;
|
|
47
|
+
// The API's retry_after (seconds, in the error or the Retry-After header) is waited for, up
|
|
48
|
+
// to the cap. Longer than the cap: no wait here, the error goes to the agent to plan around.
|
|
49
|
+
const after = retryAfterMs(e);
|
|
50
|
+
if (after !== null && after > retryCapMs())
|
|
51
|
+
throw e;
|
|
52
|
+
const wait = after ?? backoffMs() * 3 ** (attempt - 1);
|
|
53
|
+
process.stderr.write(JSON.stringify({ retrying: { code: e.code, attempt, of: TRIES - 1, in_ms: wait } }) + '\n');
|
|
54
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function once(req, ctx) {
|
|
59
|
+
const headers = { 'user-agent': USER_AGENT, accept: 'application/json' };
|
|
60
|
+
if (req.body !== undefined)
|
|
61
|
+
headers['content-type'] = 'application/json';
|
|
62
|
+
if (ctx.token)
|
|
63
|
+
headers.authorization = `Bearer ${ctx.token}`;
|
|
64
|
+
if (req.idempotencyKey)
|
|
65
|
+
headers['idempotency-key'] = req.idempotencyKey;
|
|
66
|
+
const url = urlOf(req, ctx);
|
|
67
|
+
const timeout = req.timeoutMs ?? timeoutMs();
|
|
68
|
+
let res;
|
|
69
|
+
try {
|
|
70
|
+
res = await fetch(url, { method: req.method, headers, body: req.body === undefined ? undefined : JSON.stringify(req.body), redirect: 'manual', signal: AbortSignal.timeout(timeout) });
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
const why = e.name === 'TimeoutError' ? `no answer in ${timeout / 1000} s` : e.message;
|
|
74
|
+
fail('api_unreachable', `The c11r API at ${new URL(url).origin} did not answer: ${why}.${process.env.C11R_API_URL ? ` C11R_API_URL is set to ${process.env.C11R_API_URL}.` : ''}`, {
|
|
75
|
+
// A write may have arrived before the answer was lost: never "retry" it blindly.
|
|
76
|
+
next_step: req.method === 'GET' ? { action: 'retry_later' } : { action: 'none' },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const text = await res.text();
|
|
80
|
+
let data;
|
|
81
|
+
try {
|
|
82
|
+
data = text ? JSON.parse(text) : null;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
fail('invalid_response', `The API answered ${res.status} with something that is not JSON.`, { details: { status: res.status } }, res.ok ? 'unavailable' : kindOfStatus(res.status));
|
|
86
|
+
}
|
|
87
|
+
if (!res.ok)
|
|
88
|
+
throw apiError(res.status, data, res.headers.get('retry-after'));
|
|
89
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || !('data' in data)) {
|
|
90
|
+
fail('invalid_response', `The API answered ${res.status} without the envelope (data, state).`, { details: { status: res.status } });
|
|
91
|
+
}
|
|
92
|
+
return { data: data.data, state: data.state ?? null, warnings: data.warnings ?? [], next: data.next ?? [] };
|
|
93
|
+
}
|
|
94
|
+
/** The API's error, kept as the API wrote it, with the HTTP status in details. */
|
|
95
|
+
function apiError(status, data, retryAfterHeader = null) {
|
|
96
|
+
const e = data?.error;
|
|
97
|
+
if (!e || typeof e !== 'object' || typeof e.code !== 'string') {
|
|
98
|
+
return new CliError(errorBody(status >= 500 ? 'api_error' : 'invalid_response', `The API answered ${status} without an error object.`, { details: { status } }), kindOfStatus(status), data?.state ?? null);
|
|
99
|
+
}
|
|
100
|
+
const body = {
|
|
101
|
+
...errorBody(e.code, String(e.message ?? `The API answered ${status} (${e.code}).`)),
|
|
102
|
+
...e,
|
|
103
|
+
details: { ...(e.details ?? {}), status, ...(retryAfterHeader !== null && e.retry_after === undefined && /^\d+(\.\d+)?$/.test(retryAfterHeader) ? { retry_after: Number(retryAfterHeader) } : {}) },
|
|
104
|
+
};
|
|
105
|
+
const known = new CliError(body, undefined, data.state ?? null);
|
|
106
|
+
return known.kind === 'unexpected' ? new CliError(body, kindOfStatus(status), data.state ?? null) : known;
|
|
107
|
+
}
|
|
108
|
+
/** retry_after in seconds, from the error or the Retry-After header, in ms; null when absent. */
|
|
109
|
+
function retryAfterMs(e) {
|
|
110
|
+
const raw = e.body.retry_after ?? e.body.details?.retry_after;
|
|
111
|
+
const n = Number(raw);
|
|
112
|
+
return raw === undefined || raw === null || !Number.isFinite(n) || n < 0 ? null : n * 1000;
|
|
113
|
+
}
|
|
114
|
+
/** The longest wait for one retry: 30 s, C11R_RETRY_CAP_S to change it. */
|
|
115
|
+
export const retryCapMs = () => (process.env.C11R_RETRY_CAP_S === undefined ? 30 : Number(process.env.C11R_RETRY_CAP_S)) * 1000;
|
|
116
|
+
const timeoutMs = () => Number(process.env.C11R_TIMEOUT_MS) || 30_000;
|
|
117
|
+
const backoffMs = () => (process.env.C11R_RETRY_MS === undefined ? 1000 : Number(process.env.C11R_RETRY_MS));
|
package/dist/inputs.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Values read from files before anything is sent (contract section 28.6 and 39).
|
|
2
|
+
//
|
|
3
|
+
// A secret field (secret_input: provider credentials) is taken only from --<name>-file, as JSON or
|
|
4
|
+
// KEY=value lines, from a plain file only its owner can read; or, in a terminal, typed without
|
|
5
|
+
// echo. Never from a flag value or --data, never printed: an error names the file, not the value.
|
|
6
|
+
// A document field (file_input: price rules, rule filters) is also read from a JSON file, --file.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { toFlag } from './argv.js';
|
|
9
|
+
import { fail } from './errors.js';
|
|
10
|
+
import { readPrivateFile } from './login.js';
|
|
11
|
+
/** The flag a secret field is read from, if the key is one. */
|
|
12
|
+
export function secretFileFlag(verb, key) {
|
|
13
|
+
const k = `${key}_file`;
|
|
14
|
+
return verb.flags[k]?.input?.kind === 'secret' ? k : null;
|
|
15
|
+
}
|
|
16
|
+
/** Reads the files given; with a prompter, also asks for a required secret not given (null: files only). */
|
|
17
|
+
export async function readInputs(noun, verb, parsed, prompter) {
|
|
18
|
+
const cmd = `c11r ${noun} ${verb.name}`;
|
|
19
|
+
for (const [key, spec] of Object.entries(verb.flags)) {
|
|
20
|
+
const input = spec.input;
|
|
21
|
+
if (!input)
|
|
22
|
+
continue;
|
|
23
|
+
const file = parsed.values[key];
|
|
24
|
+
// The second pass only asks for a secret: the files were read in the first.
|
|
25
|
+
if (prompter !== null && (input.kind === 'document' || parsed.values[input.target] !== undefined))
|
|
26
|
+
continue;
|
|
27
|
+
if (input.kind === 'document') {
|
|
28
|
+
if (file === undefined)
|
|
29
|
+
continue;
|
|
30
|
+
if (parsed.values[input.target] !== undefined)
|
|
31
|
+
fail('invalid_value', `${cmd} takes ${input.target} from ${toFlag(input.target)} or from ${toFlag(key)}, not both.`, { path: key });
|
|
32
|
+
parsed.values[input.target] = documentOf(file, key, spec);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (file !== undefined) {
|
|
36
|
+
parsed.values[input.target] = secretOf(readPrivateFile(file, key), file, key, input.target_type);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!input.required || prompter === null)
|
|
40
|
+
continue;
|
|
41
|
+
if (prompter.interactive && prompter.askSecret) {
|
|
42
|
+
const typed = (await prompter.askSecret(`${input.target} as JSON, or KEY=value pairs separated by spaces (not shown): `)).trim();
|
|
43
|
+
if (!typed)
|
|
44
|
+
fail('secret_required', `Nothing was typed: ${cmd} needs ${input.target}.`, { path: key });
|
|
45
|
+
parsed.values[input.target] = secretOf(typed.startsWith('{') ? typed : typed.split(/\s+/).join('\n'), null, key, input.target_type);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
fail('secret_required', `${cmd} needs ${input.target}, and there is no terminal to type it in. Ask the human to save it in a file only they can read (mode 600), as JSON or KEY=value lines, then run the command with ${toFlag(key)} <file>. Do not open or print that file.`, {
|
|
49
|
+
path: key,
|
|
50
|
+
next_step: { action: 'human_step', command: null, url: null },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function documentOf(file, key, spec) {
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = fs.readFileSync(file, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
fail('invalid_value', `${toFlag(key)} ${file}: ${e.code === 'ENOENT' ? 'no such file' : e.message}.`, { path: key });
|
|
61
|
+
}
|
|
62
|
+
let v;
|
|
63
|
+
try {
|
|
64
|
+
v = JSON.parse(text);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
fail('invalid_value', `${file} is not valid JSON: ${e.message}.`, { path: key });
|
|
68
|
+
}
|
|
69
|
+
const want = spec.input.target_type;
|
|
70
|
+
if (want === 'object' && (!v || typeof v !== 'object' || Array.isArray(v)))
|
|
71
|
+
fail('invalid_value', `${file} is not a JSON object.`, { path: key });
|
|
72
|
+
if (want === 'array' && !Array.isArray(v))
|
|
73
|
+
fail('invalid_value', `${file} is not a JSON list.`, { path: key });
|
|
74
|
+
return v;
|
|
75
|
+
}
|
|
76
|
+
/** JSON, or KEY=value lines (keys lowercased: API_KEY is api_key). The value never goes in a message. */
|
|
77
|
+
function secretOf(text, file, key, type) {
|
|
78
|
+
const where = file ?? 'What was typed';
|
|
79
|
+
const trimmed = text.trim();
|
|
80
|
+
if (trimmed.startsWith('{')) {
|
|
81
|
+
let v;
|
|
82
|
+
try {
|
|
83
|
+
v = JSON.parse(trimmed);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
fail('invalid_value', `${where} is not valid JSON.`, { path: key });
|
|
87
|
+
}
|
|
88
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
89
|
+
fail('invalid_value', `${where} is not a JSON object.`, { path: key });
|
|
90
|
+
return v;
|
|
91
|
+
}
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const line of trimmed.split(/\r?\n/).map((l) => l.trim())) {
|
|
94
|
+
if (!line || line.startsWith('#'))
|
|
95
|
+
continue;
|
|
96
|
+
const eq = line.indexOf('=');
|
|
97
|
+
if (eq < 1)
|
|
98
|
+
fail('invalid_value', `${where} has a line that is not KEY=value.`, { path: key });
|
|
99
|
+
const value = line.slice(eq + 1).trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
100
|
+
if (value)
|
|
101
|
+
out[line.slice(0, eq).trim().toLowerCase()] = value;
|
|
102
|
+
}
|
|
103
|
+
if (!Object.keys(out).length)
|
|
104
|
+
fail('secret_required', `${where} has no values.`, { path: key });
|
|
105
|
+
if (type !== 'object') {
|
|
106
|
+
const values = Object.values(out);
|
|
107
|
+
if (values.length !== 1)
|
|
108
|
+
fail('invalid_value', `${where} has to hold one value.`, { path: key });
|
|
109
|
+
return values[0];
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The human's login: their own Provisioning API application, client id and secret (contract
|
|
2
|
+
// section 18). The secret never leaves this machine: the CLI exchanges it for a Provisioning
|
|
3
|
+
// access token at Commerce Layer's auth endpoint and sends only that token to the c11r API. Both
|
|
4
|
+
// stay in credentials.json, mode 600, and are printed never.
|
|
5
|
+
//
|
|
6
|
+
// The token lasts two hours. A command that needs the login takes the stored token while it has
|
|
7
|
+
// more than a minute left, else mints a new one. C11R_PROVISIONING_TOKEN, for CI, is a ready token
|
|
8
|
+
// used as is.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { authUrl, storedLogin, storeLogin } from './config.js';
|
|
13
|
+
import { CliError, errorBody, fail } from './errors.js';
|
|
14
|
+
import { USER_AGENT } from './version.js';
|
|
15
|
+
export const PROVISIONING_URL = 'https://dashboard.commercelayer.io/user/provisioning_api';
|
|
16
|
+
const MARGIN_MS = 60_000;
|
|
17
|
+
export { authUrl };
|
|
18
|
+
/** Where the human saves the client id and secret when there is no terminal to type them in. */
|
|
19
|
+
export function secretFileDefault() {
|
|
20
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
21
|
+
return path.join(base, 'c11r', 'provisioning.secret');
|
|
22
|
+
}
|
|
23
|
+
/** client_credentials at Commerce Layer. A refusal is the human's to fix: new values in the dashboard. */
|
|
24
|
+
export async function mint(clientId, clientSecret) {
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await fetch(authUrl(), {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': USER_AGENT },
|
|
30
|
+
body: JSON.stringify({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret }),
|
|
31
|
+
signal: AbortSignal.timeout(Number(process.env.C11R_TIMEOUT_MS) || 30_000),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
fail('api_unreachable', `Commerce Layer's auth endpoint (${new URL(authUrl()).origin}) did not answer: ${e.message}.`, { next_step: { action: 'retry_later' } });
|
|
36
|
+
}
|
|
37
|
+
const body = (await res.json().catch(() => null));
|
|
38
|
+
if (res.status === 400 || res.status === 401) {
|
|
39
|
+
fail('unauthorized', 'Commerce Layer refused this client id and secret. They have to be the ones of the human\'s own Provisioning API application, from the dashboard.', {
|
|
40
|
+
path: 'client_secret',
|
|
41
|
+
next_step: { action: 'human_step', command: 'c11r auth login', url: PROVISIONING_URL },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (!res.ok || typeof body?.access_token !== 'string') {
|
|
45
|
+
fail('commercelayer_unavailable', `Commerce Layer's auth endpoint answered ${res.status} without a token.`, { details: { status: res.status }, next_step: { action: 'retry_later' } });
|
|
46
|
+
}
|
|
47
|
+
const seconds = Number(body.expires_in) || 7200;
|
|
48
|
+
return { token: body.access_token, expires_at: new Date(Date.now() + seconds * 1000).toISOString() };
|
|
49
|
+
}
|
|
50
|
+
export function loginRequired(message) {
|
|
51
|
+
return new CliError(errorBody('login_required', message, {
|
|
52
|
+
next_step: { action: 'human_step', command: 'c11r auth login', url: PROVISIONING_URL },
|
|
53
|
+
details: { secret_file: secretFileDefault() },
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
/** The token a login command sends; minted again when it is about to end. */
|
|
57
|
+
export async function loginToken(opts = {}) {
|
|
58
|
+
if (process.env.C11R_PROVISIONING_TOKEN)
|
|
59
|
+
return process.env.C11R_PROVISIONING_TOKEN;
|
|
60
|
+
const login = storedLogin();
|
|
61
|
+
if (!login) {
|
|
62
|
+
throw loginRequired('This needs the human\'s Commerce Layer login, and this machine has none. Ask the human for the client id and secret of their Provisioning API application, from the dashboard, saved in the file named in details.secret_file; then c11r auth login reads it.');
|
|
63
|
+
}
|
|
64
|
+
if (!opts.fresh && login.token && login.expires_at && Date.parse(login.expires_at) - Date.now() > MARGIN_MS)
|
|
65
|
+
return login.token;
|
|
66
|
+
let minted;
|
|
67
|
+
try {
|
|
68
|
+
minted = await mint(login.client_id, login.client_secret);
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
if (e instanceof CliError && e.code === 'unauthorized') {
|
|
72
|
+
throw loginRequired('The stored login no longer works: Commerce Layer refused its client id and secret, as when the Provisioning API application was deleted or changed. The human saves the new ones; then c11r auth login reads them.');
|
|
73
|
+
}
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
storeLogin({ ...login, token: minted.token, expires_at: minted.expires_at });
|
|
77
|
+
return minted.token;
|
|
78
|
+
}
|
|
79
|
+
export const hasLogin = () => Boolean(process.env.C11R_PROVISIONING_TOKEN) || storedLogin() !== null;
|
|
80
|
+
/** A secret file's text: a plain file only its owner can read, else insecure_file. */
|
|
81
|
+
export function readPrivateFile(file, flagPath) {
|
|
82
|
+
let st;
|
|
83
|
+
try {
|
|
84
|
+
st = fs.lstatSync(file);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
fail('invalid_value', `${file} does not exist.`, { path: flagPath });
|
|
88
|
+
}
|
|
89
|
+
if (st.isSymbolicLink() || !st.isFile())
|
|
90
|
+
fail('insecure_file', `${file} is not a plain file. c11r reads a secret only from a plain file.`, { path: flagPath });
|
|
91
|
+
if (st.mode & 0o077) {
|
|
92
|
+
fail('insecure_file', `${file} can be read by other users (mode ${(st.mode & 0o777).toString(8)}). c11r reads a secret only from a file that only its owner can read, mode 600.`, {
|
|
93
|
+
path: flagPath,
|
|
94
|
+
next_step: { action: 'human_step', command: null, url: null },
|
|
95
|
+
details: { file, mode: (st.mode & 0o777).toString(8) },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return fs.readFileSync(file, 'utf8');
|
|
99
|
+
}
|
|
100
|
+
/** The client id and secret, from a file the human saved: key=value lines, JSON, or the secret alone. */
|
|
101
|
+
export function readSecretFile(file) {
|
|
102
|
+
const text = readPrivateFile(file, 'secret_file');
|
|
103
|
+
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
104
|
+
try {
|
|
105
|
+
const json = JSON.parse(text);
|
|
106
|
+
if (json && typeof json === 'object')
|
|
107
|
+
return { client_id: str(json.client_id), client_secret: str(json.client_secret) };
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Not JSON.
|
|
111
|
+
}
|
|
112
|
+
if (lines.length === 1 && !lines[0].includes('='))
|
|
113
|
+
return { client_secret: lines[0] };
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const l of lines) {
|
|
116
|
+
const eq = l.indexOf('=');
|
|
117
|
+
if (eq === -1)
|
|
118
|
+
continue;
|
|
119
|
+
const key = l.slice(0, eq).trim().toLowerCase().replace(/^(cl_|commercelayer_)/, '');
|
|
120
|
+
const value = l.slice(eq + 1).trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
121
|
+
if (key === 'client_id' && value)
|
|
122
|
+
out.client_id = value;
|
|
123
|
+
if (key === 'client_secret' && value)
|
|
124
|
+
out.client_secret = value;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
129
|
+
/** The two-line template for the human to complete, mode 600 from the start; an existing file is left as is. */
|
|
130
|
+
export function writeSecretTemplate(file) {
|
|
131
|
+
if (fs.existsSync(file))
|
|
132
|
+
return false;
|
|
133
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
134
|
+
fs.writeFileSync(file, 'client_id=\nclient_secret=\n', { mode: 0o600, flag: 'wx' });
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// One URL grammar for every noun (contract section 1), so no noun has its own path code:
|
|
2
|
+
// list GET /<noun> filters as ?filter[<field>][<op>]=<value>
|
|
3
|
+
// get GET /<noun>/<id>
|
|
4
|
+
// create POST /<noun>
|
|
5
|
+
// update PATCH /<noun>/<id>
|
|
6
|
+
// delete DELETE /<noun>/<id>
|
|
7
|
+
// <verb> POST /<noun>/<id>/<verb>, or /<noun>/<verb> for a verb on the collection
|
|
8
|
+
// The schema's path wins when it has one. Each value goes where its flag says: the body, the
|
|
9
|
+
// query, or a filter; a local flag (--out) is the CLI's and is never sent.
|
|
10
|
+
export function resourceRequest(noun, verb, id, values) {
|
|
11
|
+
const base = `/${noun}`;
|
|
12
|
+
const one = id === undefined ? base : `${base}/${encodeURIComponent(id)}`;
|
|
13
|
+
const query = {};
|
|
14
|
+
const body = {};
|
|
15
|
+
for (const [key, value] of Object.entries(values)) {
|
|
16
|
+
const flag = verb.flags[key];
|
|
17
|
+
if (value === undefined)
|
|
18
|
+
continue;
|
|
19
|
+
if (!flag || flag.to === 'body')
|
|
20
|
+
body[key] = value;
|
|
21
|
+
else if (flag.to === 'local')
|
|
22
|
+
continue;
|
|
23
|
+
else if (flag.to === 'filter')
|
|
24
|
+
query[`filter[${flag.filter.field}][${flag.filter.op}]`] = text(value);
|
|
25
|
+
else
|
|
26
|
+
query[key] = text(value);
|
|
27
|
+
}
|
|
28
|
+
// The schema's own route when it gives one (/plans/current, /members/invite), else the grammar.
|
|
29
|
+
const path = verb.path
|
|
30
|
+
? // No id (one given by its id_alternative flags): the segment goes, /prices/{id}/tiers is /prices/tiers.
|
|
31
|
+
verb.path.replace(/\/\{[^}]+\}/, id === undefined ? '' : `/${encodeURIComponent(id)}`)
|
|
32
|
+
: ['list', 'get', 'create', 'update', 'delete'].includes(verb.name)
|
|
33
|
+
? verb.name === 'list' || verb.name === 'create'
|
|
34
|
+
? base
|
|
35
|
+
: one
|
|
36
|
+
: `${one}/${verb.name}`;
|
|
37
|
+
const req = { method: verb.method, path, query };
|
|
38
|
+
if (verb.method !== 'GET' && verb.method !== 'DELETE')
|
|
39
|
+
req.body = body;
|
|
40
|
+
return req;
|
|
41
|
+
}
|
|
42
|
+
const text = (v) => (Array.isArray(v) ? v.join(',') : typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v));
|