@commercelayer/agent-cli 0.8.1 → 1.0.0-dev.1
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 +98 -215
- package/dist/argv.js +227 -0
- package/dist/bin.js +15 -178
- package/dist/builtins/auth.js +169 -0
- package/dist/builtins/extensions.js +107 -0
- package/dist/builtins/index.js +8 -0
- package/dist/builtins/loops.js +215 -0
- package/dist/builtins/sandbox.js +186 -0
- package/dist/config.js +238 -0
- package/dist/confirm.js +56 -0
- package/dist/engine.js +205 -0
- package/dist/errors.js +146 -0
- package/dist/help.js +81 -0
- package/dist/http.js +117 -0
- package/dist/inputs.js +112 -0
- package/dist/login.js +136 -0
- package/dist/resources.js +42 -0
- package/dist/schema.js +182 -0
- package/dist/schemas.js +72 -0
- package/dist/secrets.js +204 -0
- package/dist/version.js +4 -8
- package/package.json +4 -9
- package/dist/api/client.js +0 -190
- package/dist/api/server.js +0 -26
- package/dist/api/stub/db.js +0 -37
- package/dist/api/stub/http.js +0 -18
- package/dist/api/stub/index.js +0 -52
- package/dist/api/stub/locale.js +0 -52
- package/dist/api/stub/management.js +0 -409
- package/dist/api/stub/messages.js +0 -126
- package/dist/api/stub/orders.js +0 -625
- package/dist/api/stub/search.js +0 -78
- package/dist/api/stub/shopping.js +0 -673
- package/dist/api/stub/theme.js +0 -197
- package/dist/api/stub/ucp.js +0 -377
- package/dist/api/transport.js +0 -57
- package/dist/api/types.js +0 -4
- package/dist/assets.js +0 -100
- package/dist/catalog.js +0 -372
- package/dist/check.js +0 -187
- package/dist/commands.js +0 -1241
- package/dist/cursor.js +0 -31
- package/dist/files.js +0 -98
- package/dist/inbox/cursor.js +0 -11
- package/dist/inbox/fake.js +0 -95
- package/dist/inbox/imap.js +0 -139
- package/dist/inbox/mailbox.js +0 -25
- package/dist/inbox/message.js +0 -88
- package/dist/inspect.js +0 -237
- package/dist/languages.js +0 -49
- package/dist/links.js +0 -118
- package/dist/locales.js +0 -43
- package/dist/ops.js +0 -190
- package/dist/orderRequest.js +0 -141
- package/dist/output.js +0 -39
- package/dist/products.js +0 -151
- package/dist/promotions.js +0 -291
- package/dist/store.js +0 -379
- package/dist/storefront.js +0 -285
- package/dist/ucp/buy.js +0 -90
- package/dist/ucp/check.js +0 -362
- package/dist/ucp/client.js +0 -129
- package/dist/ucp/images.js +0 -62
- package/dist/ucp/platform.js +0 -90
- package/dist/ucp/shop.js +0 -357
- package/dist/ucp/signing.js +0 -67
- package/dist/web.js +0 -316
- package/dist/website.js +0 -390
package/dist/schema.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The schema of a noun, as the contract's schema document has it (section 5.1), and what the
|
|
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
|
|
4
|
+
// schema is read from GET /v1/schema/<noun>. Built-in nouns (sandbox, loops, report) write theirs
|
|
5
|
+
// in the same document format, so one reader serves both.
|
|
6
|
+
import { closest, fail, fixWith } from './errors.js';
|
|
7
|
+
const FIELD_TYPES = ['string', 'integer', 'number', 'money', 'boolean', 'date', 'datetime', 'enum', 'object', 'array', 'ref'];
|
|
8
|
+
export const FILTER_OPS = ['eq', 'in', 'gt', 'gte', 'lt', 'lte', 'start', 'cont', 'null'];
|
|
9
|
+
const CREDENTIALS = { none: 'none', organization: 'organization', login: 'login', 'organization|login': 'organization|login' };
|
|
10
|
+
const STANDARD = ['list', 'get', 'create', 'update', 'delete'];
|
|
11
|
+
const METHOD = { list: 'GET', get: 'GET', create: 'POST', update: 'PATCH', delete: 'DELETE' };
|
|
12
|
+
export function parseSchema(raw, expected) {
|
|
13
|
+
const doc = raw;
|
|
14
|
+
if (!doc || typeof doc !== 'object' || typeof doc.noun !== 'string' || !Array.isArray(doc.verbs))
|
|
15
|
+
bad(expected, 'it has no noun or no verbs');
|
|
16
|
+
const fields = (doc.fields ?? []).map((f) => field(f, doc.noun));
|
|
17
|
+
const verbs = {};
|
|
18
|
+
for (const v of doc.verbs) {
|
|
19
|
+
if (!v || typeof v.verb !== 'string')
|
|
20
|
+
bad(doc.noun, 'a verb has no name');
|
|
21
|
+
const name = v.verb;
|
|
22
|
+
const flags = {};
|
|
23
|
+
if (name === 'list')
|
|
24
|
+
listFlags(flags, fields, doc);
|
|
25
|
+
if (name === 'create')
|
|
26
|
+
for (const f of fields)
|
|
27
|
+
if (f.create === 'required' || f.create === 'optional')
|
|
28
|
+
flags[f.name] = flag(f, 'body', f.create === 'required');
|
|
29
|
+
if (name === 'update')
|
|
30
|
+
for (const f of fields)
|
|
31
|
+
if (f.update)
|
|
32
|
+
flags[f.name] = flag(f, 'body', false);
|
|
33
|
+
const method = v.method?.toUpperCase() ?? METHOD[name] ?? 'POST';
|
|
34
|
+
// A name in body refers to a field of the document. On a GET it is a query parameter.
|
|
35
|
+
const own = (list) => (list ?? []).filter((x) => x !== '...' && !(name === 'list' && x === 'waiting')).map((x) => (typeof x === 'string' ? byName(fields, x) : field(x, doc.noun)));
|
|
36
|
+
for (const f of own(v.body))
|
|
37
|
+
if (!flags[f.name] || f.create === 'required')
|
|
38
|
+
flags[f.name] = flag(f, method === 'GET' ? 'query' : 'body', Boolean(f.required || (name === 'create' && f.create === 'required')));
|
|
39
|
+
for (const f of own(v.query))
|
|
40
|
+
flags[f.name] = flag(f, 'query', Boolean(f.required));
|
|
41
|
+
fileInputs(flags, fields, own(v.body));
|
|
42
|
+
if (v.secret) {
|
|
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
|
+
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.' };
|
|
45
|
+
}
|
|
46
|
+
const destructive = Boolean(v.destructive);
|
|
47
|
+
const state = v.state ?? 'sandbox';
|
|
48
|
+
if (v.credential !== undefined && !CREDENTIALS[v.credential])
|
|
49
|
+
bad(doc.noun, `verb ${name} has credential ${String(v.credential)}`);
|
|
50
|
+
verbs[name] = {
|
|
51
|
+
name,
|
|
52
|
+
description: v.effect ?? (STANDARD.includes(name) ? standardDescription(name, doc.noun) : ''),
|
|
53
|
+
method,
|
|
54
|
+
id: v.path ? /\{[^}]+\}/.test(v.path) : !['list', 'create'].includes(name),
|
|
55
|
+
destructive,
|
|
56
|
+
// A policy never deletes nor returns money, whatever else the document says.
|
|
57
|
+
loopable: Boolean(v.loopable) && !v.deletes && !v.returns_money && name !== 'delete',
|
|
58
|
+
live: v.live !== false,
|
|
59
|
+
state,
|
|
60
|
+
plan: v.plan ?? null,
|
|
61
|
+
credential: v.credential ? CREDENTIALS[v.credential] : state === 'none' ? 'none' : 'organization',
|
|
62
|
+
secret: Boolean(v.secret),
|
|
63
|
+
idAlternative: Array.isArray(v.id_alternative) ? v.id_alternative.filter((x) => typeof x === 'string' && flags[x]) : [],
|
|
64
|
+
confirmIf: Array.isArray(v.confirm_if) ? v.confirm_if.filter((x) => typeof x === 'string') : [],
|
|
65
|
+
refused: v.served === false ? { by: v.refused_by ?? 'the API' } : null,
|
|
66
|
+
path: v.path ? v.path.replace(/^\/v\d+(?=\/)/, '') : null,
|
|
67
|
+
flags,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
noun: doc.noun,
|
|
72
|
+
description: doc.description ?? '',
|
|
73
|
+
skill: doc.skill ?? null,
|
|
74
|
+
verbs,
|
|
75
|
+
id: doc.id ? { accepts: doc.id.accepts ?? [], example: doc.id.example ?? null } : null,
|
|
76
|
+
waiting: doc.waiting ?? null,
|
|
77
|
+
loop: doc.loop ?? null,
|
|
78
|
+
doc,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A secret field gets --<name>-file in place of its own flag: the value never sits in a command
|
|
83
|
+
* line or a shell history. A document field keeps its flag (inline JSON) and gets --file, or
|
|
84
|
+
* --<name>-file when the verb has more than one.
|
|
85
|
+
*/
|
|
86
|
+
function fileInputs(flags, fields, body) {
|
|
87
|
+
const doc = (k) => fields.find((f) => f.name === k) ?? body.find((f) => f.name === k);
|
|
88
|
+
const keys = Object.keys(flags).filter((k) => flags[k].to === 'body' || flags[k].to === 'query');
|
|
89
|
+
for (const k of keys.filter((k) => doc(k)?.secret_input)) {
|
|
90
|
+
const f = flags[k];
|
|
91
|
+
delete flags[k];
|
|
92
|
+
flags[`${k}_file`] = {
|
|
93
|
+
type: 'string',
|
|
94
|
+
to: 'local',
|
|
95
|
+
input: { kind: 'secret', target: k, required: Boolean(f.required), target_type: f.type },
|
|
96
|
+
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.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const docs = keys.filter((k) => flags[k] && doc(k)?.file_input);
|
|
100
|
+
for (const k of docs) {
|
|
101
|
+
flags[docs.length === 1 ? 'file' : `${k}_file`] = {
|
|
102
|
+
type: 'string',
|
|
103
|
+
to: 'local',
|
|
104
|
+
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
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const toFlag = (key) => `--${key.replace(/_/g, '-')}`;
|
|
110
|
+
function listFlags(flags, fields, doc) {
|
|
111
|
+
for (const f of fields) {
|
|
112
|
+
for (const op of f.filter ?? []) {
|
|
113
|
+
const key = op === 'eq' ? f.name : `${f.name}.${op}`;
|
|
114
|
+
const base = flag(f, 'filter', false);
|
|
115
|
+
if (op === 'in')
|
|
116
|
+
flags[key] = { ...base, type: 'array', items: f.type === 'array' ? f.items : f.type, filter: { field: f.name, op } };
|
|
117
|
+
else if (op === 'null')
|
|
118
|
+
flags[key] = { ...base, type: 'boolean', values: undefined, filter: { field: f.name, op } };
|
|
119
|
+
else
|
|
120
|
+
flags[key] = { ...base, filter: { field: f.name, op } };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (doc.waiting)
|
|
124
|
+
flags.waiting = { type: 'boolean', to: 'query', description: doc.waiting.meaning ?? 'Only what needs a decision now.' };
|
|
125
|
+
flags.page = { type: 'integer', to: 'query', description: 'The page to return, from 1.' };
|
|
126
|
+
flags.limit = { type: 'integer', to: 'query', description: 'How many to return on a page.' };
|
|
127
|
+
const sortable = fields.filter((f) => f.sort).map((f) => f.name);
|
|
128
|
+
if (sortable.length)
|
|
129
|
+
flags.sort = { type: 'string', to: 'query', description: `A field to sort by (${sortable.join(', ')}); a leading minus sorts descending.` };
|
|
130
|
+
}
|
|
131
|
+
/** A field named in a verb's body. One the document does not define is taken as a string. */
|
|
132
|
+
function byName(fields, name) {
|
|
133
|
+
return fields.find((x) => x.name === name) ?? { name, type: 'string' };
|
|
134
|
+
}
|
|
135
|
+
function field(f, noun) {
|
|
136
|
+
if (!f || typeof f.name !== 'string')
|
|
137
|
+
bad(noun, 'a field has no name');
|
|
138
|
+
if (!FIELD_TYPES.includes(f.type))
|
|
139
|
+
bad(noun, `field ${f.name} has type ${String(f.type)}`);
|
|
140
|
+
return f;
|
|
141
|
+
}
|
|
142
|
+
function flag(f, to, required) {
|
|
143
|
+
return {
|
|
144
|
+
type: f.type,
|
|
145
|
+
to,
|
|
146
|
+
...(f.values ? { values: f.values } : {}),
|
|
147
|
+
...(f.items ? { items: f.items } : {}),
|
|
148
|
+
...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, p.type])) } : {}),
|
|
149
|
+
...(f.noun ? { noun: f.noun } : {}),
|
|
150
|
+
...(required ? { required: true } : {}),
|
|
151
|
+
...(f.description ? { description: f.description } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function bad(noun, why) {
|
|
155
|
+
fail('invalid_response', `The API's schema${noun ? ` for ${noun}` : ''} is not one c11r reads: ${why}.`, { next_step: { action: 'none' } });
|
|
156
|
+
}
|
|
157
|
+
function standardDescription(verb, noun) {
|
|
158
|
+
return ({
|
|
159
|
+
list: `Lists the ${noun}, filtered by any of the flags.`,
|
|
160
|
+
get: `Returns one of the ${noun} by id.`,
|
|
161
|
+
create: `Creates one of the ${noun} from the flags.`,
|
|
162
|
+
update: `Changes the given fields of one of the ${noun}; the others stay as they are.`,
|
|
163
|
+
delete: `Deletes one of the ${noun}. It cannot be undone.`,
|
|
164
|
+
}[verb] ?? '');
|
|
165
|
+
}
|
|
166
|
+
/** The verb, or unknown_verb naming the ones there are. */
|
|
167
|
+
export function verbOf(schema, verb) {
|
|
168
|
+
const names = Object.keys(schema.verbs);
|
|
169
|
+
if (!verb)
|
|
170
|
+
fail('missing_argument', `c11r ${schema.noun} needs a verb: ${names.join(', ')}.`, { details: { verbs: names }, next_step: fixWith(`c11r ${schema.noun} --help`) });
|
|
171
|
+
const v = schema.verbs[verb];
|
|
172
|
+
if (!v) {
|
|
173
|
+
const standard = STANDARD.includes(verb);
|
|
174
|
+
// Whether Commerce Layer has it or this API does not serve it yet, the CLI cannot tell.
|
|
175
|
+
fail('unknown_verb', `${schema.noun} has no verb ${verb}${standard ? ' in this API' : ''}. Its verbs are ${names.join(', ')}.`, {
|
|
176
|
+
did_you_mean: closest(verb, names),
|
|
177
|
+
details: { verbs: names },
|
|
178
|
+
next_step: fixWith(`c11r ${schema.noun} --help`),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return v;
|
|
182
|
+
}
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Where plain nouns' schemas come from: GET /v1/schema for the list, GET /v1/schema/<noun> for
|
|
2
|
+
// one (contract section 5), cached on disk for a few minutes so a command costs one request, not
|
|
3
|
+
// two. Schemas are the same at every state, so no token is sent. C11R_SCHEMA_CACHE=0 turns the
|
|
4
|
+
// cache off.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { CliError, closest, fail, fixWith } from './errors.js';
|
|
10
|
+
import { apiUrl, request } from './http.js';
|
|
11
|
+
import { parseSchema } from './schema.js';
|
|
12
|
+
import { VERSION } from './version.js';
|
|
13
|
+
const TTL_MS = 10 * 60_000;
|
|
14
|
+
function cacheFile(key) {
|
|
15
|
+
if (process.env.C11R_SCHEMA_CACHE === '0')
|
|
16
|
+
return null;
|
|
17
|
+
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
18
|
+
const host = crypto.createHash('sha256').update(apiUrl()).digest('hex').slice(0, 12);
|
|
19
|
+
return path.join(base, 'c11r', `schema-${VERSION}-${host}`, `${key}.json`);
|
|
20
|
+
}
|
|
21
|
+
async function cached(key, load) {
|
|
22
|
+
const file = cacheFile(key);
|
|
23
|
+
if (file) {
|
|
24
|
+
try {
|
|
25
|
+
if (Date.now() - fs.statSync(file).mtimeMs < TTL_MS)
|
|
26
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Not cached, or unreadable: load it.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const value = await load();
|
|
33
|
+
if (file) {
|
|
34
|
+
try {
|
|
35
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
+
fs.writeFileSync(file, JSON.stringify(value));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// A read-only home is fine: no cache.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
const anonymous = { live: false, token: null };
|
|
45
|
+
/** Every noun of the spec the API knows, served or not yet. */
|
|
46
|
+
export async function apiNouns() {
|
|
47
|
+
const data = await cached('_index', async () => (await request({ method: 'GET', path: '/schema' }, anonymous)).data);
|
|
48
|
+
return Array.isArray(data?.nouns) ? data.nouns : [];
|
|
49
|
+
}
|
|
50
|
+
/** One plain noun's schema, or unknown_noun with close matches. */
|
|
51
|
+
export async function apiSchema(noun, builtins) {
|
|
52
|
+
if (!/^[a-z][a-z0-9-]*$/.test(noun))
|
|
53
|
+
unknownNoun(noun, builtins);
|
|
54
|
+
let doc;
|
|
55
|
+
try {
|
|
56
|
+
doc = await cached(noun, async () => (await request({ method: 'GET', path: `/schema/${noun}` }, anonymous)).data);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
if (e instanceof CliError && e.kind === 'not_found') {
|
|
60
|
+
const known = await apiNouns().catch(() => []);
|
|
61
|
+
unknownNoun(noun, [...builtins, ...known.map((n) => n.noun)]);
|
|
62
|
+
}
|
|
63
|
+
if (e instanceof CliError && e.kind === 'unavailable') {
|
|
64
|
+
fail('schema_unavailable', `c11r reads the fields and verbs of ${noun} from the API, and could not: ${e.body.message}`, { next_step: e.body.next_step ?? { action: 'retry_later' }, details: { cause: e.code } });
|
|
65
|
+
}
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
return parseSchema(doc, noun);
|
|
69
|
+
}
|
|
70
|
+
function unknownNoun(noun, known) {
|
|
71
|
+
fail('unknown_noun', `c11r has no noun ${noun}.`, { did_you_mean: closest(noun, known), next_step: fixWith('c11r help') });
|
|
72
|
+
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Secrets in answers (contract section 22). credentials create and credentials token answer a
|
|
2
|
+
// secret once, in data.secret: the CLI writes it to a file with mode 600 and prints the file, the
|
|
3
|
+
// format and a hint, never the value. The file is checked before the request is sent, so a create
|
|
4
|
+
// does not make a secret that then has nowhere to go.
|
|
5
|
+
//
|
|
6
|
+
// Default file: ~/.config/c11r/secrets/<org>/<credential name>.env (or <id>-token.env for a token),
|
|
7
|
+
// outside the project so it cannot be committed. --out picks another; --format env|json. A file
|
|
8
|
+
// there already is replaced only with --yes. A file inside a git repository that git does not
|
|
9
|
+
// ignore gets the warning secret_not_ignored.
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { markOutWritten } from './config.js';
|
|
16
|
+
import { fail } from './errors.js';
|
|
17
|
+
export function secretsDir() {
|
|
18
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
19
|
+
return path.join(base, 'c11r', 'secrets');
|
|
20
|
+
}
|
|
21
|
+
const safe = (s) => s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'credential';
|
|
22
|
+
/** Where the secret will go, checked now: writable, not a link, not there already unless --yes. */
|
|
23
|
+
export function planSecret(opts) {
|
|
24
|
+
const out = opts.values.out;
|
|
25
|
+
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'));
|
|
27
|
+
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`];
|
|
30
|
+
for (const c of candidates) {
|
|
31
|
+
let st = null;
|
|
32
|
+
try {
|
|
33
|
+
st = fs.lstatSync(c);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Not there: fine.
|
|
37
|
+
}
|
|
38
|
+
if (st?.isSymbolicLink())
|
|
39
|
+
fail('insecure_file', `${c} is a symbolic link. c11r writes a secret only to a plain file.`, { path: 'out' });
|
|
40
|
+
if (st && !st.isFile())
|
|
41
|
+
fail('invalid_value', `${c} is not a file.`, { path: 'out' });
|
|
42
|
+
if (st && !opts.yes) {
|
|
43
|
+
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
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const dir = path.dirname(candidates[0]);
|
|
47
|
+
const existing = nearestExisting(dir);
|
|
48
|
+
try {
|
|
49
|
+
fs.accessSync(existing, fs.constants.W_OK);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
fail('invalid_value', `${dir} cannot be written by this user, so the secret would have nowhere to go.`, { path: 'out' });
|
|
53
|
+
}
|
|
54
|
+
return plan;
|
|
55
|
+
}
|
|
56
|
+
function nearestExisting(dir) {
|
|
57
|
+
let d = dir;
|
|
58
|
+
while (!fs.existsSync(d)) {
|
|
59
|
+
const up = path.dirname(d);
|
|
60
|
+
if (up === d)
|
|
61
|
+
break;
|
|
62
|
+
d = up;
|
|
63
|
+
}
|
|
64
|
+
return d;
|
|
65
|
+
}
|
|
66
|
+
const RECORD_LINE = '# c11r-record: ';
|
|
67
|
+
/** Writes data.secret to its file and answers the envelope without the value. */
|
|
68
|
+
export function writeSecret(r, plan, ctx) {
|
|
69
|
+
const data = (r.data ?? {});
|
|
70
|
+
const secret = data.secret;
|
|
71
|
+
// credentials answer { credential: {...}, secret }, other nouns the record itself and secret.
|
|
72
|
+
const rec = (data.credential && typeof data.credential === 'object' ? data.credential : data);
|
|
73
|
+
const str = (v) => (typeof v === 'string' || typeof v === 'number' ? String(v) : null);
|
|
74
|
+
const credentialId = str(rec.id) ?? ctx.id;
|
|
75
|
+
const record = { noun: ctx.noun, id: credentialId ?? null, name: str(rec.name), code: str(rec.code) };
|
|
76
|
+
const format = plan.format ?? (secret.format === 'json' ? 'json' : 'env');
|
|
77
|
+
const file = plan.fixed || plan.format ? plan.file : `${plan.file}.${format}`;
|
|
78
|
+
const env = secret.env && Object.keys(secret.env).length ? { ...secret.env } : { [`CL_${(secret.kind ?? 'secret').toUpperCase()}`]: String(secret.value ?? '') };
|
|
79
|
+
// The endpoint goes with every secret, so the file is enough to call Commerce Layer.
|
|
80
|
+
if (!env.CL_BASE_ENDPOINT && typeof data.base_endpoint === 'string')
|
|
81
|
+
env.CL_BASE_ENDPOINT = data.base_endpoint;
|
|
82
|
+
const written = new Date().toISOString();
|
|
83
|
+
const content = format === 'json'
|
|
84
|
+
? 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'
|
|
85
|
+
: [
|
|
86
|
+
`# ${secret.kind ?? 'secret'} of ${ctx.noun} ${credentialId ?? '?'} in ${ctx.org ?? '?'}, written by c11r at ${written}.`,
|
|
87
|
+
`${RECORD_LINE}${JSON.stringify({ org: ctx.org, ...record })}`,
|
|
88
|
+
'# Keep it out of git.',
|
|
89
|
+
...Object.entries(env).map(([k, v]) => `${k}=${v}`),
|
|
90
|
+
'',
|
|
91
|
+
].join('\n');
|
|
92
|
+
try {
|
|
93
|
+
atomicWrite(file, content, plan.overwrite);
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
const id = credentialId ?? '<id>';
|
|
97
|
+
const created = ctx.verb === 'create';
|
|
98
|
+
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
|
+
}
|
|
100
|
+
// An --out file is not removed with its credential: remember that one exists, for the warning.
|
|
101
|
+
if (plan.fixed && ctx.org)
|
|
102
|
+
markOutWritten(ctx.org, true);
|
|
103
|
+
// The API says the secret is shown once: c11r kept it in the file, and printed none of it.
|
|
104
|
+
const warnings = r.warnings.filter((w) => w.code !== 'secret_shown_once' && w.code !== 'token_shown_once');
|
|
105
|
+
if (!ignoredByGit(file))
|
|
106
|
+
warnings.push({ code: 'secret_not_ignored', message: `${file} is inside a git repository and git does not ignore it: add it to .gitignore before any commit.`, path: 'out' });
|
|
107
|
+
const { value: _value, env: _env, ...rest } = secret;
|
|
108
|
+
return {
|
|
109
|
+
...r,
|
|
110
|
+
data: { ...data, secret: { kind: rest.kind ?? null, file, format, hint: rest.hint ?? null, mode: '600', expires_at: rest.expires_at ?? null } },
|
|
111
|
+
warnings,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** What a dry run says about the file: where it would go and whether one is there. */
|
|
115
|
+
export function describePlan(plan) {
|
|
116
|
+
const files = plan.fixed || plan.format ? [plan.file] : [`${plan.file}.env`, `${plan.file}.json`];
|
|
117
|
+
return { file: files[0], format: plan.format ?? 'env', would_overwrite: files.some((f) => fs.existsSync(f)), writable: true };
|
|
118
|
+
}
|
|
119
|
+
function atomicWrite(file, content, overwrite) {
|
|
120
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
121
|
+
if (!overwrite && fs.existsSync(file))
|
|
122
|
+
throw new Error('the file appeared meanwhile');
|
|
123
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
124
|
+
fs.writeFileSync(tmp, content, { mode: 0o600, flag: 'wx' });
|
|
125
|
+
try {
|
|
126
|
+
fs.chmodSync(tmp, 0o600);
|
|
127
|
+
fs.renameSync(tmp, file);
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
fs.rmSync(tmp, { force: true });
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** True unless the file is inside a git repository that does not ignore it. */
|
|
135
|
+
function ignoredByGit(file) {
|
|
136
|
+
const r = spawnSync('git', ['check-ignore', '-q', path.basename(file)], { cwd: path.dirname(file), stdio: 'ignore' });
|
|
137
|
+
// 0 ignored, 1 not ignored, 128 not a repository (or no git).
|
|
138
|
+
return r.status !== 1;
|
|
139
|
+
}
|
|
140
|
+
/** The folder of an organization's secret files. */
|
|
141
|
+
export const orgSecretsDir = (org) => path.join(secretsDir(), safe(org));
|
|
142
|
+
/** The record a file in the secrets folder belongs to, or null when c11r did not write it. */
|
|
143
|
+
function recordOf(text) {
|
|
144
|
+
const line = text.split('\n').find((l) => l.startsWith(RECORD_LINE));
|
|
145
|
+
try {
|
|
146
|
+
const v = line ? JSON.parse(line.slice(RECORD_LINE.length)) : JSON.parse(text).record;
|
|
147
|
+
return v && typeof v === 'object' && typeof v.noun === 'string' ? v : null;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* After a delete: the files c11r wrote in the organization's secrets folder for that record (a
|
|
155
|
+
* credential's secret and tokens, a webhook's shared secret), found by the noun and the id, name
|
|
156
|
+
* or code each file records. Files written with --out elsewhere are not tracked.
|
|
157
|
+
*/
|
|
158
|
+
export function removeRecordFiles(org, noun, keys) {
|
|
159
|
+
const dir = orgSecretsDir(org);
|
|
160
|
+
const wanted = new Set(keys.filter((k) => typeof k === 'string' && k !== ''));
|
|
161
|
+
let names;
|
|
162
|
+
try {
|
|
163
|
+
names = fs.readdirSync(dir);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
const removed = [];
|
|
169
|
+
for (const n of names) {
|
|
170
|
+
const file = path.join(dir, n);
|
|
171
|
+
let text;
|
|
172
|
+
try {
|
|
173
|
+
if (!fs.lstatSync(file).isFile())
|
|
174
|
+
continue;
|
|
175
|
+
text = fs.readFileSync(file, 'utf8');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const rec = recordOf(text);
|
|
181
|
+
if (!rec || rec.noun !== noun || ![rec.id, rec.name, rec.code].some((k) => k !== null && wanted.has(k)))
|
|
182
|
+
continue;
|
|
183
|
+
fs.rmSync(file);
|
|
184
|
+
removed.push(file);
|
|
185
|
+
}
|
|
186
|
+
if (removed.length) {
|
|
187
|
+
try {
|
|
188
|
+
fs.rmdirSync(dir);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Not empty: other records' files stay.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return removed;
|
|
195
|
+
}
|
|
196
|
+
/** After sandbox destroy: the organization's whole secrets folder. Null if there was none. */
|
|
197
|
+
export function removeOrgSecrets(org) {
|
|
198
|
+
const dir = orgSecretsDir(org);
|
|
199
|
+
if (!fs.existsSync(dir))
|
|
200
|
+
return null;
|
|
201
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
202
|
+
return dir;
|
|
203
|
+
}
|
|
204
|
+
export const OUT_NOT_TRACKED = { code: 'out_files_not_tracked', message: 'Only files in c11r\'s secrets folder (~/.config/c11r/secrets/<org>/) are removed with what they belong to. A file written with --out is not tracked: delete it where it was written.' };
|
package/dist/version.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
// The CLI's version
|
|
1
|
+
// The CLI's version, from package.json, and the User-Agent every request carries.
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
-
|
|
4
|
-
export const VERSION =
|
|
5
|
-
|
|
6
|
-
export const USER_AGENT = `c11r/${VERSION} (node/${process.versions.node.split('.')[0]})`;
|
|
7
|
-
/** The one way to run the CLI that is always current, whatever is installed. */
|
|
8
|
-
export const NPX = 'npx -y @commercelayer/agent-cli@latest';
|
|
9
|
-
export const LATEST_HINT = `${NPX} always runs the newest version, even where a c11r binary is installed.`;
|
|
3
|
+
const pkg = createRequire(import.meta.url)('../package.json');
|
|
4
|
+
export const VERSION = pkg.version;
|
|
5
|
+
export const USER_AGENT = `c11r/${VERSION} (node ${process.versions.node}; ${process.platform})`;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commercelayer/agent-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "c11r: the Commerce Layer CLI for coding agents.
|
|
3
|
+
"version": "1.0.0-dev.1",
|
|
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": [
|
|
7
7
|
"commercelayer",
|
|
@@ -24,20 +24,15 @@
|
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"build": "rm -rf dist.next && tsc -p tsconfig.json --outDir dist.next && chmod +x dist.next/bin.js
|
|
27
|
+
"build": "rm -rf dist.next && tsc -p tsconfig.json --outDir dist.next && chmod +x dist.next/bin.js && rm -rf dist.old && { mv dist dist.old 2>/dev/null; mv dist.next dist; } && rm -rf dist.old",
|
|
28
28
|
"dev": "tsx src/bin.ts",
|
|
29
|
-
"
|
|
30
|
-
"test": "npm run build && vitest run",
|
|
31
|
-
"prepublishOnly": "npm run build"
|
|
29
|
+
"test": "npm run build && vitest run"
|
|
32
30
|
},
|
|
33
31
|
"dependencies": {
|
|
34
|
-
"imapflow": "^2.10.2",
|
|
35
|
-
"postal-mime": "^4.2.0",
|
|
36
32
|
"yaml": "^2.6.0"
|
|
37
33
|
},
|
|
38
34
|
"devDependencies": {
|
|
39
35
|
"@types/node": "^22.0.0",
|
|
40
|
-
"happy-dom": "^21.0.4",
|
|
41
36
|
"tsx": "^4.19.0",
|
|
42
37
|
"typescript": "^5.6.0",
|
|
43
38
|
"vitest": "^3.0.0"
|