@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.
- package/README.md +83 -11
- package/dist/argv.js +23 -5
- package/dist/builtins/auth.js +21 -3
- package/dist/builtins/extensions.js +329 -5
- package/dist/builtins/index.js +4 -2
- package/dist/builtins/loops.js +68 -31
- package/dist/builtins/sandbox.js +45 -15
- package/dist/builtins/shop.js +504 -0
- package/dist/builtins/skills.js +355 -0
- package/dist/config.js +25 -8
- package/dist/confirm.js +17 -6
- package/dist/engine.js +192 -28
- package/dist/errors.js +6 -0
- package/dist/help.js +36 -6
- package/dist/http.js +10 -0
- package/dist/inputs.js +21 -1
- package/dist/project.js +185 -0
- package/dist/schema.js +15 -4
- package/dist/schemas.js +82 -8
- package/dist/secrets.js +25 -7
- package/dist/storefront.js +280 -0
- package/dist/ucp.js +207 -0
- package/package.json +1 -1
|
@@ -8,14 +8,22 @@
|
|
|
8
8
|
// sales-calls book answers a cancel token once (section 20): stored the same way, by booking id,
|
|
9
9
|
// and sent by sales-calls cancel <booking> without the agent ever holding it.
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
|
-
import
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import zlib from 'node:zlib';
|
|
14
|
+
import { credentialsFile, forgetOrg, forgetOutWritten, hintOf, projectDir, readLink, removeLink, storeBooking, storedBooking, storedOrgs, storeOrg, writeLink } from '../config.js';
|
|
15
|
+
import { OUT_NOT_TRACKED, removeOrgSecrets } from '../secrets.js';
|
|
12
16
|
import { confirm, terminal } from '../confirm.js';
|
|
13
17
|
import { fail } from '../errors.js';
|
|
14
18
|
import { apiUrl } from '../http.js';
|
|
19
|
+
import { clearDrift, driftWarning, readDrift, readStoreFiles, storeFileIs, writeStoreFiles } from '../project.js';
|
|
15
20
|
import { resourceRequest } from '../resources.js';
|
|
21
|
+
import { CALL_MAX, kb, lookImage, lookProblem, withShop } from '../storefront.js';
|
|
16
22
|
const NAME_MAX = 25;
|
|
17
23
|
export const organizations = {
|
|
18
24
|
async run(run) {
|
|
25
|
+
if (run.verb.name === 'disconnect')
|
|
26
|
+
return disconnect(run);
|
|
19
27
|
if (run.verb.name !== 'create' && run.verb.name !== 'connect')
|
|
20
28
|
return undefined;
|
|
21
29
|
const name = run.parsed.values.name;
|
|
@@ -25,9 +33,10 @@ export const organizations = {
|
|
|
25
33
|
const req = resourceRequest('organizations', run.verb, run.parsed.id, run.parsed.values);
|
|
26
34
|
// Ending every other token of the organization is confirmed: by the schema's confirm_if, or
|
|
27
35
|
// here for a schema without it.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
// With confirm_if the engine has it confirmed, by the human here or by the API's 428 with its
|
|
37
|
+
// sentence; only a schema without it is confirmed here, and then sends confirm=true itself.
|
|
38
|
+
if (run.verb.name === 'connect' && run.parsed.values.revoke_others && !run.globals.dryRun && !run.verb.confirmIf.includes('revoke_others')) {
|
|
39
|
+
await confirm(`Connect ${run.parsed.id} and end every other organization token of it`, run.line, { yes: run.globals.yes, prompter: run.prompter ?? terminal });
|
|
31
40
|
req.confirm = true;
|
|
32
41
|
req.body = { ...req.body, confirm: true };
|
|
33
42
|
}
|
|
@@ -69,6 +78,23 @@ export const organizations = {
|
|
|
69
78
|
return { ...r, data: { ...data, org, token_hint: hint, credentials: credentialsFile(), linked }, warnings };
|
|
70
79
|
},
|
|
71
80
|
};
|
|
81
|
+
/**
|
|
82
|
+
* organizations disconnect: the API revokes its own credentials and the organization tokens, so
|
|
83
|
+
* this machine forgets them too, as sandbox destroy does: the token, its secret files, a link
|
|
84
|
+
* naming it.
|
|
85
|
+
*/
|
|
86
|
+
async function disconnect(run) {
|
|
87
|
+
const r = await run.send(resourceRequest('organizations', run.verb, run.parsed.id, run.parsed.values));
|
|
88
|
+
if (run.globals.dryRun || r.data?.disconnected !== true)
|
|
89
|
+
return r;
|
|
90
|
+
const org = r.data.org ?? run.parsed.id;
|
|
91
|
+
const out = forgetOutWritten(org);
|
|
92
|
+
const credentials = Boolean(storedOrgs()[org]);
|
|
93
|
+
forgetOrg(org);
|
|
94
|
+
const link = removeLink(org);
|
|
95
|
+
const secrets = removeOrgSecrets(org);
|
|
96
|
+
return { ...r, data: { ...r.data, forgotten: { credentials, link, secrets } }, warnings: [...r.warnings, ...(out ? [OUT_NOT_TRACKED] : [])] };
|
|
97
|
+
}
|
|
72
98
|
export const salesCalls = {
|
|
73
99
|
positional: { book: 'slot' },
|
|
74
100
|
prepare(verb, parsed) {
|
|
@@ -104,4 +130,302 @@ export const salesCalls = {
|
|
|
104
130
|
return undefined;
|
|
105
131
|
},
|
|
106
132
|
};
|
|
107
|
-
|
|
133
|
+
// config (contract section 45.2): the API answers and applies documents; the files are the CLI's.
|
|
134
|
+
// export writes store.yml and price_lists/<code>.csv, never over a file changed since the last
|
|
135
|
+
// export without --yes, and clears the drift note.
|
|
136
|
+
// plan sends the folder's files and says what apply would change, and since when the
|
|
137
|
+
// folder is behind when Configure writes were made here.
|
|
138
|
+
// apply sends them; while imports run (202, running) it shows each noun's state, and with
|
|
139
|
+
// --wait applies again until done or failed. prune is confirmed by the schema.
|
|
140
|
+
const WAIT_MAX_MS = 15 * 60_000;
|
|
141
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
142
|
+
const waitMs = (data) => {
|
|
143
|
+
const s = Number(data?.retry_after);
|
|
144
|
+
const test = process.env.C11R_WAIT_MS;
|
|
145
|
+
if (test !== undefined)
|
|
146
|
+
return Number(test);
|
|
147
|
+
return (Number.isFinite(s) && s > 0 ? Math.min(s, 60) : 5) * 1000;
|
|
148
|
+
};
|
|
149
|
+
export const config = {
|
|
150
|
+
flags: {
|
|
151
|
+
apply: { wait: { type: 'boolean', to: 'local', description: 'While imports run, apply again every retry_after seconds until done or failed (15 minutes at most).' } },
|
|
152
|
+
},
|
|
153
|
+
prepare(verb, parsed) {
|
|
154
|
+
if (verb.name !== 'plan' && verb.name !== 'apply')
|
|
155
|
+
return;
|
|
156
|
+
if (parsed.values.store !== undefined)
|
|
157
|
+
return;
|
|
158
|
+
const files = readStoreFiles();
|
|
159
|
+
parsed.values.store = files.store;
|
|
160
|
+
parsed.values.price_lists = files.price_lists;
|
|
161
|
+
},
|
|
162
|
+
async run(run) {
|
|
163
|
+
const name = run.verb.name;
|
|
164
|
+
if (name === 'export') {
|
|
165
|
+
const r = await run.send(resourceRequest('config', run.verb, undefined, run.parsed.values));
|
|
166
|
+
if (run.globals.dryRun)
|
|
167
|
+
return r;
|
|
168
|
+
const data = r.data ?? {};
|
|
169
|
+
if (!data.store || typeof data.store !== 'object')
|
|
170
|
+
fail('invalid_response', 'config export answered without store.', { next_step: { action: 'none' } });
|
|
171
|
+
const out = writeStoreFiles({ store: data.store, price_lists: data.price_lists ?? {}, meta: { org: data.meta?.org ?? run.org.org ?? undefined, exported_at: data.meta?.exported_at } }, { overwrite: run.globals.yes, mode: run.globals.live ? 'live' : 'test' });
|
|
172
|
+
const ambiguous = Array.isArray(data.ambiguous) ? data.ambiguous : [];
|
|
173
|
+
const counts = Object.fromEntries(Object.entries(data.store).map(([k, v]) => [k, Array.isArray(v) ? v.length : 0]));
|
|
174
|
+
return {
|
|
175
|
+
...r,
|
|
176
|
+
data: {
|
|
177
|
+
org: data.meta?.org ?? run.org.org,
|
|
178
|
+
exported_at: data.meta?.exported_at ?? null,
|
|
179
|
+
folder: out.dir,
|
|
180
|
+
files: out.written,
|
|
181
|
+
removed: out.removed,
|
|
182
|
+
records: counts,
|
|
183
|
+
price_lists: Object.keys(data.price_lists ?? {}),
|
|
184
|
+
// Keys several records share (45.9): apply refuses them until they differ.
|
|
185
|
+
ambiguous,
|
|
186
|
+
},
|
|
187
|
+
next: [
|
|
188
|
+
...(ambiguous[0]?.command ? [{ command: String(ambiguous[0].command), why: 'records share a key: plan and apply cannot match them until it differs' }] : []),
|
|
189
|
+
{ command: 'c11r config plan', why: 'what the files would change in an organization, after you edit them or for another one (--org)' },
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (name === 'plan' || name === 'apply') {
|
|
194
|
+
const values = run.parsed.values;
|
|
195
|
+
let r = await run.send(resourceRequest('config', run.verb, undefined, values));
|
|
196
|
+
const started = Date.now();
|
|
197
|
+
while (name === 'apply' && values.wait && r.data?.status === 'running' && Date.now() - started < WAIT_MAX_MS) {
|
|
198
|
+
const ms = waitMs(r.data);
|
|
199
|
+
process.stderr.write(JSON.stringify({ running: { nouns: states(r.data), next_in_ms: ms } }) + '\n');
|
|
200
|
+
await sleep(ms);
|
|
201
|
+
// The same run carries on (45.9): its token goes back with the same files, so done counts
|
|
202
|
+
// everything the run wrote.
|
|
203
|
+
if (typeof r.data?.run === 'string')
|
|
204
|
+
values.run = r.data.run;
|
|
205
|
+
r = await run.send(resourceRequest('config', run.verb, undefined, values));
|
|
206
|
+
}
|
|
207
|
+
const warnings = [...r.warnings];
|
|
208
|
+
const here = storeFileIs(run.org.org, run.globals.live ? 'live' : 'test');
|
|
209
|
+
const drift = readDrift();
|
|
210
|
+
if (name === 'plan' && drift.length && here)
|
|
211
|
+
warnings.push(driftWarning(drift));
|
|
212
|
+
// The organization now matches the files: the folder is no longer behind.
|
|
213
|
+
if (name === 'apply' && !run.globals.dryRun && r.data?.status === 'done' && here)
|
|
214
|
+
clearDrift();
|
|
215
|
+
const data = r.data ?? {};
|
|
216
|
+
const running = name === 'apply' && data.status === 'running';
|
|
217
|
+
return {
|
|
218
|
+
...r,
|
|
219
|
+
data: running ? { ...data, retry_after: waitMs(data) / 1000, states: states(data) } : data,
|
|
220
|
+
warnings,
|
|
221
|
+
next: running ? [{ command: carryOn(run.line, run.verb.flags.run ? data.run : undefined), why: 'carry on with the same files, and wait until every noun is done or failed' }, ...r.next] : r.next,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* The apply that carries this run on: the same line with its --run token and --wait. Without --yes:
|
|
229
|
+
* a yes covers the run the human saw, so carrying on a prune is confirmed again.
|
|
230
|
+
*/
|
|
231
|
+
function carryOn(line, token) {
|
|
232
|
+
let out = line
|
|
233
|
+
.replace(/\s--run(=|\s+)("[^"]*"|'[^']*'|\S+)/g, '')
|
|
234
|
+
.replace(/\s--wait\b/g, '')
|
|
235
|
+
.replace(/\s(--yes|-y)(?=\s|$)/g, '');
|
|
236
|
+
if (typeof token === 'string' && token)
|
|
237
|
+
out += ` --run ${/^[\w.~:+\/=-]+$/.test(token) ? token : `'${token.replace(/'/g, `'\\''`)}'`}`;
|
|
238
|
+
return `${out} --wait`;
|
|
239
|
+
}
|
|
240
|
+
/** Each noun's state while apply runs: done, running (with its import), waiting, failed or unchanged. */
|
|
241
|
+
function states(data) {
|
|
242
|
+
const out = {};
|
|
243
|
+
for (const n of Array.isArray(data?.nouns) ? data.nouns : [])
|
|
244
|
+
out[n.noun] = n.import?.id && n.status === 'running' ? `running (import ${n.import.id})` : String(n.status);
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
// exports (45.3): get --out downloads the file while its link is good (5 minutes), unzipped unless
|
|
248
|
+
// the name ends in .gz; --wait polls a job not completed yet.
|
|
249
|
+
export const exports = {
|
|
250
|
+
flags: {
|
|
251
|
+
get: {
|
|
252
|
+
out: { type: 'string', to: 'local', description: 'Download the export to this file once completed (its link lasts 5 minutes). Unzipped, unless the name ends in .gz.' },
|
|
253
|
+
wait: { type: 'boolean', to: 'local', description: 'Until the export is completed, failed or interrupted, read it again every few seconds (15 minutes at most).' },
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
async run(run) {
|
|
257
|
+
if (run.verb.name !== 'get' || (!run.parsed.values.out && !run.parsed.values.wait))
|
|
258
|
+
return undefined;
|
|
259
|
+
const req = resourceRequest('exports', run.verb, run.parsed.id, run.parsed.values);
|
|
260
|
+
let r = await run.send(req);
|
|
261
|
+
const started = Date.now();
|
|
262
|
+
const busy = (s) => s === 'pending' || s === 'in_progress';
|
|
263
|
+
while (run.parsed.values.wait && busy(r.data?.status) && Date.now() - started < WAIT_MAX_MS) {
|
|
264
|
+
const ms = waitMs(null);
|
|
265
|
+
process.stderr.write(JSON.stringify({ waiting: { export: run.parsed.id, status: r.data?.status, next_in_ms: ms } }) + '\n');
|
|
266
|
+
await sleep(ms);
|
|
267
|
+
r = await run.send(req);
|
|
268
|
+
}
|
|
269
|
+
const out = run.parsed.values.out;
|
|
270
|
+
if (!out)
|
|
271
|
+
return r;
|
|
272
|
+
const data = r.data ?? {};
|
|
273
|
+
if (data.status !== 'completed' || typeof data.url !== 'string') {
|
|
274
|
+
fail('conflict', `Export ${run.parsed.id} is ${data.status ?? 'not completed'}: there is no file to download yet.`, { path: 'out', details: { status: data.status ?? null }, next_step: { action: 'fix_request', command: `${run.line} --wait` } }, 'rejected');
|
|
275
|
+
}
|
|
276
|
+
const file = path.resolve(out);
|
|
277
|
+
let res;
|
|
278
|
+
try {
|
|
279
|
+
res = await fetch(data.url, { signal: AbortSignal.timeout(120_000) });
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
fail('api_unreachable', `The export's file could not be downloaded: ${e.message}.`, { path: 'out', next_step: { action: 'fix_request', command: run.line } });
|
|
283
|
+
}
|
|
284
|
+
// An expired link: get the export again for a new one.
|
|
285
|
+
if (!res.ok)
|
|
286
|
+
fail('expired', `The export's link answered ${res.status}: it lasts 5 minutes. Get the export again for a new one.`, { path: 'out', next_step: { action: 'fix_request', command: run.line } }, 'rejected');
|
|
287
|
+
let bytes = Buffer.from(await res.arrayBuffer());
|
|
288
|
+
const zipped = bytes[0] === 0x1f && bytes[1] === 0x8b;
|
|
289
|
+
if (zipped && !file.endsWith('.gz'))
|
|
290
|
+
bytes = zlib.gunzipSync(bytes);
|
|
291
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
292
|
+
fs.writeFileSync(file, bytes);
|
|
293
|
+
const { url: _url, ...rest } = data;
|
|
294
|
+
return { ...r, data: { ...rest, file, bytes: bytes.length, unzipped: zipped && !file.endsWith('.gz') } };
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
/** Where a path really lands: symlinks followed, dangling ones too, and the missing rest as written. */
|
|
298
|
+
function realPath(p, hops = 0) {
|
|
299
|
+
let st = null;
|
|
300
|
+
try {
|
|
301
|
+
st = fs.lstatSync(p);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
st = null;
|
|
305
|
+
}
|
|
306
|
+
if (st?.isSymbolicLink() && hops < 40)
|
|
307
|
+
return realPath(path.resolve(realPath(path.dirname(p), hops + 1), fs.readlinkSync(p)), hops + 1);
|
|
308
|
+
if (st)
|
|
309
|
+
return fs.realpathSync(p);
|
|
310
|
+
const parent = path.dirname(p);
|
|
311
|
+
return parent === p ? p : path.join(realPath(parent, hops), path.basename(p));
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* --out resolved from here, refused (invalid_value on out) when it lands outside the project folder:
|
|
315
|
+
* by .., by an absolute path, or by a symlink on the way (the file itself or a folder above it).
|
|
316
|
+
*/
|
|
317
|
+
function insideProject(out) {
|
|
318
|
+
const file = path.resolve(out);
|
|
319
|
+
const root = fs.realpathSync(projectDir());
|
|
320
|
+
const target = realPath(file);
|
|
321
|
+
const rel = path.relative(root, target);
|
|
322
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
323
|
+
fail('invalid_value', `--out ${out} is outside the project folder ${root}: c11r writes only inside it.`, { path: 'out', details: { project: root, resolved: target } });
|
|
324
|
+
}
|
|
325
|
+
return file;
|
|
326
|
+
}
|
|
327
|
+
// scaffold (46): drop-in --out writes the snippet as one HTML file, its head part and its body part
|
|
328
|
+
// each under a comment saying where it goes. It never replaces a different file without --yes.
|
|
329
|
+
export const scaffold = {
|
|
330
|
+
flags: {
|
|
331
|
+
'drop-in': {
|
|
332
|
+
out: { type: 'string', to: 'local', description: 'Write the snippet to this HTML file: the part for the page\'s <head>, then the part for its <body>, each under a comment saying where it goes. It must be inside the project folder. A different file there is replaced only with --yes.' },
|
|
333
|
+
},
|
|
334
|
+
storefront: {
|
|
335
|
+
product_image: {
|
|
336
|
+
type: 'array',
|
|
337
|
+
items: 'string',
|
|
338
|
+
to: 'local',
|
|
339
|
+
repeat: '--product-image SKU=FILE --product-image SKU=FILE, once per sku: a local svg, png, jpg or webp file, or an https URL; SKU=null takes the image off.',
|
|
340
|
+
description: 'The image of one sku in the shop, as SKU=FILE or SKU=https://...: a local file is read here and sent with its sha256. At most 512 KB a file and 2 MB a call.',
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
async run(run) {
|
|
345
|
+
if (run.verb.name === 'storefront')
|
|
346
|
+
return storefront(run);
|
|
347
|
+
const out = run.parsed.values.out;
|
|
348
|
+
if (run.verb.name !== 'drop-in' || !out)
|
|
349
|
+
return undefined;
|
|
350
|
+
const file = insideProject(out);
|
|
351
|
+
// Before anything is sent: a different file there stays without --yes.
|
|
352
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
353
|
+
const r = await run.send(resourceRequest('scaffold', run.verb, undefined, run.parsed.values));
|
|
354
|
+
if (run.globals.dryRun)
|
|
355
|
+
return r;
|
|
356
|
+
const data = r.data ?? {};
|
|
357
|
+
const snippet = data.snippet;
|
|
358
|
+
if (!snippet || typeof snippet.head !== 'string' || typeof snippet.body !== 'string')
|
|
359
|
+
fail('invalid_response', 'scaffold drop-in answered without snippet.head and snippet.body.', { next_step: { action: 'none' } });
|
|
360
|
+
const html = [
|
|
361
|
+
`<!-- c11r scaffold drop-in${run.org.org ? ` (${run.org.org})` : ''}: this part goes in the page's <head>. -->`,
|
|
362
|
+
snippet.head.trimEnd(),
|
|
363
|
+
'',
|
|
364
|
+
`<!-- This part goes in the page's <body>, where the products show. -->`,
|
|
365
|
+
snippet.body.trimEnd(),
|
|
366
|
+
'',
|
|
367
|
+
].join('\n');
|
|
368
|
+
if (before !== null && before !== html && !run.globals.yes) {
|
|
369
|
+
fail('conflict', `${file} is there already, and differs from this snippet. c11r replaces it only with --yes.`, { path: 'out', details: { files: [file] }, next_step: { action: 'fix_request', command: null } }, 'rejected');
|
|
370
|
+
}
|
|
371
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
372
|
+
if (before !== html)
|
|
373
|
+
fs.writeFileSync(file, html);
|
|
374
|
+
const { snippet: _snippet, ...rest } = data;
|
|
375
|
+
return { ...r, data: { ...rest, file, replaced: before !== null && before !== html } };
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
/**
|
|
379
|
+
* scaffold storefront (48.3): the look checked here, the image files read into { type,
|
|
380
|
+
* content_base64, sha256 }, then one call; the answer carries the shop link.
|
|
381
|
+
*/
|
|
382
|
+
async function storefront(run) {
|
|
383
|
+
const v = { ...run.parsed.values };
|
|
384
|
+
for (const key of ['name', 'color', 'background', 'font', 'corners', 'tagline', 'badge']) {
|
|
385
|
+
if (v[key] === undefined)
|
|
386
|
+
continue;
|
|
387
|
+
const why = lookProblem(key, v[key]);
|
|
388
|
+
if (why)
|
|
389
|
+
fail('invalid_value', `--${key} ${JSON.stringify(v[key])} ${why}. Nothing was sent.`, { path: key, next_step: { action: 'fix_request', command: null } });
|
|
390
|
+
}
|
|
391
|
+
const pairs = v.product_image ?? [];
|
|
392
|
+
delete v.product_image;
|
|
393
|
+
if (pairs.length) {
|
|
394
|
+
const images = { ...(v.product_images ?? {}) };
|
|
395
|
+
for (const pair of pairs) {
|
|
396
|
+
const eq = pair.indexOf('=');
|
|
397
|
+
if (eq < 1)
|
|
398
|
+
fail('invalid_value', `--product-image ${JSON.stringify(pair)} is not SKU=FILE: the sku code, then = and a file or an https URL.`, { path: 'product_image', next_step: { action: 'fix_request', command: null } });
|
|
399
|
+
const sku = pair.slice(0, eq).trim();
|
|
400
|
+
const value = pair.slice(eq + 1).trim();
|
|
401
|
+
if (sku in images)
|
|
402
|
+
fail('invalid_value', `--product-image names ${sku} twice.`, { path: `product_images.${sku}` });
|
|
403
|
+
images[sku] = value === 'null' || value === '' ? null : value;
|
|
404
|
+
}
|
|
405
|
+
v.product_images = images;
|
|
406
|
+
}
|
|
407
|
+
let bytes = 0;
|
|
408
|
+
for (const key of ['logo', 'hero']) {
|
|
409
|
+
if (v[key] === undefined)
|
|
410
|
+
continue;
|
|
411
|
+
const r = lookImage(key, `--${key}`, v[key]);
|
|
412
|
+
v[key] = r.value;
|
|
413
|
+
bytes += r.bytes;
|
|
414
|
+
}
|
|
415
|
+
if (v.product_images && typeof v.product_images === 'object') {
|
|
416
|
+
const images = v.product_images;
|
|
417
|
+
for (const [sku, x] of Object.entries(images)) {
|
|
418
|
+
const r = lookImage(`product_images.${sku}`, `--product-image ${sku}=`, x);
|
|
419
|
+
images[sku] = r.value;
|
|
420
|
+
bytes += r.bytes;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (bytes > CALL_MAX) {
|
|
424
|
+
fail('invalid_value', `The images of this call add up to ${kb(bytes)}: one call carries at most 2 MB. Send them in more than one c11r scaffold storefront. Nothing was sent.`, { path: 'product_images', details: { bytes, max: CALL_MAX }, next_step: { action: 'fix_request', command: null } });
|
|
425
|
+
}
|
|
426
|
+
const r = await run.send(resourceRequest('scaffold', run.verb, undefined, v));
|
|
427
|
+
if (run.globals.dryRun)
|
|
428
|
+
return r;
|
|
429
|
+
return withShop(r, r.data?.store);
|
|
430
|
+
}
|
|
431
|
+
export const extensions = { organizations, 'sales-calls': salesCalls, config, exports, scaffold };
|
package/dist/builtins/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// The nouns with code of their own. Each brings a schema document in the API's format, so help,
|
|
2
2
|
// validation and confirmation are the engine's. report is a plain noun: the API's schema drives
|
|
3
|
-
// it.
|
|
3
|
+
// it.
|
|
4
4
|
import { auth } from './auth.js';
|
|
5
5
|
import { loops } from './loops.js';
|
|
6
6
|
import { sandbox, status } from './sandbox.js';
|
|
7
|
-
|
|
7
|
+
import { shop } from './shop.js';
|
|
8
|
+
import { skills } from './skills.js';
|
|
9
|
+
export const builtins = { sandbox, status, loops, auth, shop, skills };
|
|
8
10
|
export { extensions } from './extensions.js';
|
package/dist/builtins/loops.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// schedules the agent repeats it every `every`.
|
|
5
5
|
//
|
|
6
6
|
// A policy allows verbs: a bare verb is the skill's own noun (approve), <noun>.<verb> another
|
|
7
|
-
// Manage noun's verb on the same item (payments.capture).
|
|
8
|
-
// loop.verbs; the API checks
|
|
7
|
+
// Manage noun's verb on the same item (payments.capture). The skill must be one a Manage or Report
|
|
8
|
+
// noun's schema names in loop.skill or loop.skills, and each verb in that loop.verbs; the API checks
|
|
9
|
+
// again on every pass.
|
|
9
10
|
import fs from 'node:fs';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
import YAML from 'yaml';
|
|
@@ -13,15 +14,15 @@ import { projectDir } from '../config.js';
|
|
|
13
14
|
import { CliError, closest, fail, fixWith } from '../errors.js';
|
|
14
15
|
import { envelope } from '../http.js';
|
|
15
16
|
import { parseSchema } from '../schema.js';
|
|
16
|
-
import { apiSchema } from '../schemas.js';
|
|
17
|
+
import { apiNouns, apiSchema } from '../schemas.js';
|
|
17
18
|
const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
18
19
|
const EVERY = /^[1-9]\d*(m|h|d)$/;
|
|
19
20
|
const policyFlags = (required) => [
|
|
20
|
-
{ name: 'skill', type: 'string', required, description: 'The
|
|
21
|
+
{ name: 'skill', type: 'string', required, description: 'The skill the loop runs, as in manage-orders or report-sales: one a noun\'s schema names in loop.skill or loop.skills.' },
|
|
21
22
|
{ name: 'every', type: 'string', required, description: 'How often a pass should run, as in 15m, 2h or 1d. The scheduler reads it; the CLI does not wait.' },
|
|
22
|
-
{ name: 'allow', type: 'array',
|
|
23
|
-
{ name: 'max_total', type: 'array', description: 'The largest order total acted on, in major units: 500 for every currency, or EUR=500, repeated
|
|
24
|
-
{ name: 'market', type: 'array', description: 'A market code the loop acts in, repeated. Items in other markets wait. Without it, every market.' },
|
|
23
|
+
{ name: 'allow', type: 'array', description: 'A verb the policy allows, repeated: approve, payments.capture. Only verbs the noun\'s schema lists in loop.verbs. Without it the loop only reports what is waiting.', repeat: '--allow VERB --allow VERB, one verb per flag, or one JSON array.' },
|
|
24
|
+
{ name: 'max_total', type: 'array', description: 'The largest order total acted on, in major units: 500 for every currency, or EUR=500 per currency, the flag repeated or separated by commas. Above it, the item waits for the human.', repeat: '--max-total AMOUNT for every currency, or --max-total CURRENCY=AMOUNT once per currency (also separated by commas: EUR=500,USD=550), or one JSON array.' },
|
|
25
|
+
{ name: 'market', type: 'array', description: 'A market code the loop acts in, repeated. Items in other markets wait. Without it, every market.', repeat: '--market CODE --market CODE, one market code per flag, or one JSON array.' },
|
|
25
26
|
];
|
|
26
27
|
export const loops = {
|
|
27
28
|
schema: parseSchema({
|
|
@@ -32,12 +33,12 @@ export const loops = {
|
|
|
32
33
|
description: 'Loops: a skill on a schedule, under a policy written in advance. Definitions are in loops.yml in the project folder; each pass is one c11r loops run.',
|
|
33
34
|
id: { accepts: ['name'], example: 'orders-15m' },
|
|
34
35
|
verbs: [
|
|
35
|
-
{ verb: 'list', method: 'GET', state: 'none', effect: 'Lists the loops of loops.yml.' },
|
|
36
|
-
{ verb: 'get', method: 'GET', path: '/v1/loops/{id}', state: 'none', effect: 'Returns a loop from loops.yml, with its last pass when the organization has run it.' },
|
|
37
|
-
{ verb: 'create', method: 'POST', path: '/v1/loops/{id}', state: 'none', body: policyFlags(true), effect: 'Adds a loop to loops.yml, after checking its policy against the skill noun\'s schema. Nothing runs until a pass.' },
|
|
38
|
-
{ verb: 'update', method: 'PATCH', path: '/v1/loops/{id}', state: 'none', body: policyFlags(false), effect: 'Changes a loop in loops.yml. A flag given replaces what is there: --allow replaces the whole list.' },
|
|
39
|
-
{ verb: 'delete', method: 'DELETE', path: '/v1/loops/{id}', state: 'none', destructive: true, deletes: true, effect: 'Removes a loop from loops.yml, and its cursor from the organization.' },
|
|
40
|
-
{ verb: 'run', method: 'POST', path: '/v1/loops/{id}/run', effect: 'One pass: what is waiting since the last pass, and the actions the policy allows. Answers done, waiting (new: true for what appeared since the last pass) and failed. With --dry-run nothing changes and the last pass time stays.' },
|
|
36
|
+
{ verb: 'list', live: false, method: 'GET', state: 'none', effect: 'Lists the loops of loops.yml.' },
|
|
37
|
+
{ verb: 'get', live: false, method: 'GET', path: '/v1/loops/{id}', state: 'none', effect: 'Returns a loop from loops.yml, with its last pass when the organization has run it.' },
|
|
38
|
+
{ verb: 'create', live: false, method: 'POST', path: '/v1/loops/{id}', state: 'none', body: policyFlags(true), effect: 'Adds a loop to loops.yml, after checking its policy against the skill noun\'s schema. Nothing runs until a pass.' },
|
|
39
|
+
{ verb: 'update', live: false, method: 'PATCH', path: '/v1/loops/{id}', state: 'none', body: policyFlags(false), effect: 'Changes a loop in loops.yml. A flag given replaces what is there: --allow replaces the whole list.' },
|
|
40
|
+
{ verb: 'delete', live: false, method: 'DELETE', path: '/v1/loops/{id}', state: 'none', destructive: true, deletes: true, effect: 'Removes a loop from loops.yml, and its cursor from the organization.' },
|
|
41
|
+
{ verb: 'run', live: false, method: 'POST', path: '/v1/loops/{id}/run', effect: 'One pass: what is waiting since the last pass, and the actions the policy allows. Answers done, waiting (new: true for what appeared since the last pass) and failed. With --dry-run nothing changes and the last pass time stays.' },
|
|
41
42
|
],
|
|
42
43
|
}),
|
|
43
44
|
async run(run) {
|
|
@@ -77,9 +78,11 @@ export function readLoops(file) {
|
|
|
77
78
|
const out = {};
|
|
78
79
|
for (const [name, d] of Object.entries(raw.loops ?? {})) {
|
|
79
80
|
const def = d;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// allow may be left out, or empty: the loop only reports.
|
|
82
|
+
const allow = def?.policy?.allow ?? [];
|
|
83
|
+
if (!def || typeof def.skill !== 'string' || !Array.isArray(allow))
|
|
84
|
+
fail('invalid_config', `${file}: loop ${name} needs a skill, and its policy.allow is a list of verbs.`, { path: `loops.${name}` });
|
|
85
|
+
out[name] = { ...def, policy: { ...(def.policy ?? {}), allow } };
|
|
83
86
|
}
|
|
84
87
|
return out;
|
|
85
88
|
}
|
|
@@ -118,7 +121,7 @@ async function write(run, name, file) {
|
|
|
118
121
|
skill: v.skill ?? existing.skill,
|
|
119
122
|
every: v.every ?? existing.every,
|
|
120
123
|
policy: {
|
|
121
|
-
allow: v.allow ?? existing
|
|
124
|
+
allow: v.allow ?? existing?.policy.allow ?? [],
|
|
122
125
|
...(v.max_total !== undefined ? { max_total: maxTotal(v.max_total) } : existing?.policy.max_total !== undefined ? { max_total: existing.policy.max_total } : {}),
|
|
123
126
|
...(v.market !== undefined ? { markets: v.market } : existing?.policy.markets ? { markets: existing.policy.markets } : {}),
|
|
124
127
|
},
|
|
@@ -144,7 +147,9 @@ async function write(run, name, file) {
|
|
|
144
147
|
});
|
|
145
148
|
}
|
|
146
149
|
/** 500, or EUR=500 per currency. */
|
|
147
|
-
function maxTotal(
|
|
150
|
+
function maxTotal(given) {
|
|
151
|
+
// Amounts and currency codes hold no comma: EUR=500,USD=550 is two, as is the flag repeated.
|
|
152
|
+
const items = given.flatMap((i) => i.split(',')).map((i) => i.trim()).filter(Boolean);
|
|
148
153
|
const plain = items.filter((i) => !i.includes('='));
|
|
149
154
|
if (plain.length && plain.length !== items.length)
|
|
150
155
|
fail('invalid_value', '--max-total takes one amount, or one CURRENCY=amount per currency, not both.', { path: 'max_total' });
|
|
@@ -166,23 +171,55 @@ function maxTotal(items) {
|
|
|
166
171
|
return [ccy, amount(n)];
|
|
167
172
|
}));
|
|
168
173
|
}
|
|
169
|
-
/**
|
|
174
|
+
/**
|
|
175
|
+
* The skill and the policy's verbs against the same source loops run uses: each noun schema's loop,
|
|
176
|
+
* { skill, verbs }. A skill no schema's loop names is refused with the ones that are, and so is a
|
|
177
|
+
* verb its loop does not list.
|
|
178
|
+
*/
|
|
170
179
|
async function checkPolicy(def) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (!
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
+
const skills = await loopSkills();
|
|
181
|
+
const allowed = skills.get(def.skill);
|
|
182
|
+
if (!allowed) {
|
|
183
|
+
const names = [...skills.keys()].sort();
|
|
184
|
+
fail('invalid_value', `--skill ${def.skill} runs no loop. A loop runs ${names.length ? names.join(', ') : 'no skill yet'}.`, {
|
|
185
|
+
path: 'skill',
|
|
186
|
+
did_you_mean: closest(def.skill, names),
|
|
187
|
+
details: { values: names },
|
|
188
|
+
next_step: fixWith('c11r loops create --help'),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
180
191
|
for (const verb of def.policy.allow) {
|
|
181
|
-
if (!allowed.includes(verb)) {
|
|
182
|
-
fail('policy_forbidden', `A ${def.skill} loop may not allow ${verb}. It may allow ${allowed.join(', ')}
|
|
192
|
+
if (!allowed.verbs.includes(verb)) {
|
|
193
|
+
fail('policy_forbidden', `A ${def.skill} loop may not allow ${verb}. ${allowed.verbs.length ? `It may allow ${allowed.verbs.join(', ')}.` : 'It allows no verb: it only reports what is waiting, so it takes no --allow.'}`, { path: 'allow', did_you_mean: closest(verb, allowed.verbs), details: { loopable: allowed.verbs }, next_step: fixWith(`c11r schema ${allowed.noun}`) });
|
|
183
194
|
}
|
|
184
195
|
}
|
|
185
|
-
|
|
196
|
+
// Allowed and bounded by nothing: kept, and said, so the agent tells the human.
|
|
197
|
+
const open = [];
|
|
198
|
+
if (def.policy.allow.length && def.policy.max_total === undefined)
|
|
199
|
+
open.push('any order total (no --max-total)');
|
|
200
|
+
if (def.policy.allow.length && !def.policy.markets?.length)
|
|
201
|
+
open.push('every market (no --market)');
|
|
202
|
+
return open.length ? [{ code: 'no_limit', message: `This loop may ${def.policy.allow.join(', ')} for ${open.join(' and in ')}. Tell the human in one line.` }] : [];
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The skills a loop can run, from the schemas of the nouns that have a loop (Manage and Report):
|
|
206
|
+
* each skill to its noun and the verbs its policy may allow. loop.skills, when given, lists every
|
|
207
|
+
* skill of the noun's loop (a report loop per report); else loop.skill is the one.
|
|
208
|
+
*/
|
|
209
|
+
async function loopSkills() {
|
|
210
|
+
const nouns = (await apiNouns()).filter((n) => (n.group === 'manage' || n.group === 'report') && n.served !== false);
|
|
211
|
+
const schemas = await Promise.all(nouns.map((n) => apiSchema(n.noun, []).catch(() => null)));
|
|
212
|
+
const out = new Map();
|
|
213
|
+
for (const s of schemas) {
|
|
214
|
+
if (!s?.loop)
|
|
215
|
+
continue;
|
|
216
|
+
const names = Array.isArray(s.loop.skills) && s.loop.skills.length ? s.loop.skills : s.loop.skill ? [s.loop.skill] : [];
|
|
217
|
+
for (const skill of names) {
|
|
218
|
+
const seen = out.get(skill);
|
|
219
|
+
out.set(skill, { noun: seen?.noun ?? s.noun, verbs: [...new Set([...(seen?.verbs ?? []), ...(s.loop.verbs ?? [])])] });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
186
223
|
}
|
|
187
224
|
async function remove(run, name, file) {
|
|
188
225
|
one(file, name);
|