@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.
Files changed (68) hide show
  1. package/README.md +98 -215
  2. package/dist/argv.js +227 -0
  3. package/dist/bin.js +15 -178
  4. package/dist/builtins/auth.js +169 -0
  5. package/dist/builtins/extensions.js +107 -0
  6. package/dist/builtins/index.js +8 -0
  7. package/dist/builtins/loops.js +215 -0
  8. package/dist/builtins/sandbox.js +186 -0
  9. package/dist/config.js +238 -0
  10. package/dist/confirm.js +56 -0
  11. package/dist/engine.js +205 -0
  12. package/dist/errors.js +146 -0
  13. package/dist/help.js +81 -0
  14. package/dist/http.js +117 -0
  15. package/dist/inputs.js +112 -0
  16. package/dist/login.js +136 -0
  17. package/dist/resources.js +42 -0
  18. package/dist/schema.js +182 -0
  19. package/dist/schemas.js +72 -0
  20. package/dist/secrets.js +204 -0
  21. package/dist/version.js +4 -8
  22. package/package.json +4 -9
  23. package/dist/api/client.js +0 -190
  24. package/dist/api/server.js +0 -26
  25. package/dist/api/stub/db.js +0 -37
  26. package/dist/api/stub/http.js +0 -18
  27. package/dist/api/stub/index.js +0 -52
  28. package/dist/api/stub/locale.js +0 -52
  29. package/dist/api/stub/management.js +0 -409
  30. package/dist/api/stub/messages.js +0 -126
  31. package/dist/api/stub/orders.js +0 -625
  32. package/dist/api/stub/search.js +0 -78
  33. package/dist/api/stub/shopping.js +0 -673
  34. package/dist/api/stub/theme.js +0 -197
  35. package/dist/api/stub/ucp.js +0 -377
  36. package/dist/api/transport.js +0 -57
  37. package/dist/api/types.js +0 -4
  38. package/dist/assets.js +0 -100
  39. package/dist/catalog.js +0 -372
  40. package/dist/check.js +0 -187
  41. package/dist/commands.js +0 -1241
  42. package/dist/cursor.js +0 -31
  43. package/dist/files.js +0 -98
  44. package/dist/inbox/cursor.js +0 -11
  45. package/dist/inbox/fake.js +0 -95
  46. package/dist/inbox/imap.js +0 -139
  47. package/dist/inbox/mailbox.js +0 -25
  48. package/dist/inbox/message.js +0 -88
  49. package/dist/inspect.js +0 -237
  50. package/dist/languages.js +0 -49
  51. package/dist/links.js +0 -118
  52. package/dist/locales.js +0 -43
  53. package/dist/ops.js +0 -190
  54. package/dist/orderRequest.js +0 -141
  55. package/dist/output.js +0 -39
  56. package/dist/products.js +0 -151
  57. package/dist/promotions.js +0 -291
  58. package/dist/store.js +0 -379
  59. package/dist/storefront.js +0 -285
  60. package/dist/ucp/buy.js +0 -90
  61. package/dist/ucp/check.js +0 -362
  62. package/dist/ucp/client.js +0 -129
  63. package/dist/ucp/images.js +0 -62
  64. package/dist/ucp/platform.js +0 -90
  65. package/dist/ucp/shop.js +0 -357
  66. package/dist/ucp/signing.js +0 -67
  67. package/dist/web.js +0 -316
  68. package/dist/website.js +0 -390
package/dist/ucp/check.js DELETED
@@ -1,362 +0,0 @@
1
- // Walks every UCP capability a store advertises and reports what passes. This is
2
- // what the ucp skill's "done when" uses: a store that passes can be bought from
3
- // by any shopping agent, not only by this CLI.
4
- import crypto from 'node:crypto';
5
- import { send } from '../api/transport.js';
6
- import { sandboxInstrument, TEST_BUYER, testAddress } from './buy.js';
7
- import { discover, replay, request, shopping } from './client.js';
8
- import { EXTENSIONS, SHOPPING_CAPABILITIES, UCP_VERSION } from './platform.js';
9
- /** One nonsense word, no space or hyphen: the store's search splits a query into words, and "no" or "product" match real descriptions. */
10
- export const UNMATCHED_QUERY = 'qxzjvwkq';
11
- class Fail extends Error {
12
- }
13
- const must = (cond, detail) => {
14
- if (!cond)
15
- throw new Fail(detail);
16
- };
17
- const sum = (totals) => totals.filter((t) => t.type !== 'total').reduce((s, t) => s + t.amount, 0);
18
- const total = (totals) => totals.find((t) => t.type === 'total')?.amount;
19
- export async function check(origin) {
20
- const checks = [];
21
- let store = null;
22
- const run = async (capability, name, fn) => {
23
- try {
24
- checks.push({ capability, name, pass: true, detail: await fn() });
25
- }
26
- catch (e) {
27
- checks.push({ capability, name, pass: false, detail: e instanceof Error ? e.message : String(e) });
28
- }
29
- };
30
- // ---- profile
31
- await run('profile', 'served at /.well-known/ucp', async () => {
32
- const res = await send('GET', `${origin}/.well-known/ucp`, { accept: 'application/json' });
33
- must(res.status === 200, `answered ${res.status}`);
34
- must(/public/.test(res.headers['cache-control'] ?? '') && /max-age=(\d+)/.test(res.headers['cache-control'] ?? '') && Number(res.headers['cache-control'].match(/max-age=(\d+)/)[1]) >= 60, `Cache-Control must be public with max-age >= 60; got "${res.headers['cache-control']}"`);
35
- const p = JSON.parse(res.body);
36
- must(/^\d{4}-\d{2}-\d{2}$/.test(p.ucp?.version), `ucp.version must be a date; got ${p.ucp?.version}`);
37
- must(p.ucp.services && p.ucp.payment_handlers, 'ucp.services and ucp.payment_handlers must be present');
38
- for (const [name, entries] of Object.entries({ ...p.ucp.services, ...p.ucp.capabilities })) {
39
- if (!name.startsWith('dev.ucp.'))
40
- continue;
41
- for (const e of entries)
42
- must(e.version === p.ucp.version, `${name} declares version ${e.version}, profile is ${p.ucp.version}`);
43
- }
44
- must(Array.isArray(p.keys) && p.keys.some((k) => k.kid && k.kty === 'EC' && k.crv === 'P-256' && !k.d), 'keys[] must hold a public P-256 key with a kid and no private members');
45
- return `UCP ${p.ucp.version}, ${Object.keys(p.ucp.capabilities ?? {}).length} capabilities, ${Object.keys(p.ucp.payment_handlers).length} payment handler(s)`;
46
- });
47
- await run('profile', 'negotiates with this CLI', async () => {
48
- store = await discover(origin);
49
- const missing = SHOPPING_CAPABILITIES.filter((c) => !store.capabilities.has(c));
50
- must(missing.length === 0, `not shared: ${missing.join(', ')}`);
51
- const exts = Object.keys(EXTENSIONS).filter((e) => store.capabilities.has(e));
52
- return `REST at ${store.endpoint}; extensions: ${exts.map((e) => e.split('.').pop()).join(', ') || 'none'}`;
53
- });
54
- if (!store)
55
- return finish(origin, checks);
56
- const s = store;
57
- // ---- transport rules
58
- await run('transport', 'rejects a request without UCP-Agent', async () => {
59
- const c = await request(s, 'POST', `${s.endpoint}/catalog/search`, { query: '' }, { omit: ['ucp-agent'] });
60
- must(c.status === 400 && c.body?.code === 'invalid_profile_url', `expected 400 invalid_profile_url, got ${c.status} ${c.body?.code ?? ''}`);
61
- return '400 invalid_profile_url';
62
- });
63
- await run('transport', 'rejects a request without Request-Id', async () => {
64
- const c = await request(s, 'POST', `${s.endpoint}/catalog/search`, { query: '' }, { omit: ['request-id'] });
65
- must(c.status === 400, `expected 400, got ${c.status}`);
66
- return `400 ${c.body?.code ?? ''}`;
67
- });
68
- await run('transport', 'requires Idempotency-Key on state changes', async () => {
69
- const c = await request(s, 'POST', `${s.endpoint}/carts`, { line_items: [] }, { omit: ['idempotency-key'] });
70
- must(c.status === 400, `expected 400, got ${c.status}`);
71
- return `400 ${c.body?.code ?? ''}`;
72
- });
73
- await run('transport', 'verifies Content-Digest', async () => {
74
- const res = await send('POST', `${s.endpoint}/catalog/search`, { 'ucp-agent': 'profile="https://example.com/p"', 'request-id': crypto.randomUUID(), 'content-digest': 'sha-256=:AAAA:', 'content-type': 'application/json' }, '{}');
75
- must(res.status === 400, `expected 400 digest_mismatch, got ${res.status}`);
76
- return '400 digest_mismatch';
77
- });
78
- // ---- catalog
79
- let variant = null;
80
- let country = 'US';
81
- await run('dev.ucp.shopping.catalog.search', 'search returns well-formed products', async () => {
82
- const c = await shopping.search(s, { query: '' });
83
- must(c.body.ucp?.version === UCP_VERSION && c.body.ucp.capabilities?.['dev.ucp.shopping.catalog.search'], 'ucp envelope must carry version and the search capability');
84
- must(Array.isArray(c.body.products) && c.body.products.length > 0, 'no products; apply a store with products first');
85
- must(c.body.pagination?.has_next_page !== undefined, 'pagination.has_next_page is required');
86
- for (const p of c.body.products) {
87
- must(p.id && p.title && p.description?.plain && p.price_range?.min?.amount !== undefined && p.variants?.length, `product ${p.id ?? '?'} misses a required field`);
88
- must(Number.isInteger(p.variants[0].price?.amount) && /^[A-Z]{3}$/.test(p.variants[0].price?.currency), `variant ${p.variants[0].id} price must be integer minor units with a currency`);
89
- }
90
- variant = c.body.products[0].variants[0];
91
- const m = String(variant.fulfillment?.methods?.[0]?.description?.plain ?? '').match(/Ships to ([A-Z]{2})/);
92
- if (m)
93
- country = m[1];
94
- return `${c.body.products.length} products, first variant ${variant.id} at ${variant.price.amount} ${variant.price.currency}`;
95
- });
96
- if (!variant)
97
- return finish(origin, checks);
98
- const v = variant;
99
- await run('dev.ucp.shopping.catalog.search', 'search filters by query and price', async () => {
100
- const c = await shopping.search(s, { query: UNMATCHED_QUERY });
101
- must(c.body.products.length === 0, 'an unmatched query must return no products');
102
- const p = await shopping.search(s, { filters: { price: { max: v.price.amount } }, context: { currency: v.price.currency } });
103
- must(p.body.products.some((x) => x.variants[0].id === v.id), 'a price filter including the variant must return it');
104
- return 'query and price filters apply';
105
- });
106
- await run('dev.ucp.shopping.catalog.lookup', 'lookup by variant id carries inputs', async () => {
107
- const c = await shopping.lookup(s, { ids: [v.id, v.id, 'no-such-id'] });
108
- const found = c.body.products.flatMap((p) => p.variants).find((x) => x.id === v.id);
109
- must(found, `variant ${v.id} not returned`);
110
- must(found.inputs?.some((i) => i.id === v.id && i.match === 'exact'), 'returned variant must list inputs with match exact');
111
- must(c.body.products.filter((p) => p.variants.some((x) => x.id === v.id)).length === 1, 'duplicate ids must be deduplicated');
112
- return `inputs ok, unknown id reported as ${c.body.messages?.[0]?.code ?? 'nothing'}`;
113
- });
114
- await run('dev.ucp.shopping.catalog.lookup', 'get product by id, not_found for unknown', async () => {
115
- const c = await shopping.product(s, { id: v.id });
116
- must(c.body.product?.variants?.[0]?.id === v.id, 'product must be returned with the variant first');
117
- const miss = await request(s, 'POST', `${s.endpoint}/catalog/product`, { id: 'no-such-id' });
118
- must(miss.status === 200 && miss.body?.ucp?.status === 'error' && miss.body.messages?.[0]?.code === 'not_found', 'unknown id must be a 200 error_response with not_found');
119
- return 'ok';
120
- });
121
- // ---- cart
122
- const context = { address_country: country, currency: v.price.currency };
123
- let cartId = null;
124
- await run('dev.ucp.shopping.cart', 'create, get, update, totals', async () => {
125
- const c = await shopping.createCart(s, { line_items: [{ item: { id: v.id }, quantity: 1 }], context });
126
- must(c.status === 201, `create must answer 201, got ${c.status}`);
127
- const cart = c.body;
128
- must(cart.id && cart.currency && cart.line_items?.length === 1 && cart.totals, 'cart misses id, currency, line_items or totals');
129
- must(cart.line_items[0].item.price === v.price.amount, 'line item price must match the catalog');
130
- must(total(cart.totals) === sum(cart.totals), 'totals: sum of non-total entries must equal total');
131
- cartId = cart.id;
132
- const got = await shopping.getCart(s, cart.id);
133
- must(got.body.id === cart.id, 'get cart must return it');
134
- const up = await shopping.updateCart(s, cart.id, { line_items: [{ id: cart.line_items[0].id, item: { id: v.id }, quantity: 2 }], context });
135
- must(up.body.line_items[0].quantity === 2 && total(up.body.totals) === v.price.amount * 2, 'update must replace lines and recompute totals');
136
- return `cart ${cart.id}, total ${total(up.body.totals)} ${cart.currency} for 2`;
137
- });
138
- if (s.capabilities.has('dev.ucp.shopping.discount')) {
139
- await run('dev.ucp.shopping.discount', 'codes apply and invalid codes warn', async () => {
140
- const c = await shopping.createCart(s, { line_items: [{ item: { id: v.id }, quantity: 1 }], context, discounts: { codes: ['SANDBOX10', 'NOPE'] } });
141
- const cart = c.body;
142
- must(cart.discounts?.applied?.length === 1 && cart.discounts.applied[0].amount > 0, 'SANDBOX10 must be applied with a positive amount');
143
- must(cart.totals.some((t) => t.type === 'discount' && t.amount < 0), 'a negative discount total must appear');
144
- must(cart.messages?.some((m) => m.type === 'warning' && m.code === 'discount_code_invalid' && m.path === '$.discounts.codes[1]'), 'NOPE must produce a discount_code_invalid warning at its path');
145
- must(total(cart.totals) === sum(cart.totals), 'totals identity must hold with discounts');
146
- return `${cart.discounts.applied[0].title}: ${cart.discounts.applied[0].amount} off`;
147
- });
148
- }
149
- await run('dev.ucp.shopping.cart', 'cancel returns the cart, then not_found', async () => {
150
- const c = await shopping.createCart(s, { line_items: [{ item: { id: v.id }, quantity: 1 }], context });
151
- const canceled = await shopping.cancelCart(s, c.body.id);
152
- must(canceled.body.id === c.body.id, 'cancel must return the cart');
153
- const gone = await request(s, 'GET', `${s.endpoint}/carts/${c.body.id}`);
154
- must(gone.body?.ucp?.status === 'error' && gone.body.messages?.[0]?.code === 'not_found', 'a canceled cart must be not_found');
155
- return 'ok';
156
- });
157
- // ---- checkout
158
- let ready = null;
159
- await run('dev.ucp.shopping.checkout', 'create is incomplete with recoverable messages', async () => {
160
- const c = await shopping.createCheckout(s, { line_items: [{ item: { id: v.id }, quantity: 1 }], context });
161
- must(c.status === 201, `create must answer 201, got ${c.status}`);
162
- const ck = c.body;
163
- must(ck.status === 'incomplete', `expected incomplete, got ${ck.status}`);
164
- must(ck.ucp?.payment_handlers && Object.keys(ck.ucp.payment_handlers).length, 'checkout responses must carry ucp.payment_handlers');
165
- must(Array.isArray(ck.links) && ck.links.some((l) => l.type === 'privacy_policy') && ck.links.some((l) => l.type === 'terms_of_service'), 'links must include privacy_policy and terms_of_service');
166
- must(ck.messages?.every((m) => m.type !== 'error' || m.severity === 'recoverable'), 'errors on a fresh checkout must be recoverable');
167
- must(/^https:\/\//.test(ck.continue_url ?? ''), 'continue_url must be an absolute HTTPS URL');
168
- ready = ck;
169
- return `${ck.id}: ${(ck.messages ?? []).map((m) => m.code).join(', ')}`;
170
- });
171
- if (!ready)
172
- return finish(origin, checks);
173
- let ck = ready;
174
- await run('dev.ucp.shopping.checkout', 'rejects an update without line_items', async () => {
175
- const c = await shopping.updateCheckout(s, ck.id, { buyer: TEST_BUYER });
176
- must(c.body.messages?.some((m) => m.type === 'error'), 'update without line_items must be refused with an error message');
177
- return 'refused';
178
- });
179
- if (s.capabilities.has('dev.ucp.shopping.fulfillment')) {
180
- await run('dev.ucp.shopping.fulfillment', 'address selection yields options and the fulfillment total', async () => {
181
- const u = replay(ck);
182
- u.buyer = TEST_BUYER;
183
- u.fulfillment.methods[0].destinations = [testAddress(country)];
184
- u.fulfillment.methods[0].selected_destination_id = 'dest_1';
185
- const c = await shopping.updateCheckout(s, ck.id, u);
186
- ck = c.body;
187
- const method = ck.fulfillment?.methods?.[0];
188
- must(method?.selected_destination_id === 'dest_1' && method.destinations?.length === 1 && method.destinations[0].type === 'shipping_address', 'the selected destination must be echoed with type shipping_address');
189
- must(method.groups?.[0]?.options?.length && method.groups[0].selected_option_id, 'a group with options and a selected option is expected');
190
- must(ck.totals.some((t) => t.type === 'fulfillment'), 'a fulfillment total is expected');
191
- must(ck.status === 'ready_for_complete', `expected ready_for_complete, got ${ck.status}: ${(ck.messages ?? []).map((m) => m.code).join(', ')}`);
192
- return `${method.groups[0].options.map((o) => `${o.title} ${o.totals[0].amount}`).join(', ')}`;
193
- });
194
- await run('dev.ucp.shopping.fulfillment', 'an undeliverable country is refused', async () => {
195
- const u = replay(ck);
196
- u.fulfillment.methods[0].destinations = [testAddress('AQ')];
197
- u.fulfillment.methods[0].selected_destination_id = 'dest_1';
198
- const c = await shopping.updateCheckout(s, ck.id, u);
199
- must(c.body.status === 'incomplete' && c.body.messages?.some((m) => m.code === 'address_undeliverable'), 'Antarctica must give address_undeliverable');
200
- u.fulfillment.methods[0].destinations = [testAddress(country)];
201
- ck = (await shopping.updateCheckout(s, ck.id, u)).body;
202
- must(ck.status === 'ready_for_complete', 'the checkout must recover after a valid address');
203
- return 'address_undeliverable, then recovered';
204
- });
205
- }
206
- else {
207
- const u = replay(ck);
208
- u.buyer = TEST_BUYER;
209
- ck = (await shopping.updateCheckout(s, ck.id, u)).body;
210
- }
211
- if (s.capabilities.has('dev.ucp.shopping.buyer_consent')) {
212
- await run('dev.ucp.shopping.buyer_consent', 'consent is echoed with descriptions', async () => {
213
- const u = replay(ck);
214
- u.buyer = { ...TEST_BUYER, consent: { 'dev.ucp.consent.marketing': { granted: false, source: 'platform' } } };
215
- const c = await shopping.updateCheckout(s, ck.id, u);
216
- ck = c.body;
217
- const given = ck.buyer?.consent?.['dev.ucp.consent.marketing'];
218
- must(given && given.granted === false && given.source === 'platform' && given.description, 'consent must come back with granted, source and description');
219
- return given.description;
220
- });
221
- }
222
- await run('dev.ucp.shopping.checkout', 'totals identity and ready_for_complete', async () => {
223
- must(ck.status === 'ready_for_complete', `status is ${ck.status}`);
224
- must(ck.totals.filter((t) => t.type === 'subtotal').length === 1 && ck.totals.filter((t) => t.type === 'total').length === 1, 'exactly one subtotal and one total');
225
- must(total(ck.totals) === sum(ck.totals), `sum of entries ${sum(ck.totals)} must equal total ${total(ck.totals)}`);
226
- return `total ${total(ck.totals)} ${ck.currency}`;
227
- });
228
- await run('ai.commercelayer.sandbox', 'a declined token fails recoverably', async () => {
229
- const c = await shopping.completeCheckout(s, ck.id, { payment: { instruments: [sandboxInstrument(true)] } });
230
- must(c.body.status !== 'completed' && c.body.messages?.some((m) => m.code === 'payment_failed' && m.severity === 'recoverable'), 'expected payment_failed, recoverable');
231
- return `payment_failed, status ${c.body.status}`;
232
- });
233
- await run('ai.commercelayer.sandbox', 'an unknown handler is refused', async () => {
234
- const pi = { ...sandboxInstrument(), handler_id: 'nope' };
235
- const c = await shopping.completeCheckout(s, ck.id, { payment: { instruments: [pi] } });
236
- must(c.body.status !== 'completed' && c.body.messages?.some((m) => m.code === 'payment_failed'), 'expected payment_failed');
237
- return 'payment_failed';
238
- });
239
- let completeCall = null;
240
- let placed;
241
- let orderId = null;
242
- const key = crypto.randomUUID();
243
- const completeBody = { payment: { instruments: [sandboxInstrument()] }, signals: { 'dev.ucp.buyer_ip': '203.0.113.1', 'dev.ucp.user_agent': 'c11r' } };
244
- await run('dev.ucp.shopping.checkout', 'complete with the sandbox token places the order', async () => {
245
- completeCall = await shopping.completeCheckout(s, ck.id, completeBody, { idempotencyKey: key });
246
- const done = completeCall.body;
247
- must(done.status === 'completed', `expected completed, got ${done.status}: ${(done.messages ?? []).map((m) => m.code).join(', ')}`);
248
- must(done.order?.id && /^https:\/\//.test(done.order.permalink_url ?? ''), 'order.id and order.permalink_url are required');
249
- must(done.continue_url === undefined && done.expires_at === undefined, 'completed checkouts should omit continue_url and expires_at');
250
- must(JSON.stringify(done.payment ?? {}).includes('"token"') === false, 'the credential token must not be echoed');
251
- orderId = done.order.id;
252
- placed = { id: done.order.id, label: done.order.label };
253
- return `order ${done.order.id} ${done.order.label ?? ''}`;
254
- });
255
- await run('signatures', 'the completion response is signed and verifies', async () => {
256
- must(completeCall, 'no completion');
257
- must(completeCall.digest === 'verified', `Content-Digest ${completeCall.digest ?? 'missing'}`);
258
- must(completeCall.signature === 'verified', `Signature ${completeCall.signature ?? 'missing'}`);
259
- return 'ES256 over @status, content-digest, content-type';
260
- });
261
- await run('transport', 'the same Idempotency-Key replays, a different body conflicts', async () => {
262
- const again = await request(s, 'POST', `${s.endpoint}/checkout-sessions/${ck.id}/complete`, completeBody, { idempotencyKey: key });
263
- must(again.status === completeCall.status && JSON.stringify(again.body) === JSON.stringify(completeCall.body), 'the replay must return the same response');
264
- const other = await request(s, 'POST', `${s.endpoint}/checkout-sessions/${ck.id}/complete`, { ...completeBody, attribution: { x: '1' } }, { idempotencyKey: key });
265
- must(other.status === 409, `expected 409, got ${other.status}`);
266
- return 'replayed, then 409';
267
- });
268
- await run('dev.ucp.shopping.checkout', 'a completed checkout is immutable; a fresh one cancels', async () => {
269
- const c = await shopping.cancelCheckout(s, ck.id);
270
- must(c.body.status === 'completed' && c.body.messages?.some((m) => m.severity === 'unrecoverable'), 'cancel after completion must be refused as unrecoverable');
271
- const fresh = await shopping.createCheckout(s, { line_items: [{ item: { id: v.id }, quantity: 1 }], context });
272
- const canceled = await shopping.cancelCheckout(s, fresh.body.id);
273
- must(canceled.body.status === 'canceled', 'a fresh checkout must cancel');
274
- return 'ok';
275
- });
276
- if (cartId) {
277
- await run('dev.ucp.shopping.cart', 'checkout from cart_id is idempotent', async () => {
278
- const a = await shopping.createCheckout(s, { cart_id: cartId, buyer: TEST_BUYER });
279
- must(a.body.line_items?.length === 1 && a.body.line_items[0].quantity === 2, 'the checkout must take the cart lines');
280
- const b = await shopping.createCheckout(s, { cart_id: cartId });
281
- must(b.body.id === a.body.id, 'a second create for the same cart must return the same checkout');
282
- return `checkout ${a.body.id} from cart ${cartId}`;
283
- });
284
- }
285
- // ---- order
286
- if (orderId) {
287
- const oid = orderId;
288
- await run('dev.ucp.shopping.order', 'GET /orders/{id} returns the order entity', async () => {
289
- const c = await shopping.getOrder(s, oid);
290
- const o = c.body;
291
- must(o.id === oid && o.checkout_id === ck.id && o.permalink_url && o.currency === ck.currency, 'id, checkout_id, permalink_url, currency');
292
- must(o.line_items?.[0]?.quantity?.total !== undefined && o.line_items[0].quantity.fulfilled !== undefined && o.line_items[0].status === 'processing', 'order line items need quantity {total, fulfilled} and status');
293
- must(o.fulfillment && Array.isArray(o.fulfillment.expectations ?? []) && Array.isArray(o.fulfillment.events ?? []), 'fulfillment with expectations and events');
294
- must(total(o.totals) === total(ck.totals), 'order totals must match the checkout');
295
- const miss = await request(s, 'GET', `${s.endpoint}/orders/no-such-order`);
296
- must(miss.body?.ucp?.status === 'error' && miss.body.messages?.[0]?.code === 'not_found', 'unknown order must be not_found');
297
- return `${o.label ?? o.id}, ${o.fulfillment.expectations?.length ?? 0} expectation(s)`;
298
- });
299
- await run('dev.ucp.shopping.order', 'orders are private: a bad token is challenged', async () => {
300
- const c = await request(s, 'GET', `${s.endpoint}/orders/${oid}`, undefined, { bearer: 'not-a-token' });
301
- must(c.status === 401 && /^Bearer realm=/.test(c.headers['www-authenticate'] ?? ''), `expected 401 with WWW-Authenticate: Bearer, got ${c.status}`);
302
- must(c.body?.messages?.[0]?.code === 'identity_required', 'the body must carry identity_required');
303
- return '401 identity_required';
304
- });
305
- }
306
- // ---- identity linking
307
- if (s.capabilities.has('dev.ucp.common.identity_linking')) {
308
- await run('dev.ucp.common.identity_linking', 'OAuth metadata, PKCE and token exchange', async () => {
309
- const meta = await send('GET', `${origin}/.well-known/oauth-authorization-server`, {});
310
- must(meta.status === 200, `metadata answered ${meta.status}`);
311
- const m = JSON.parse(meta.body);
312
- for (const f of ['issuer', 'authorization_endpoint', 'token_endpoint', 'revocation_endpoint', 'jwks_uri', 'scopes_supported', 'code_challenge_methods_supported'])
313
- must(m[f], `metadata misses ${f}`);
314
- must(m.code_challenge_methods_supported.includes('S256') && m.authorization_response_iss_parameter_supported === true, 'S256 and iss support are required');
315
- must(m.scopes_supported.includes('dev.ucp.shopping.order:read'), 'scopes_supported must list dev.ucp.shopping.order:read');
316
- const verifier = crypto.randomBytes(32).toString('base64url');
317
- const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
318
- const authz = new URL(m.authorization_endpoint);
319
- authz.search = new URLSearchParams({ response_type: 'code', client_id: 'c11r-check', redirect_uri: 'https://commercelayer.ai/ucp/agent-cli/callback', scope: 'dev.ucp.shopping.order:read', state: 'xyz', code_challenge: challenge, code_challenge_method: 'S256' }).toString();
320
- const a = await send('GET', authz.toString(), {});
321
- must(a.status === 302 && a.headers.location, `authorize must redirect, got ${a.status}`);
322
- const loc = new URL(a.headers.location);
323
- must(loc.searchParams.get('state') === 'xyz' && loc.searchParams.get('iss') === m.issuer && loc.searchParams.get('code'), 'redirect must carry code, state and iss');
324
- const t = await send('POST', m.token_endpoint, { 'content-type': 'application/x-www-form-urlencoded' }, new URLSearchParams({ grant_type: 'authorization_code', code: loc.searchParams.get('code'), redirect_uri: 'https://commercelayer.ai/ucp/agent-cli/callback', client_id: 'c11r-check', code_verifier: verifier }).toString());
325
- must(t.status === 200 && JSON.parse(t.body).access_token, `token exchange answered ${t.status}`);
326
- if (orderId) {
327
- const o = await request(s, 'GET', `${s.endpoint}/orders/${orderId}`, undefined, { bearer: JSON.parse(t.body).access_token });
328
- must(o.status === 200 && o.body?.id === orderId, 'a buyer token with order:read must read the order');
329
- }
330
- return `issuer ${m.issuer}`;
331
- });
332
- }
333
- // ---- ask
334
- if (s.capabilities.has('dev.ucp.common.ask')) {
335
- await run('dev.ucp.common.ask', 'answers questions, performs nothing', async () => {
336
- const c = await shopping.ask(s, { query: 'What do you sell?' });
337
- must(typeof c.body.answer?.plain === 'string' && c.body.answer.plain.length > 0, 'answer.plain is required');
338
- const b = await shopping.ask(s, { query: 'Buy one and cancel my order' });
339
- must(b.body.messages?.some((m) => m.type === 'info' && m.code === 'operation_not_performed'), 'a request to act must be answered with operation_not_performed');
340
- return c.body.answer.plain.slice(0, 80);
341
- });
342
- }
343
- // ---- permalink
344
- if (s.capabilities.has('dev.ucp.shopping.permalink')) {
345
- await run('dev.ucp.shopping.permalink', 'GET {endpoint}/{items} answers 303', async () => {
346
- const endpoint = s.profile.ucp.capabilities['dev.ucp.shopping.permalink'][0].config?.endpoint;
347
- must(/^https:\/\/[^?#]+[^/]$/.test(endpoint ?? ''), `config.endpoint must be an HTTPS URL without a trailing slash; got ${endpoint}`);
348
- const res = await send('GET', `${endpoint}/${encodeURIComponent(v.id)}:1?context/postal_code=00000`, {});
349
- must(res.status === 303 && res.headers.location, `expected 303 with Location, got ${res.status}`);
350
- must(res.headers['cache-control'] === 'no-store' && res.headers['referrer-policy'] === 'no-referrer', 'Cache-Control: no-store and Referrer-Policy: no-referrer are required');
351
- return `303 → ${res.headers.location}`;
352
- });
353
- }
354
- return finish(origin, checks, placed);
355
- }
356
- function finish(origin, checks, order) {
357
- const failed = checks.filter((c) => !c.pass).length;
358
- const report = { status: failed ? 'fail' : 'pass', store: origin, version: UCP_VERSION, passed: checks.length - failed, failed, checks };
359
- if (order)
360
- report.order = order;
361
- return report;
362
- }
@@ -1,129 +0,0 @@
1
- // A UCP platform client: what any shopping agent does to buy from a store.
2
- // Discovers the business profile, negotiates capabilities, calls the REST
3
- // service with the required headers, verifies digests and signatures.
4
- import crypto from 'node:crypto';
5
- import { send } from '../api/transport.js';
6
- import { fail } from '../output.js';
7
- import { AGENT_PROFILE_URL, UCP_VERSION } from './platform.js';
8
- import { contentDigest, parseSignature, signatureBase, verify } from './signing.js';
9
- export class UcpError extends Error {
10
- call;
11
- constructor(call, message) {
12
- super(message);
13
- this.call = call;
14
- }
15
- }
16
- /** Raw HTTP to a store host, with the UCP headers; returns the parsed response. */
17
- export async function request(store, method, url, body, opts = {}) {
18
- const raw = body === undefined ? undefined : JSON.stringify(body);
19
- const headers = { accept: 'application/json' };
20
- const omit = new Set(opts.omit ?? []);
21
- if (!omit.has('ucp-agent'))
22
- headers['ucp-agent'] = `profile="${AGENT_PROFILE_URL}"`;
23
- if (!omit.has('request-id'))
24
- headers['request-id'] = crypto.randomUUID();
25
- if ((method === 'POST' || method === 'PUT') && !omit.has('idempotency-key') && !/\/catalog\/|\/ask$/.test(url)) {
26
- headers['idempotency-key'] = opts.idempotencyKey ?? crypto.randomUUID();
27
- }
28
- if (raw !== undefined)
29
- headers['content-digest'] = contentDigest(raw);
30
- if (opts.bearer)
31
- headers.authorization = `Bearer ${opts.bearer}`;
32
- const res = await send(method, url, headers, raw);
33
- return inspect(store.keys, method, url, res);
34
- }
35
- function inspect(keys, method, url, res) {
36
- let body = null;
37
- try {
38
- body = res.body ? JSON.parse(res.body) : null;
39
- }
40
- catch {
41
- body = null;
42
- }
43
- const call = { method, url, status: res.status, headers: res.headers, body };
44
- if (res.headers['content-digest'])
45
- call.digest = res.headers['content-digest'] === contentDigest(res.body) ? 'verified' : 'mismatch';
46
- const sig = parseSignature(res.headers['signature-input'], res.headers.signature);
47
- if (sig) {
48
- const key = keys.find((k) => k.kid === sig.params.keyid);
49
- if (!key)
50
- call.signature = 'unknown_key';
51
- else {
52
- const values = { '@status': String(res.status), 'content-digest': res.headers['content-digest'] ?? '', 'content-type': res.headers['content-type'] ?? '' };
53
- call.signature = verify(signatureBase(values, sig.params), sig.signature, key) ? 'verified' : 'invalid';
54
- }
55
- }
56
- return call;
57
- }
58
- /** Fetch /.well-known/ucp and work out what we can do with this store. */
59
- export async function discover(origin) {
60
- const res = await send('GET', `${origin}/.well-known/ucp`, { accept: 'application/json' });
61
- if (res.status !== 200)
62
- fail('ucp_profile_unreachable', `${origin}/.well-known/ucp answered ${res.status}.`, 'The store does not speak UCP, or the sandbox expired. Run `c11r status`.');
63
- let profile;
64
- try {
65
- profile = JSON.parse(res.body);
66
- }
67
- catch {
68
- fail('ucp_profile_malformed', `${origin}/.well-known/ucp is not JSON.`, 'The store host answered badly. Try again; if it keeps happening, tell the human, with the output of `c11r status`.');
69
- }
70
- const ucp = profile.ucp ?? {};
71
- if (ucp.version !== UCP_VERSION) {
72
- fail('ucp_version_unsupported', `The store speaks UCP ${ucp.version}; this CLI speaks ${UCP_VERSION}.`, 'Update the CLI: npx -y @commercelayer/agent-cli@latest.');
73
- }
74
- const rest = (name) => (ucp.services?.[name] ?? []).find((s) => s.transport === 'rest')?.endpoint ?? null;
75
- const endpoint = rest('dev.ucp.shopping');
76
- if (!endpoint)
77
- fail('ucp_no_rest_service', 'The store profile has no REST dev.ucp.shopping service.', 'The store host serves a profile this CLI cannot use. Tell the human, with the output of `c11r ucp profile`.');
78
- const capabilities = new Set();
79
- for (const [name, entries] of Object.entries(ucp.capabilities ?? {})) {
80
- if (entries.some((e) => e.version === UCP_VERSION))
81
- capabilities.add(name);
82
- }
83
- return { origin, profile, endpoint, commonEndpoint: rest('dev.ucp.common'), capabilities, keys: profile.keys ?? [] };
84
- }
85
- /** A call that must come back with a UCP resource; throws UcpError otherwise. */
86
- async function expectResource(call, what) {
87
- const c = await call;
88
- if (c.status >= 400)
89
- throw new UcpError(c, `${what}: the store answered ${c.status}${c.body?.code ? ` ${c.body.code}` : ''}.`);
90
- if (c.body?.ucp?.status === 'error')
91
- throw new UcpError(c, `${what}: ${c.body.messages?.map((m) => `${m.code}: ${m.content}`).join('; ')}`);
92
- return c;
93
- }
94
- export const shopping = {
95
- search: (s, body) => expectResource(request(s, 'POST', `${s.endpoint}/catalog/search`, body), 'catalog search'),
96
- lookup: (s, body) => expectResource(request(s, 'POST', `${s.endpoint}/catalog/lookup`, body), 'catalog lookup'),
97
- product: (s, body) => expectResource(request(s, 'POST', `${s.endpoint}/catalog/product`, body), 'get product'),
98
- createCart: (s, body, o) => expectResource(request(s, 'POST', `${s.endpoint}/carts`, body, o), 'create cart'),
99
- getCart: (s, id) => expectResource(request(s, 'GET', `${s.endpoint}/carts/${id}`), 'get cart'),
100
- updateCart: (s, id, body) => expectResource(request(s, 'PUT', `${s.endpoint}/carts/${id}`, body), 'update cart'),
101
- cancelCart: (s, id) => expectResource(request(s, 'POST', `${s.endpoint}/carts/${id}/cancel`, {}), 'cancel cart'),
102
- createCheckout: (s, body, o) => expectResource(request(s, 'POST', `${s.endpoint}/checkout-sessions`, body, o), 'create checkout'),
103
- getCheckout: (s, id) => expectResource(request(s, 'GET', `${s.endpoint}/checkout-sessions/${id}`), 'get checkout'),
104
- updateCheckout: (s, id, body) => expectResource(request(s, 'PUT', `${s.endpoint}/checkout-sessions/${id}`, body), 'update checkout'),
105
- completeCheckout: (s, id, body, o) => expectResource(request(s, 'POST', `${s.endpoint}/checkout-sessions/${id}/complete`, body, o), 'complete checkout'),
106
- cancelCheckout: (s, id) => expectResource(request(s, 'POST', `${s.endpoint}/checkout-sessions/${id}/cancel`, {}), 'cancel checkout'),
107
- getOrder: (s, id, o) => expectResource(request(s, 'GET', `${s.endpoint}/orders/${id}`, undefined, o), 'get order'),
108
- ask: (s, body) => expectResource(request(s, 'POST', `${s.commonEndpoint ?? s.endpoint}/ask`, body), 'ask'),
109
- };
110
- /** The update payload for a checkout: everything the platform may write, re-sent whole. */
111
- export function replay(checkout) {
112
- const out = { line_items: checkout.line_items.map((l) => ({ id: l.id, item: { id: l.item.id }, quantity: l.quantity })) };
113
- for (const k of ['buyer', 'context', 'signals', 'attribution', 'discounts'])
114
- if (checkout[k] !== undefined)
115
- out[k] = k === 'discounts' ? { codes: checkout.discounts.codes ?? [] } : checkout[k];
116
- if (checkout.fulfillment?.methods) {
117
- out.fulfillment = {
118
- methods: checkout.fulfillment.methods.map((m) => ({
119
- id: m.id,
120
- type: m.type,
121
- line_item_ids: m.line_item_ids,
122
- destinations: m.destinations,
123
- selected_destination_id: m.selected_destination_id ?? null,
124
- groups: (m.groups ?? []).map((g) => ({ id: g.id, selected_option_id: g.selected_option_id ?? null })),
125
- })),
126
- };
127
- }
128
- return out;
129
- }
@@ -1,62 +0,0 @@
1
- // --images: each product's image_url saved as a file, for agents that can read images and
2
- // want to look at or describe a product. Files go under <tmpdir>/c11r/images/<store host>/,
3
- // named by product id and a hash of the URL, so a file already there is reused and a new
4
- // image gets a new file. A chat thumbnail needs only image_url, never this.
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 { download } from '../api/transport.js';
10
- export const MAX_IMAGES = 24;
11
- const TIMEOUT_MS = 5000;
12
- const MAX_BYTES = 10 * 1024 * 1024;
13
- const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif', 'image/svg+xml': 'svg' };
14
- /** The folder of a store's images: C11R_IMAGES_DIR (tests) or the OS temp dir. */
15
- export const imagesDir = (origin) => path.join(process.env.C11R_IMAGES_DIR ?? path.join(os.tmpdir(), 'c11r', 'images'), new URL(origin).host);
16
- const safe = (s) => s.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 80) || 'product';
17
- /** One product's image: the path of its file, or why there is none. */
18
- async function fetchImage(dir, p) {
19
- const url = p.image_url;
20
- if (typeof url !== 'string' || !url)
21
- return { image_error: 'The product has no image_url.' };
22
- let parsed;
23
- try {
24
- parsed = new URL(url);
25
- }
26
- catch {
27
- return { image_error: `image_url ${url} is not a URL.` };
28
- }
29
- if (!['https:', 'http:'].includes(parsed.protocol))
30
- return { image_error: `image_url ${url} is not an http(s) address.` };
31
- const stem = `${safe(String(p.id ?? ''))}-${crypto.createHash('sha256').update(url).digest('hex').slice(0, 10)}`;
32
- const kept = fs.existsSync(dir) ? fs.readdirSync(dir).find((f) => f.startsWith(`${stem}.`) && Object.values(EXT).includes(f.slice(stem.length + 1))) : undefined;
33
- if (kept)
34
- return { image_file: path.join(dir, kept) };
35
- try {
36
- const r = await download(url, TIMEOUT_MS);
37
- if (r.status !== 200)
38
- return { image_error: `The store answered ${r.status} for ${url}.` };
39
- const ext = EXT[r.type.split(';')[0].trim().toLowerCase()];
40
- if (!ext)
41
- return { image_error: `${url} is not an image (content-type ${r.type || 'missing'}).` };
42
- if (r.bytes.length > MAX_BYTES)
43
- return { image_error: `${url} is over 10 MB.` };
44
- fs.mkdirSync(dir, { recursive: true });
45
- const file = path.join(dir, `${stem}.${ext}`);
46
- // Written aside then renamed, so a parallel run never reads half a file.
47
- const tmp = `${file}.${process.pid}.part`;
48
- fs.writeFileSync(tmp, r.bytes);
49
- fs.renameSync(tmp, file);
50
- return { image_file: file };
51
- }
52
- catch (e) {
53
- const err = e;
54
- return { image_error: err.name === 'TimeoutError' ? `${url} took more than ${TIMEOUT_MS / 1000} s.` : `Could not download ${url}: ${err.message}` };
55
- }
56
- }
57
- /** Products with image_file or image_error, the first MAX_IMAGES in parallel; how many were left out. */
58
- export async function withImages(origin, products) {
59
- const dir = imagesDir(origin);
60
- const got = await Promise.all(products.slice(0, MAX_IMAGES).map((p) => fetchImage(dir, p)));
61
- return { products: products.map((p, i) => (i < MAX_IMAGES ? { ...p, ...got[i] } : p)), skipped: Math.max(0, products.length - MAX_IMAGES) };
62
- }