@commercelayer/agent-cli 1.0.0-dev.2 → 1.0.0-dev.22
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 +87 -13
- package/dist/argv.js +23 -5
- package/dist/builtins/auth.js +26 -5
- package/dist/builtins/extensions.js +336 -7
- package/dist/builtins/index.js +4 -2
- package/dist/builtins/loops.js +68 -31
- package/dist/builtins/sandbox.js +59 -23
- package/dist/builtins/shop.js +504 -0
- package/dist/builtins/skills.js +355 -0
- package/dist/config.js +39 -12
- package/dist/confirm.js +17 -6
- package/dist/engine.js +194 -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 +16 -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
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// The starter storefront on this machine (contract section 48): the catalogue file of a custom
|
|
2
|
+
// seed, and the look files of scaffold storefront, both checked here before anything is sent.
|
|
3
|
+
//
|
|
4
|
+
// A catalogue is YAML: name, products (name, description, prices in EUR and USD, code) and an
|
|
5
|
+
// optional look. A refusal names the field as the API would (catalogue.products[2].prices.USD) and
|
|
6
|
+
// the line of the file. A look file (--logo ./logo.svg) goes as { type, content_base64, sha256 }:
|
|
7
|
+
// svg, png, jpg or webp, by its name and its first bytes, at most 512 KB, and 2 MB in one call. An
|
|
8
|
+
// https URL goes as it is, and null takes the image off.
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import YAML, { LineCounter, isMap, isSeq } from 'yaml';
|
|
13
|
+
import { fail } from './errors.js';
|
|
14
|
+
export const SEED_TYPES = ['coffee', 'tshirts', 'games', 'custom'];
|
|
15
|
+
export const FONTS = ['classic', 'modern', 'rounded', 'mono'];
|
|
16
|
+
export const CORNERS = ['round', 'square'];
|
|
17
|
+
export const TAGLINE_MAX = 120;
|
|
18
|
+
export const BADGE_MAX = 20;
|
|
19
|
+
/** The shop's name in its title and header (scaffold storefront --name), trimmed. */
|
|
20
|
+
export const NAME_MAX = 40;
|
|
21
|
+
export const FILE_MAX = 512 * 1024;
|
|
22
|
+
export const CALL_MAX = 2 * 1024 * 1024;
|
|
23
|
+
const PRODUCTS_MAX = 24;
|
|
24
|
+
const CURRENCIES = ['EUR', 'USD'];
|
|
25
|
+
const LOOK_KEYS = ['color', 'background', 'font', 'corners', 'tagline', 'badge'];
|
|
26
|
+
/** The answer's next with the shop's entry first, and the API's own entry for the same command left out. */
|
|
27
|
+
export function withShop(r, store) {
|
|
28
|
+
const shop = shopNext(store);
|
|
29
|
+
if (!shop)
|
|
30
|
+
return r;
|
|
31
|
+
return { ...r, data: { ...r.data, shop_url: shop.url }, next: [shop.next, ...r.next.filter((n) => n.command !== shop.next.command)] };
|
|
32
|
+
}
|
|
33
|
+
/** The shop link of an answer's store, and the next entry that hands it to the human. */
|
|
34
|
+
export function shopNext(store) {
|
|
35
|
+
const host = store && typeof store === 'object' ? store.host : undefined;
|
|
36
|
+
if (typeof host !== 'string' || !host)
|
|
37
|
+
return null;
|
|
38
|
+
const given = store.url;
|
|
39
|
+
const url = typeof given === 'string' && given ? given : `https://${host}/`;
|
|
40
|
+
return { url, next: { command: 'c11r scaffold storefront', why: `The shop is at ${url}: give the human the link. Every change to its look goes through this command.` } };
|
|
41
|
+
}
|
|
42
|
+
/** A look value checked as the API checks it; a message, or null when it is right. */
|
|
43
|
+
export function lookProblem(key, v) {
|
|
44
|
+
if (v === null)
|
|
45
|
+
return null;
|
|
46
|
+
switch (key) {
|
|
47
|
+
case 'color':
|
|
48
|
+
return typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v) ? null : 'is a colour as #rrggbb';
|
|
49
|
+
case 'background':
|
|
50
|
+
return typeof v === 'string' && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v) ? null : 'is a colour as #rgb or #rrggbb';
|
|
51
|
+
case 'font':
|
|
52
|
+
return typeof v === 'string' && FONTS.includes(v) ? null : `is one of ${FONTS.join(', ')}`;
|
|
53
|
+
case 'corners':
|
|
54
|
+
return typeof v === 'string' && CORNERS.includes(v) ? null : `is one of ${CORNERS.join(', ')}`;
|
|
55
|
+
case 'name':
|
|
56
|
+
return typeof v === 'string' && v.trim().length >= 1 && [...v.trim()].length <= NAME_MAX ? null : `has 1 to ${NAME_MAX} characters${typeof v === 'string' ? `, not ${[...v.trim()].length}` : ''}`;
|
|
57
|
+
case 'tagline':
|
|
58
|
+
case 'badge': {
|
|
59
|
+
const max = key === 'tagline' ? TAGLINE_MAX : BADGE_MAX;
|
|
60
|
+
return typeof v === 'string' && v.trim().length >= 1 && [...v].length <= max ? null : `has 1 to ${max} characters${typeof v === 'string' ? `, not ${[...v].length}` : ''}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/** The catalogue file read and checked: the object the API takes as body.catalogue. */
|
|
66
|
+
export function readCatalogue(file) {
|
|
67
|
+
let text;
|
|
68
|
+
try {
|
|
69
|
+
text = fs.readFileSync(file, 'utf8');
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
fail('invalid_value', `--catalogue ${file}: ${e.code === 'ENOENT' ? 'no such file' : e.message}.`, { path: 'catalogue' });
|
|
73
|
+
}
|
|
74
|
+
const lines = new LineCounter();
|
|
75
|
+
const doc = YAML.parseDocument(text, { lineCounter: lines, prettyErrors: false });
|
|
76
|
+
if (doc.errors.length) {
|
|
77
|
+
const e = doc.errors[0];
|
|
78
|
+
const at = lines.linePos(e.pos[0]);
|
|
79
|
+
fail('invalid_value', `${file} is not YAML${at ? ` (line ${at.line})` : ''}: ${e.message.split('\n')[0]}`, { path: 'catalogue', details: { file, ...(at ? { line: at.line } : {}) } });
|
|
80
|
+
}
|
|
81
|
+
const v = doc.toJS();
|
|
82
|
+
const bad = (at, why) => {
|
|
83
|
+
const field = ['catalogue', ...at].reduce((acc, k) => (typeof k === 'number' ? `${acc}[${k}]` : acc ? `${acc}.${k}` : String(k)), '');
|
|
84
|
+
const line = lineOf(doc, lines, at);
|
|
85
|
+
fail('invalid_value', `${file}${line ? ` line ${line}` : ''}: ${field.replace(/^catalogue\.?/, '') || 'the file'} ${why}. Nothing was sent.`, {
|
|
86
|
+
path: field,
|
|
87
|
+
details: { file, ...(line ? { line } : {}) },
|
|
88
|
+
next_step: { action: 'fix_request', command: null },
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
92
|
+
bad([], 'is a mapping with name and products');
|
|
93
|
+
const c = v;
|
|
94
|
+
for (const k of Object.keys(c))
|
|
95
|
+
if (!['name', 'products', 'look'].includes(k))
|
|
96
|
+
bad([k], 'is not a catalogue field: it takes name, products and look');
|
|
97
|
+
text1(c.name, ['name'], 40, true, bad);
|
|
98
|
+
if (!Array.isArray(c.products) || !c.products.length)
|
|
99
|
+
bad(['products'], `is a list of 1 to ${PRODUCTS_MAX} products`);
|
|
100
|
+
const products = c.products;
|
|
101
|
+
if (products.length > PRODUCTS_MAX)
|
|
102
|
+
bad(['products'], `has ${products.length} products: at most ${PRODUCTS_MAX}`);
|
|
103
|
+
const names = new Map();
|
|
104
|
+
const codes = new Map();
|
|
105
|
+
products.forEach((p, i) => {
|
|
106
|
+
const at = ['products', i];
|
|
107
|
+
if (!p || typeof p !== 'object' || Array.isArray(p))
|
|
108
|
+
bad(at, 'is a product with name and prices');
|
|
109
|
+
const o = p;
|
|
110
|
+
for (const k of Object.keys(o))
|
|
111
|
+
if (!['name', 'description', 'prices', 'code'].includes(k))
|
|
112
|
+
bad([...at, k], 'is not a product field: it takes name, description, prices and code');
|
|
113
|
+
text1(o.name, [...at, 'name'], 80, true, bad);
|
|
114
|
+
const name = o.name.trim().toLowerCase();
|
|
115
|
+
if (names.has(name))
|
|
116
|
+
bad([...at, 'name'], `repeats the name of products[${names.get(name)}]: names are unique`);
|
|
117
|
+
names.set(name, i);
|
|
118
|
+
if (o.description !== undefined && o.description !== null)
|
|
119
|
+
text1(o.description, [...at, 'description'], 500, false, bad);
|
|
120
|
+
if (!o.prices || typeof o.prices !== 'object' || Array.isArray(o.prices))
|
|
121
|
+
bad([...at, 'prices'], 'has a price in EUR and one in USD, as { EUR: 18, USD: 20 }');
|
|
122
|
+
const prices = o.prices;
|
|
123
|
+
for (const k of Object.keys(prices))
|
|
124
|
+
if (!CURRENCIES.includes(k))
|
|
125
|
+
bad([...at, 'prices', k], 'is not a currency of the seed: it takes EUR and USD');
|
|
126
|
+
for (const cur of CURRENCIES) {
|
|
127
|
+
const given = prices[cur];
|
|
128
|
+
if (given === undefined)
|
|
129
|
+
bad([...at, 'prices'], `has no ${cur} price: it needs EUR and USD`);
|
|
130
|
+
// A quoted amount ("9.50") is taken as the API takes it: digits, a dot, digits.
|
|
131
|
+
const n = typeof given === 'string' && /^\d+(\.\d+)?$/.test(given.trim()) ? Number(given) : given;
|
|
132
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0 || n > 100000)
|
|
133
|
+
bad([...at, 'prices', cur], 'is an amount above 0 and at most 100000');
|
|
134
|
+
if (Math.round(n * 100) !== Number((n * 100).toFixed(6)))
|
|
135
|
+
bad([...at, 'prices', cur], 'has two decimals at most');
|
|
136
|
+
}
|
|
137
|
+
if (o.code !== undefined) {
|
|
138
|
+
if (typeof o.code !== 'string' || !/^[A-Z0-9-]{2,24}$/.test(o.code))
|
|
139
|
+
bad([...at, 'code'], 'is 2 to 24 capitals, digits and dashes');
|
|
140
|
+
if (codes.has(o.code))
|
|
141
|
+
bad([...at, 'code'], `repeats the code of products[${codes.get(o.code)}]: codes are unique`);
|
|
142
|
+
codes.set(o.code, i);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
if (c.look !== undefined && c.look !== null) {
|
|
146
|
+
if (typeof c.look !== 'object' || Array.isArray(c.look))
|
|
147
|
+
bad(['look'], `is a mapping of ${LOOK_KEYS.join(', ')}`);
|
|
148
|
+
for (const [k, x] of Object.entries(c.look)) {
|
|
149
|
+
if (!LOOK_KEYS.includes(k))
|
|
150
|
+
bad(['look', k], `is not a look field here: it takes ${LOOK_KEYS.join(', ')} (images come with c11r scaffold storefront)`);
|
|
151
|
+
const why = lookProblem(k, x);
|
|
152
|
+
if (why)
|
|
153
|
+
bad(['look', k], why);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return c;
|
|
157
|
+
}
|
|
158
|
+
function text1(v, at, max, required, bad) {
|
|
159
|
+
if (v === undefined || v === null) {
|
|
160
|
+
if (required)
|
|
161
|
+
bad(at, 'is missing');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (typeof v !== 'string')
|
|
165
|
+
bad(at, 'is text');
|
|
166
|
+
const n = [...v].length;
|
|
167
|
+
if ((required && !v.trim()) || n > max)
|
|
168
|
+
bad(at, `has ${required ? 1 : 0} to ${max} characters, not ${n}`);
|
|
169
|
+
}
|
|
170
|
+
/** The line of the field at the path: its key's line, or its item's; else the nearest parent's. */
|
|
171
|
+
function lineOf(doc, lines, at) {
|
|
172
|
+
for (let n = at.length; n > 0; n--) {
|
|
173
|
+
const parent = n === 1 ? doc.contents : doc.getIn(at.slice(0, n - 1), true);
|
|
174
|
+
const last = at[n - 1];
|
|
175
|
+
if (isMap(parent)) {
|
|
176
|
+
const pair = parent.items.find((p) => p.key?.value === last);
|
|
177
|
+
const range = pair?.key?.range;
|
|
178
|
+
if (range)
|
|
179
|
+
return lines.linePos(range[0]).line;
|
|
180
|
+
}
|
|
181
|
+
else if (isSeq(parent) && typeof last === 'number') {
|
|
182
|
+
const range = parent.items[last]?.range;
|
|
183
|
+
if (range)
|
|
184
|
+
return lines.linePos(range[0]).line;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const EXT = { '.svg': 'svg', '.png': 'png', '.jpg': 'jpg', '.jpeg': 'jpg', '.webp': 'webp' };
|
|
190
|
+
/** What the first bytes say the file is, or null. */
|
|
191
|
+
function sniff(b) {
|
|
192
|
+
if (b.length >= 8 && b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
|
193
|
+
return 'png';
|
|
194
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
|
|
195
|
+
return 'jpg';
|
|
196
|
+
if (b.length >= 12 && b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP')
|
|
197
|
+
return 'webp';
|
|
198
|
+
const head = b.subarray(0, 1024).toString('utf8').replace(/^/, '').trimStart();
|
|
199
|
+
if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg[\s>]/i.test(head))
|
|
200
|
+
return 'svg';
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* An image value as the API takes it: null as null, an https URL as it is, else a local file read
|
|
205
|
+
* into { type, content_base64, sha256 }. field names it in errors (logo, product_images.BB-ETH-250).
|
|
206
|
+
*/
|
|
207
|
+
export function lookImage(field, flag, v) {
|
|
208
|
+
if (v === null)
|
|
209
|
+
return { value: null, bytes: 0 };
|
|
210
|
+
if (typeof v !== 'string' || !v.trim())
|
|
211
|
+
fail('invalid_value', `${flag} takes a file, an https URL, or null to take the image off.`, { path: field });
|
|
212
|
+
const s = v.trim();
|
|
213
|
+
if (/^https:\/\//i.test(s))
|
|
214
|
+
return { value: s, bytes: 0 };
|
|
215
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s))
|
|
216
|
+
fail('invalid_value', `${flag} ${s}: a link is https only. Or give a local file.`, { path: field });
|
|
217
|
+
const ext = EXT[path.extname(s).toLowerCase()];
|
|
218
|
+
if (!ext)
|
|
219
|
+
fail('invalid_value', `${flag} ${s} is not an svg, png, jpg or webp file, by its name.`, { path: field, details: { types: ['svg', 'png', 'jpg', 'webp'] } });
|
|
220
|
+
let stat;
|
|
221
|
+
try {
|
|
222
|
+
stat = fs.statSync(s);
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
fail('invalid_value', `${flag} ${s}: ${e.code === 'ENOENT' ? 'no such file' : e.message}.`, { path: field });
|
|
226
|
+
}
|
|
227
|
+
if (!stat.isFile())
|
|
228
|
+
fail('invalid_value', `${flag} ${s} is not a file.`, { path: field });
|
|
229
|
+
if (stat.size > FILE_MAX)
|
|
230
|
+
fail('invalid_value', `${flag} ${s} has ${kb(stat.size)}: an image is at most 512 KB.`, { path: field, details: { bytes: stat.size, max: FILE_MAX } });
|
|
231
|
+
if (stat.size === 0)
|
|
232
|
+
fail('invalid_value', `${flag} ${s} is empty.`, { path: field });
|
|
233
|
+
const b = fs.readFileSync(s);
|
|
234
|
+
const is = sniff(b);
|
|
235
|
+
if (is !== ext)
|
|
236
|
+
fail('invalid_value', `${flag} ${s} is named ${ext}, but its first bytes ${is ? `are ${is}'s` : 'are none of svg, png, jpg or webp'}.`, { path: field });
|
|
237
|
+
if (ext === 'svg') {
|
|
238
|
+
let text = '';
|
|
239
|
+
try {
|
|
240
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(b);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
fail('invalid_value', `${flag} ${s} is not UTF-8 text, as an svg file is. Save it again as UTF-8. Nothing was sent.`, { path: field, next_step: { action: 'fix_request', command: null } });
|
|
244
|
+
}
|
|
245
|
+
const why = svgRefusal(text);
|
|
246
|
+
if (why)
|
|
247
|
+
fail('invalid_value', `${flag} ${s} would be refused by the store: ${why.reason}. ${why.fix} Nothing was sent.`, { path: field, details: { refused: why.reason }, next_step: { action: 'fix_request', command: null } });
|
|
248
|
+
}
|
|
249
|
+
return { value: { type: ext, content_base64: b.toString('base64'), sha256: crypto.createHash('sha256').update(b).digest('hex') }, bytes: b.length };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Why the store would refuse an SVG, and the way out; null when it takes it. The store's rules
|
|
253
|
+
* (its assets.ts svgRefusal), checked here so the agent learns before the upload.
|
|
254
|
+
*/
|
|
255
|
+
export function svgRefusal(text) {
|
|
256
|
+
const body = text.replace(/^\uFEFF/, '').replace(/<\?xml[\s\S]*?\?>/, '').replace(/<!--[\s\S]*?-->/g, '').trim();
|
|
257
|
+
if (/<!DOCTYPE|<!ENTITY/i.test(text))
|
|
258
|
+
return { reason: 'it declares a DOCTYPE or an entity', fix: 'Remove the DOCTYPE line (and any ENTITY) from the file.' };
|
|
259
|
+
if (!/^<svg[\s>]/i.test(body))
|
|
260
|
+
return { reason: 'it is not an <svg> document', fix: 'The file starts with <svg>, after an optional <?xml ...?> line and comments.' };
|
|
261
|
+
const embedded = body.match(/<\s*(script|foreignObject|iframe|embed|object|audio|video|handler|listener)\b/i);
|
|
262
|
+
if (embedded)
|
|
263
|
+
return { reason: `it has a <${embedded[1]}> element`, fix: `Remove the <${embedded[1]}> element: a shop image has no script or embedded content.` };
|
|
264
|
+
const event = body.match(/\s(on[a-z]+)\s*=/i);
|
|
265
|
+
if (event)
|
|
266
|
+
return { reason: `it has the event attribute ${event[1]}`, fix: 'Remove every on... attribute.' };
|
|
267
|
+
if (/javascript:|vbscript:/i.test(body))
|
|
268
|
+
return { reason: 'it has a script URL', fix: 'Remove every javascript: or vbscript: link.' };
|
|
269
|
+
for (const m of body.matchAll(/\b(?:xlink:)?href\s*=\s*(["'])(.*?)\1/gi))
|
|
270
|
+
if (!allowedRef(m[2]))
|
|
271
|
+
return { reason: `it links to ${m[2].slice(0, 60)}`, fix: 'Keep only links inside the file (#id) or images embedded as data:image/...;base64.' };
|
|
272
|
+
for (const m of body.matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/gi))
|
|
273
|
+
if (!allowedRef(m[2]))
|
|
274
|
+
return { reason: `it loads ${m[2].slice(0, 60)}`, fix: 'Keep only url(#id) or images embedded as data:image/...;base64.' };
|
|
275
|
+
if (/@import/i.test(body))
|
|
276
|
+
return { reason: 'it imports a stylesheet', fix: 'Remove the @import: put the styles in the file.' };
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const allowedRef = (v) => /^#/.test(v.trim()) || /^data:image\/(png|jpe?g|webp|gif);base64,/i.test(v.trim());
|
|
280
|
+
export const kb = (n) => `${Math.ceil(n / 1024)} KB`;
|
package/dist/ucp.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// A UCP platform client: what any shopping agent does to buy from a store that speaks UCP.
|
|
2
|
+
// The CLI talks to the store directly, as the agent CLI platform: its profile is the static
|
|
3
|
+
// https://commercelayer.ai/ucp/agent-cli/profile.json, it holds no keys, and stores verify no
|
|
4
|
+
// request signature. Every call carries UCP-Agent and Request-Id, a Content-Digest with a body,
|
|
5
|
+
// and an Idempotency-Key on writes. The store signs its answers (RFC 9421, ES256 over @status,
|
|
6
|
+
// content-digest and content-type): the CLI checks them against keys[] of its /.well-known/ucp.
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { CliError, fail } from './errors.js';
|
|
9
|
+
import { USER_AGENT } from './version.js';
|
|
10
|
+
export const UCP_VERSION = '2026-08-25';
|
|
11
|
+
export const AGENT_PROFILE_URL = 'https://commercelayer.ai/ucp/agent-cli/profile.json';
|
|
12
|
+
/** The sandbox payment handler: no money moves. The only one this CLI pays with. */
|
|
13
|
+
export const SANDBOX_HANDLER = 'ai.commercelayer.sandbox';
|
|
14
|
+
const SANDBOX_HANDLER_ID = 'cl_sandbox';
|
|
15
|
+
const SANDBOX_CREDENTIAL_TYPE = 'sandbox_token';
|
|
16
|
+
const SANDBOX_TOKEN_SUCCESS = 'sbx_tok_success';
|
|
17
|
+
const TIMEOUT_MS = 30_000;
|
|
18
|
+
/** A store from a host or a URL on it: https only (http for localhost, in tests). */
|
|
19
|
+
export function originOf(given) {
|
|
20
|
+
let url = null;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(given) ? given : `https://${given}`);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
url = null;
|
|
26
|
+
}
|
|
27
|
+
if (!url || !url.hostname)
|
|
28
|
+
fail('invalid_value', `${given} is not a store: give its host, as in shop.example.com, or an https URL on it.`, { path: 'id' });
|
|
29
|
+
const local = ['localhost', '127.0.0.1'].includes(url.hostname);
|
|
30
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local))
|
|
31
|
+
fail('invalid_value', `${given} is not https: UCP stores are served over https.`, { path: 'id' });
|
|
32
|
+
return url.origin;
|
|
33
|
+
}
|
|
34
|
+
/** GET /.well-known/ucp: what the store offers this CLI. */
|
|
35
|
+
export async function discover(origin) {
|
|
36
|
+
const res = await http('GET', `${origin}/.well-known/ucp`, { accept: 'application/json' });
|
|
37
|
+
const host = new URL(origin).host;
|
|
38
|
+
if (res.status === 404)
|
|
39
|
+
fail('not_found', `${host} serves no UCP profile (/.well-known/ucp answered 404): it does not sell to agents.`, { path: 'id', next_step: { action: 'human_step', url: origin } });
|
|
40
|
+
if (res.status !== 200)
|
|
41
|
+
fail('store_unavailable', `${host}/.well-known/ucp answered ${res.status}.`, { details: { status: res.status }, next_step: { action: 'retry_later' } });
|
|
42
|
+
const profile = parse(res.body);
|
|
43
|
+
if (!profile || typeof profile.ucp !== 'object')
|
|
44
|
+
fail('invalid_response', `${host}/.well-known/ucp is not a UCP profile.`, { next_step: { action: 'none' } });
|
|
45
|
+
const ucp = profile.ucp;
|
|
46
|
+
if (ucp.version !== UCP_VERSION)
|
|
47
|
+
fail('not_supported', `${host} speaks UCP ${ucp.version ?? '(no version)'}; this CLI speaks ${UCP_VERSION}.`, { details: { store_version: ucp.version ?? null, cli_version: UCP_VERSION }, next_step: { action: 'none' } });
|
|
48
|
+
const rest = (name) => (ucp.services?.[name] ?? []).find((s) => s.transport === 'rest')?.endpoint ?? null;
|
|
49
|
+
const endpoint = rest('dev.ucp.shopping');
|
|
50
|
+
if (!endpoint)
|
|
51
|
+
fail('not_supported', `${host}'s profile has no REST dev.ucp.shopping service.`, { next_step: { action: 'none' } });
|
|
52
|
+
const capabilities = new Set();
|
|
53
|
+
for (const [name, entries] of Object.entries((ucp.capabilities ?? {})))
|
|
54
|
+
if (Array.isArray(entries) && entries.some((e) => e.version === UCP_VERSION))
|
|
55
|
+
capabilities.add(name);
|
|
56
|
+
return { origin, host, profile, endpoint, commonEndpoint: rest('dev.ucp.common'), capabilities, keys: Array.isArray(profile.keys) ? profile.keys : [] };
|
|
57
|
+
}
|
|
58
|
+
/** The payment handlers the store offers, by name, and whether this CLI can pay there. */
|
|
59
|
+
export function handlersOf(store) {
|
|
60
|
+
const handlers = Object.keys(store.profile.ucp?.payment_handlers ?? {});
|
|
61
|
+
return { handlers, can_pay: handlers.includes(SANDBOX_HANDLER) && store.capabilities.has('dev.ucp.shopping.checkout') };
|
|
62
|
+
}
|
|
63
|
+
/** Before anything of the human's is sent: a store this CLI cannot pay at is refused. */
|
|
64
|
+
export function mustPay(store, extra = {}) {
|
|
65
|
+
if (!store.capabilities.has('dev.ucp.shopping.checkout')) {
|
|
66
|
+
fail('not_supported', `${store.host} offers no UCP checkout: an agent can browse it, not buy there.`, { next_step: { action: 'human_step', url: store.origin } });
|
|
67
|
+
}
|
|
68
|
+
const { handlers } = handlersOf(store);
|
|
69
|
+
if (!handlers.includes(SANDBOX_HANDLER)) {
|
|
70
|
+
fail('payment_handler_unsupported', `${store.host} takes ${handlers.length ? handlers.join(', ') : 'no payment handler'}; this CLI pays only with the ${SANDBOX_HANDLER} test handler, where no money moves. The human buys there in person.`, { details: { handlers }, next_step: { action: 'human_step', url: extra.continue_url ?? store.origin } }, 'rejected');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export const sandboxPayment = () => ({ payment: { instruments: [{ id: 'pi_1', handler_id: SANDBOX_HANDLER_ID, type: 'card', selected: true, credential: { type: SANDBOX_CREDENTIAL_TYPE, token: SANDBOX_TOKEN_SUCCESS } }] } });
|
|
74
|
+
/** A UCP call. The answer's digest and signature are checked; an error is a CliError. */
|
|
75
|
+
export async function call(store, method, url, body, what = 'the store') {
|
|
76
|
+
const raw = body === undefined ? undefined : JSON.stringify(body);
|
|
77
|
+
const headers = { accept: 'application/json', 'ucp-agent': `profile="${AGENT_PROFILE_URL}"`, 'request-id': crypto.randomUUID() };
|
|
78
|
+
if (raw !== undefined) {
|
|
79
|
+
headers['content-type'] = 'application/json';
|
|
80
|
+
headers['content-digest'] = contentDigest(raw);
|
|
81
|
+
}
|
|
82
|
+
if (method !== 'GET' && !/\/catalog\/|\/ask$/.test(url))
|
|
83
|
+
headers['idempotency-key'] = crypto.randomUUID();
|
|
84
|
+
const res = await http(method, url, headers, raw);
|
|
85
|
+
const signature = checkAnswer(store, res);
|
|
86
|
+
const parsed = parse(res.body);
|
|
87
|
+
if (res.status >= 400 || parsed?.ucp?.status === 'error')
|
|
88
|
+
refused(store, res.status, parsed, what);
|
|
89
|
+
if (!parsed || typeof parsed !== 'object')
|
|
90
|
+
fail('invalid_response', `${store.host} answered ${what} with no JSON.`, { details: { status: res.status }, next_step: { action: 'retry_later' } });
|
|
91
|
+
return { status: res.status, body: parsed, signature };
|
|
92
|
+
}
|
|
93
|
+
/** The store's own words, as an error the agent can show the human. */
|
|
94
|
+
function refused(store, status, body, what) {
|
|
95
|
+
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
96
|
+
const first = messages.find((m) => m.type === 'error') ?? messages[0];
|
|
97
|
+
const code = body?.code ?? first?.code;
|
|
98
|
+
const said = body?.content ?? first?.content ?? `it answered ${status}`;
|
|
99
|
+
const details = { status, store_code: code ?? null, messages: messages.length ? messages : undefined };
|
|
100
|
+
if (status === 404 || code === 'not_found')
|
|
101
|
+
fail('not_found', `${store.host} does not know ${what}: ${said}`, { details, next_step: { action: 'none' } });
|
|
102
|
+
if (status === 401 && /signature/i.test(`${code} ${said}`)) {
|
|
103
|
+
fail('not_supported', `${store.host} wants a signed request, and this CLI signs none: ${said}`, { details, next_step: { action: 'human_step', url: store.origin } });
|
|
104
|
+
}
|
|
105
|
+
if (status === 401 || code === 'identity_required')
|
|
106
|
+
fail('forbidden', `${store.host} refused ${what}: ${said} This CLI reads only orders it placed.`, { details, next_step: { action: 'none' } });
|
|
107
|
+
if (status === 429 || status >= 500)
|
|
108
|
+
fail('store_unavailable', `${store.host} could not answer ${what}: ${said}`, { details, next_step: { action: 'retry_later' } });
|
|
109
|
+
fail('store_refused', `${store.host} refused ${what}: ${said}`, { details, next_step: { action: 'human_step' } }, 'rejected');
|
|
110
|
+
}
|
|
111
|
+
async function http(method, url, headers, body) {
|
|
112
|
+
let res;
|
|
113
|
+
try {
|
|
114
|
+
res = await fetch(url, { method, headers: { 'user-agent': USER_AGENT, ...headers }, body, redirect: 'follow', signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
const host = (() => {
|
|
118
|
+
try {
|
|
119
|
+
return new URL(url).host;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return url;
|
|
123
|
+
}
|
|
124
|
+
})();
|
|
125
|
+
throw new CliError({ code: 'store_unavailable', message: `${host} could not be reached: ${e.message}.`, required_state: null, required_plan: null, missing: null, path: null, next_step: { action: 'retry_later', command: null, url: null } }, 'unavailable');
|
|
126
|
+
}
|
|
127
|
+
const out = {};
|
|
128
|
+
res.headers.forEach((v, k) => (out[k.toLowerCase()] = v));
|
|
129
|
+
return { status: res.status, headers: out, body: await res.text() };
|
|
130
|
+
}
|
|
131
|
+
function parse(text) {
|
|
132
|
+
if (!text)
|
|
133
|
+
return null;
|
|
134
|
+
try {
|
|
135
|
+
const v = JSON.parse(text);
|
|
136
|
+
return v && typeof v === 'object' ? v : null;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// ---------- digests and signatures (RFC 9530, RFC 9421)
|
|
143
|
+
export function contentDigest(body) {
|
|
144
|
+
return `sha-256=:${crypto.createHash('sha256').update(body).digest('base64')}:`;
|
|
145
|
+
}
|
|
146
|
+
function serializeParams(p) {
|
|
147
|
+
let s = `(${p.components.map((c) => `"${c}"`).join(' ')})`;
|
|
148
|
+
if (p.created !== undefined)
|
|
149
|
+
s += `;created=${p.created}`;
|
|
150
|
+
return `${s};keyid="${p.keyid}"`;
|
|
151
|
+
}
|
|
152
|
+
export function signatureBase(values, p) {
|
|
153
|
+
return [...p.components.map((c) => `"${c}": ${values[c] ?? ''}`), `"@signature-params": ${serializeParams(p)}`].join('\n');
|
|
154
|
+
}
|
|
155
|
+
function parseSignature(input, signature) {
|
|
156
|
+
if (!input || !signature)
|
|
157
|
+
return null;
|
|
158
|
+
const m = input.match(/^\s*([a-z0-9_-]+)=\(([^)]*)\)((?:;[a-z]+=[^;]*)*)\s*$/i);
|
|
159
|
+
if (!m)
|
|
160
|
+
return null;
|
|
161
|
+
const [, label, list, rest] = m;
|
|
162
|
+
const params = { components: [...list.matchAll(/"([^"]+)"/g)].map((x) => x[1]), keyid: '' };
|
|
163
|
+
for (const [, k, v] of rest.matchAll(/;([a-z]+)=("[^"]*"|[^;]*)/g)) {
|
|
164
|
+
if (k === 'keyid')
|
|
165
|
+
params.keyid = v.replace(/^"|"$/g, '');
|
|
166
|
+
if (k === 'created')
|
|
167
|
+
params.created = Number(v);
|
|
168
|
+
}
|
|
169
|
+
const sig = signature.match(new RegExp(`${label}=:([A-Za-z0-9+/=]+):`));
|
|
170
|
+
return sig ? { params, signature: sig[1] } : null;
|
|
171
|
+
}
|
|
172
|
+
function verify(base, signature, jwk) {
|
|
173
|
+
try {
|
|
174
|
+
const { kid: _k, use: _u, alg: _a, key_ops: _o, ...key } = jwk;
|
|
175
|
+
return crypto.verify('sha256', Buffer.from(base), { key: crypto.createPublicKey({ key: key, format: 'jwk' }), dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64'));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** A digest that does not match, or a signature that does not verify: the answer is not trusted. */
|
|
182
|
+
function checkAnswer(store, res) {
|
|
183
|
+
const digest = res.headers['content-digest'];
|
|
184
|
+
if (digest && digest !== contentDigest(res.body))
|
|
185
|
+
fail('invalid_response', `${store.host}'s answer does not match its Content-Digest: it was changed on the way, and is not used.`, { next_step: { action: 'retry_later' } });
|
|
186
|
+
const sig = parseSignature(res.headers['signature-input'], res.headers.signature);
|
|
187
|
+
if (!sig)
|
|
188
|
+
return 'unsigned';
|
|
189
|
+
const key = store.keys.find((k) => k.kid === sig.params.keyid);
|
|
190
|
+
const values = { '@status': String(res.status), 'content-digest': digest ?? '', 'content-type': res.headers['content-type'] ?? '' };
|
|
191
|
+
if (!key || !verify(signatureBase(values, sig.params), sig.signature, key)) {
|
|
192
|
+
fail('invalid_response', `${store.host}'s answer is signed with ${key ? 'a signature that does not verify' : `a key its profile does not publish (${sig.params.keyid})`}: it is not used.`, { next_step: { action: 'none' } });
|
|
193
|
+
}
|
|
194
|
+
return 'verified';
|
|
195
|
+
}
|
|
196
|
+
export function money(amount, currency) {
|
|
197
|
+
let digits = 2;
|
|
198
|
+
try {
|
|
199
|
+
digits = new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits ?? 2;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
digits = 2;
|
|
203
|
+
}
|
|
204
|
+
const major = amount / 10 ** digits;
|
|
205
|
+
return { amount, currency, display: `${amount < 0 ? '-' : ''}${currency} ${Math.abs(major).toFixed(digits)}` };
|
|
206
|
+
}
|
|
207
|
+
export const minorOf = (major, currency) => Math.round(major * 10 ** (money(0, currency).display.split('.')[1]?.length ?? 0));
|
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.22",
|
|
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": [
|