@commercelayer/agent-cli 1.0.0-dev.0 → 1.0.0-dev.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -7
- package/dist/argv.js +47 -7
- package/dist/builtins/auth.js +2 -2
- package/dist/builtins/extensions.js +148 -13
- package/dist/builtins/loops.js +50 -21
- package/dist/builtins/sandbox.js +5 -7
- package/dist/config.js +61 -17
- package/dist/engine.js +89 -14
- package/dist/help.js +14 -8
- package/dist/http.js +10 -0
- package/dist/inputs.js +112 -0
- package/dist/login.js +12 -9
- package/dist/project.js +175 -0
- package/dist/resources.js +6 -2
- package/dist/schema.js +40 -4
- package/dist/schemas.js +82 -8
- package/dist/secrets.js +61 -28
- package/package.json +1 -1
package/dist/project.js
ADDED
|
@@ -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
|
-
?
|
|
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,
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
80
|
-
: [
|
|
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
|
|
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,
|
|
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
|
|
133
|
-
* credential
|
|
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
|
|
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
|
-
|
|
158
|
-
if (!
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
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.
|
|
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": [
|