@commercelayer/agent-cli 1.0.0-dev.2 → 1.0.0-dev.22
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 +87 -13
- package/dist/argv.js +23 -5
- package/dist/builtins/auth.js +26 -5
- package/dist/builtins/extensions.js +336 -7
- package/dist/builtins/index.js +4 -2
- package/dist/builtins/loops.js +68 -31
- package/dist/builtins/sandbox.js +59 -23
- package/dist/builtins/shop.js +504 -0
- package/dist/builtins/skills.js +355 -0
- package/dist/config.js +39 -12
- package/dist/confirm.js +17 -6
- package/dist/engine.js +194 -28
- package/dist/errors.js +6 -0
- package/dist/help.js +36 -6
- package/dist/http.js +10 -0
- package/dist/inputs.js +21 -1
- package/dist/project.js +185 -0
- package/dist/schema.js +16 -4
- package/dist/schemas.js +82 -8
- package/dist/secrets.js +25 -7
- package/dist/storefront.js +280 -0
- package/dist/ucp.js +207 -0
- package/package.json +1 -1
package/dist/engine.js
CHANGED
|
@@ -4,19 +4,47 @@
|
|
|
4
4
|
// destructive verb, then the request. A plain noun needs no code here: its schema says
|
|
5
5
|
// everything. Every answer is the API's envelope, { data, state, warnings, next }.
|
|
6
6
|
import { parseCommand, parseFields, toFlag } from './argv.js';
|
|
7
|
-
import {
|
|
8
|
-
import { confirm, terminal } from './confirm.js';
|
|
7
|
+
import { forgetOutWritten, raiseProblem, resolveOrg } from './config.js';
|
|
8
|
+
import { confirm, terminal, withYes } from './confirm.js';
|
|
9
9
|
import { CliError, closest, fail, fixWith } from './errors.js';
|
|
10
10
|
import { indexHelp, nounHelp, verbHelp } from './help.js';
|
|
11
11
|
import { envelope, request } from './http.js';
|
|
12
12
|
import { readInputs } from './inputs.js';
|
|
13
13
|
import { hasLogin, loginToken } from './login.js';
|
|
14
14
|
import { resourceRequest } from './resources.js';
|
|
15
|
+
import { markDrift } from './project.js';
|
|
15
16
|
import { describePlan, OUT_NOT_TRACKED, planSecret, removeRecordFiles, writeSecret } from './secrets.js';
|
|
16
17
|
import { verbOf } from './schema.js';
|
|
17
|
-
import { apiNouns, apiSchema } from './schemas.js';
|
|
18
|
+
import { apiNouns, apiSchema, refreshSchemas, refusedBySchema } from './schemas.js';
|
|
18
19
|
import { VERSION } from './version.js';
|
|
20
|
+
/**
|
|
21
|
+
* One command. When it was read against a cached schema the API has since changed (a deploy), and
|
|
22
|
+
* the refusal is one that explains (refusedBySchema), it runs once more on fresh schemas. The API
|
|
23
|
+
* refused, so nothing happened; the second run asks again what the fresh schema asks, and never
|
|
24
|
+
* adds confirm=true on its own.
|
|
25
|
+
*/
|
|
19
26
|
export async function execute(argv, opts = {}) {
|
|
27
|
+
try {
|
|
28
|
+
return await executeOnce(argv, opts);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
if (!(e instanceof CliError) || !refusedBySchema(e.code) || !refusedBeforeEffect(e))
|
|
32
|
+
throw e;
|
|
33
|
+
refreshSchemas();
|
|
34
|
+
process.stderr.write(JSON.stringify({ retrying: { code: e.code, why: 'the schema changed on the API: read again' } }) + '\n');
|
|
35
|
+
return executeOnce(argv, opts);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** A refusal the API answered (4xx), or a flag or verb the cached schema did not know: nothing was done. */
|
|
39
|
+
function refusedBeforeEffect(e) {
|
|
40
|
+
const status = e.body.details?.status;
|
|
41
|
+
if (typeof status === 'number')
|
|
42
|
+
return status >= 400 && status < 500;
|
|
43
|
+
return e.code === 'unknown_field' || e.code === 'unknown_verb';
|
|
44
|
+
}
|
|
45
|
+
/** Verbs that only run in live mode. */
|
|
46
|
+
const LIVE_ONLY = new Set(['events writes']);
|
|
47
|
+
async function executeOnce(argv, opts) {
|
|
20
48
|
const cmd = parseCommand(argv);
|
|
21
49
|
const builtins = opts.builtins ?? {};
|
|
22
50
|
if (cmd.globals.version && !cmd.noun)
|
|
@@ -28,7 +56,10 @@ export async function execute(argv, opts = {}) {
|
|
|
28
56
|
const schema = builtins[cmd.noun]?.schema ?? (await apiSchema(cmd.noun, Object.keys(builtins)));
|
|
29
57
|
if (cmd.globals.help && !cmd.verb && !builtins[cmd.noun]?.defaultVerb)
|
|
30
58
|
return envelope(nounHelp(schema));
|
|
31
|
-
const
|
|
59
|
+
const ext = opts.extensions?.[schema.noun];
|
|
60
|
+
const found = verbOf(schema, cmd.verb ?? builtins[cmd.noun]?.defaultVerb);
|
|
61
|
+
const local = ext?.flags?.[found.name];
|
|
62
|
+
const verb = local ? { ...found, flags: { ...found.flags, ...local } } : found;
|
|
32
63
|
if (cmd.globals.help)
|
|
33
64
|
return envelope(verbHelp(schema, verb));
|
|
34
65
|
// A verb the schema lists as refused: no request, no confirmation for something that cannot happen.
|
|
@@ -36,9 +67,11 @@ export async function execute(argv, opts = {}) {
|
|
|
36
67
|
const by = verb.refused.by === 'commercelayer' ? 'Commerce Layer' : verb.refused.by;
|
|
37
68
|
fail('not_supported', `c11r ${schema.noun} ${verb.name} is refused by ${by}: ${verb.description || 'it cannot be done through the API.'}`, { details: { refused_by: verb.refused.by }, next_step: { action: 'none' } });
|
|
38
69
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
70
|
+
// A bare value that is a flag's: the first for a verb with no id (sales-calls book <slot>), the
|
|
71
|
+
// second, after the id, for one with (shop order <store> <checkout>).
|
|
72
|
+
const positional = (builtins[schema.noun]?.positional ?? ext?.positional)?.[verb.name];
|
|
73
|
+
const bare = (i) => cmd.rest[i] !== undefined && !cmd.rest[i].startsWith('-');
|
|
74
|
+
const rest = !positional ? cmd.rest : !verb.id && bare(0) ? [`--${positional}`, ...cmd.rest] : verb.id && bare(0) && bare(1) ? [cmd.rest[0], `--${positional}`, ...cmd.rest.slice(1)] : cmd.rest;
|
|
42
75
|
const parsed = parseFields(schema.noun, verb, rest);
|
|
43
76
|
ext?.prepare?.(verb, parsed);
|
|
44
77
|
// Files first, so a required document given with --file counts; a secret is typed only once
|
|
@@ -46,16 +79,41 @@ export async function execute(argv, opts = {}) {
|
|
|
46
79
|
await readInputs(schema.noun, verb, parsed, null);
|
|
47
80
|
check(schema, verb, parsed);
|
|
48
81
|
await readInputs(schema.noun, verb, parsed, opts.prompter ?? terminal);
|
|
82
|
+
const unknown = unknownValues(verb, parsed.values);
|
|
49
83
|
const line = ['c11r', ...argv.filter((a) => a !== '--yes' && a !== '-y')].map(quote).join(' ');
|
|
50
84
|
const org = resolveOrg(cmd.globals.org);
|
|
51
85
|
const login = hasLogin();
|
|
86
|
+
const live = cmd.globals.live;
|
|
87
|
+
// The command as typed: c11r status, not c11r status get, when the verb was left out.
|
|
88
|
+
const named = `c11r ${schema.noun}${cmd.verb ? ` ${verb.name}` : ''}`;
|
|
89
|
+
if (live && !verb.live) {
|
|
90
|
+
fail('state_required', `${named} does not run in live mode.`, { required_state: 'claimed', path: 'live', next_step: fixWith(line.replace(/\s--live\b/, '')) });
|
|
91
|
+
}
|
|
92
|
+
// A verb that only reads live data (events writes, the audit of live writes): without --live the
|
|
93
|
+
// API answers the login a bare 401, so the command says what it needs first.
|
|
94
|
+
if (!live && LIVE_ONLY.has(`${schema.noun} ${verb.name}`)) {
|
|
95
|
+
const org = cmd.globals.org ? '' : ' --org ORG_SLUG';
|
|
96
|
+
fail('missing_value', `${named} reads live data only: it needs --live and the organization, named with --org.`, { path: 'live', next_step: fixWith(`${line}${org} --live`) });
|
|
97
|
+
}
|
|
98
|
+
// Live mode acts on an organization named on the command, never the folder's link or the
|
|
99
|
+
// default, which are a sandbox's: --org, or C11R_ORG.
|
|
100
|
+
const liveOrg = live ? (cmd.globals.org ?? (process.env.C11R_ORG || null)) : null;
|
|
101
|
+
if (live && !liveOrg) {
|
|
102
|
+
// The folder's link is not taken in live mode; named, it is the value to pass when it is the one meant.
|
|
103
|
+
const linked = org.source === 'link' ? org.org : null;
|
|
104
|
+
fail('missing_value', `${named} --live acts on a claimed organization, named with --org.${linked ? ` This folder is linked to ${linked}: pass it with --org when it is the one meant.` : ''}`, { path: 'org', next_step: fixWith(`${line.replace(/\s--live\b/, '')} --org ${linked ?? 'ORG_SLUG'} --live`) });
|
|
105
|
+
}
|
|
52
106
|
// The bearer, from the schema (contract section 23). An organization token for the organization
|
|
53
107
|
// nouns, the login for the Provisioning ones; credentials take the login when there is one and
|
|
54
108
|
// the organization token is not a sandbox's.
|
|
55
|
-
|
|
109
|
+
// In live mode, always the login, never an organization token: the API answers login_required
|
|
110
|
+
// to anything else, and to no token at all.
|
|
111
|
+
let bearer = live
|
|
112
|
+
? 'login'
|
|
113
|
+
: verb.credential === 'login' ? 'login' : verb.credential === 'organization|login' ? (login && !org.token?.startsWith('sbx_') ? 'login' : 'organization') : verb.credential === 'none' ? 'none' : 'organization';
|
|
56
114
|
// members and roles with an organization token and no login: the API says what is needed (a
|
|
57
115
|
// claim for a sandbox, a login for a claimed organization). organizations and auth stop here.
|
|
58
|
-
if (bearer === 'login' && !login && org.token && schema.noun !== 'organizations' && schema.noun !== 'auth')
|
|
116
|
+
if (!live && bearer === 'login' && !login && org.token && schema.noun !== 'organizations' && schema.noun !== 'auth')
|
|
59
117
|
bearer = 'organization';
|
|
60
118
|
// A verb that needs an organization token: say which is missing before the API does.
|
|
61
119
|
if (bearer === 'organization' && verb.state !== 'none' && !org.token) {
|
|
@@ -68,20 +126,39 @@ export async function execute(argv, opts = {}) {
|
|
|
68
126
|
// The login names its organization with ?org=, from --org, C11R_ORG, .c11r.json or the default:
|
|
69
127
|
// never a guess from the only token stored, which may be a sandbox the login does not reach.
|
|
70
128
|
const loginOrg = org.source && org.source !== 'only_stored' ? org.org : null;
|
|
71
|
-
if (cmd.globals.live && !verb.live) {
|
|
72
|
-
fail('state_required', `c11r ${schema.noun} ${verb.name} does not run in live mode.`, { required_state: 'claimed', path: 'live', next_step: fixWith(line.replace(/\s--live\b/, '')) });
|
|
73
|
-
}
|
|
74
129
|
let confirmed = false;
|
|
75
130
|
const confirmFlags = verb.confirmIf.filter((k) => parsed.values[k] !== undefined && parsed.values[k] !== false);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
131
|
+
// In live mode the API asks for the verbs it confirms, with a sentence naming the action, the
|
|
132
|
+
// amount and the mode ("Refunds EUR 1.00 on order N, in LIVE mode, real money."): the CLI sends
|
|
133
|
+
// without confirm=true and shows that 428 as it is, or asks it in a terminal. --yes sends
|
|
134
|
+
// confirm=true on every live write, payments capture included.
|
|
135
|
+
// So does a login verb (organizations disconnect), whose sentence names what stops answering,
|
|
136
|
+
// and one the schema marks confirm_when: the API asks only in that state of the target
|
|
137
|
+
// (connect on an organization c11r does not know), with a sentence naming the access given.
|
|
138
|
+
const apiAsks = (live || verb.credential === 'login') && (verb.confirm || confirmFlags.length > 0 || verb.confirmWhen.length > 0);
|
|
139
|
+
if (apiAsks && cmd.globals.yes && !cmd.globals.dryRun)
|
|
140
|
+
confirmed = true;
|
|
141
|
+
if (live && cmd.globals.yes && verb.method !== 'GET')
|
|
142
|
+
confirmed = true;
|
|
143
|
+
if ((verb.destructive || verb.confirm || confirmFlags.length) && !cmd.globals.dryRun && !apiAsks) {
|
|
144
|
+
// The command itself names the action ("c11r markets delete eu"): no verb to inflect.
|
|
145
|
+
const sentence = `c11r ${schema.noun} ${verb.name}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
|
|
146
|
+
await confirm(sentence, line, { yes: cmd.globals.yes, prompter: opts.prompter });
|
|
79
147
|
confirmed = true;
|
|
80
148
|
}
|
|
149
|
+
// A live secret goes only to a file the human chose: never the default folder, never printed.
|
|
150
|
+
if (live && verb.secret && parsed.values.out === undefined) {
|
|
151
|
+
fail('missing_value', `${named} --live answers a live secret: it is written only to a file the human chooses, with --out.`, { path: 'out', next_step: fixWith(`${line} --out FILE`) });
|
|
152
|
+
}
|
|
81
153
|
// Where a secret goes, checked before the request makes one.
|
|
82
|
-
const secretPlan = verb.secret ? planSecret({ org: org.org, verb: verb.name, id: parsed.id, values: parsed.values, yes: cmd.globals.yes }) : null;
|
|
83
|
-
|
|
84
|
-
|
|
154
|
+
const secretPlan = verb.secret ? planSecret({ org: org.org, noun: schema.noun, verb: verb.name, id: parsed.id, values: parsed.values, yes: cmd.globals.yes }) : null;
|
|
155
|
+
// Live without a login: the organization token this machine holds for the organization named,
|
|
156
|
+
// and no other (never C11R_TOKEN, whose organization is unknown). The API answers a sandbox's
|
|
157
|
+
// state_required with its claim step, a claimed one's login_required; a slug alone stays
|
|
158
|
+
// login_required, so no one learns which slugs exist.
|
|
159
|
+
const liveOwn = live && !login && org.org === liveOrg && (org.source === 'flag' || org.source === 'env') ? org.token : null;
|
|
160
|
+
const ctx = { live, token: live ? (login ? await loginToken() : liveOwn) : bearer === 'login' ? await loginToken() : org.token };
|
|
161
|
+
const orgQuery = liveOrg ? { org: liveOrg } : bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
|
|
85
162
|
const sendOnce = (req) => request({ ...req, query: { ...(req.query ?? {}), ...orgQuery }, dryRun: req.dryRun ?? cmd.globals.dryRun, confirm: confirmed || req.confirm }, ctx);
|
|
86
163
|
const run = {
|
|
87
164
|
globals: cmd.globals,
|
|
@@ -98,6 +175,16 @@ export async function execute(argv, opts = {}) {
|
|
|
98
175
|
return await sendOnce(req);
|
|
99
176
|
}
|
|
100
177
|
catch (e) {
|
|
178
|
+
// The API's own confirmation, in live: as it is with no terminal; asked with its words in one.
|
|
179
|
+
if (apiAsks && !confirmed && e instanceof CliError && e.code === 'confirmation_required') {
|
|
180
|
+
const p = opts.prompter ?? terminal;
|
|
181
|
+
// Without a command of the API's, the next step is this one with --yes, under confirm.
|
|
182
|
+
if (!p.interactive)
|
|
183
|
+
throw e.body.next_step?.command ? e : new CliError({ ...e.body, next_step: { action: 'confirm', command: withYes(line), url: null } }, e.kind, e.state);
|
|
184
|
+
await confirm(e.body.message, line, { yes: false, prompter: p, sentence: e.body.message });
|
|
185
|
+
confirmed = true;
|
|
186
|
+
return sendOnce(req);
|
|
187
|
+
}
|
|
101
188
|
// A login token Commerce Layer no longer takes: mint a new one, once.
|
|
102
189
|
if (!(e instanceof CliError && e.code === 'login_required') || !login)
|
|
103
190
|
throw e;
|
|
@@ -107,29 +194,108 @@ export async function execute(argv, opts = {}) {
|
|
|
107
194
|
throw e;
|
|
108
195
|
ctx.token = await loginToken({ fresh: bearer === 'login' });
|
|
109
196
|
run.bearer = 'login';
|
|
110
|
-
if (loginOrg && schema.noun !== 'organizations')
|
|
197
|
+
if (!live && loginOrg && schema.noun !== 'organizations')
|
|
111
198
|
Object.assign(orgQuery, { org: loginOrg });
|
|
112
199
|
return sendOnce(req);
|
|
113
200
|
}
|
|
114
201
|
},
|
|
115
202
|
};
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
203
|
+
// A secret read from a file (provider or payment keys) is never printed, whatever the answer
|
|
204
|
+
// holds: a dry run or an error that echoes it shows [secret] in its place.
|
|
205
|
+
const secrets = secretValues(verb, parsed.values);
|
|
206
|
+
try {
|
|
207
|
+
const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
|
|
208
|
+
const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
|
|
209
|
+
const out = drifted(filesGone(secretOf(r, run, secretPlan), run), run);
|
|
210
|
+
return scrub(unknown.length ? { ...out, warnings: [...unknown, ...out.warnings] } : out, secrets);
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
if (e instanceof CliError && secrets.length)
|
|
214
|
+
throw new CliError(scrub(e.body, secrets), e.kind, scrub(e.state, secrets));
|
|
215
|
+
throw e;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** The string values of the secret fields given, longest first. */
|
|
219
|
+
function secretValues(verb, values) {
|
|
220
|
+
const out = [];
|
|
221
|
+
const walk = (v) => {
|
|
222
|
+
if (typeof v === 'string' && v.length >= 4)
|
|
223
|
+
out.push(v);
|
|
224
|
+
else if (v && typeof v === 'object')
|
|
225
|
+
for (const x of Object.values(v))
|
|
226
|
+
walk(x);
|
|
227
|
+
};
|
|
228
|
+
for (const spec of Object.values(verb.flags))
|
|
229
|
+
if (spec.input?.kind === 'secret')
|
|
230
|
+
walk(values[spec.input.target]);
|
|
231
|
+
return [...new Set(out)].sort((a, b) => b.length - a.length);
|
|
232
|
+
}
|
|
233
|
+
function scrub(v, secrets) {
|
|
234
|
+
if (!secrets.length || v === undefined)
|
|
235
|
+
return v;
|
|
236
|
+
let text = JSON.stringify(v);
|
|
237
|
+
if (text === undefined)
|
|
238
|
+
return v;
|
|
239
|
+
for (const s of secrets)
|
|
240
|
+
text = text.split(JSON.stringify(s).slice(1, -1)).join('[secret]');
|
|
241
|
+
return JSON.parse(text);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* A value outside a flag's documented values (known: webhook topics): not enforced, so it is sent,
|
|
245
|
+
* with a warning and the closest documented ones.
|
|
246
|
+
*/
|
|
247
|
+
function unknownValues(verb, values) {
|
|
248
|
+
const out = [];
|
|
249
|
+
for (const [key, f] of Object.entries(verb.flags)) {
|
|
250
|
+
const v = values[key];
|
|
251
|
+
if (!f.known || v === undefined || v === null)
|
|
252
|
+
continue;
|
|
253
|
+
for (const x of (Array.isArray(v) ? v : [v]).filter((x) => typeof x === 'string' && x !== '')) {
|
|
254
|
+
if (f.known.includes(x))
|
|
255
|
+
continue;
|
|
256
|
+
const near = closest(x, f.known);
|
|
257
|
+
out.push({
|
|
258
|
+
code: 'unknown_value',
|
|
259
|
+
message: `${x} is not one of the documented values of ${toFlag(key)}${near.length ? ` (closest: ${near.join(', ')})` : ''}; it was sent anyway.`,
|
|
260
|
+
path: key,
|
|
261
|
+
...(near.length ? { did_you_mean: near } : {}),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* A Configure write that went through, in a folder whose store.yml names this organization: the
|
|
269
|
+
* file is now behind it. Noted in .c11r/drift, with the warning config_drift.
|
|
270
|
+
*/
|
|
271
|
+
function drifted(r, run) {
|
|
272
|
+
if (run.globals.dryRun || !run.org.org || run.verb.method === 'GET')
|
|
273
|
+
return r;
|
|
274
|
+
if (run.schema.doc?.group !== 'configure')
|
|
275
|
+
return r;
|
|
276
|
+
const data = (r.data && typeof r.data === 'object' ? r.data : {});
|
|
277
|
+
const id = [run.parsed.id, data.code, data.name, data.id].find((k) => typeof k === 'string' && k !== '');
|
|
278
|
+
const warning = markDrift(run.org.org, run.globals.live ? 'live' : 'test', { noun: run.schema.noun, verb: run.verb.name, id: id ?? null });
|
|
279
|
+
return warning ? { ...r, warnings: [...r.warnings, warning] } : r;
|
|
119
280
|
}
|
|
120
281
|
/**
|
|
121
|
-
* A delete
|
|
122
|
-
*
|
|
123
|
-
*
|
|
282
|
+
* A delete on a noun that answers secrets: the files c11r wrote for that record in the secrets
|
|
283
|
+
* folder go with it, listed in removed_files. out_files_not_tracked only when that record's secret
|
|
284
|
+
* was written with --out on this machine: that file stays where it was written.
|
|
124
285
|
*/
|
|
125
286
|
function filesGone(r, run) {
|
|
126
287
|
if (run.verb.name !== 'delete' || run.globals.dryRun || !run.org.org)
|
|
127
288
|
return r;
|
|
128
289
|
if (!Object.values(run.schema.verbs).some((v) => v.secret))
|
|
129
290
|
return r;
|
|
291
|
+
// In live mode the secret went to a file the human chose, which is theirs to delete; the
|
|
292
|
+
// secrets folder holds the test mode's files of the same organization, which stay.
|
|
293
|
+
if (run.globals.live)
|
|
294
|
+
return { ...r, warnings: [...r.warnings, OUT_NOT_TRACKED] };
|
|
130
295
|
const data = (r.data && typeof r.data === 'object' ? r.data : {});
|
|
131
|
-
const
|
|
132
|
-
const
|
|
296
|
+
const keys = [run.parsed.id, data.id, data.name, data.code].filter((k) => typeof k === 'string' && k !== '');
|
|
297
|
+
const removed = removeRecordFiles(run.org.org, run.schema.noun, keys);
|
|
298
|
+
const warn = forgetOutWritten(run.org.org, { noun: run.schema.noun, keys });
|
|
133
299
|
return { ...r, data: { ...data, removed_files: removed }, warnings: [...r.warnings, ...(warn ? [OUT_NOT_TRACKED] : [])] };
|
|
134
300
|
}
|
|
135
301
|
/** A secret in the answer goes to its file; a dry run says where it would go. */
|
|
@@ -138,7 +304,7 @@ function secretOf(r, run, plan) {
|
|
|
138
304
|
const secret = data && typeof data === 'object' ? data.secret : undefined;
|
|
139
305
|
if (secret && typeof secret === 'object' && 'value' in secret) {
|
|
140
306
|
// A verb the schema did not mark: still never printed.
|
|
141
|
-
const p = plan ?? planSecret({ org: run.org.org, verb: run.verb.name, id: run.parsed.id, values: run.parsed.values, yes: true });
|
|
307
|
+
const p = plan ?? planSecret({ org: run.org.org, noun: run.schema.noun, verb: run.verb.name, id: run.parsed.id, values: run.parsed.values, yes: true });
|
|
142
308
|
return writeSecret(r, p, { org: run.org.org, noun: run.schema.noun, verb: run.verb.name, id: run.parsed.id });
|
|
143
309
|
}
|
|
144
310
|
if (plan && run.globals.dryRun)
|
package/dist/errors.js
CHANGED
|
@@ -62,6 +62,12 @@ const KIND = {
|
|
|
62
62
|
no_credentials: 'auth',
|
|
63
63
|
forbidden: 'rejected',
|
|
64
64
|
claim_not_found: 'not_found',
|
|
65
|
+
// A store spoken to over UCP (c11r shop).
|
|
66
|
+
store_unavailable: 'unavailable',
|
|
67
|
+
store_refused: 'rejected',
|
|
68
|
+
payment_handler_unsupported: 'rejected',
|
|
69
|
+
// The site that publishes the skills (c11r skills sync).
|
|
70
|
+
site_unavailable: 'unavailable',
|
|
65
71
|
};
|
|
66
72
|
/** For an API error whose code the CLI does not know: from the HTTP status. */
|
|
67
73
|
export function kindOfStatus(status) {
|
package/dist/help.js
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
// reads it and offers to do the work.
|
|
4
4
|
import { toFlag } from './argv.js';
|
|
5
5
|
import { EXIT } from './errors.js';
|
|
6
|
+
import { clearable } from './schema.js';
|
|
6
7
|
export const USAGE = 'c11r <noun> <verb> [id] [--field value ...]';
|
|
7
8
|
export const GLOBAL_FLAG_HELP = {
|
|
8
9
|
'--json': 'Output as JSON. The default, and the only format: one object on stdout, { data, state, warnings, next } or { error, state }.',
|
|
9
10
|
'--dry-run': 'On a write, the API checks everything and says what would change, and changes nothing. Reads ignore it.',
|
|
10
11
|
'--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
12
|
'--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
|
+
'--live': 'Acts on live data, on a claimed organization named with --org, with the human\'s login. Without it every command works in test mode.',
|
|
13
14
|
'--help': 'Describes the noun or verb it follows, from its schema.',
|
|
14
15
|
};
|
|
15
16
|
export const EXIT_CODE_HELP = {
|
|
@@ -21,7 +22,7 @@ export const EXIT_CODE_HELP = {
|
|
|
21
22
|
[EXIT.not_found]: 'no such resource',
|
|
22
23
|
[EXIT.rejected]: 'the API or Commerce Layer refused the request as it is',
|
|
23
24
|
[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,
|
|
25
|
+
[EXIT.auth]: 'a wrong or revoked token, no token stored for the organization named, or no login (login_required)',
|
|
25
26
|
};
|
|
26
27
|
export function indexHelp(builtins, api) {
|
|
27
28
|
return {
|
|
@@ -29,7 +30,7 @@ export function indexHelp(builtins, api) {
|
|
|
29
30
|
description: 'c11r is the Commerce Layer CLI for coding agents. Every answer, errors included, is one JSON object on stdout.',
|
|
30
31
|
nouns: [...builtins, ...(api ?? [])].sort((a, b) => (a.noun < b.noun ? -1 : 1)),
|
|
31
32
|
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 several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it, and a list that has one is sent as a JSON array. A dotted flag sets part of an object (--customer.email). Any other array takes a JSON array,
|
|
33
|
+
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 several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it, and a list that has one is sent as a JSON array. A ref flag, or a flag the schema marks nullable, given null or an empty value sends null and takes it off (--customer-group null, --coupon-code null). A dotted flag sets part of an object (--customer.email). Any other array takes the flag repeated or a JSON array, and the same rule holds: enums, numbers and dates also separated by commas, a name or text one value per flag (--payment-settings "Invoice, 60 days"); 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
34
|
global_flags: GLOBAL_FLAG_HELP,
|
|
34
35
|
exit_codes: EXIT_CODE_HELP,
|
|
35
36
|
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.',
|
|
@@ -53,19 +54,48 @@ export function verbHelp(s, v) {
|
|
|
53
54
|
state: v.state,
|
|
54
55
|
...(v.plan ? { plan: v.plan } : {}),
|
|
55
56
|
...(v.refused ? { served: false, refused_by: v.refused.by } : {}),
|
|
56
|
-
...(v.destructive ? { destructive: true
|
|
57
|
+
...(v.destructive ? { destructive: true } : {}),
|
|
58
|
+
...(v.confirm ? { confirmation: 'Asks for confirmation unless --yes; with no terminal and no --yes it stops with confirmation_required.' } : {}),
|
|
57
59
|
...(v.loopable ? { loopable: 'A loop policy may allow this verb.' } : {}),
|
|
58
60
|
...(v.live ? {} : { live: 'Not in live mode.' }),
|
|
59
|
-
flags: Object.entries(v.flags).map(([key, f]) => flagHelp(key, f)),
|
|
61
|
+
flags: Object.entries(v.flags).map(([key, f]) => flagHelp(key, f, s.noun)),
|
|
60
62
|
};
|
|
61
63
|
}
|
|
62
|
-
|
|
64
|
+
/** Up to this many documented values are listed in help; past it, help says where they are. */
|
|
65
|
+
const KNOWN_IN_FULL = 20;
|
|
66
|
+
const CAPS = (name) => name.toUpperCase().replace(/-/g, '_');
|
|
67
|
+
/** How an array flag takes several values, built from its items and, for objects, their parts. */
|
|
68
|
+
function repeatOf(key, f) {
|
|
69
|
+
if (f.repeat)
|
|
70
|
+
return f.repeat;
|
|
71
|
+
const flag = toFlag(key);
|
|
72
|
+
const item = f.items ?? 'string';
|
|
73
|
+
if (item === 'object') {
|
|
74
|
+
const parts = Object.entries(f.properties ?? {});
|
|
75
|
+
const required = parts.filter(([, p]) => p.required);
|
|
76
|
+
const shown = required.length ? required : parts;
|
|
77
|
+
const optional = required.length ? parts.filter(([, p]) => !p.required).map(([n]) => n) : [];
|
|
78
|
+
const pairs = shown.length ? shown.map(([n]) => `${n}=${CAPS(n)}`).join(',') : 'KEY=VALUE,KEY=VALUE';
|
|
79
|
+
return `${flag} ${pairs}, the flag once per item, or one JSON array of objects.${optional.length ? ` Optional parts: ${optional.join(', ')}.` : ''}`;
|
|
80
|
+
}
|
|
81
|
+
if (item === 'string' || item === 'ref') {
|
|
82
|
+
const v = item === 'ref' ? 'ID' : 'VALUE';
|
|
83
|
+
return `${flag} ${v} ${flag} ${v}, one value per flag (a comma is part of the value), or one JSON array.`;
|
|
84
|
+
}
|
|
85
|
+
const v = item === 'integer' || item === 'number' || item === 'money' ? 'N' : item === 'date' || item === 'datetime' ? 'DATE' : 'VALUE';
|
|
86
|
+
return `${flag} ${v},${v}, or the flag repeated, or one JSON array.`;
|
|
87
|
+
}
|
|
88
|
+
function flagHelp(key, f, noun) {
|
|
63
89
|
return {
|
|
64
90
|
flag: toFlag(key),
|
|
65
91
|
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,
|
|
92
|
+
...(f.type === 'array' ? { repeat: repeatOf(key, f) } : {}),
|
|
66
93
|
...(f.required || (f.input?.kind === 'secret' && f.input.required) ? { required: true } : {}),
|
|
67
94
|
...(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
95
|
...(f.values ? { values: f.values } : {}),
|
|
96
|
+
...(f.bare ? { alone: `${toFlag(key)} with no value sends true` } : {}),
|
|
97
|
+
...(f.known ? { known: f.known.length <= KNOWN_IN_FULL ? f.known : `one of ${f.known.length} documented values: c11r schema ${noun}` } : {}),
|
|
98
|
+
...(clearable(f) ? { clear: 'null, or an empty value, takes it off' } : {}),
|
|
69
99
|
...(f.filter ? { filter: f.filter.op } : {}),
|
|
70
100
|
...(f.description ? { description: f.description } : {}),
|
|
71
101
|
};
|
package/dist/http.js
CHANGED
|
@@ -33,6 +33,13 @@ export function urlOf(req, ctx) {
|
|
|
33
33
|
const qs = q.toString();
|
|
34
34
|
return `${apiUrl()}${req.path}${qs ? `?${qs}` : ''}`;
|
|
35
35
|
}
|
|
36
|
+
/** The header with the hash of the schemas the API serves: it changes with a deploy that changes them. */
|
|
37
|
+
export const SCHEMA_HEADER = 'c11r-schema';
|
|
38
|
+
let schemaWatcher = null;
|
|
39
|
+
/** Called with the header's value on every answer that has it (the schema cache listens). */
|
|
40
|
+
export function watchSchemaHeader(fn) {
|
|
41
|
+
schemaWatcher = fn;
|
|
42
|
+
}
|
|
36
43
|
/** Sends the request: the envelope, or the API's error as a CliError. */
|
|
37
44
|
export async function request(req, ctx) {
|
|
38
45
|
for (let attempt = 1;; attempt++) {
|
|
@@ -76,6 +83,9 @@ async function once(req, ctx) {
|
|
|
76
83
|
next_step: req.method === 'GET' ? { action: 'retry_later' } : { action: 'none' },
|
|
77
84
|
});
|
|
78
85
|
}
|
|
86
|
+
const version = res.headers.get(SCHEMA_HEADER);
|
|
87
|
+
if (version && schemaWatcher)
|
|
88
|
+
schemaWatcher(version);
|
|
79
89
|
const text = await res.text();
|
|
80
90
|
let data;
|
|
81
91
|
try {
|
package/dist/inputs.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// KEY=value lines, from a plain file only its owner can read; or, in a terminal, typed without
|
|
5
5
|
// echo. Never from a flag value or --data, never printed: an error names the file, not the value.
|
|
6
6
|
// A document field (file_input: price rules, rule filters) is also read from a JSON file, --file.
|
|
7
|
+
// Where the verb takes --format csv (imports), the file is sent as CSV text with --format csv, or
|
|
8
|
+
// when it ends in .csv and no format is given; JSON otherwise.
|
|
7
9
|
import fs from 'node:fs';
|
|
8
10
|
import { toFlag } from './argv.js';
|
|
9
11
|
import { fail } from './errors.js';
|
|
@@ -29,7 +31,12 @@ export async function readInputs(noun, verb, parsed, prompter) {
|
|
|
29
31
|
continue;
|
|
30
32
|
if (parsed.values[input.target] !== undefined)
|
|
31
33
|
fail('invalid_value', `${cmd} takes ${input.target} from ${toFlag(input.target)} or from ${toFlag(key)}, not both.`, { path: key });
|
|
32
|
-
|
|
34
|
+
if (isCsv(verb, parsed, file)) {
|
|
35
|
+
parsed.values.format ??= 'csv';
|
|
36
|
+
parsed.values[input.target] = csvOf(file, key);
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
parsed.values[input.target] = documentOf(file, key, spec);
|
|
33
40
|
continue;
|
|
34
41
|
}
|
|
35
42
|
if (file !== undefined) {
|
|
@@ -51,6 +58,19 @@ export async function readInputs(noun, verb, parsed, prompter) {
|
|
|
51
58
|
});
|
|
52
59
|
}
|
|
53
60
|
}
|
|
61
|
+
const isCsv = (verb, parsed, file) => Boolean(verb.flags.format?.values?.includes('csv')) && (parsed.values.format === 'csv' || (parsed.values.format === undefined && /\.csv$/i.test(file)));
|
|
62
|
+
function csvOf(file, key) {
|
|
63
|
+
let text;
|
|
64
|
+
try {
|
|
65
|
+
text = fs.readFileSync(file, 'utf8');
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
fail('invalid_value', `${toFlag(key)} ${file}: ${e.code === 'ENOENT' ? 'no such file' : e.message}.`, { path: key });
|
|
69
|
+
}
|
|
70
|
+
if (!text.trim())
|
|
71
|
+
fail('invalid_value', `${file} is empty: a CSV file starts with its header row.`, { path: key });
|
|
72
|
+
return text;
|
|
73
|
+
}
|
|
54
74
|
function documentOf(file, key, spec) {
|
|
55
75
|
let text;
|
|
56
76
|
try {
|