@commercelayer/agent-cli 1.0.0-dev.2 → 1.0.0-dev.21

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/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 { outWritten, raiseProblem, resolveOrg } from './config.js';
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 verb = verbOf(schema, cmd.verb ?? builtins[cmd.noun]?.defaultVerb);
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
- const ext = opts.extensions?.[schema.noun];
40
- const positional = ext?.positional?.[verb.name];
41
- const rest = positional && !verb.id && cmd.rest[0] !== undefined && !cmd.rest[0].startsWith('-') ? [`--${positional}`, ...cmd.rest] : cmd.rest;
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
- let bearer = verb.credential === 'login' ? 'login' : verb.credential === 'organization|login' ? (login && !org.token?.startsWith('sbx_') ? 'login' : 'organization') : verb.credential === 'none' ? 'none' : 'organization';
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,37 @@ 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
- if ((verb.destructive || confirmFlags.length) && !cmd.globals.dryRun) {
77
- const what = `${verb.name} ${schema.noun}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` with ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
78
- await confirm(what.charAt(0).toUpperCase() + what.slice(1), line, { yes: cmd.globals.yes, prompter: opts.prompter });
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
+ const apiAsks = (live || verb.credential === 'login') && (verb.confirm || confirmFlags.length > 0);
137
+ if (apiAsks && cmd.globals.yes && !cmd.globals.dryRun)
138
+ confirmed = true;
139
+ if (live && cmd.globals.yes && verb.method !== 'GET')
140
+ confirmed = true;
141
+ if ((verb.destructive || verb.confirm || confirmFlags.length) && !cmd.globals.dryRun && !apiAsks) {
142
+ // The command itself names the action ("c11r markets delete eu"): no verb to inflect.
143
+ const sentence = `c11r ${schema.noun} ${verb.name}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
144
+ await confirm(sentence, line, { yes: cmd.globals.yes, prompter: opts.prompter });
79
145
  confirmed = true;
80
146
  }
147
+ // A live secret goes only to a file the human chose: never the default folder, never printed.
148
+ if (live && verb.secret && parsed.values.out === undefined) {
149
+ 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`) });
150
+ }
81
151
  // 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
- const ctx = { live: cmd.globals.live, token: bearer === 'login' ? await loginToken() : org.token };
84
- const orgQuery = bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
152
+ 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;
153
+ // Live without a login: the organization token this machine holds for the organization named,
154
+ // and no other (never C11R_TOKEN, whose organization is unknown). The API answers a sandbox's
155
+ // state_required with its claim step, a claimed one's login_required; a slug alone stays
156
+ // login_required, so no one learns which slugs exist.
157
+ const liveOwn = live && !login && org.org === liveOrg && (org.source === 'flag' || org.source === 'env') ? org.token : null;
158
+ const ctx = { live, token: live ? (login ? await loginToken() : liveOwn) : bearer === 'login' ? await loginToken() : org.token };
159
+ const orgQuery = liveOrg ? { org: liveOrg } : bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
85
160
  const sendOnce = (req) => request({ ...req, query: { ...(req.query ?? {}), ...orgQuery }, dryRun: req.dryRun ?? cmd.globals.dryRun, confirm: confirmed || req.confirm }, ctx);
86
161
  const run = {
87
162
  globals: cmd.globals,
@@ -98,6 +173,16 @@ export async function execute(argv, opts = {}) {
98
173
  return await sendOnce(req);
99
174
  }
100
175
  catch (e) {
176
+ // The API's own confirmation, in live: as it is with no terminal; asked with its words in one.
177
+ if (apiAsks && !confirmed && e instanceof CliError && e.code === 'confirmation_required') {
178
+ const p = opts.prompter ?? terminal;
179
+ // Without a command of the API's, the next step is this one with --yes, under confirm.
180
+ if (!p.interactive)
181
+ throw e.body.next_step?.command ? e : new CliError({ ...e.body, next_step: { action: 'confirm', command: withYes(line), url: null } }, e.kind, e.state);
182
+ await confirm(e.body.message, line, { yes: false, prompter: p, sentence: e.body.message });
183
+ confirmed = true;
184
+ return sendOnce(req);
185
+ }
101
186
  // A login token Commerce Layer no longer takes: mint a new one, once.
102
187
  if (!(e instanceof CliError && e.code === 'login_required') || !login)
103
188
  throw e;
@@ -107,29 +192,108 @@ export async function execute(argv, opts = {}) {
107
192
  throw e;
108
193
  ctx.token = await loginToken({ fresh: bearer === 'login' });
109
194
  run.bearer = 'login';
110
- if (loginOrg && schema.noun !== 'organizations')
195
+ if (!live && loginOrg && schema.noun !== 'organizations')
111
196
  Object.assign(orgQuery, { org: loginOrg });
112
197
  return sendOnce(req);
113
198
  }
114
199
  },
115
200
  };
116
- const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
117
- const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
118
- return filesGone(secretOf(r, run, secretPlan), run);
201
+ // A secret read from a file (provider or payment keys) is never printed, whatever the answer
202
+ // holds: a dry run or an error that echoes it shows [secret] in its place.
203
+ const secrets = secretValues(verb, parsed.values);
204
+ try {
205
+ const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
206
+ const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
207
+ const out = drifted(filesGone(secretOf(r, run, secretPlan), run), run);
208
+ return scrub(unknown.length ? { ...out, warnings: [...unknown, ...out.warnings] } : out, secrets);
209
+ }
210
+ catch (e) {
211
+ if (e instanceof CliError && secrets.length)
212
+ throw new CliError(scrub(e.body, secrets), e.kind, scrub(e.state, secrets));
213
+ throw e;
214
+ }
215
+ }
216
+ /** The string values of the secret fields given, longest first. */
217
+ function secretValues(verb, values) {
218
+ const out = [];
219
+ const walk = (v) => {
220
+ if (typeof v === 'string' && v.length >= 4)
221
+ out.push(v);
222
+ else if (v && typeof v === 'object')
223
+ for (const x of Object.values(v))
224
+ walk(x);
225
+ };
226
+ for (const spec of Object.values(verb.flags))
227
+ if (spec.input?.kind === 'secret')
228
+ walk(values[spec.input.target]);
229
+ return [...new Set(out)].sort((a, b) => b.length - a.length);
230
+ }
231
+ function scrub(v, secrets) {
232
+ if (!secrets.length || v === undefined)
233
+ return v;
234
+ let text = JSON.stringify(v);
235
+ if (text === undefined)
236
+ return v;
237
+ for (const s of secrets)
238
+ text = text.split(JSON.stringify(s).slice(1, -1)).join('[secret]');
239
+ return JSON.parse(text);
240
+ }
241
+ /**
242
+ * A value outside a flag's documented values (known: webhook topics): not enforced, so it is sent,
243
+ * with a warning and the closest documented ones.
244
+ */
245
+ function unknownValues(verb, values) {
246
+ const out = [];
247
+ for (const [key, f] of Object.entries(verb.flags)) {
248
+ const v = values[key];
249
+ if (!f.known || v === undefined || v === null)
250
+ continue;
251
+ for (const x of (Array.isArray(v) ? v : [v]).filter((x) => typeof x === 'string' && x !== '')) {
252
+ if (f.known.includes(x))
253
+ continue;
254
+ const near = closest(x, f.known);
255
+ out.push({
256
+ code: 'unknown_value',
257
+ message: `${x} is not one of the documented values of ${toFlag(key)}${near.length ? ` (closest: ${near.join(', ')})` : ''}; it was sent anyway.`,
258
+ path: key,
259
+ ...(near.length ? { did_you_mean: near } : {}),
260
+ });
261
+ }
262
+ }
263
+ return out;
264
+ }
265
+ /**
266
+ * A Configure write that went through, in a folder whose store.yml names this organization: the
267
+ * file is now behind it. Noted in .c11r/drift, with the warning config_drift.
268
+ */
269
+ function drifted(r, run) {
270
+ if (run.globals.dryRun || !run.org.org || run.verb.method === 'GET')
271
+ return r;
272
+ if (run.schema.doc?.group !== 'configure')
273
+ return r;
274
+ const data = (r.data && typeof r.data === 'object' ? r.data : {});
275
+ const id = [run.parsed.id, data.code, data.name, data.id].find((k) => typeof k === 'string' && k !== '');
276
+ const warning = markDrift(run.org.org, run.globals.live ? 'live' : 'test', { noun: run.schema.noun, verb: run.verb.name, id: id ?? null });
277
+ return warning ? { ...r, warnings: [...r.warnings, warning] } : r;
119
278
  }
120
279
  /**
121
- * A delete of a record whose verbs answer secrets (credentials, webhooks): the files c11r wrote
122
- * for it go with it, listed in removed_files. out_files_not_tracked only when it may apply:
123
- * nothing found here, or a secret of this organization written with --out on this machine.
280
+ * A delete on a noun that answers secrets: the files c11r wrote for that record in the secrets
281
+ * folder go with it, listed in removed_files. out_files_not_tracked only when that record's secret
282
+ * was written with --out on this machine: that file stays where it was written.
124
283
  */
125
284
  function filesGone(r, run) {
126
285
  if (run.verb.name !== 'delete' || run.globals.dryRun || !run.org.org)
127
286
  return r;
128
287
  if (!Object.values(run.schema.verbs).some((v) => v.secret))
129
288
  return r;
289
+ // In live mode the secret went to a file the human chose, which is theirs to delete; the
290
+ // secrets folder holds the test mode's files of the same organization, which stay.
291
+ if (run.globals.live)
292
+ return { ...r, warnings: [...r.warnings, OUT_NOT_TRACKED] };
130
293
  const data = (r.data && typeof r.data === 'object' ? r.data : {});
131
- const removed = removeRecordFiles(run.org.org, run.schema.noun, [run.parsed.id, data.id, data.name, data.code]);
132
- const warn = removed.length === 0 || outWritten(run.org.org);
294
+ const keys = [run.parsed.id, data.id, data.name, data.code].filter((k) => typeof k === 'string' && k !== '');
295
+ const removed = removeRecordFiles(run.org.org, run.schema.noun, keys);
296
+ const warn = forgetOutWritten(run.org.org, { noun: run.schema.noun, keys });
133
297
  return { ...r, data: { ...data, removed_files: removed }, warnings: [...r.warnings, ...(warn ? [OUT_NOT_TRACKED] : [])] };
134
298
  }
135
299
  /** A secret in the answer goes to its file; a dry run says where it would go. */
@@ -138,7 +302,7 @@ function secretOf(r, run, plan) {
138
302
  const secret = data && typeof data === 'object' ? data.secret : undefined;
139
303
  if (secret && typeof secret === 'object' && 'value' in secret) {
140
304
  // 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 });
305
+ 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
306
  return writeSecret(r, p, { org: run.org.org, noun: run.schema.noun, verb: run.verb.name, id: run.parsed.id });
143
307
  }
144
308
  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, or no token stored for the organization named',
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, 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
+ 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, confirmation: 'Asks for confirmation unless --yes; with no terminal and no --yes it stops with confirmation_required.' } : {}),
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
- function flagHelp(key, f) {
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
- parsed.values[input.target] = documentOf(file, key, spec);
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 {