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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
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
+ // Live is a mode of the same organization, with the same slug: store.yml, the export record and
11
+ // the drift notes say which mode they are of, so test and live notes never mix.
12
+ //
13
+ // store.yml carries the export's meta ({ org, mode, exported_at }) as its first key; the CLI takes it
14
+ // off before it sends the file, so the API sees { <noun>: [records] } only.
15
+ import crypto from 'node:crypto';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import YAML from 'yaml';
19
+ import { projectDir } from './config.js';
20
+ import { fail } from './errors.js';
21
+ const sameSource = (a, b) => a.org !== null && a.org === b.org && a.mode === b.mode;
22
+ export const STORE_FILE = 'store.yml';
23
+ export const PRICE_LISTS_DIR = 'price_lists';
24
+ const NOTES_DIR = '.c11r';
25
+ const EXPORT_FILE = 'export.json';
26
+ const DRIFT_FILE = 'drift';
27
+ const hashOf = (text) => `sha256-${crypto.createHash('sha256').update(text).digest('hex')}`;
28
+ const notes = (dir) => path.join(dir, NOTES_DIR);
29
+ /** store.yml and the price list CSVs of the project folder, or store_not_found. */
30
+ export function readStoreFiles(dir = projectDir()) {
31
+ const file = path.join(dir, STORE_FILE);
32
+ let text;
33
+ try {
34
+ text = fs.readFileSync(file, 'utf8');
35
+ }
36
+ catch {
37
+ 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' } });
38
+ }
39
+ let doc;
40
+ try {
41
+ doc = YAML.parse(text) ?? {};
42
+ }
43
+ catch (e) {
44
+ fail('invalid_config', `${file} is not YAML: ${e.message}`, { path: 'store' });
45
+ }
46
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc))
47
+ fail('invalid_config', `${file} holds no nouns: it is { <noun>: [records] }.`, { path: 'store' });
48
+ const { meta, ...store } = doc;
49
+ const org = meta && typeof meta === 'object' && typeof meta.org === 'string' ? meta.org : null;
50
+ const price_lists = {};
51
+ const csvDir = path.join(dir, PRICE_LISTS_DIR);
52
+ let names = [];
53
+ try {
54
+ names = fs.readdirSync(csvDir);
55
+ }
56
+ catch {
57
+ // No price lists: fine.
58
+ }
59
+ for (const n of names.sort())
60
+ if (n.endsWith('.csv'))
61
+ price_lists[n.slice(0, -4)] = fs.readFileSync(path.join(csvDir, n), 'utf8');
62
+ return { store, price_lists, org };
63
+ }
64
+ /** The organization and mode the folder's store.yml names; org null without one. */
65
+ export function storeFileSource(dir = projectDir()) {
66
+ try {
67
+ const doc = YAML.parse(fs.readFileSync(path.join(dir, STORE_FILE), 'utf8'));
68
+ return { org: typeof doc?.meta?.org === 'string' ? doc.meta.org : null, mode: doc?.meta?.mode === 'live' ? 'live' : 'test' };
69
+ }
70
+ catch {
71
+ return { org: null, mode: 'test' };
72
+ }
73
+ }
74
+ /** Whether the folder's store.yml is of this organization in this mode. */
75
+ export const storeFileIs = (org, mode, dir = projectDir()) => sameSource(storeFileSource(dir), { org, mode });
76
+ /**
77
+ * Writes an export: store.yml (meta first) and price_lists/<code>.csv. A file there that differs
78
+ * from what the last export wrote (or that no export wrote) is the user's: without overwrite,
79
+ * nothing is written and the answer lists them. Price list files the last export wrote, unchanged
80
+ * since, and no longer in the organization, go.
81
+ */
82
+ export function writeStoreFiles(data, opts) {
83
+ const dir = opts.dir ?? projectDir();
84
+ const last = readRecord(dir);
85
+ const meta = { org: data.meta?.org ?? null, mode: opts.mode, exported_at: data.meta?.exported_at ?? new Date().toISOString() };
86
+ 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 })}`;
87
+ const files = { [STORE_FILE]: yaml };
88
+ for (const [code, csv] of Object.entries(data.price_lists ?? {}))
89
+ files[path.join(PRICE_LISTS_DIR, `${safeName(code)}.csv`)] = csv;
90
+ // Files of another organization or mode are not the last export's to replace: --yes first.
91
+ const other = last.org !== null && !sameSource(last, meta);
92
+ const changed = (rel) => {
93
+ const abs = path.join(dir, rel);
94
+ if (!fs.existsSync(abs))
95
+ return false;
96
+ if (other)
97
+ return true;
98
+ const now = hashOf(fs.readFileSync(abs, 'utf8'));
99
+ return last.files[rel] !== now;
100
+ };
101
+ const stale = Object.keys(last.files).filter((rel) => rel.startsWith(`${PRICE_LISTS_DIR}${path.sep}`) && !(rel in files) && fs.existsSync(path.join(dir, rel)));
102
+ const mine = [...Object.keys(files).filter((rel) => files[rel] !== readOr(path.join(dir, rel))), ...stale].filter(changed);
103
+ if (mine.length && !opts.overwrite) {
104
+ 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.`, {
105
+ path: 'store',
106
+ details: { files: mine.map((rel) => path.join(dir, rel)) },
107
+ next_step: { action: 'fix_request', command: 'c11r config plan' },
108
+ }, 'rejected');
109
+ }
110
+ const written = [];
111
+ for (const [rel, text] of Object.entries(files)) {
112
+ const abs = path.join(dir, rel);
113
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
114
+ if (readOr(abs) !== text)
115
+ fs.writeFileSync(abs, text);
116
+ written.push(abs);
117
+ }
118
+ const removed = [];
119
+ for (const rel of stale) {
120
+ fs.rmSync(path.join(dir, rel), { force: true });
121
+ removed.push(path.join(dir, rel));
122
+ }
123
+ writeRecord(dir, { org: meta.org, mode: meta.mode, exported_at: meta.exported_at, files: Object.fromEntries(Object.entries(files).map(([rel, text]) => [rel, hashOf(text)])) });
124
+ clearDrift(dir);
125
+ return { written, removed, dir };
126
+ }
127
+ /**
128
+ * After a Configure write: in a folder whose store.yml names this organization, notes it in
129
+ * .c11r/drift and answers the warning. Elsewhere, nothing.
130
+ */
131
+ export function markDrift(org, mode, entry, dir = projectDir()) {
132
+ if (!storeFileIs(org, mode, dir))
133
+ return null;
134
+ const list = readDrift(dir);
135
+ list.push({ ...entry, at: new Date().toISOString() });
136
+ fs.mkdirSync(notes(dir), { recursive: true });
137
+ // The first ones say since when; the last 50 say what.
138
+ const kept = list.length > 50 ? [list[0], ...list.slice(-49)] : list;
139
+ fs.writeFileSync(path.join(notes(dir), DRIFT_FILE), JSON.stringify(kept, null, 2) + '\n');
140
+ return driftWarning(kept);
141
+ }
142
+ /** The writes noted since the last export, oldest first. */
143
+ export function readDrift(dir = projectDir()) {
144
+ try {
145
+ const v = JSON.parse(fs.readFileSync(path.join(notes(dir), DRIFT_FILE), 'utf8'));
146
+ return Array.isArray(v) ? v : [];
147
+ }
148
+ catch {
149
+ return [];
150
+ }
151
+ }
152
+ export function clearDrift(dir = projectDir()) {
153
+ fs.rmSync(path.join(notes(dir), DRIFT_FILE), { force: true });
154
+ }
155
+ export function driftWarning(list) {
156
+ const first = list[0];
157
+ const what = (e) => `${e.noun} ${e.verb}${e.id ? ` ${e.id}` : ''}`;
158
+ return {
159
+ code: 'config_drift',
160
+ 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.`,
161
+ };
162
+ }
163
+ function readRecord(dir) {
164
+ try {
165
+ const v = JSON.parse(fs.readFileSync(path.join(notes(dir), EXPORT_FILE), 'utf8'));
166
+ return { org: v.org ?? null, mode: v.mode === 'live' ? 'live' : 'test', exported_at: v.exported_at ?? null, files: v.files && typeof v.files === 'object' ? v.files : {} };
167
+ }
168
+ catch {
169
+ return { org: null, mode: 'test', exported_at: null, files: {} };
170
+ }
171
+ }
172
+ function writeRecord(dir, r) {
173
+ fs.mkdirSync(notes(dir), { recursive: true });
174
+ fs.writeFileSync(path.join(notes(dir), EXPORT_FILE), JSON.stringify(r, null, 2) + '\n');
175
+ }
176
+ function readOr(file) {
177
+ try {
178
+ return fs.readFileSync(file, 'utf8');
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ }
184
+ /** A price list code as a file name: letters, digits, dot, dash and underscore. */
185
+ const safeName = (code) => code.replace(/[^A-Za-z0-9._-]+/g, '-');
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
  }
@@ -115,7 +118,7 @@ function listFlags(flags, fields, doc) {
115
118
  if (op === 'in')
116
119
  flags[key] = { ...base, type: 'array', items: f.type === 'array' ? f.items : f.type, filter: { field: f.name, op } };
117
120
  else if (op === 'null')
118
- flags[key] = { ...base, type: 'boolean', values: undefined, filter: { field: f.name, op } };
121
+ flags[key] = { ...base, type: 'boolean', values: undefined, known: undefined, filter: { field: f.name, op } };
119
122
  else
120
123
  flags[key] = { ...base, filter: { field: f.name, op } };
121
124
  }
@@ -144,11 +147,15 @@ function flag(f, to, required) {
144
147
  type: f.type,
145
148
  to,
146
149
  ...(f.values ? { values: f.values } : {}),
150
+ ...(Array.isArray(f.known) && f.known.length ? { known: f.known } : {}),
147
151
  ...(f.items ? { items: f.items } : {}),
148
- ...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, { type: p.type, ...(p.values ? { values: p.values } : {}) }])) } : {}),
152
+ ...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, { type: p.type, ...(p.values ? { values: p.values } : {}), ...(p.required ? { required: true } : {}) }])) } : {}),
149
153
  ...(f.noun ? { noun: f.noun } : {}),
154
+ ...(f.nullable !== undefined ? { nullable: f.nullable } : {}),
150
155
  ...(required ? { required: true } : {}),
151
156
  ...(f.description ? { description: f.description } : {}),
157
+ ...(typeof f.repeat === 'string' && f.repeat ? { repeat: f.repeat } : {}),
158
+ ...(f.bare ? { bare: true } : {}),
152
159
  };
153
160
  }
154
161
  function bad(noun, why) {
@@ -180,3 +187,7 @@ export function verbOf(schema, verb) {
180
187
  }
181
188
  return v;
182
189
  }
190
+ /** A body flag that null clears: a field marked nullable, or a ref unless marked nullable: false. */
191
+ export function clearable(f) {
192
+ return f.to === 'body' && (f.nullable === true || (f.type === 'ref' && f.nullable !== false));
193
+ }
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);
@@ -74,7 +80,7 @@ export function writeSecret(r, plan, ctx) {
74
80
  const credentialId = str(rec.id) ?? ctx.id;
75
81
  const record = { noun: ctx.noun, id: credentialId ?? null, name: str(rec.name), code: str(rec.code) };
76
82
  const format = plan.format ?? (secret.format === 'json' ? 'json' : 'env');
77
- 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);
78
84
  const env = secret.env && Object.keys(secret.env).length ? { ...secret.env } : { [`CL_${(secret.kind ?? 'secret').toUpperCase()}`]: String(secret.value ?? '') };
79
85
  // The endpoint goes with every secret, so the file is enough to call Commerce Layer.
80
86
  if (!env.CL_BASE_ENDPOINT && typeof data.base_endpoint === 'string')
@@ -97,9 +103,9 @@ export function writeSecret(r, plan, ctx) {
97
103
  const created = ctx.verb === 'create';
98
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 } });
99
105
  }
100
- // 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.
101
107
  if (plan.fixed && ctx.org)
102
- 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 !== '') });
103
109
  // The API says the secret is shown once: c11r kept it in the file, and printed none of it.
104
110
  const warnings = r.warnings.filter((w) => w.code !== 'secret_shown_once' && w.code !== 'token_shown_once');
105
111
  if (!ignoredByGit(file))
@@ -111,6 +117,18 @@ export function writeSecret(r, plan, ctx) {
111
117
  warnings,
112
118
  };
113
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
+ }
114
132
  /** What a dry run says about the file: where it would go and whether one is there. */
115
133
  export function describePlan(plan) {
116
134
  const files = plan.fixed || plan.format ? [plan.file] : [`${plan.file}.env`, `${plan.file}.json`];