@commercelayer/agent-cli 1.0.0-dev.1 → 1.0.0-dev.11

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,7 +4,7 @@
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';
7
+ import { forgetOutWritten, raiseProblem, resolveOrg } from './config.js';
8
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';
@@ -12,11 +12,37 @@ 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
+ async function executeOnce(argv, opts) {
20
46
  const cmd = parseCommand(argv);
21
47
  const builtins = opts.builtins ?? {};
22
48
  if (cmd.globals.version && !cmd.noun)
@@ -28,7 +54,10 @@ export async function execute(argv, opts = {}) {
28
54
  const schema = builtins[cmd.noun]?.schema ?? (await apiSchema(cmd.noun, Object.keys(builtins)));
29
55
  if (cmd.globals.help && !cmd.verb && !builtins[cmd.noun]?.defaultVerb)
30
56
  return envelope(nounHelp(schema));
31
- 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;
32
61
  if (cmd.globals.help)
33
62
  return envelope(verbHelp(schema, verb));
34
63
  // A verb the schema lists as refused: no request, no confirmation for something that cannot happen.
@@ -36,7 +65,6 @@ export async function execute(argv, opts = {}) {
36
65
  const by = verb.refused.by === 'commercelayer' ? 'Commerce Layer' : verb.refused.by;
37
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' } });
38
67
  }
39
- const ext = opts.extensions?.[schema.noun];
40
68
  const positional = ext?.positional?.[verb.name];
41
69
  const rest = positional && !verb.id && cmd.rest[0] !== undefined && !cmd.rest[0].startsWith('-') ? [`--${positional}`, ...cmd.rest] : cmd.rest;
42
70
  const parsed = parseFields(schema.noun, verb, rest);
@@ -73,13 +101,13 @@ export async function execute(argv, opts = {}) {
73
101
  }
74
102
  let confirmed = false;
75
103
  const confirmFlags = verb.confirmIf.filter((k) => parsed.values[k] !== undefined && parsed.values[k] !== false);
76
- if ((verb.destructive || confirmFlags.length) && !cmd.globals.dryRun) {
104
+ if ((verb.destructive || verb.confirm || confirmFlags.length) && !cmd.globals.dryRun) {
77
105
  const what = `${verb.name} ${schema.noun}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` with ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
78
106
  await confirm(what.charAt(0).toUpperCase() + what.slice(1), line, { yes: cmd.globals.yes, prompter: opts.prompter });
79
107
  confirmed = true;
80
108
  }
81
109
  // 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;
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;
83
111
  const ctx = { live: cmd.globals.live, token: bearer === 'login' ? await loginToken() : org.token };
84
112
  const orgQuery = bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
85
113
  const sendOnce = (req) => request({ ...req, query: { ...(req.query ?? {}), ...orgQuery }, dryRun: req.dryRun ?? cmd.globals.dryRun, confirm: confirmed || req.confirm }, ctx);
@@ -115,12 +143,26 @@ export async function execute(argv, opts = {}) {
115
143
  };
116
144
  const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
117
145
  const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
118
- return filesGone(secretOf(r, run, secretPlan), run);
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;
119
161
  }
120
162
  /**
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.
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.
124
166
  */
125
167
  function filesGone(r, run) {
126
168
  if (run.verb.name !== 'delete' || run.globals.dryRun || !run.org.org)
@@ -128,8 +170,9 @@ function filesGone(r, run) {
128
170
  if (!Object.values(run.schema.verbs).some((v) => v.secret))
129
171
  return r;
130
172
  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);
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 });
133
176
  return { ...r, data: { ...data, removed_files: removed }, warnings: [...r.warnings, ...(warn ? [OUT_NOT_TRACKED] : [])] };
134
177
  }
135
178
  /** A secret in the answer goes to its file; a dry run says where it would go. */
@@ -138,7 +181,7 @@ function secretOf(r, run, plan) {
138
181
  const secret = data && typeof data === 'object' ? data.secret : undefined;
139
182
  if (secret && typeof secret === 'object' && 'value' in secret) {
140
183
  // 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 });
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 });
142
185
  return writeSecret(r, p, { org: run.org.org, noun: run.schema.noun, verb: run.verb.name, id: run.parsed.id });
143
186
  }
144
187
  if (plan && run.globals.dryRun)
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)),
@@ -66,6 +68,7 @@ function flagHelp(key, f) {
66
68
  ...(f.required || (f.input?.kind === 'secret' && f.input.required) ? { required: true } : {}),
67
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` } : {}),
68
70
  ...(f.values ? { values: f.values } : {}),
71
+ ...(clearable(f) ? { clear: 'null, or an empty value, takes it off' } : {}),
69
72
  ...(f.filter ? { filter: f.filter.op } : {}),
70
73
  ...(f.description ? { description: f.description } : {}),
71
74
  };
@@ -74,7 +77,7 @@ export function usageOf(noun, v) {
74
77
  const needed = (f) => f.required || (f.input?.kind === 'secret' && f.input.required);
75
78
  const required = Object.entries(v.flags)
76
79
  .filter(([, f]) => needed(f))
77
- .map(([k, f]) => `${toFlag(k)} <${f.input ? 'file' : f.type === 'enum' ? f.values?.join('|') : f.type}>`);
80
+ .map(([k, f]) => `${toFlag(k)} <${f.input ? 'file' : f.type === 'enum' && f.values?.length ? f.values.join('|') : f.type}>`);
78
81
  const optional = Object.entries(v.flags).some(([k, f]) => !needed(f) && !v.idAlternative.includes(k));
79
82
  const id = v.id ? (v.idAlternative.length ? `<id | ${v.idAlternative.map((k) => `${toFlag(k)} <${v.flags[k].type}>`).join(' ')}>` : '<id>') : '';
80
83
  return [`c11r ${noun} ${v.name}`, id, ...required, optional ? '[--field value ...]' : ''].filter(Boolean).join(' ');
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 {
@@ -0,0 +1,175 @@
1
+ // The project folder's configuration files (contract section 45.2): store.yml and
2
+ // price_lists/<code>.csv, written by config export and read by config plan and apply, and the
3
+ // CLI's own notes about them in .c11r/:
4
+ //
5
+ // .c11r/export.json what the last export wrote: the organization, when, and each file's hash,
6
+ // so the next export never overwrites a file changed since without --yes.
7
+ // .c11r/drift the Configure writes made since, in a folder whose store.yml names the
8
+ // same organization: config plan mentions them, config export clears them.
9
+ //
10
+ // store.yml carries the export's meta ({ org, exported_at }) as its first key; the CLI takes it
11
+ // off before it sends the file, so the API sees { <noun>: [records] } only.
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import YAML from 'yaml';
16
+ import { projectDir } from './config.js';
17
+ import { fail } from './errors.js';
18
+ export const STORE_FILE = 'store.yml';
19
+ export const PRICE_LISTS_DIR = 'price_lists';
20
+ const NOTES_DIR = '.c11r';
21
+ const EXPORT_FILE = 'export.json';
22
+ const DRIFT_FILE = 'drift';
23
+ const hashOf = (text) => `sha256-${crypto.createHash('sha256').update(text).digest('hex')}`;
24
+ const notes = (dir) => path.join(dir, NOTES_DIR);
25
+ /** store.yml and the price list CSVs of the project folder, or store_not_found. */
26
+ export function readStoreFiles(dir = projectDir()) {
27
+ const file = path.join(dir, STORE_FILE);
28
+ let text;
29
+ try {
30
+ text = fs.readFileSync(file, 'utf8');
31
+ }
32
+ catch {
33
+ fail('not_found', `There is no ${STORE_FILE} in ${dir}. c11r config export writes one from the organization.`, { path: 'store', next_step: { action: 'fix_request', command: 'c11r config export' } });
34
+ }
35
+ let doc;
36
+ try {
37
+ doc = YAML.parse(text) ?? {};
38
+ }
39
+ catch (e) {
40
+ fail('invalid_config', `${file} is not YAML: ${e.message}`, { path: 'store' });
41
+ }
42
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc))
43
+ fail('invalid_config', `${file} holds no nouns: it is { <noun>: [records] }.`, { path: 'store' });
44
+ const { meta, ...store } = doc;
45
+ const org = meta && typeof meta === 'object' && typeof meta.org === 'string' ? meta.org : null;
46
+ const price_lists = {};
47
+ const csvDir = path.join(dir, PRICE_LISTS_DIR);
48
+ let names = [];
49
+ try {
50
+ names = fs.readdirSync(csvDir);
51
+ }
52
+ catch {
53
+ // No price lists: fine.
54
+ }
55
+ for (const n of names.sort())
56
+ if (n.endsWith('.csv'))
57
+ price_lists[n.slice(0, -4)] = fs.readFileSync(path.join(csvDir, n), 'utf8');
58
+ return { store, price_lists, org };
59
+ }
60
+ /** The organization the folder's store.yml names, or null without one. */
61
+ export function storeFileOrg(dir = projectDir()) {
62
+ try {
63
+ const doc = YAML.parse(fs.readFileSync(path.join(dir, STORE_FILE), 'utf8'));
64
+ return typeof doc?.meta?.org === 'string' ? doc.meta.org : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /**
71
+ * Writes an export: store.yml (meta first) and price_lists/<code>.csv. A file there that differs
72
+ * from what the last export wrote (or that no export wrote) is the user's: without overwrite,
73
+ * nothing is written and the answer lists them. Price list files the last export wrote, unchanged
74
+ * since, and no longer in the organization, go.
75
+ */
76
+ export function writeStoreFiles(data, opts) {
77
+ const dir = opts.dir ?? projectDir();
78
+ const last = readRecord(dir);
79
+ const meta = { org: data.meta?.org ?? null, exported_at: data.meta?.exported_at ?? new Date().toISOString() };
80
+ const yaml = `# The organization's setup, written by c11r config export. Edit it, then c11r config plan and c11r config apply.\n${YAML.stringify({ meta, ...data.store }, { lineWidth: 0 })}`;
81
+ const files = { [STORE_FILE]: yaml };
82
+ for (const [code, csv] of Object.entries(data.price_lists ?? {}))
83
+ files[path.join(PRICE_LISTS_DIR, `${safeName(code)}.csv`)] = csv;
84
+ const changed = (rel) => {
85
+ const abs = path.join(dir, rel);
86
+ if (!fs.existsSync(abs))
87
+ return false;
88
+ const now = hashOf(fs.readFileSync(abs, 'utf8'));
89
+ return last.files[rel] !== now;
90
+ };
91
+ const stale = Object.keys(last.files).filter((rel) => rel.startsWith(`${PRICE_LISTS_DIR}${path.sep}`) && !(rel in files) && fs.existsSync(path.join(dir, rel)));
92
+ const mine = [...Object.keys(files).filter((rel) => files[rel] !== readOr(path.join(dir, rel))), ...stale].filter(changed);
93
+ if (mine.length && !opts.overwrite) {
94
+ fail('conflict', `${mine.join(', ')} changed since the last export${last.exported_at ? ` (${last.exported_at})` : ''}, or no export wrote ${mine.length === 1 ? 'it' : 'them'}. c11r replaces ${mine.length === 1 ? 'it' : 'them'} only with --yes; c11r config plan shows what the files would change in the organization.`, {
95
+ path: 'store',
96
+ details: { files: mine.map((rel) => path.join(dir, rel)) },
97
+ next_step: { action: 'fix_request', command: 'c11r config plan' },
98
+ }, 'rejected');
99
+ }
100
+ const written = [];
101
+ for (const [rel, text] of Object.entries(files)) {
102
+ const abs = path.join(dir, rel);
103
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
104
+ if (readOr(abs) !== text)
105
+ fs.writeFileSync(abs, text);
106
+ written.push(abs);
107
+ }
108
+ const removed = [];
109
+ for (const rel of stale) {
110
+ fs.rmSync(path.join(dir, rel), { force: true });
111
+ removed.push(path.join(dir, rel));
112
+ }
113
+ writeRecord(dir, { org: meta.org, exported_at: meta.exported_at, files: Object.fromEntries(Object.entries(files).map(([rel, text]) => [rel, hashOf(text)])) });
114
+ clearDrift(dir);
115
+ return { written, removed, dir };
116
+ }
117
+ /**
118
+ * After a Configure write: in a folder whose store.yml names this organization, notes it in
119
+ * .c11r/drift and answers the warning. Elsewhere, nothing.
120
+ */
121
+ export function markDrift(org, entry, dir = projectDir()) {
122
+ if (storeFileOrg(dir) !== org)
123
+ return null;
124
+ const list = readDrift(dir);
125
+ list.push({ ...entry, at: new Date().toISOString() });
126
+ fs.mkdirSync(notes(dir), { recursive: true });
127
+ // The first ones say since when; the last 50 say what.
128
+ const kept = list.length > 50 ? [list[0], ...list.slice(-49)] : list;
129
+ fs.writeFileSync(path.join(notes(dir), DRIFT_FILE), JSON.stringify(kept, null, 2) + '\n');
130
+ return driftWarning(kept);
131
+ }
132
+ /** The writes noted since the last export, oldest first. */
133
+ export function readDrift(dir = projectDir()) {
134
+ try {
135
+ const v = JSON.parse(fs.readFileSync(path.join(notes(dir), DRIFT_FILE), 'utf8'));
136
+ return Array.isArray(v) ? v : [];
137
+ }
138
+ catch {
139
+ return [];
140
+ }
141
+ }
142
+ export function clearDrift(dir = projectDir()) {
143
+ fs.rmSync(path.join(notes(dir), DRIFT_FILE), { force: true });
144
+ }
145
+ export function driftWarning(list) {
146
+ const first = list[0];
147
+ const what = (e) => `${e.noun} ${e.verb}${e.id ? ` ${e.id}` : ''}`;
148
+ return {
149
+ code: 'config_drift',
150
+ message: `store.yml is now behind the organization, since ${first.at} (${what(first)}${list.length > 1 ? ` and ${list.length - 1} more` : ''}): c11r config export refreshes it.`,
151
+ };
152
+ }
153
+ function readRecord(dir) {
154
+ try {
155
+ const v = JSON.parse(fs.readFileSync(path.join(notes(dir), EXPORT_FILE), 'utf8'));
156
+ return { org: v.org ?? null, exported_at: v.exported_at ?? null, files: v.files && typeof v.files === 'object' ? v.files : {} };
157
+ }
158
+ catch {
159
+ return { org: null, exported_at: null, files: {} };
160
+ }
161
+ }
162
+ function writeRecord(dir, r) {
163
+ fs.mkdirSync(notes(dir), { recursive: true });
164
+ fs.writeFileSync(path.join(notes(dir), EXPORT_FILE), JSON.stringify(r, null, 2) + '\n');
165
+ }
166
+ function readOr(file) {
167
+ try {
168
+ return fs.readFileSync(file, 'utf8');
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ /** A price list code as a file name: letters, digits, dot, dash and underscore. */
175
+ const safeName = (code) => code.replace(/[^A-Za-z0-9._-]+/g, '-');
package/dist/resources.js CHANGED
@@ -21,7 +21,7 @@ export function resourceRequest(noun, verb, id, values) {
21
21
  else if (flag.to === 'local')
22
22
  continue;
23
23
  else if (flag.to === 'filter')
24
- query[`filter[${flag.filter.field}][${flag.filter.op}]`] = text(value);
24
+ query[`filter[${flag.filter.field}][${flag.filter.op}]`] = flag.filter.op === 'in' ? inList(value) : text(value);
25
25
  else
26
26
  query[key] = text(value);
27
27
  }
@@ -39,4 +39,7 @@ export function resourceRequest(noun, verb, id, values) {
39
39
  req.body = body;
40
40
  return req;
41
41
  }
42
+ // An in list is joined by commas, or sent as a JSON array of strings when a value has a comma of its
43
+ // own ("UK VAT, manual"), so the API keeps each value whole (contract 28.4).
44
+ const inList = (v) => (Array.isArray(v) && v.some((x) => typeof x === 'string' && x.includes(',')) ? JSON.stringify(v.map(String)) : text(v));
42
45
  const text = (v) => (Array.isArray(v) ? v.join(',') : typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v));
package/dist/schema.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The schema of a noun, as the contract's schema document has it (section 5.1), and what the
2
2
  // engine makes of it: for every verb, the flags it takes and where each one goes (body, query or
3
- // filter). A plain noun (markets, orders, carts, about 60 of them) has no code in the CLI: its
3
+ // filter). A plain noun (markets, orders, payments, about 60 of them) has no code in the CLI: its
4
4
  // schema is read from GET /v1/schema/<noun>. Built-in nouns (sandbox, loops, report) write theirs
5
5
  // in the same document format, so one reader serves both.
6
6
  import { closest, fail, fixWith } from './errors.js';
@@ -53,6 +53,7 @@ export function parseSchema(raw, expected) {
53
53
  method,
54
54
  id: v.path ? /\{[^}]+\}/.test(v.path) : !['list', 'create'].includes(name),
55
55
  destructive,
56
+ confirm: destructive || v.confirm === true,
56
57
  // A policy never deletes nor returns money, whatever else the document says.
57
58
  loopable: Boolean(v.loopable) && !v.deletes && !v.returns_money && name !== 'delete',
58
59
  live: v.live !== false,
@@ -102,7 +103,9 @@ function fileInputs(flags, fields, body) {
102
103
  type: 'string',
103
104
  to: 'local',
104
105
  input: { kind: 'document', target: k, required: Boolean(flags[k].required), target_type: flags[k].type },
105
- description: `A JSON file with ${k}, in place of ${toFlag(k)}.`,
106
+ description: flags.format?.values?.includes('csv')
107
+ ? `A file with ${k}, in place of ${toFlag(k)}: JSON, or CSV with --format csv or a name ending in .csv.`
108
+ : `A JSON file with ${k}, in place of ${toFlag(k)}.`,
106
109
  };
107
110
  }
108
111
  }
@@ -145,8 +148,9 @@ function flag(f, to, required) {
145
148
  to,
146
149
  ...(f.values ? { values: f.values } : {}),
147
150
  ...(f.items ? { items: f.items } : {}),
148
- ...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, p.type])) } : {}),
151
+ ...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, { type: p.type, ...(p.values ? { values: p.values } : {}) }])) } : {}),
149
152
  ...(f.noun ? { noun: f.noun } : {}),
153
+ ...(f.nullable !== undefined ? { nullable: f.nullable } : {}),
150
154
  ...(required ? { required: true } : {}),
151
155
  ...(f.description ? { description: f.description } : {}),
152
156
  };
@@ -180,3 +184,7 @@ export function verbOf(schema, verb) {
180
184
  }
181
185
  return v;
182
186
  }
187
+ /** A body flag that null clears: a field marked nullable, or a ref unless marked nullable: false. */
188
+ export function clearable(f) {
189
+ return f.to === 'body' && (f.nullable === true || (f.type === 'ref' && f.nullable !== false));
190
+ }
package/dist/schemas.js CHANGED
@@ -2,28 +2,91 @@
2
2
  // one (contract section 5), cached on disk for a few minutes so a command costs one request, not
3
3
  // two. Schemas are the same at every state, so no token is sent. C11R_SCHEMA_CACHE=0 turns the
4
4
  // cache off.
5
+ //
6
+ // A deploy can change a schema inside those minutes. Every answer carries C11R-Schema, the hash of
7
+ // the schemas served: an entry records the one it was loaded with, and an answer with another
8
+ // drops the cache. The engine then runs the command once more on fresh schemas when the API
9
+ // refused it (refusedBySchema). Without the header, a refusal that a stale schema explains
10
+ // (confirmation_required, unknown_field, unknown_verb) does the same.
5
11
  import crypto from 'node:crypto';
6
12
  import fs from 'node:fs';
7
13
  import os from 'node:os';
8
14
  import path from 'node:path';
9
15
  import { CliError, closest, fail, fixWith } from './errors.js';
10
- import { apiUrl, request } from './http.js';
16
+ import { apiUrl, request, watchSchemaHeader } from './http.js';
11
17
  import { parseSchema } from './schema.js';
12
18
  import { VERSION } from './version.js';
13
19
  const TTL_MS = 10 * 60_000;
14
- function cacheFile(key) {
20
+ function cacheDir() {
15
21
  if (process.env.C11R_SCHEMA_CACHE === '0')
16
22
  return null;
17
23
  const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
18
24
  const host = crypto.createHash('sha256').update(apiUrl()).digest('hex').slice(0, 12);
19
- return path.join(base, 'c11r', `schema-${VERSION}-${host}`, `${key}.json`);
25
+ return path.join(base, 'c11r', `schema-${VERSION}-${host}`);
26
+ }
27
+ /** This process: the header last seen, the versions of the cache entries used, and whether to skip the cache. */
28
+ const seen = { header: null, used: new Set(), fromCache: false, stale: false, fresh: false, written: null };
29
+ /** The hash the cache on disk was written under, in its own file: any answer can check it, whatever the command read. */
30
+ const HASH_FILE = '_hash';
31
+ watchSchemaHeader((version) => {
32
+ seen.header = version;
33
+ // Schemas this command read from the cache, under another hash: the refusal may be theirs.
34
+ if (seen.fromCache && [...seen.used].some((v) => v !== version))
35
+ seen.stale = true;
36
+ // The cache on disk, under another hash (or none recorded): gone, whatever this command read.
37
+ const dir = cacheDir();
38
+ if (!dir || seen.written === version)
39
+ return;
40
+ let names;
41
+ try {
42
+ names = fs.readdirSync(dir);
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ const hash = names.includes(HASH_FILE) ? fs.readFileSync(path.join(dir, HASH_FILE), 'utf8').trim() : null;
48
+ if (hash !== version && names.some((n) => n.endsWith('.json')))
49
+ dropSchemaCache();
50
+ });
51
+ /** Every cached schema of this API, gone: the next command loads them again. */
52
+ export function dropSchemaCache() {
53
+ const dir = cacheDir();
54
+ if (dir)
55
+ fs.rmSync(dir, { recursive: true, force: true });
56
+ }
57
+ /**
58
+ * Whether a refusal may come from a schema older than the API's: one was served from the cache,
59
+ * and the API's header says the schemas changed, or, with no header, the refusal is one a stale
60
+ * schema explains. The caller then refreshes (refreshSchemas) and runs the command once more.
61
+ */
62
+ export function refusedBySchema(code) {
63
+ if (!seen.fromCache || seen.fresh)
64
+ return false;
65
+ if (seen.stale)
66
+ return true;
67
+ return seen.header === null && STALE_CODES.has(code);
68
+ }
69
+ const STALE_CODES = new Set(['confirmation_required', 'unknown_field', 'unknown_verb']);
70
+ /** From now on, in this process, schemas come from the API, and the cache is rewritten. */
71
+ export function refreshSchemas() {
72
+ dropSchemaCache();
73
+ seen.fresh = true;
74
+ seen.fromCache = false;
75
+ seen.used.clear();
20
76
  }
21
77
  async function cached(key, load) {
22
- const file = cacheFile(key);
23
- if (file) {
78
+ const dir = cacheDir();
79
+ const file = dir ? path.join(dir, `${key}.json`) : null;
80
+ if (file && !seen.fresh) {
24
81
  try {
25
- if (Date.now() - fs.statSync(file).mtimeMs < TTL_MS)
26
- return JSON.parse(fs.readFileSync(file, 'utf8'));
82
+ if (Date.now() - fs.statSync(file).mtimeMs < TTL_MS) {
83
+ const entry = JSON.parse(fs.readFileSync(file, 'utf8'));
84
+ if (entry && typeof entry === 'object' && 'value' in entry) {
85
+ seen.fromCache = true;
86
+ seen.used.add(entry.version ?? null);
87
+ return entry.value;
88
+ }
89
+ }
27
90
  }
28
91
  catch {
29
92
  // Not cached, or unreadable: load it.
@@ -33,7 +96,18 @@ async function cached(key, load) {
33
96
  if (file) {
34
97
  try {
35
98
  fs.mkdirSync(path.dirname(file), { recursive: true });
36
- fs.writeFileSync(file, JSON.stringify(value));
99
+ // Written under another hash than the files there: those go first, so the cache is one hash.
100
+ if (seen.header && seen.written !== seen.header) {
101
+ const hashFile = path.join(path.dirname(file), HASH_FILE);
102
+ const hash = fs.existsSync(hashFile) ? fs.readFileSync(hashFile, 'utf8').trim() : null;
103
+ if (hash !== seen.header) {
104
+ dropSchemaCache();
105
+ fs.mkdirSync(path.dirname(file), { recursive: true });
106
+ fs.writeFileSync(hashFile, seen.header);
107
+ }
108
+ seen.written = seen.header;
109
+ }
110
+ fs.writeFileSync(file, JSON.stringify({ version: seen.header, value }));
37
111
  }
38
112
  catch {
39
113
  // A read-only home is fine: no cache.