@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.
@@ -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,13 +21,14 @@ 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
  }
28
28
  // The schema's own route when it gives one (/plans/current, /members/invite), else the grammar.
29
29
  const path = verb.path
30
- ? verb.path.replace(/\{[^}]+\}/, id === undefined ? '' : encodeURIComponent(id))
30
+ ? // No id (one given by its id_alternative flags): the segment goes, /prices/{id}/tiers is /prices/tiers.
31
+ verb.path.replace(/\/\{[^}]+\}/, id === undefined ? '' : `/${encodeURIComponent(id)}`)
31
32
  : ['list', 'get', 'create', 'update', 'delete'].includes(verb.name)
32
33
  ? verb.name === 'list' || verb.name === 'create'
33
34
  ? base
@@ -38,4 +39,7 @@ export function resourceRequest(noun, verb, id, values) {
38
39
  req.body = body;
39
40
  return req;
40
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));
41
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,13 +1,12 @@
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';
7
7
  const FIELD_TYPES = ['string', 'integer', 'number', 'money', 'boolean', 'date', 'datetime', 'enum', 'object', 'array', 'ref'];
8
8
  export const FILTER_OPS = ['eq', 'in', 'gt', 'gte', 'lt', 'lte', 'start', 'cont', 'null'];
9
- /** The contract's names; sandbox, the first proposal's name for an organization token, still reads. */
10
- const CREDENTIALS = { none: 'none', organization: 'organization', login: 'login', 'organization|login': 'organization|login', sandbox: 'organization', 'sandbox|login': 'organization|login' };
9
+ const CREDENTIALS = { none: 'none', organization: 'organization', login: 'login', 'organization|login': 'organization|login' };
11
10
  const STANDARD = ['list', 'get', 'create', 'update', 'delete'];
12
11
  const METHOD = { list: 'GET', get: 'GET', create: 'POST', update: 'PATCH', delete: 'DELETE' };
13
12
  export function parseSchema(raw, expected) {
@@ -39,6 +38,7 @@ export function parseSchema(raw, expected) {
39
38
  flags[f.name] = flag(f, method === 'GET' ? 'query' : 'body', Boolean(f.required || (name === 'create' && f.create === 'required')));
40
39
  for (const f of own(v.query))
41
40
  flags[f.name] = flag(f, 'query', Boolean(f.required));
41
+ fileInputs(flags, fields, own(v.body));
42
42
  if (v.secret) {
43
43
  flags.out = { type: 'string', to: 'local', description: 'The file the secret is written to, mode 600. By default one under ~/.config/c11r/secrets/<org>/.' };
44
44
  flags.format = { type: 'enum', to: 'local', values: ['env', 'json'], description: 'env: KEY=value lines, for a .env file; json: one JSON object. By default as the API answers it.' };
@@ -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,
@@ -60,6 +61,7 @@ export function parseSchema(raw, expected) {
60
61
  plan: v.plan ?? null,
61
62
  credential: v.credential ? CREDENTIALS[v.credential] : state === 'none' ? 'none' : 'organization',
62
63
  secret: Boolean(v.secret),
64
+ idAlternative: Array.isArray(v.id_alternative) ? v.id_alternative.filter((x) => typeof x === 'string' && flags[x]) : [],
63
65
  confirmIf: Array.isArray(v.confirm_if) ? v.confirm_if.filter((x) => typeof x === 'string') : [],
64
66
  refused: v.served === false ? { by: v.refused_by ?? 'the API' } : null,
65
67
  path: v.path ? v.path.replace(/^\/v\d+(?=\/)/, '') : null,
@@ -77,6 +79,35 @@ export function parseSchema(raw, expected) {
77
79
  doc,
78
80
  };
79
81
  }
82
+ /**
83
+ * A secret field gets --<name>-file in place of its own flag: the value never sits in a command
84
+ * line or a shell history. A document field keeps its flag (inline JSON) and gets --file, or
85
+ * --<name>-file when the verb has more than one.
86
+ */
87
+ function fileInputs(flags, fields, body) {
88
+ const doc = (k) => fields.find((f) => f.name === k) ?? body.find((f) => f.name === k);
89
+ const keys = Object.keys(flags).filter((k) => flags[k].to === 'body' || flags[k].to === 'query');
90
+ for (const k of keys.filter((k) => doc(k)?.secret_input)) {
91
+ const f = flags[k];
92
+ delete flags[k];
93
+ flags[`${k}_file`] = {
94
+ type: 'string',
95
+ to: 'local',
96
+ input: { kind: 'secret', target: k, required: Boolean(f.required), target_type: f.type },
97
+ description: `${f.description ? `${f.description.replace(/\.?$/, '.')} ` : ''}A file with ${k}, as JSON or KEY=value lines, readable only by its owner (mode 600). Never given as a flag value; without the file, c11r asks in a terminal. Never printed.`,
98
+ };
99
+ }
100
+ const docs = keys.filter((k) => flags[k] && doc(k)?.file_input);
101
+ for (const k of docs) {
102
+ flags[docs.length === 1 ? 'file' : `${k}_file`] = {
103
+ type: 'string',
104
+ to: 'local',
105
+ input: { kind: 'document', target: k, required: Boolean(flags[k].required), target_type: flags[k].type },
106
+ description: `A JSON file with ${k}, in place of ${toFlag(k)}.`,
107
+ };
108
+ }
109
+ }
110
+ const toFlag = (key) => `--${key.replace(/_/g, '-')}`;
80
111
  function listFlags(flags, fields, doc) {
81
112
  for (const f of fields) {
82
113
  for (const op of f.filter ?? []) {
@@ -115,8 +146,9 @@ function flag(f, to, required) {
115
146
  to,
116
147
  ...(f.values ? { values: f.values } : {}),
117
148
  ...(f.items ? { items: f.items } : {}),
118
- ...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, p.type])) } : {}),
149
+ ...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, { type: p.type, ...(p.values ? { values: p.values } : {}) }])) } : {}),
119
150
  ...(f.noun ? { noun: f.noun } : {}),
151
+ ...(f.nullable !== undefined ? { nullable: f.nullable } : {}),
120
152
  ...(required ? { required: true } : {}),
121
153
  ...(f.description ? { description: f.description } : {}),
122
154
  };
@@ -150,3 +182,7 @@ export function verbOf(schema, verb) {
150
182
  }
151
183
  return v;
152
184
  }
185
+ /** A body flag that null clears: a field marked nullable, or a ref unless marked nullable: false. */
186
+ export function clearable(f) {
187
+ return f.to === 'body' && (f.nullable === true || (f.type === 'ref' && f.nullable !== false));
188
+ }
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.
package/dist/secrets.js CHANGED
@@ -14,6 +14,8 @@ import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import { markOutWritten } from './config.js';
16
16
  import { fail } from './errors.js';
17
+ /** Where the record's id goes in a file named after it, once the answer gives the id. */
18
+ const ID_SLOT = '<id>';
17
19
  export function secretsDir() {
18
20
  const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
19
21
  return path.join(base, 'c11r', 'secrets');
@@ -23,10 +25,14 @@ const safe = (s) => s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|
23
25
  export function planSecret(opts) {
24
26
  const out = opts.values.out;
25
27
  const format = opts.values.format ?? null;
26
- const base = opts.verb === 'token' ? `${safe(opts.id ?? 'credential')}-token` : safe(String(opts.values.name ?? opts.id ?? 'credential'));
28
+ // By the name given, else the id given; a record with neither (a gift card) by the id it is
29
+ // given, so each has a file of its own: gift-card-<id>.env.
30
+ const named = opts.values.name ?? opts.id;
31
+ const byRecord = !out && opts.verb !== 'token' && (named === undefined || named === null || named === '');
32
+ const base = opts.verb === 'token' ? `${safe(opts.id ?? 'credential')}-token` : byRecord ? `${safe(opts.noun.replace(/s$/, ''))}-${ID_SLOT}` : safe(String(named));
27
33
  const file = out ? path.resolve(out) : path.join(secretsDir(), safe(opts.org ?? 'default'), base);
28
- const plan = { file: out || !format ? file : `${file}.${format}`, format, fixed: Boolean(out), overwrite: opts.yes };
29
- const candidates = plan.fixed || format ? [plan.file] : [`${file}.env`, `${file}.json`];
34
+ const plan = { file: out || !format ? file : `${file}.${format}`, format, fixed: Boolean(out), overwrite: opts.yes, byRecord };
35
+ const candidates = byRecord ? [] : plan.fixed || format ? [plan.file] : [`${file}.env`, `${file}.json`];
30
36
  for (const c of candidates) {
31
37
  let st = null;
32
38
  try {
@@ -43,7 +49,7 @@ export function planSecret(opts) {
43
49
  fail('conflict', `${c} exists. c11r replaces it only with --yes, or writes the secret elsewhere with --out.`, { path: 'out', next_step: { action: 'fix_request', command: null }, details: { file: c } }, 'rejected');
44
50
  }
45
51
  }
46
- const dir = path.dirname(candidates[0]);
52
+ const dir = path.dirname(plan.file);
47
53
  const existing = nearestExisting(dir);
48
54
  try {
49
55
  fs.accessSync(existing, fs.constants.W_OK);
@@ -63,21 +69,32 @@ function nearestExisting(dir) {
63
69
  }
64
70
  return d;
65
71
  }
72
+ const RECORD_LINE = '# c11r-record: ';
66
73
  /** Writes data.secret to its file and answers the envelope without the value. */
67
74
  export function writeSecret(r, plan, ctx) {
68
75
  const data = (r.data ?? {});
69
76
  const secret = data.secret;
70
- const credentialId = data.credential?.id ?? data.id ?? ctx.id;
77
+ // credentials answer { credential: {...}, secret }, other nouns the record itself and secret.
78
+ const rec = (data.credential && typeof data.credential === 'object' ? data.credential : data);
79
+ const str = (v) => (typeof v === 'string' || typeof v === 'number' ? String(v) : null);
80
+ const credentialId = str(rec.id) ?? ctx.id;
81
+ const record = { noun: ctx.noun, id: credentialId ?? null, name: str(rec.name), code: str(rec.code) };
71
82
  const format = plan.format ?? (secret.format === 'json' ? 'json' : 'env');
72
- const file = plan.fixed || plan.format ? plan.file : `${plan.file}.${format}`;
83
+ const file = byRecordFile(plan.fixed || plan.format ? plan.file : `${plan.file}.${format}`, plan, credentialId);
73
84
  const env = secret.env && Object.keys(secret.env).length ? { ...secret.env } : { [`CL_${(secret.kind ?? 'secret').toUpperCase()}`]: String(secret.value ?? '') };
74
85
  // The endpoint goes with every secret, so the file is enough to call Commerce Layer.
75
86
  if (!env.CL_BASE_ENDPOINT && typeof data.base_endpoint === 'string')
76
87
  env.CL_BASE_ENDPOINT = data.base_endpoint;
77
88
  const written = new Date().toISOString();
78
89
  const content = format === 'json'
79
- ? JSON.stringify({ org: ctx.org, credential_id: credentialId ?? null, kind: secret.kind ?? null, value: secret.value ?? null, expires_at: secret.expires_at ?? null, env, written_at: written }, null, 2) + '\n'
80
- : [`# ${secret.kind ?? 'secret'} of credential ${credentialId ?? '?'} in ${ctx.org ?? '?'}, written by c11r at ${written}.`, '# Keep it out of git.', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ''].join('\n');
90
+ ? JSON.stringify({ org: ctx.org, record, kind: secret.kind ?? null, value: secret.value ?? null, expires_at: secret.expires_at ?? null, env, written_at: written }, null, 2) + '\n'
91
+ : [
92
+ `# ${secret.kind ?? 'secret'} of ${ctx.noun} ${credentialId ?? '?'} in ${ctx.org ?? '?'}, written by c11r at ${written}.`,
93
+ `${RECORD_LINE}${JSON.stringify({ org: ctx.org, ...record })}`,
94
+ '# Keep it out of git.',
95
+ ...Object.entries(env).map(([k, v]) => `${k}=${v}`),
96
+ '',
97
+ ].join('\n');
81
98
  try {
82
99
  atomicWrite(file, content, plan.overwrite);
83
100
  }
@@ -86,9 +103,9 @@ export function writeSecret(r, plan, ctx) {
86
103
  const created = ctx.verb === 'create';
87
104
  fail('secret_not_written', `The API answered the ${secret.kind ?? 'secret'} of credential ${id} once, and c11r could not write it to ${file}: ${e.message}. It was not printed, and the API cannot show it again. ${created ? `c11r credentials token ${id} gives a working token now; for a new secret, create another credential and delete ${id}.` : `c11r credentials token ${id} gives another token.`}`, { path: 'out', next_step: { action: 'fix_request', command: `c11r credentials token ${id}` }, details: { credential_id: credentialId ?? null, file } });
88
105
  }
89
- // An --out file is not removed with its credential: remember that one exists, for the warning.
106
+ // An --out file is not removed with its record: remember which record has one, for the warning.
90
107
  if (plan.fixed && ctx.org)
91
- markOutWritten(ctx.org, true);
108
+ markOutWritten(ctx.org, { noun: ctx.noun, keys: [record.id, record.name, record.code, ctx.id].filter((k) => typeof k === 'string' && k !== '') });
92
109
  // The API says the secret is shown once: c11r kept it in the file, and printed none of it.
93
110
  const warnings = r.warnings.filter((w) => w.code !== 'secret_shown_once' && w.code !== 'token_shown_once');
94
111
  if (!ignoredByGit(file))
@@ -100,6 +117,18 @@ export function writeSecret(r, plan, ctx) {
100
117
  warnings,
101
118
  };
102
119
  }
120
+ /** A file named after its record: the id in its slot, and a file there already left alone. */
121
+ function byRecordFile(file, plan, id) {
122
+ if (!plan.byRecord)
123
+ return file;
124
+ // The id as the API gives it, case kept: ids are case-sensitive (lqgwUkXlBw).
125
+ const slot = (id ?? crypto.randomBytes(4).toString('hex')).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '') || crypto.randomBytes(4).toString('hex');
126
+ const named = file.replace(ID_SLOT, slot);
127
+ if (plan.overwrite || !fs.existsSync(named))
128
+ return named;
129
+ const ext = path.extname(named);
130
+ return `${named.slice(0, -ext.length || undefined)}-${crypto.randomBytes(3).toString('hex')}${ext}`;
131
+ }
103
132
  /** What a dry run says about the file: where it would go and whether one is there. */
104
133
  export function describePlan(plan) {
105
134
  const files = plan.fixed || plan.format ? [plan.file] : [`${plan.file}.env`, `${plan.file}.json`];
@@ -128,13 +157,25 @@ function ignoredByGit(file) {
128
157
  }
129
158
  /** The folder of an organization's secret files. */
130
159
  export const orgSecretsDir = (org) => path.join(secretsDir(), safe(org));
160
+ /** The record a file in the secrets folder belongs to, or null when c11r did not write it. */
161
+ function recordOf(text) {
162
+ const line = text.split('\n').find((l) => l.startsWith(RECORD_LINE));
163
+ try {
164
+ const v = line ? JSON.parse(line.slice(RECORD_LINE.length)) : JSON.parse(text).record;
165
+ return v && typeof v === 'object' && typeof v.noun === 'string' ? v : null;
166
+ }
167
+ catch {
168
+ return null;
169
+ }
170
+ }
131
171
  /**
132
- * After credentials delete: the files c11r wrote in the organization's secrets folder for that
133
- * credential (its secret and its tokens), found by the credential id each file records. Files
134
- * written with --out elsewhere are not tracked.
172
+ * After a delete: the files c11r wrote in the organization's secrets folder for that record (a
173
+ * credential's secret and tokens, a webhook's shared secret), found by the noun and the id, name
174
+ * or code each file records. Files written with --out elsewhere are not tracked.
135
175
  */
136
- export function removeCredentialFiles(org, credentialId) {
176
+ export function removeRecordFiles(org, noun, keys) {
137
177
  const dir = orgSecretsDir(org);
178
+ const wanted = new Set(keys.filter((k) => typeof k === 'string' && k !== ''));
138
179
  let names;
139
180
  try {
140
181
  names = fs.readdirSync(dir);
@@ -154,26 +195,18 @@ export function removeCredentialFiles(org, credentialId) {
154
195
  catch {
155
196
  continue;
156
197
  }
157
- let mine = text.startsWith('#') && new RegExp(`^# \\S+ of credential ${credentialId.replace(/[^\w-]/g, '')} in `).test(text);
158
- if (!mine) {
159
- try {
160
- mine = JSON.parse(text).credential_id === credentialId;
161
- }
162
- catch {
163
- // Not JSON, not ours.
164
- }
165
- }
166
- if (mine) {
167
- fs.rmSync(file);
168
- removed.push(file);
169
- }
198
+ const rec = recordOf(text);
199
+ if (!rec || rec.noun !== noun || ![rec.id, rec.name, rec.code].some((k) => k !== null && wanted.has(k)))
200
+ continue;
201
+ fs.rmSync(file);
202
+ removed.push(file);
170
203
  }
171
204
  if (removed.length) {
172
205
  try {
173
206
  fs.rmdirSync(dir);
174
207
  }
175
208
  catch {
176
- // Not empty: other credentials' files stay.
209
+ // Not empty: other records' files stay.
177
210
  }
178
211
  }
179
212
  return removed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "1.0.0-dev.0",
3
+ "version": "1.0.0-dev.10",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. One grammar, c11r <noun> <verb>, from a sandbox to a running organization.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [