@commercelayer/agent-cli 0.8.0 → 1.0.0-dev.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.
Files changed (67) hide show
  1. package/README.md +95 -215
  2. package/dist/argv.js +214 -0
  3. package/dist/bin.js +15 -178
  4. package/dist/builtins/auth.js +169 -0
  5. package/dist/builtins/extensions.js +123 -0
  6. package/dist/builtins/index.js +8 -0
  7. package/dist/builtins/loops.js +215 -0
  8. package/dist/builtins/sandbox.js +186 -0
  9. package/dist/config.js +211 -0
  10. package/dist/confirm.js +56 -0
  11. package/dist/engine.js +173 -0
  12. package/dist/errors.js +146 -0
  13. package/dist/help.js +78 -0
  14. package/dist/http.js +117 -0
  15. package/dist/login.js +133 -0
  16. package/dist/resources.js +41 -0
  17. package/dist/schema.js +152 -0
  18. package/dist/schemas.js +72 -0
  19. package/dist/secrets.js +189 -0
  20. package/dist/version.js +4 -8
  21. package/package.json +4 -9
  22. package/dist/api/client.js +0 -190
  23. package/dist/api/server.js +0 -26
  24. package/dist/api/stub/db.js +0 -37
  25. package/dist/api/stub/http.js +0 -18
  26. package/dist/api/stub/index.js +0 -52
  27. package/dist/api/stub/locale.js +0 -52
  28. package/dist/api/stub/management.js +0 -409
  29. package/dist/api/stub/messages.js +0 -126
  30. package/dist/api/stub/orders.js +0 -625
  31. package/dist/api/stub/search.js +0 -78
  32. package/dist/api/stub/shopping.js +0 -673
  33. package/dist/api/stub/theme.js +0 -197
  34. package/dist/api/stub/ucp.js +0 -377
  35. package/dist/api/transport.js +0 -57
  36. package/dist/api/types.js +0 -4
  37. package/dist/assets.js +0 -100
  38. package/dist/catalog.js +0 -366
  39. package/dist/check.js +0 -187
  40. package/dist/commands.js +0 -1241
  41. package/dist/cursor.js +0 -31
  42. package/dist/files.js +0 -98
  43. package/dist/inbox/cursor.js +0 -11
  44. package/dist/inbox/fake.js +0 -95
  45. package/dist/inbox/imap.js +0 -139
  46. package/dist/inbox/mailbox.js +0 -25
  47. package/dist/inbox/message.js +0 -88
  48. package/dist/inspect.js +0 -230
  49. package/dist/languages.js +0 -49
  50. package/dist/links.js +0 -118
  51. package/dist/locales.js +0 -43
  52. package/dist/ops.js +0 -190
  53. package/dist/orderRequest.js +0 -141
  54. package/dist/output.js +0 -39
  55. package/dist/products.js +0 -151
  56. package/dist/promotions.js +0 -291
  57. package/dist/store.js +0 -379
  58. package/dist/storefront.js +0 -285
  59. package/dist/ucp/buy.js +0 -90
  60. package/dist/ucp/check.js +0 -360
  61. package/dist/ucp/client.js +0 -129
  62. package/dist/ucp/images.js +0 -62
  63. package/dist/ucp/platform.js +0 -90
  64. package/dist/ucp/shop.js +0 -357
  65. package/dist/ucp/signing.js +0 -67
  66. package/dist/web.js +0 -316
  67. package/dist/website.js +0 -390
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/login.js ADDED
@@ -0,0 +1,133 @@
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 { 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 AUTH_URL = 'https://auth.commercelayer.io/oauth/token';
17
+ const MARGIN_MS = 60_000;
18
+ export const authUrl = () => process.env.C11R_AUTH_URL || AUTH_URL;
19
+ /** Where the human saves the client id and secret when there is no terminal to type them in. */
20
+ export function secretFileDefault() {
21
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
22
+ return path.join(base, 'c11r', 'provisioning.secret');
23
+ }
24
+ /** client_credentials at Commerce Layer. A refusal is the human's to fix: new values in the dashboard. */
25
+ export async function mint(clientId, clientSecret) {
26
+ let res;
27
+ try {
28
+ res = await fetch(authUrl(), {
29
+ method: 'POST',
30
+ headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': USER_AGENT },
31
+ body: JSON.stringify({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret }),
32
+ signal: AbortSignal.timeout(Number(process.env.C11R_TIMEOUT_MS) || 30_000),
33
+ });
34
+ }
35
+ catch (e) {
36
+ fail('api_unreachable', `Commerce Layer's auth endpoint (${new URL(authUrl()).origin}) did not answer: ${e.message}.`, { next_step: { action: 'retry_later' } });
37
+ }
38
+ const body = (await res.json().catch(() => null));
39
+ if (res.status === 400 || res.status === 401) {
40
+ 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.', {
41
+ path: 'client_secret',
42
+ next_step: { action: 'human_step', command: 'c11r auth login', url: PROVISIONING_URL },
43
+ });
44
+ }
45
+ if (!res.ok || typeof body?.access_token !== 'string') {
46
+ fail('commercelayer_unavailable', `Commerce Layer's auth endpoint answered ${res.status} without a token.`, { details: { status: res.status }, next_step: { action: 'retry_later' } });
47
+ }
48
+ const seconds = Number(body.expires_in) || 7200;
49
+ return { token: body.access_token, expires_at: new Date(Date.now() + seconds * 1000).toISOString() };
50
+ }
51
+ export function loginRequired(message) {
52
+ return new CliError(errorBody('login_required', message, {
53
+ next_step: { action: 'human_step', command: 'c11r auth login', url: PROVISIONING_URL },
54
+ details: { secret_file: secretFileDefault() },
55
+ }));
56
+ }
57
+ /** The token a login command sends; minted again when it is about to end. */
58
+ export async function loginToken(opts = {}) {
59
+ if (process.env.C11R_PROVISIONING_TOKEN)
60
+ return process.env.C11R_PROVISIONING_TOKEN;
61
+ const login = storedLogin();
62
+ if (!login) {
63
+ 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.');
64
+ }
65
+ if (!opts.fresh && login.token && login.expires_at && Date.parse(login.expires_at) - Date.now() > MARGIN_MS)
66
+ return login.token;
67
+ let minted;
68
+ try {
69
+ minted = await mint(login.client_id, login.client_secret);
70
+ }
71
+ catch (e) {
72
+ if (e instanceof CliError && e.code === 'unauthorized') {
73
+ 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.');
74
+ }
75
+ throw e;
76
+ }
77
+ storeLogin({ ...login, token: minted.token, expires_at: minted.expires_at });
78
+ return minted.token;
79
+ }
80
+ export const hasLogin = () => Boolean(process.env.C11R_PROVISIONING_TOKEN) || storedLogin() !== null;
81
+ /** The client id and secret, from a file the human saved: key=value lines, JSON, or the secret alone. */
82
+ export function readSecretFile(file) {
83
+ let st;
84
+ try {
85
+ st = fs.lstatSync(file);
86
+ }
87
+ catch {
88
+ fail('invalid_value', `${file} does not exist.`, { path: 'secret_file' });
89
+ }
90
+ if (st.isSymbolicLink() || !st.isFile())
91
+ fail('insecure_file', `${file} is not a plain file. c11r reads a secret only from a plain file.`, { path: 'secret_file' });
92
+ if (st.mode & 0o077) {
93
+ 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.`, {
94
+ path: 'secret_file',
95
+ next_step: { action: 'human_step', command: null, url: null },
96
+ details: { file, mode: (st.mode & 0o777).toString(8) },
97
+ });
98
+ }
99
+ const text = fs.readFileSync(file, 'utf8');
100
+ const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
101
+ try {
102
+ const json = JSON.parse(text);
103
+ if (json && typeof json === 'object')
104
+ return { client_id: str(json.client_id), client_secret: str(json.client_secret) };
105
+ }
106
+ catch {
107
+ // Not JSON.
108
+ }
109
+ if (lines.length === 1 && !lines[0].includes('='))
110
+ return { client_secret: lines[0] };
111
+ const out = {};
112
+ for (const l of lines) {
113
+ const eq = l.indexOf('=');
114
+ if (eq === -1)
115
+ continue;
116
+ const key = l.slice(0, eq).trim().toLowerCase().replace(/^(cl_|commercelayer_)/, '');
117
+ const value = l.slice(eq + 1).trim().replace(/^(['"])(.*)\1$/, '$2');
118
+ if (key === 'client_id' && value)
119
+ out.client_id = value;
120
+ if (key === 'client_secret' && value)
121
+ out.client_secret = value;
122
+ }
123
+ return out;
124
+ }
125
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
126
+ /** The two-line template for the human to complete, mode 600 from the start; an existing file is left as is. */
127
+ export function writeSecretTemplate(file) {
128
+ if (fs.existsSync(file))
129
+ return false;
130
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
131
+ fs.writeFileSync(file, 'client_id=\nclient_secret=\n', { mode: 0o600, flag: 'wx' });
132
+ return true;
133
+ }
@@ -0,0 +1,41 @@
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
+ ? verb.path.replace(/\{[^}]+\}/, id === undefined ? '' : encodeURIComponent(id))
31
+ : ['list', 'get', 'create', 'update', 'delete'].includes(verb.name)
32
+ ? verb.name === 'list' || verb.name === 'create'
33
+ ? base
34
+ : one
35
+ : `${one}/${verb.name}`;
36
+ const req = { method: verb.method, path, query };
37
+ if (verb.method !== 'GET' && verb.method !== 'DELETE')
38
+ req.body = body;
39
+ return req;
40
+ }
41
+ const text = (v) => (Array.isArray(v) ? v.join(',') : typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v));
package/dist/schema.js ADDED
@@ -0,0 +1,152 @@
1
+ // The schema of a noun, as the contract's schema document has it (section 5.1), and what the
2
+ // engine makes of it: for every verb, the flags it takes and where each one goes (body, query or
3
+ // filter). A plain noun (markets, orders, carts, about 60 of them) has no code in the CLI: its
4
+ // schema is read from GET /v1/schema/<noun>. Built-in nouns (sandbox, loops, report) write theirs
5
+ // in the same document format, so one reader serves both.
6
+ import { closest, fail, fixWith } from './errors.js';
7
+ const FIELD_TYPES = ['string', 'integer', 'number', 'money', 'boolean', 'date', 'datetime', 'enum', 'object', 'array', 'ref'];
8
+ export const FILTER_OPS = ['eq', 'in', 'gt', 'gte', 'lt', 'lte', 'start', 'cont', 'null'];
9
+ /** The contract's names; sandbox, the first proposal's name for an organization token, still reads. */
10
+ const CREDENTIALS = { none: 'none', organization: 'organization', login: 'login', 'organization|login': 'organization|login', sandbox: 'organization', 'sandbox|login': 'organization|login' };
11
+ const STANDARD = ['list', 'get', 'create', 'update', 'delete'];
12
+ const METHOD = { list: 'GET', get: 'GET', create: 'POST', update: 'PATCH', delete: 'DELETE' };
13
+ export function parseSchema(raw, expected) {
14
+ const doc = raw;
15
+ if (!doc || typeof doc !== 'object' || typeof doc.noun !== 'string' || !Array.isArray(doc.verbs))
16
+ bad(expected, 'it has no noun or no verbs');
17
+ const fields = (doc.fields ?? []).map((f) => field(f, doc.noun));
18
+ const verbs = {};
19
+ for (const v of doc.verbs) {
20
+ if (!v || typeof v.verb !== 'string')
21
+ bad(doc.noun, 'a verb has no name');
22
+ const name = v.verb;
23
+ const flags = {};
24
+ if (name === 'list')
25
+ listFlags(flags, fields, doc);
26
+ if (name === 'create')
27
+ for (const f of fields)
28
+ if (f.create === 'required' || f.create === 'optional')
29
+ flags[f.name] = flag(f, 'body', f.create === 'required');
30
+ if (name === 'update')
31
+ for (const f of fields)
32
+ if (f.update)
33
+ flags[f.name] = flag(f, 'body', false);
34
+ const method = v.method?.toUpperCase() ?? METHOD[name] ?? 'POST';
35
+ // A name in body refers to a field of the document. On a GET it is a query parameter.
36
+ const own = (list) => (list ?? []).filter((x) => x !== '...' && !(name === 'list' && x === 'waiting')).map((x) => (typeof x === 'string' ? byName(fields, x) : field(x, doc.noun)));
37
+ for (const f of own(v.body))
38
+ if (!flags[f.name] || f.create === 'required')
39
+ flags[f.name] = flag(f, method === 'GET' ? 'query' : 'body', Boolean(f.required || (name === 'create' && f.create === 'required')));
40
+ for (const f of own(v.query))
41
+ flags[f.name] = flag(f, 'query', Boolean(f.required));
42
+ if (v.secret) {
43
+ flags.out = { type: 'string', to: 'local', description: 'The file the secret is written to, mode 600. By default one under ~/.config/c11r/secrets/<org>/.' };
44
+ flags.format = { type: 'enum', to: 'local', values: ['env', 'json'], description: 'env: KEY=value lines, for a .env file; json: one JSON object. By default as the API answers it.' };
45
+ }
46
+ const destructive = Boolean(v.destructive);
47
+ const state = v.state ?? 'sandbox';
48
+ if (v.credential !== undefined && !CREDENTIALS[v.credential])
49
+ bad(doc.noun, `verb ${name} has credential ${String(v.credential)}`);
50
+ verbs[name] = {
51
+ name,
52
+ description: v.effect ?? (STANDARD.includes(name) ? standardDescription(name, doc.noun) : ''),
53
+ method,
54
+ id: v.path ? /\{[^}]+\}/.test(v.path) : !['list', 'create'].includes(name),
55
+ destructive,
56
+ // A policy never deletes nor returns money, whatever else the document says.
57
+ loopable: Boolean(v.loopable) && !v.deletes && !v.returns_money && name !== 'delete',
58
+ live: v.live !== false,
59
+ state,
60
+ plan: v.plan ?? null,
61
+ credential: v.credential ? CREDENTIALS[v.credential] : state === 'none' ? 'none' : 'organization',
62
+ secret: Boolean(v.secret),
63
+ confirmIf: Array.isArray(v.confirm_if) ? v.confirm_if.filter((x) => typeof x === 'string') : [],
64
+ refused: v.served === false ? { by: v.refused_by ?? 'the API' } : null,
65
+ path: v.path ? v.path.replace(/^\/v\d+(?=\/)/, '') : null,
66
+ flags,
67
+ };
68
+ }
69
+ return {
70
+ noun: doc.noun,
71
+ description: doc.description ?? '',
72
+ skill: doc.skill ?? null,
73
+ verbs,
74
+ id: doc.id ? { accepts: doc.id.accepts ?? [], example: doc.id.example ?? null } : null,
75
+ waiting: doc.waiting ?? null,
76
+ loop: doc.loop ?? null,
77
+ doc,
78
+ };
79
+ }
80
+ function listFlags(flags, fields, doc) {
81
+ for (const f of fields) {
82
+ for (const op of f.filter ?? []) {
83
+ const key = op === 'eq' ? f.name : `${f.name}.${op}`;
84
+ const base = flag(f, 'filter', false);
85
+ if (op === 'in')
86
+ flags[key] = { ...base, type: 'array', items: f.type === 'array' ? f.items : f.type, filter: { field: f.name, op } };
87
+ else if (op === 'null')
88
+ flags[key] = { ...base, type: 'boolean', values: undefined, filter: { field: f.name, op } };
89
+ else
90
+ flags[key] = { ...base, filter: { field: f.name, op } };
91
+ }
92
+ }
93
+ if (doc.waiting)
94
+ flags.waiting = { type: 'boolean', to: 'query', description: doc.waiting.meaning ?? 'Only what needs a decision now.' };
95
+ flags.page = { type: 'integer', to: 'query', description: 'The page to return, from 1.' };
96
+ flags.limit = { type: 'integer', to: 'query', description: 'How many to return on a page.' };
97
+ const sortable = fields.filter((f) => f.sort).map((f) => f.name);
98
+ if (sortable.length)
99
+ flags.sort = { type: 'string', to: 'query', description: `A field to sort by (${sortable.join(', ')}); a leading minus sorts descending.` };
100
+ }
101
+ /** A field named in a verb's body. One the document does not define is taken as a string. */
102
+ function byName(fields, name) {
103
+ return fields.find((x) => x.name === name) ?? { name, type: 'string' };
104
+ }
105
+ function field(f, noun) {
106
+ if (!f || typeof f.name !== 'string')
107
+ bad(noun, 'a field has no name');
108
+ if (!FIELD_TYPES.includes(f.type))
109
+ bad(noun, `field ${f.name} has type ${String(f.type)}`);
110
+ return f;
111
+ }
112
+ function flag(f, to, required) {
113
+ return {
114
+ type: f.type,
115
+ to,
116
+ ...(f.values ? { values: f.values } : {}),
117
+ ...(f.items ? { items: f.items } : {}),
118
+ ...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, p.type])) } : {}),
119
+ ...(f.noun ? { noun: f.noun } : {}),
120
+ ...(required ? { required: true } : {}),
121
+ ...(f.description ? { description: f.description } : {}),
122
+ };
123
+ }
124
+ function bad(noun, why) {
125
+ fail('invalid_response', `The API's schema${noun ? ` for ${noun}` : ''} is not one c11r reads: ${why}.`, { next_step: { action: 'none' } });
126
+ }
127
+ function standardDescription(verb, noun) {
128
+ return ({
129
+ list: `Lists the ${noun}, filtered by any of the flags.`,
130
+ get: `Returns one of the ${noun} by id.`,
131
+ create: `Creates one of the ${noun} from the flags.`,
132
+ update: `Changes the given fields of one of the ${noun}; the others stay as they are.`,
133
+ delete: `Deletes one of the ${noun}. It cannot be undone.`,
134
+ }[verb] ?? '');
135
+ }
136
+ /** The verb, or unknown_verb naming the ones there are. */
137
+ export function verbOf(schema, verb) {
138
+ const names = Object.keys(schema.verbs);
139
+ if (!verb)
140
+ fail('missing_argument', `c11r ${schema.noun} needs a verb: ${names.join(', ')}.`, { details: { verbs: names }, next_step: fixWith(`c11r ${schema.noun} --help`) });
141
+ const v = schema.verbs[verb];
142
+ if (!v) {
143
+ const standard = STANDARD.includes(verb);
144
+ // Whether Commerce Layer has it or this API does not serve it yet, the CLI cannot tell.
145
+ fail('unknown_verb', `${schema.noun} has no verb ${verb}${standard ? ' in this API' : ''}. Its verbs are ${names.join(', ')}.`, {
146
+ did_you_mean: closest(verb, names),
147
+ details: { verbs: names },
148
+ next_step: fixWith(`c11r ${schema.noun} --help`),
149
+ });
150
+ }
151
+ return v;
152
+ }
@@ -0,0 +1,72 @@
1
+ // Where plain nouns' schemas come from: GET /v1/schema for the list, GET /v1/schema/<noun> for
2
+ // one (contract section 5), cached on disk for a few minutes so a command costs one request, not
3
+ // two. Schemas are the same at every state, so no token is sent. C11R_SCHEMA_CACHE=0 turns the
4
+ // cache off.
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { CliError, closest, fail, fixWith } from './errors.js';
10
+ import { apiUrl, request } from './http.js';
11
+ import { parseSchema } from './schema.js';
12
+ import { VERSION } from './version.js';
13
+ const TTL_MS = 10 * 60_000;
14
+ function cacheFile(key) {
15
+ if (process.env.C11R_SCHEMA_CACHE === '0')
16
+ return null;
17
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
18
+ const host = crypto.createHash('sha256').update(apiUrl()).digest('hex').slice(0, 12);
19
+ return path.join(base, 'c11r', `schema-${VERSION}-${host}`, `${key}.json`);
20
+ }
21
+ async function cached(key, load) {
22
+ const file = cacheFile(key);
23
+ if (file) {
24
+ try {
25
+ if (Date.now() - fs.statSync(file).mtimeMs < TTL_MS)
26
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
27
+ }
28
+ catch {
29
+ // Not cached, or unreadable: load it.
30
+ }
31
+ }
32
+ const value = await load();
33
+ if (file) {
34
+ try {
35
+ fs.mkdirSync(path.dirname(file), { recursive: true });
36
+ fs.writeFileSync(file, JSON.stringify(value));
37
+ }
38
+ catch {
39
+ // A read-only home is fine: no cache.
40
+ }
41
+ }
42
+ return value;
43
+ }
44
+ const anonymous = { live: false, token: null };
45
+ /** Every noun of the spec the API knows, served or not yet. */
46
+ export async function apiNouns() {
47
+ const data = await cached('_index', async () => (await request({ method: 'GET', path: '/schema' }, anonymous)).data);
48
+ return Array.isArray(data?.nouns) ? data.nouns : [];
49
+ }
50
+ /** One plain noun's schema, or unknown_noun with close matches. */
51
+ export async function apiSchema(noun, builtins) {
52
+ if (!/^[a-z][a-z0-9-]*$/.test(noun))
53
+ unknownNoun(noun, builtins);
54
+ let doc;
55
+ try {
56
+ doc = await cached(noun, async () => (await request({ method: 'GET', path: `/schema/${noun}` }, anonymous)).data);
57
+ }
58
+ catch (e) {
59
+ if (e instanceof CliError && e.kind === 'not_found') {
60
+ const known = await apiNouns().catch(() => []);
61
+ unknownNoun(noun, [...builtins, ...known.map((n) => n.noun)]);
62
+ }
63
+ if (e instanceof CliError && e.kind === 'unavailable') {
64
+ fail('schema_unavailable', `c11r reads the fields and verbs of ${noun} from the API, and could not: ${e.body.message}`, { next_step: e.body.next_step ?? { action: 'retry_later' }, details: { cause: e.code } });
65
+ }
66
+ throw e;
67
+ }
68
+ return parseSchema(doc, noun);
69
+ }
70
+ function unknownNoun(noun, known) {
71
+ fail('unknown_noun', `c11r has no noun ${noun}.`, { did_you_mean: closest(noun, known), next_step: fixWith('c11r help') });
72
+ }