@commercelayer/agent-cli 1.0.0-dev.0 → 1.0.0-dev.10

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/config.js CHANGED
@@ -10,10 +10,16 @@
10
10
  // The organization: --org, then C11R_ORG, then .c11r.json, then the stored one when exactly one
11
11
  // is stored. With several stored and nothing naming one, the command stops and names them.
12
12
  // C11R_TOKEN, for CI, is used as is, whatever is stored.
13
+ //
14
+ // Every stored token records the host that issued it: an organization token and a cancel token
15
+ // the API base (api_url), the login the auth endpoint (auth_url). An entry is used, listed and
16
+ // forgotten only while that base is the one in effect, so a token never goes to a host it was not
17
+ // issued by. An entry that records no host is not used, and goes at the next write.
13
18
  import fs from 'node:fs';
14
19
  import os from 'node:os';
15
20
  import path from 'node:path';
16
21
  import { fail, fixWith } from './errors.js';
22
+ import { apiUrl } from './http.js';
17
23
  export const LINK_FILE = '.c11r.json';
18
24
  export function credentialsFile() {
19
25
  const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
@@ -32,6 +38,12 @@ function readCredentials() {
32
38
  }
33
39
  function writeCredentials(c) {
34
40
  const file = credentialsFile();
41
+ // An entry that records no issuing host is never used: it is not kept either.
42
+ c.orgs = Object.fromEntries(Object.entries(c.orgs).filter(([, o]) => o?.api_url));
43
+ if (c.login && !c.login.auth_url)
44
+ delete c.login;
45
+ if (c.bookings)
46
+ c.bookings = Object.fromEntries(Object.entries(c.bookings).filter(([, b]) => b?.api_url));
35
47
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
36
48
  // Written aside then renamed, so a crash never leaves half a file; 600 from the start.
37
49
  const tmp = `${file}.${process.pid}.tmp`;
@@ -39,19 +51,26 @@ function writeCredentials(c) {
39
51
  fs.chmodSync(tmp, 0o600);
40
52
  fs.renameSync(tmp, file);
41
53
  }
54
+ export const DEFAULT_AUTH_URL = 'https://auth.commercelayer.io/oauth/token';
55
+ export const authUrl = () => process.env.C11R_AUTH_URL || DEFAULT_AUTH_URL;
56
+ const norm = (u) => u.replace(/\/+$/, '');
57
+ /** True when an entry was issued by the API base in effect; one that records none never is. */
58
+ const fromApi = (e) => !!e?.api_url && norm(e.api_url) === norm(apiUrl());
59
+ const fromAuth = (e) => !!e?.auth_url && norm(e.auth_url) === norm(authUrl());
60
+ /** The organization tokens issued by the API in effect. */
42
61
  export function storedOrgs() {
43
- return readCredentials().orgs;
62
+ return Object.fromEntries(Object.entries(readCredentials().orgs).filter(([, o]) => fromApi(o)));
44
63
  }
45
64
  export function storeOrg(slug, org) {
46
65
  const c = readCredentials();
47
- c.orgs[slug] = org;
66
+ c.orgs[slug] = { ...org, api_url: apiUrl() };
48
67
  writeCredentials(c);
49
68
  }
50
69
  /** After a reset: the same token, a new organization. */
51
70
  export function renameOrg(from, to, patch = {}) {
52
71
  const c = readCredentials();
53
72
  const entry = c.orgs[from];
54
- if (!entry)
73
+ if (!fromApi(entry))
55
74
  return;
56
75
  delete c.orgs[from];
57
76
  c.orgs[to] = { ...entry, ...patch };
@@ -59,20 +78,25 @@ export function renameOrg(from, to, patch = {}) {
59
78
  }
60
79
  export function forgetOrg(slug) {
61
80
  const c = readCredentials();
62
- if (!c.orgs[slug])
81
+ if (!fromApi(c.orgs[slug]))
63
82
  return;
64
83
  delete c.orgs[slug];
65
84
  writeCredentials(c);
66
85
  }
86
+ /** The login minted by the auth endpoint in effect. */
67
87
  export function storedLogin() {
68
- return readCredentials().login ?? null;
88
+ const l = readCredentials().login;
89
+ return l && fromAuth(l) ? l : null;
69
90
  }
91
+ /** Stores the login for the auth endpoint in effect; null forgets it, only if it is that one's. */
70
92
  export function storeLogin(login) {
71
93
  const c = readCredentials();
72
94
  if (login)
73
- c.login = login;
74
- else
95
+ c.login = { ...login, auth_url: authUrl() };
96
+ else if (fromAuth(c.login))
75
97
  delete c.login;
98
+ else
99
+ return;
76
100
  writeCredentials(c);
77
101
  }
78
102
  export function defaultOrg() {
@@ -86,31 +110,51 @@ export function setDefaultOrg(org) {
86
110
  delete c.default_org;
87
111
  writeCredentials(c);
88
112
  }
89
- export function outWritten(org) {
90
- return Boolean(readCredentials().out_written?.[org]);
113
+ /** A record's secret went to an --out file: remembered so its delete can say that file stays. */
114
+ export function markOutWritten(org, record) {
115
+ const keys = record.keys.filter(Boolean);
116
+ if (!keys.length)
117
+ return;
118
+ const c = readCredentials();
119
+ const all = { ...(c.out_written ?? {}) };
120
+ const list = (all[org] ?? []).filter((r) => !(r.noun === record.noun && r.keys.some((k) => keys.includes(k))));
121
+ all[org] = [...list, { noun: record.noun, keys }];
122
+ c.out_written = all;
123
+ writeCredentials(c);
91
124
  }
92
- export function markOutWritten(org, written) {
125
+ /**
126
+ * After a delete: whether that record had a secret written with --out, which is then forgotten.
127
+ * With no record named, the organization goes (sandbox destroy).
128
+ */
129
+ export function forgetOutWritten(org, record) {
93
130
  const c = readCredentials();
131
+ const list = c.out_written?.[org] ?? [];
132
+ const hit = (r) => !record || (r.noun === record.noun && r.keys.some((k) => record.keys.includes(k)));
133
+ if (!list.some(hit))
134
+ return false;
135
+ const rest = list.filter((r) => !hit(r));
94
136
  const all = { ...(c.out_written ?? {}) };
95
- if (written === Boolean(all[org]))
96
- return;
97
- if (written)
98
- all[org] = true;
137
+ if (rest.length)
138
+ all[org] = rest;
99
139
  else
100
140
  delete all[org];
101
141
  c.out_written = all;
102
142
  writeCredentials(c);
143
+ return true;
103
144
  }
104
145
  export function storedBooking(id) {
105
- return readCredentials().bookings?.[id] ?? null;
146
+ const b = readCredentials().bookings?.[id];
147
+ return b && fromApi(b) ? b : null;
106
148
  }
107
149
  export function storeBooking(id, booking) {
108
150
  const c = readCredentials();
109
151
  const all = { ...(c.bookings ?? {}) };
110
152
  if (booking)
111
- all[id] = booking;
112
- else
153
+ all[id] = { ...booking, api_url: apiUrl() };
154
+ else if (fromApi(all[id]))
113
155
  delete all[id];
156
+ else
157
+ return;
114
158
  c.bookings = all;
115
159
  writeCredentials(c);
116
160
  }
package/dist/engine.js CHANGED
@@ -4,18 +4,45 @@
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 { raiseProblem, resolveOrg } from './config.js';
8
- import { confirm } from './confirm.js';
7
+ import { forgetOutWritten, raiseProblem, resolveOrg } from './config.js';
8
+ import { confirm, terminal } 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
+ import { readInputs } from './inputs.js';
12
13
  import { hasLogin, loginToken } from './login.js';
13
14
  import { resourceRequest } from './resources.js';
14
- import { describePlan, planSecret, writeSecret } from './secrets.js';
15
+ import { markDrift } from './project.js';
16
+ import { describePlan, OUT_NOT_TRACKED, planSecret, removeRecordFiles, writeSecret } from './secrets.js';
15
17
  import { verbOf } from './schema.js';
16
- import { apiNouns, apiSchema } from './schemas.js';
18
+ import { apiNouns, apiSchema, refreshSchemas, refusedBySchema } from './schemas.js';
17
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
+ */
18
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
+ async function executeOnce(argv, opts) {
19
46
  const cmd = parseCommand(argv);
20
47
  const builtins = opts.builtins ?? {};
21
48
  if (cmd.globals.version && !cmd.noun)
@@ -27,7 +54,10 @@ export async function execute(argv, opts = {}) {
27
54
  const schema = builtins[cmd.noun]?.schema ?? (await apiSchema(cmd.noun, Object.keys(builtins)));
28
55
  if (cmd.globals.help && !cmd.verb && !builtins[cmd.noun]?.defaultVerb)
29
56
  return envelope(nounHelp(schema));
30
- const verb = verbOf(schema, cmd.verb ?? builtins[cmd.noun]?.defaultVerb);
57
+ const ext = opts.extensions?.[schema.noun];
58
+ const found = verbOf(schema, cmd.verb ?? builtins[cmd.noun]?.defaultVerb);
59
+ const local = ext?.flags?.[found.name];
60
+ const verb = local ? { ...found, flags: { ...found.flags, ...local } } : found;
31
61
  if (cmd.globals.help)
32
62
  return envelope(verbHelp(schema, verb));
33
63
  // A verb the schema lists as refused: no request, no confirmation for something that cannot happen.
@@ -35,12 +65,15 @@ export async function execute(argv, opts = {}) {
35
65
  const by = verb.refused.by === 'commercelayer' ? 'Commerce Layer' : verb.refused.by;
36
66
  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' } });
37
67
  }
38
- const ext = opts.extensions?.[schema.noun];
39
68
  const positional = ext?.positional?.[verb.name];
40
69
  const rest = positional && !verb.id && cmd.rest[0] !== undefined && !cmd.rest[0].startsWith('-') ? [`--${positional}`, ...cmd.rest] : cmd.rest;
41
70
  const parsed = parseFields(schema.noun, verb, rest);
42
71
  ext?.prepare?.(verb, parsed);
72
+ // Files first, so a required document given with --file counts; a secret is typed only once
73
+ // the command is otherwise complete.
74
+ await readInputs(schema.noun, verb, parsed, null);
43
75
  check(schema, verb, parsed);
76
+ await readInputs(schema.noun, verb, parsed, opts.prompter ?? terminal);
44
77
  const line = ['c11r', ...argv.filter((a) => a !== '--yes' && a !== '-y')].map(quote).join(' ');
45
78
  const org = resolveOrg(cmd.globals.org);
46
79
  const login = hasLogin();
@@ -68,13 +101,13 @@ export async function execute(argv, opts = {}) {
68
101
  }
69
102
  let confirmed = false;
70
103
  const confirmFlags = verb.confirmIf.filter((k) => parsed.values[k] !== undefined && parsed.values[k] !== false);
71
- if ((verb.destructive || confirmFlags.length) && !cmd.globals.dryRun) {
104
+ if ((verb.destructive || verb.confirm || confirmFlags.length) && !cmd.globals.dryRun) {
72
105
  const what = `${verb.name} ${schema.noun}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` with ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
73
106
  await confirm(what.charAt(0).toUpperCase() + what.slice(1), line, { yes: cmd.globals.yes, prompter: opts.prompter });
74
107
  confirmed = true;
75
108
  }
76
109
  // Where a secret goes, checked before the request makes one.
77
- const secretPlan = verb.secret ? planSecret({ org: org.org, verb: verb.name, id: parsed.id, values: parsed.values, yes: cmd.globals.yes }) : null;
110
+ 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;
78
111
  const ctx = { live: cmd.globals.live, token: bearer === 'login' ? await loginToken() : org.token };
79
112
  const orgQuery = bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
80
113
  const sendOnce = (req) => request({ ...req, query: { ...(req.query ?? {}), ...orgQuery }, dryRun: req.dryRun ?? cmd.globals.dryRun, confirm: confirmed || req.confirm }, ctx);
@@ -110,7 +143,37 @@ export async function execute(argv, opts = {}) {
110
143
  };
111
144
  const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
112
145
  const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
113
- return secretOf(r, run, secretPlan);
146
+ return drifted(filesGone(secretOf(r, run, secretPlan), run), run);
147
+ }
148
+ /**
149
+ * A Configure write that went through, in a folder whose store.yml names this organization: the
150
+ * file is now behind it. Noted in .c11r/drift, with the warning config_drift.
151
+ */
152
+ function drifted(r, run) {
153
+ if (run.globals.dryRun || !run.org.org || run.verb.method === 'GET')
154
+ return r;
155
+ if (run.schema.doc?.group !== 'configure')
156
+ return r;
157
+ const data = (r.data && typeof r.data === 'object' ? r.data : {});
158
+ const id = [run.parsed.id, data.code, data.name, data.id].find((k) => typeof k === 'string' && k !== '');
159
+ const warning = markDrift(run.org.org, { noun: run.schema.noun, verb: run.verb.name, id: id ?? null });
160
+ return warning ? { ...r, warnings: [...r.warnings, warning] } : r;
161
+ }
162
+ /**
163
+ * A delete on a noun that answers secrets: the files c11r wrote for that record in the secrets
164
+ * folder go with it, listed in removed_files. out_files_not_tracked only when that record's secret
165
+ * was written with --out on this machine: that file stays where it was written.
166
+ */
167
+ function filesGone(r, run) {
168
+ if (run.verb.name !== 'delete' || run.globals.dryRun || !run.org.org)
169
+ return r;
170
+ if (!Object.values(run.schema.verbs).some((v) => v.secret))
171
+ return r;
172
+ const data = (r.data && typeof r.data === 'object' ? r.data : {});
173
+ const keys = [run.parsed.id, data.id, data.name, data.code].filter((k) => typeof k === 'string' && k !== '');
174
+ const removed = removeRecordFiles(run.org.org, run.schema.noun, keys);
175
+ const warn = forgetOutWritten(run.org.org, { noun: run.schema.noun, keys });
176
+ return { ...r, data: { ...data, removed_files: removed }, warnings: [...r.warnings, ...(warn ? [OUT_NOT_TRACKED] : [])] };
114
177
  }
115
178
  /** A secret in the answer goes to its file; a dry run says where it would go. */
116
179
  function secretOf(r, run, plan) {
@@ -118,8 +181,8 @@ function secretOf(r, run, plan) {
118
181
  const secret = data && typeof data === 'object' ? data.secret : undefined;
119
182
  if (secret && typeof secret === 'object' && 'value' in secret) {
120
183
  // A verb the schema did not mark: still never printed.
121
- const p = plan ?? planSecret({ org: run.org.org, verb: run.verb.name, id: run.parsed.id, values: run.parsed.values, yes: true });
122
- return writeSecret(r, p, { org: run.org.org, verb: run.verb.name, id: run.parsed.id });
184
+ 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 });
185
+ return writeSecret(r, p, { org: run.org.org, noun: run.schema.noun, verb: run.verb.name, id: run.parsed.id });
123
186
  }
124
187
  if (plan && run.globals.dryRun)
125
188
  return { ...r, data: { ...(data && typeof data === 'object' ? data : { result: data }), secret_file: describePlan(plan) } };
@@ -128,13 +191,25 @@ function secretOf(r, run, plan) {
128
191
  /** What the command needs before anything is sent: the id, the required flags, known --data keys. */
129
192
  function check(schema, verb, parsed) {
130
193
  const cmd = `c11r ${schema.noun} ${verb.name}`;
131
- if (verb.id && parsed.id === undefined) {
194
+ const alt = verb.idAlternative;
195
+ const altGiven = alt.filter((k) => parsed.values[k] !== undefined);
196
+ if (alt.length && parsed.id !== undefined && altGiven.length) {
197
+ fail('invalid_value', `${cmd} takes the id or ${alt.map(toFlag).join(' with ')}, not both.`, { path: altGiven[0], next_step: fixWith(`${cmd} --help`) });
198
+ }
199
+ if (verb.id && parsed.id === undefined && altGiven.length < Math.max(alt.length, 1)) {
132
200
  const accepts = schema.id?.accepts.length ? ` (or its ${schema.id.accepts.filter((a) => a !== 'id').join(' or ')})` : '';
133
- fail('missing_id', `${cmd} needs the id${accepts} of one of the ${schema.noun}.`, { path: 'id', next_step: fixWith(`c11r ${schema.noun} list`) });
201
+ const instead = alt.length ? `, or ${alt.map(toFlag).join(' with ')}` : '';
202
+ const missing = alt.filter((k) => parsed.values[k] === undefined);
203
+ fail('missing_id', `${cmd} needs the id${accepts} of one of the ${schema.noun}${instead}.`, {
204
+ path: altGiven.length ? missing[0] : 'id',
205
+ ...(alt.length ? { details: { id_alternative: alt.map(toFlag) } } : {}),
206
+ next_step: fixWith(`c11r ${schema.noun} list`),
207
+ });
134
208
  }
135
209
  const keys = Object.keys(verb.flags);
210
+ const fromFiles = new Set(Object.values(verb.flags).flatMap((f) => (f.input ? [f.input.target] : [])));
136
211
  for (const key of Object.keys(parsed.values)) {
137
- if (!verb.flags[key])
212
+ if (!verb.flags[key] && !fromFiles.has(key))
138
213
  fail('unknown_field', `${cmd} has no field ${key}.`, { path: key, did_you_mean: closest(key, keys), details: { flags: keys.map(toFlag) } });
139
214
  }
140
215
  const missing = Object.entries(verb.flags)
package/dist/help.js CHANGED
@@ -3,6 +3,7 @@
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 }.',
@@ -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 values separated by commas). A dotted flag sets part of an object (--customer.email). An array takes a JSON array, values separated by commas, or the flag repeated; an array of objects takes key=value pairs (--items sku=COFFEE,quantity=2). --data takes the fields as one JSON object, or @ and a file name.',
33
+ 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 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
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,7 +54,8 @@ 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
61
  flags: Object.entries(v.flags).map(([key, f]) => flagHelp(key, f)),
@@ -63,16 +65,20 @@ function flagHelp(key, f) {
63
65
  return {
64
66
  flag: toFlag(key),
65
67
  type: f.type === 'array' ? `array of ${f.items ?? 'string'}` : f.type === 'ref' ? `${f.noun ?? 'resource'} id` : f.type === 'money' ? 'amount in major units' : f.type,
66
- ...(f.required ? { required: true } : {}),
68
+ ...(f.required || (f.input?.kind === 'secret' && f.input.required) ? { required: true } : {}),
69
+ ...(f.input ? { reads: f.input.kind === 'secret' ? `${f.input.target}, a secret: never a flag value, never printed` : `${f.input.target}, a JSON document` } : {}),
67
70
  ...(f.values ? { values: f.values } : {}),
71
+ ...(clearable(f) ? { clear: 'null, or an empty value, takes it off' } : {}),
68
72
  ...(f.filter ? { filter: f.filter.op } : {}),
69
73
  ...(f.description ? { description: f.description } : {}),
70
74
  };
71
75
  }
72
76
  export function usageOf(noun, v) {
77
+ const needed = (f) => f.required || (f.input?.kind === 'secret' && f.input.required);
73
78
  const required = Object.entries(v.flags)
74
- .filter(([, f]) => f.required)
75
- .map(([k, f]) => `${toFlag(k)} <${f.type === 'enum' ? f.values?.join('|') : f.type}>`);
76
- const optional = Object.values(v.flags).some((f) => !f.required);
77
- return [`c11r ${noun} ${v.name}`, v.id ? '<id>' : '', ...required, optional ? '[--field value ...]' : ''].filter(Boolean).join(' ');
79
+ .filter(([, f]) => needed(f))
80
+ .map(([k, f]) => `${toFlag(k)} <${f.input ? 'file' : f.type === 'enum' && f.values?.length ? f.values.join('|') : f.type}>`);
81
+ const optional = Object.entries(v.flags).some(([k, f]) => !needed(f) && !v.idAlternative.includes(k));
82
+ const id = v.id ? (v.idAlternative.length ? `<id | ${v.idAlternative.map((k) => `${toFlag(k)} <${v.flags[k].type}>`).join(' ')}>` : '<id>') : '';
83
+ return [`c11r ${noun} ${v.name}`, id, ...required, optional ? '[--field value ...]' : ''].filter(Boolean).join(' ');
78
84
  }
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 ADDED
@@ -0,0 +1,112 @@
1
+ // Values read from files before anything is sent (contract section 28.6 and 39).
2
+ //
3
+ // A secret field (secret_input: provider credentials) is taken only from --<name>-file, as JSON or
4
+ // KEY=value lines, from a plain file only its owner can read; or, in a terminal, typed without
5
+ // echo. Never from a flag value or --data, never printed: an error names the file, not the value.
6
+ // A document field (file_input: price rules, rule filters) is also read from a JSON file, --file.
7
+ import fs from 'node:fs';
8
+ import { toFlag } from './argv.js';
9
+ import { fail } from './errors.js';
10
+ import { readPrivateFile } from './login.js';
11
+ /** The flag a secret field is read from, if the key is one. */
12
+ export function secretFileFlag(verb, key) {
13
+ const k = `${key}_file`;
14
+ return verb.flags[k]?.input?.kind === 'secret' ? k : null;
15
+ }
16
+ /** Reads the files given; with a prompter, also asks for a required secret not given (null: files only). */
17
+ export async function readInputs(noun, verb, parsed, prompter) {
18
+ const cmd = `c11r ${noun} ${verb.name}`;
19
+ for (const [key, spec] of Object.entries(verb.flags)) {
20
+ const input = spec.input;
21
+ if (!input)
22
+ continue;
23
+ const file = parsed.values[key];
24
+ // The second pass only asks for a secret: the files were read in the first.
25
+ if (prompter !== null && (input.kind === 'document' || parsed.values[input.target] !== undefined))
26
+ continue;
27
+ if (input.kind === 'document') {
28
+ if (file === undefined)
29
+ continue;
30
+ if (parsed.values[input.target] !== undefined)
31
+ fail('invalid_value', `${cmd} takes ${input.target} from ${toFlag(input.target)} or from ${toFlag(key)}, not both.`, { path: key });
32
+ parsed.values[input.target] = documentOf(file, key, spec);
33
+ continue;
34
+ }
35
+ if (file !== undefined) {
36
+ parsed.values[input.target] = secretOf(readPrivateFile(file, key), file, key, input.target_type);
37
+ continue;
38
+ }
39
+ if (!input.required || prompter === null)
40
+ continue;
41
+ if (prompter.interactive && prompter.askSecret) {
42
+ const typed = (await prompter.askSecret(`${input.target} as JSON, or KEY=value pairs separated by spaces (not shown): `)).trim();
43
+ if (!typed)
44
+ fail('secret_required', `Nothing was typed: ${cmd} needs ${input.target}.`, { path: key });
45
+ parsed.values[input.target] = secretOf(typed.startsWith('{') ? typed : typed.split(/\s+/).join('\n'), null, key, input.target_type);
46
+ continue;
47
+ }
48
+ fail('secret_required', `${cmd} needs ${input.target}, and there is no terminal to type it in. Ask the human to save it in a file only they can read (mode 600), as JSON or KEY=value lines, then run the command with ${toFlag(key)} <file>. Do not open or print that file.`, {
49
+ path: key,
50
+ next_step: { action: 'human_step', command: null, url: null },
51
+ });
52
+ }
53
+ }
54
+ function documentOf(file, key, spec) {
55
+ let text;
56
+ try {
57
+ text = fs.readFileSync(file, 'utf8');
58
+ }
59
+ catch (e) {
60
+ fail('invalid_value', `${toFlag(key)} ${file}: ${e.code === 'ENOENT' ? 'no such file' : e.message}.`, { path: key });
61
+ }
62
+ let v;
63
+ try {
64
+ v = JSON.parse(text);
65
+ }
66
+ catch (e) {
67
+ fail('invalid_value', `${file} is not valid JSON: ${e.message}.`, { path: key });
68
+ }
69
+ const want = spec.input.target_type;
70
+ if (want === 'object' && (!v || typeof v !== 'object' || Array.isArray(v)))
71
+ fail('invalid_value', `${file} is not a JSON object.`, { path: key });
72
+ if (want === 'array' && !Array.isArray(v))
73
+ fail('invalid_value', `${file} is not a JSON list.`, { path: key });
74
+ return v;
75
+ }
76
+ /** JSON, or KEY=value lines (keys lowercased: API_KEY is api_key). The value never goes in a message. */
77
+ function secretOf(text, file, key, type) {
78
+ const where = file ?? 'What was typed';
79
+ const trimmed = text.trim();
80
+ if (trimmed.startsWith('{')) {
81
+ let v;
82
+ try {
83
+ v = JSON.parse(trimmed);
84
+ }
85
+ catch {
86
+ fail('invalid_value', `${where} is not valid JSON.`, { path: key });
87
+ }
88
+ if (!v || typeof v !== 'object' || Array.isArray(v))
89
+ fail('invalid_value', `${where} is not a JSON object.`, { path: key });
90
+ return v;
91
+ }
92
+ const out = {};
93
+ for (const line of trimmed.split(/\r?\n/).map((l) => l.trim())) {
94
+ if (!line || line.startsWith('#'))
95
+ continue;
96
+ const eq = line.indexOf('=');
97
+ if (eq < 1)
98
+ fail('invalid_value', `${where} has a line that is not KEY=value.`, { path: key });
99
+ const value = line.slice(eq + 1).trim().replace(/^(['"])(.*)\1$/, '$2');
100
+ if (value)
101
+ out[line.slice(0, eq).trim().toLowerCase()] = value;
102
+ }
103
+ if (!Object.keys(out).length)
104
+ fail('secret_required', `${where} has no values.`, { path: key });
105
+ if (type !== 'object') {
106
+ const values = Object.values(out);
107
+ if (values.length !== 1)
108
+ fail('invalid_value', `${where} has to hold one value.`, { path: key });
109
+ return values[0];
110
+ }
111
+ return out;
112
+ }
package/dist/login.js CHANGED
@@ -9,13 +9,12 @@
9
9
  import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
- import { storedLogin, storeLogin } from './config.js';
12
+ import { authUrl, storedLogin, storeLogin } from './config.js';
13
13
  import { CliError, errorBody, fail } from './errors.js';
14
14
  import { USER_AGENT } from './version.js';
15
15
  export const PROVISIONING_URL = 'https://dashboard.commercelayer.io/user/provisioning_api';
16
- const AUTH_URL = 'https://auth.commercelayer.io/oauth/token';
17
16
  const MARGIN_MS = 60_000;
18
- export const authUrl = () => process.env.C11R_AUTH_URL || AUTH_URL;
17
+ export { authUrl };
19
18
  /** Where the human saves the client id and secret when there is no terminal to type them in. */
20
19
  export function secretFileDefault() {
21
20
  const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
@@ -78,25 +77,29 @@ export async function loginToken(opts = {}) {
78
77
  return minted.token;
79
78
  }
80
79
  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) {
80
+ /** A secret file's text: a plain file only its owner can read, else insecure_file. */
81
+ export function readPrivateFile(file, flagPath) {
83
82
  let st;
84
83
  try {
85
84
  st = fs.lstatSync(file);
86
85
  }
87
86
  catch {
88
- fail('invalid_value', `${file} does not exist.`, { path: 'secret_file' });
87
+ fail('invalid_value', `${file} does not exist.`, { path: flagPath });
89
88
  }
90
89
  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' });
90
+ fail('insecure_file', `${file} is not a plain file. c11r reads a secret only from a plain file.`, { path: flagPath });
92
91
  if (st.mode & 0o077) {
93
92
  fail('insecure_file', `${file} can be read by other users (mode ${(st.mode & 0o777).toString(8)}). c11r reads a secret only from a file that only its owner can read, mode 600.`, {
94
- path: 'secret_file',
93
+ path: flagPath,
95
94
  next_step: { action: 'human_step', command: null, url: null },
96
95
  details: { file, mode: (st.mode & 0o777).toString(8) },
97
96
  });
98
97
  }
99
- const text = fs.readFileSync(file, 'utf8');
98
+ return fs.readFileSync(file, 'utf8');
99
+ }
100
+ /** The client id and secret, from a file the human saved: key=value lines, JSON, or the secret alone. */
101
+ export function readSecretFile(file) {
102
+ const text = readPrivateFile(file, 'secret_file');
100
103
  const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
101
104
  try {
102
105
  const json = JSON.parse(text);