@commercelayer/agent-cli 0.8.1 → 1.0.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -215
- package/dist/argv.js +227 -0
- package/dist/bin.js +15 -178
- package/dist/builtins/auth.js +169 -0
- package/dist/builtins/extensions.js +107 -0
- package/dist/builtins/index.js +8 -0
- package/dist/builtins/loops.js +215 -0
- package/dist/builtins/sandbox.js +186 -0
- package/dist/config.js +238 -0
- package/dist/confirm.js +56 -0
- package/dist/engine.js +205 -0
- package/dist/errors.js +146 -0
- package/dist/help.js +81 -0
- package/dist/http.js +117 -0
- package/dist/inputs.js +112 -0
- package/dist/login.js +136 -0
- package/dist/resources.js +42 -0
- package/dist/schema.js +182 -0
- package/dist/schemas.js +72 -0
- package/dist/secrets.js +204 -0
- package/dist/version.js +4 -8
- package/package.json +4 -9
- package/dist/api/client.js +0 -190
- package/dist/api/server.js +0 -26
- package/dist/api/stub/db.js +0 -37
- package/dist/api/stub/http.js +0 -18
- package/dist/api/stub/index.js +0 -52
- package/dist/api/stub/locale.js +0 -52
- package/dist/api/stub/management.js +0 -409
- package/dist/api/stub/messages.js +0 -126
- package/dist/api/stub/orders.js +0 -625
- package/dist/api/stub/search.js +0 -78
- package/dist/api/stub/shopping.js +0 -673
- package/dist/api/stub/theme.js +0 -197
- package/dist/api/stub/ucp.js +0 -377
- package/dist/api/transport.js +0 -57
- package/dist/api/types.js +0 -4
- package/dist/assets.js +0 -100
- package/dist/catalog.js +0 -372
- package/dist/check.js +0 -187
- package/dist/commands.js +0 -1241
- package/dist/cursor.js +0 -31
- package/dist/files.js +0 -98
- package/dist/inbox/cursor.js +0 -11
- package/dist/inbox/fake.js +0 -95
- package/dist/inbox/imap.js +0 -139
- package/dist/inbox/mailbox.js +0 -25
- package/dist/inbox/message.js +0 -88
- package/dist/inspect.js +0 -237
- package/dist/languages.js +0 -49
- package/dist/links.js +0 -118
- package/dist/locales.js +0 -43
- package/dist/ops.js +0 -190
- package/dist/orderRequest.js +0 -141
- package/dist/output.js +0 -39
- package/dist/products.js +0 -151
- package/dist/promotions.js +0 -291
- package/dist/store.js +0 -379
- package/dist/storefront.js +0 -285
- package/dist/ucp/buy.js +0 -90
- package/dist/ucp/check.js +0 -362
- package/dist/ucp/client.js +0 -129
- package/dist/ucp/images.js +0 -62
- package/dist/ucp/platform.js +0 -90
- package/dist/ucp/shop.js +0 -357
- package/dist/ucp/signing.js +0 -67
- package/dist/web.js +0 -316
- package/dist/website.js +0 -390
package/dist/ucp/platform.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
// What this CLI is on the UCP side: a platform (a buyer's agent). Its profile is
|
|
2
|
-
// served from commercelayer.ai so stores can fetch it; the stub knows it inline.
|
|
3
|
-
// Shared constants of the store side live here too, so the client, the stub and
|
|
4
|
-
// the conformance check agree on them.
|
|
5
|
-
export const UCP_VERSION = '2026-08-25';
|
|
6
|
-
export const UCP = `https://ucp.dev/${UCP_VERSION}`;
|
|
7
|
-
export const AGENT_PROFILE_URL = process.env.C11R_AGENT_PROFILE_URL ?? 'https://commercelayer.ai/ucp/agent-cli/profile.json';
|
|
8
|
-
export const AGENT_WEBHOOK_URL = 'https://commercelayer.ai/ucp/agent-cli/webhook';
|
|
9
|
-
/** The sandbox payment handler every sandbox store accepts. No money moves. */
|
|
10
|
-
export const SANDBOX_HANDLER = 'ai.commercelayer.sandbox';
|
|
11
|
-
export const SANDBOX_HANDLER_ID = 'cl_sandbox';
|
|
12
|
-
export const SANDBOX_HANDLER_SPEC = 'https://commercelayer.ai/ucp/handlers/sandbox';
|
|
13
|
-
export const SANDBOX_HANDLER_SCHEMA = 'https://commercelayer.ai/ucp/handlers/sandbox.json';
|
|
14
|
-
export const SANDBOX_CREDENTIAL_TYPE = 'sandbox_token';
|
|
15
|
-
/** Tokens the handler spec publishes: the first approves, the second declines. */
|
|
16
|
-
export const SANDBOX_TOKEN_SUCCESS = 'sbx_tok_success';
|
|
17
|
-
export const SANDBOX_TOKEN_DECLINED = 'sbx_tok_declined';
|
|
18
|
-
export const SHOPPING_CAPABILITIES = ['dev.ucp.shopping.catalog.search', 'dev.ucp.shopping.catalog.lookup', 'dev.ucp.shopping.cart', 'dev.ucp.shopping.checkout', 'dev.ucp.shopping.order'];
|
|
19
|
-
export const EXTENSIONS = {
|
|
20
|
-
'dev.ucp.shopping.fulfillment': ['dev.ucp.shopping.checkout', 'dev.ucp.shopping.catalog.search', 'dev.ucp.shopping.catalog.lookup'],
|
|
21
|
-
'dev.ucp.shopping.discount': ['dev.ucp.shopping.cart', 'dev.ucp.shopping.checkout'],
|
|
22
|
-
'dev.ucp.shopping.buyer_consent': ['dev.ucp.shopping.cart', 'dev.ucp.shopping.checkout'],
|
|
23
|
-
'dev.ucp.shopping.policy.return': ['dev.ucp.shopping.catalog.search', 'dev.ucp.shopping.catalog.lookup', 'dev.ucp.shopping.cart', 'dev.ucp.shopping.checkout', 'dev.ucp.shopping.order'],
|
|
24
|
-
};
|
|
25
|
-
const specOf = {
|
|
26
|
-
'dev.ucp.shopping.catalog.search': 'specification/shopping/catalog/search',
|
|
27
|
-
'dev.ucp.shopping.catalog.lookup': 'specification/shopping/catalog/lookup',
|
|
28
|
-
'dev.ucp.shopping.cart': 'specification/shopping/cart',
|
|
29
|
-
'dev.ucp.shopping.checkout': 'specification/shopping/checkout',
|
|
30
|
-
'dev.ucp.shopping.order': 'specification/shopping/order',
|
|
31
|
-
'dev.ucp.common.ask': 'specification/common/ask',
|
|
32
|
-
'dev.ucp.shopping.fulfillment': 'specification/shopping/extensions/fulfillment',
|
|
33
|
-
'dev.ucp.shopping.discount': 'specification/shopping/extensions/discount',
|
|
34
|
-
'dev.ucp.shopping.buyer_consent': 'specification/shopping/extensions/buyer-consent',
|
|
35
|
-
'dev.ucp.shopping.policy.return': 'specification/shopping/extensions/return-policy',
|
|
36
|
-
'dev.ucp.common.identity_linking': 'specification/common/identity-linking/',
|
|
37
|
-
'dev.ucp.shopping.permalink': 'specification/permalink',
|
|
38
|
-
};
|
|
39
|
-
const schemaOf = {
|
|
40
|
-
'dev.ucp.shopping.catalog.search': 'schemas/shopping/catalog_search.json',
|
|
41
|
-
'dev.ucp.shopping.catalog.lookup': 'schemas/shopping/catalog_lookup.json',
|
|
42
|
-
'dev.ucp.shopping.cart': 'schemas/shopping/cart.json',
|
|
43
|
-
'dev.ucp.shopping.checkout': 'schemas/shopping/checkout.json',
|
|
44
|
-
'dev.ucp.shopping.order': 'schemas/shopping/order.json',
|
|
45
|
-
'dev.ucp.common.ask': 'schemas/common/ask.json',
|
|
46
|
-
'dev.ucp.shopping.fulfillment': 'schemas/shopping/fulfillment.json',
|
|
47
|
-
'dev.ucp.shopping.discount': 'schemas/shopping/discount.json',
|
|
48
|
-
'dev.ucp.shopping.buyer_consent': 'schemas/shopping/buyer_consent.json',
|
|
49
|
-
'dev.ucp.shopping.policy.return': 'schemas/shopping/policy_return.json',
|
|
50
|
-
'dev.ucp.common.identity_linking': 'schemas/common/identity_linking.json',
|
|
51
|
-
'dev.ucp.shopping.permalink': 'schemas/shopping/permalink.json',
|
|
52
|
-
};
|
|
53
|
-
export function capabilityEntry(name, extra = {}) {
|
|
54
|
-
const entry = { version: UCP_VERSION, spec: `${UCP}/${specOf[name]}`, schema: `${UCP}/${schemaOf[name]}` };
|
|
55
|
-
if (EXTENSIONS[name])
|
|
56
|
-
entry.extends = EXTENSIONS[name];
|
|
57
|
-
return { ...entry, ...extra };
|
|
58
|
-
}
|
|
59
|
-
/** The platform profile this CLI presents in UCP-Agent. Published by the site. */
|
|
60
|
-
export function agentProfile() {
|
|
61
|
-
const capabilities = {};
|
|
62
|
-
for (const name of [...SHOPPING_CAPABILITIES, 'dev.ucp.common.ask', ...Object.keys(EXTENSIONS), 'dev.ucp.common.identity_linking']) {
|
|
63
|
-
capabilities[name] = [capabilityEntry(name, name === 'dev.ucp.shopping.order' ? { config: { webhook_url: AGENT_WEBHOOK_URL } } : {})];
|
|
64
|
-
}
|
|
65
|
-
// The fulfillment extension on the platform side: one group per method is enough.
|
|
66
|
-
capabilities['dev.ucp.shopping.fulfillment'] = [capabilityEntry('dev.ucp.shopping.fulfillment', { config: { supports_multi_group: false } })];
|
|
67
|
-
return {
|
|
68
|
-
ucp: {
|
|
69
|
-
version: UCP_VERSION,
|
|
70
|
-
services: {},
|
|
71
|
-
capabilities,
|
|
72
|
-
payment_handlers: {
|
|
73
|
-
[SANDBOX_HANDLER]: [{ id: SANDBOX_HANDLER_ID, version: UCP_VERSION, spec: SANDBOX_HANDLER_SPEC, schema: SANDBOX_HANDLER_SCHEMA, available_instruments: [{ type: 'card' }] }],
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
name: 'Commerce Layer agent CLI',
|
|
77
|
-
description: 'The Commerce Layer agent CLI: a shopping agent that buys from UCP stores on behalf of a human, places test orders in its own store and checks that a store speaks UCP.',
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
/** Minor units per currency unit (ISO 4217 exponent). */
|
|
81
|
-
export function minorUnits(currency) {
|
|
82
|
-
if (['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF', 'TWD', 'UGX', 'PYG', 'RWF', 'XAF', 'XOF'].includes(currency))
|
|
83
|
-
return 1;
|
|
84
|
-
if (['BHD', 'KWD', 'JOD', 'OMR', 'TND', 'IQD', 'LYD'].includes(currency))
|
|
85
|
-
return 1000;
|
|
86
|
-
return 100;
|
|
87
|
-
}
|
|
88
|
-
export function toMinor(amount, currency) {
|
|
89
|
-
return Math.round(amount * minorUnits(currency));
|
|
90
|
-
}
|
package/dist/ucp/shop.js
DELETED
|
@@ -1,357 +0,0 @@
|
|
|
1
|
-
// Buying from a store step by step, for an agent shopping on behalf of its human: search,
|
|
2
|
-
// look at a product, open a checkout, give the buyer's details, pay, read the order. Each
|
|
3
|
-
// step is one command, so the agent talks to the human in between. The results are trimmed
|
|
4
|
-
// for a conversation: prices in major units as well as minor, what is still missing.
|
|
5
|
-
// buy.ts is the one-shot test buyer; this is the real one, with the human's details.
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import os from 'node:os';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import { fail } from '../output.js';
|
|
10
|
-
import { discover, replay, shopping, UcpError } from './client.js';
|
|
11
|
-
import { sandboxInstrument } from './buy.js';
|
|
12
|
-
import { minorUnits, SANDBOX_HANDLER } from './platform.js';
|
|
13
|
-
// ---------- which store an id belongs to
|
|
14
|
-
/** Checkout and order ids, by the store origin they were made at: later steps need no --store. */
|
|
15
|
-
const stateFile = () => process.env.C11R_UCP_STATE ?? path.join(process.env.XDG_STATE_HOME ?? path.join(os.homedir(), '.local', 'state'), 'c11r', 'ucp.json');
|
|
16
|
-
const MAX_REMEMBERED = 200;
|
|
17
|
-
function readState() {
|
|
18
|
-
try {
|
|
19
|
-
return JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return {};
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
export function remember(id, origin) {
|
|
26
|
-
const s = readState();
|
|
27
|
-
delete s[id];
|
|
28
|
-
s[id] = origin;
|
|
29
|
-
const keys = Object.keys(s);
|
|
30
|
-
for (const k of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED)))
|
|
31
|
-
delete s[k];
|
|
32
|
-
fs.mkdirSync(path.dirname(stateFile()), { recursive: true });
|
|
33
|
-
fs.writeFileSync(stateFile(), JSON.stringify(s, null, 2) + '\n', { mode: 0o600 });
|
|
34
|
-
}
|
|
35
|
-
export const remembered = (id) => readState()[id];
|
|
36
|
-
/** A store origin from --store: an origin, a URL on the store, or a bare hostname. */
|
|
37
|
-
export function originOf(given) {
|
|
38
|
-
let url;
|
|
39
|
-
try {
|
|
40
|
-
url = new URL(/^[a-z]+:\/\//i.test(given) ? given : `https://${given}`);
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
fail('invalid_argument', `--store ${given} is not a URL or hostname.`, 'Give the store as a hostname (shop.example.com) or an origin (https://shop.example.com).');
|
|
44
|
-
}
|
|
45
|
-
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)))
|
|
46
|
-
fail('invalid_argument', `--store ${given} is not https.`, 'UCP stores are served over https: give https://<host>.');
|
|
47
|
-
return url.origin;
|
|
48
|
-
}
|
|
49
|
-
export function money(amount, currency) {
|
|
50
|
-
const minor = minorUnits(currency);
|
|
51
|
-
const decimals = Math.round(Math.log10(minor));
|
|
52
|
-
const major = amount / minor;
|
|
53
|
-
return { amount, currency, major, display: `${amount < 0 ? '-' : ''}${currency} ${Math.abs(major).toFixed(decimals)}` };
|
|
54
|
-
}
|
|
55
|
-
const plain = (d) => d?.plain ?? d?.markdown ?? d?.html;
|
|
56
|
-
/** How a variant ships, from fulfillment.methods[0]: where to, the first option's price and arrival, and whether it ships to the country asked. */
|
|
57
|
-
function shippingView(m, currency) {
|
|
58
|
-
if (!m)
|
|
59
|
-
return undefined;
|
|
60
|
-
const o = m.options?.[0];
|
|
61
|
-
const amount = o?.totals?.find((t) => t.type === 'fulfillment' || t.type === 'total')?.amount;
|
|
62
|
-
const view = {
|
|
63
|
-
to: m.ships_to,
|
|
64
|
-
option: o?.title,
|
|
65
|
-
price: amount !== undefined && currency ? money(amount, currency) : undefined,
|
|
66
|
-
earliest: o?.earliest_fulfillment_time,
|
|
67
|
-
latest: o?.latest_fulfillment_time,
|
|
68
|
-
// Only when a country was asked (--country): false when the store does not ship there.
|
|
69
|
-
ships_to_country: m.destination ? m.destination.ships !== false : undefined,
|
|
70
|
-
};
|
|
71
|
-
return Object.values(view).some((x) => x !== undefined) ? Object.fromEntries(Object.entries(view).filter(([, x]) => x !== undefined)) : undefined;
|
|
72
|
-
}
|
|
73
|
-
function variantView(v) {
|
|
74
|
-
const shipping = v.fulfillment?.methods?.map((m) => plain(m.description)).filter(Boolean).join('; ');
|
|
75
|
-
return {
|
|
76
|
-
id: v.id,
|
|
77
|
-
sku: v.sku,
|
|
78
|
-
title: v.title,
|
|
79
|
-
price: v.price ? money(v.price.amount, v.price.currency) : undefined,
|
|
80
|
-
// The price before a sale, when the store shows one.
|
|
81
|
-
list_price: v.list_price && v.list_price.amount !== v.price?.amount ? money(v.list_price.amount, v.list_price.currency) : undefined,
|
|
82
|
-
available: v.availability?.available ?? undefined,
|
|
83
|
-
availability: v.availability?.status ?? undefined,
|
|
84
|
-
image_url: v.media?.find((m) => m.type === 'image')?.url,
|
|
85
|
-
ships: shipping || undefined,
|
|
86
|
-
shipping: shippingView(v.fulfillment?.methods?.[0], v.price?.currency),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
export function productView(p, full = false) {
|
|
90
|
-
const variants = (p.variants ?? []).map(variantView);
|
|
91
|
-
const description = plain(p.description);
|
|
92
|
-
const image = p.media?.find((m) => m.type === 'image')?.url ?? variants[0]?.image_url;
|
|
93
|
-
return {
|
|
94
|
-
id: p.id,
|
|
95
|
-
title: p.title,
|
|
96
|
-
description: full || !description || description.length <= 200 ? description : `${description.slice(0, 197)}...`,
|
|
97
|
-
url: p.url,
|
|
98
|
-
image_url: image,
|
|
99
|
-
categories: p.categories?.length ? p.categories.map((c) => c.value) : undefined,
|
|
100
|
-
// A text search ranks products with every word first; partial marks one with only some of them.
|
|
101
|
-
partial: p.metadata?.search?.all_words === false || undefined,
|
|
102
|
-
matched_on: p.metadata?.search?.matched_on,
|
|
103
|
-
// A search lists a variant's image only when it is not the product's.
|
|
104
|
-
variants: full ? variants : variants.map((v) => ({ ...v, image_url: v.image_url === image ? undefined : v.image_url })),
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
const sign = (c) => c.signature ?? 'unsigned';
|
|
108
|
-
function selectedDestination(ck) {
|
|
109
|
-
const m = ck.fulfillment?.methods?.[0];
|
|
110
|
-
return m?.destinations?.find((d) => d.id === m.selected_destination_id);
|
|
111
|
-
}
|
|
112
|
-
/** A checkout for the conversation: what is in it, what it costs, what is still missing. */
|
|
113
|
-
export function checkoutView(ck, origin, call) {
|
|
114
|
-
const cur = ck.currency;
|
|
115
|
-
const method = ck.fulfillment?.methods?.[0];
|
|
116
|
-
const group = method?.groups?.[0];
|
|
117
|
-
const options = group?.options ?? [];
|
|
118
|
-
const option = options.find((o) => o.id === group?.selected_option_id);
|
|
119
|
-
const optionView = (o) => ({ id: o.id, title: o.title, description: plain(o.description), price: o.totals?.find((t) => t.type === 'total') ? money(o.totals.find((t) => t.type === 'total').amount, cur) : undefined });
|
|
120
|
-
const { id: _id, type: _type, ...address } = selectedDestination(ck) ?? {};
|
|
121
|
-
const total = (ck.totals ?? []).find((t) => t.type === 'total');
|
|
122
|
-
return {
|
|
123
|
-
id: ck.id,
|
|
124
|
-
status: ck.status,
|
|
125
|
-
store: origin,
|
|
126
|
-
currency: cur,
|
|
127
|
-
line_items: (ck.line_items ?? []).map((l) => ({
|
|
128
|
-
id: l.id,
|
|
129
|
-
item_id: l.item?.id,
|
|
130
|
-
title: l.item?.title,
|
|
131
|
-
image_url: l.item?.image_url,
|
|
132
|
-
quantity: l.quantity,
|
|
133
|
-
unit_price: l.item?.price === undefined ? undefined : money(l.item.price, cur),
|
|
134
|
-
total: money((l.totals ?? []).find((t) => t.type === 'total')?.amount ?? 0, cur),
|
|
135
|
-
})),
|
|
136
|
-
buyer: ck.buyer ? Object.fromEntries(Object.entries(ck.buyer).filter(([k]) => k !== 'consent')) : undefined,
|
|
137
|
-
ship_to: Object.keys(address).length ? address : undefined,
|
|
138
|
-
shipping: option ? optionView(option) : undefined,
|
|
139
|
-
shipping_options: options.length > 1 ? options.map(optionView) : undefined,
|
|
140
|
-
discounts: ck.discounts?.applied?.length ? ck.discounts.applied.map((d) => ({ code: d.code, title: d.title, automatic: d.automatic, amount: money(d.amount, cur) })) : undefined,
|
|
141
|
-
codes: ck.discounts?.codes?.length ? ck.discounts.codes : undefined,
|
|
142
|
-
totals: (ck.totals ?? []).map((t) => ({ type: t.type, label: t.display_text, ...money(t.amount, cur) })),
|
|
143
|
-
total: total ? money(total.amount, cur) : undefined,
|
|
144
|
-
messages: ck.messages,
|
|
145
|
-
order: ck.order,
|
|
146
|
-
continue_url: ck.continue_url,
|
|
147
|
-
expires_at: ck.expires_at,
|
|
148
|
-
links: ck.links,
|
|
149
|
-
signature: sign(call),
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
export function orderView(o, origin, call) {
|
|
153
|
-
const cur = o.currency;
|
|
154
|
-
return {
|
|
155
|
-
id: o.id,
|
|
156
|
-
label: o.label,
|
|
157
|
-
permalink_url: o.permalink_url,
|
|
158
|
-
store: origin,
|
|
159
|
-
checkout_id: o.checkout_id,
|
|
160
|
-
currency: cur,
|
|
161
|
-
line_items: (o.line_items ?? []).map((l) => ({
|
|
162
|
-
item_id: l.item?.id,
|
|
163
|
-
title: l.item?.title,
|
|
164
|
-
image_url: l.item?.image_url,
|
|
165
|
-
quantity: l.quantity?.total ?? l.quantity,
|
|
166
|
-
status: l.status,
|
|
167
|
-
total: money((l.totals ?? []).find((t) => t.type === 'total')?.amount ?? 0, cur),
|
|
168
|
-
})),
|
|
169
|
-
fulfillment: (o.fulfillment?.expectations ?? []).map((e) => ({ method: e.method_type, destination: e.destination, description: e.description })),
|
|
170
|
-
events: o.fulfillment?.events?.length ? o.fulfillment.events : undefined,
|
|
171
|
-
totals: (o.totals ?? []).map((t) => ({ type: t.type, label: t.display_text, ...money(t.amount, cur) })),
|
|
172
|
-
signature: sign(call),
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
// ---------- the steps
|
|
176
|
-
/** Turn a store's refusal into a CLI error; a missing checkout or order is not_found. */
|
|
177
|
-
async function step(origin, run) {
|
|
178
|
-
try {
|
|
179
|
-
return await run();
|
|
180
|
-
}
|
|
181
|
-
catch (e) {
|
|
182
|
-
if (!(e instanceof UcpError))
|
|
183
|
-
throw e;
|
|
184
|
-
const code = e.call.body?.messages?.[0]?.code;
|
|
185
|
-
if (code === 'not_found' || e.call.status === 404)
|
|
186
|
-
fail('not_found', e.message, `The store at ${origin} does not know it. Check the id, or pass --store <the store it was made at>.`);
|
|
187
|
-
if (code === 'identity_required' || e.call.status === 401)
|
|
188
|
-
fail('identity_required', e.message, 'Orders are readable by the platform that placed them: this CLI can only read orders it placed.');
|
|
189
|
-
fail('ucp_error', e.message, `The store at ${origin} refused it: tell the human what it said. The variant ids it sells are in c11r ucp search.`);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
/** --item ID or ID:QTY, the variant id. */
|
|
193
|
-
export function parseItem(raw) {
|
|
194
|
-
const m = String(raw).match(/^(.+?)(?::(\d+))?$/);
|
|
195
|
-
if (!m || !m[1].trim())
|
|
196
|
-
fail('invalid_argument', `--item ${raw} is not ID or ID:QUANTITY.`, 'Use a variant id from c11r ucp search (variants[].id), for example --item ETH-250:2; quantity 1 when left out, 0 removes it in checkout update.');
|
|
197
|
-
return { id: m[1].trim(), quantity: m[2] === undefined ? 1 : Number(m[2]) };
|
|
198
|
-
}
|
|
199
|
-
export async function open(origin) {
|
|
200
|
-
const store = await discover(origin);
|
|
201
|
-
if (!store.capabilities.has('dev.ucp.shopping.checkout'))
|
|
202
|
-
fail('checkout_unsupported', `The store at ${origin} does not offer UCP checkout to this CLI.`, 'It can only be browsed: give the human the store URL to buy there.');
|
|
203
|
-
return store;
|
|
204
|
-
}
|
|
205
|
-
export async function search(origin, query, f = {}) {
|
|
206
|
-
const store = await discover(origin);
|
|
207
|
-
const body = { pagination: { limit: 50 } };
|
|
208
|
-
if (query)
|
|
209
|
-
body.query = query;
|
|
210
|
-
if (f.country)
|
|
211
|
-
body.context = { address_country: f.country.toUpperCase() };
|
|
212
|
-
const filters = {};
|
|
213
|
-
if (f.categories?.length)
|
|
214
|
-
filters.categories = f.categories;
|
|
215
|
-
if (f.min !== undefined || f.max !== undefined) {
|
|
216
|
-
// filters.price is in minor units of context.currency: ask the store which currency it prices in here.
|
|
217
|
-
const probe = await step(origin, () => shopping.search(store, { ...body, ...(f.categories?.length ? { filters } : {}), pagination: { limit: 1 } }));
|
|
218
|
-
const currency = probe.body.products?.[0]?.variants?.[0]?.price?.currency;
|
|
219
|
-
if (currency) {
|
|
220
|
-
body.context = { ...body.context, currency };
|
|
221
|
-
filters.price = { ...(f.min !== undefined ? { min: Math.round(f.min * minorUnits(currency)) } : {}), ...(f.max !== undefined ? { max: Math.round(f.max * minorUnits(currency)) } : {}) };
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
if (Object.keys(filters).length)
|
|
225
|
-
body.filters = filters;
|
|
226
|
-
const call = await step(origin, () => shopping.search(store, body));
|
|
227
|
-
const r = call.body;
|
|
228
|
-
let products = r.products ?? [];
|
|
229
|
-
// A store that ignores filters.price still gets the bounds applied here.
|
|
230
|
-
if (filters.price)
|
|
231
|
-
products = products.filter((p) => (p.variants ?? []).some((v) => v.price && (filters.price.min === undefined || v.price.amount >= filters.price.min) && (filters.price.max === undefined || v.price.amount <= filters.price.max)));
|
|
232
|
-
// Which market, currency and countries the prices are for: the store says it per variant, the same for all.
|
|
233
|
-
const prices = products.flatMap((p) => p.variants ?? []).find((v) => v.metadata?.prices)?.metadata.prices;
|
|
234
|
-
return {
|
|
235
|
-
store: origin,
|
|
236
|
-
query,
|
|
237
|
-
prices: prices ? { market: prices.market, market_name: prices.market_name, currency: prices.currency, countries: prices.countries, tax_included: prices.tax_included } : undefined,
|
|
238
|
-
filters: Object.keys(filters).length ? { categories: filters.categories, min_price: f.min, max_price: f.max, currency: body.context?.currency } : undefined,
|
|
239
|
-
count: products.length,
|
|
240
|
-
total_count: r.pagination?.total_count,
|
|
241
|
-
has_more: r.pagination?.has_next_page || undefined,
|
|
242
|
-
products: products.map((p) => productView(p)),
|
|
243
|
-
messages: r.messages,
|
|
244
|
-
signature: sign(call),
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
export async function product(origin, id, country) {
|
|
248
|
-
const store = await discover(origin);
|
|
249
|
-
const body = { id };
|
|
250
|
-
if (country)
|
|
251
|
-
body.context = { address_country: country.toUpperCase() };
|
|
252
|
-
const call = await step(origin, () => shopping.product(store, body));
|
|
253
|
-
return { store: origin, product: productView(call.body.product, true), signature: sign(call) };
|
|
254
|
-
}
|
|
255
|
-
export async function createCheckout(origin, items, codes, country) {
|
|
256
|
-
const store = await open(origin);
|
|
257
|
-
const body = { line_items: items.map((l) => ({ item: { id: l.id }, quantity: l.quantity })) };
|
|
258
|
-
if (country)
|
|
259
|
-
body.context = { address_country: country.toUpperCase() };
|
|
260
|
-
if (codes.length)
|
|
261
|
-
body.discounts = { codes };
|
|
262
|
-
const call = await step(origin, () => shopping.createCheckout(store, body));
|
|
263
|
-
remember(call.body.id, origin);
|
|
264
|
-
return checkoutView(call.body, origin, call);
|
|
265
|
-
}
|
|
266
|
-
export async function getCheckout(origin, id) {
|
|
267
|
-
const store = await open(origin);
|
|
268
|
-
const call = await step(origin, () => shopping.getCheckout(store, id));
|
|
269
|
-
return checkoutView(call.body, origin, call);
|
|
270
|
-
}
|
|
271
|
-
const clean = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== ''));
|
|
272
|
-
export async function updateCheckout(origin, id, ch) {
|
|
273
|
-
const store = await open(origin);
|
|
274
|
-
const current = (await step(origin, () => shopping.getCheckout(store, id))).body;
|
|
275
|
-
if (current.status === 'completed' || current.status === 'canceled')
|
|
276
|
-
fail('checkout_closed', `The checkout ${id} is ${current.status}.`, current.status === 'completed' ? `It is an order now: c11r ucp order ${current.order?.id ?? '<id>'}.` : 'Open a new one: c11r ucp checkout create --item <sku>.');
|
|
277
|
-
const body = replay(current);
|
|
278
|
-
if (ch.buyer)
|
|
279
|
-
body.buyer = clean({ ...(current.buyer ?? {}), ...ch.buyer });
|
|
280
|
-
for (const l of ch.items ?? []) {
|
|
281
|
-
const i = body.line_items.findIndex((x) => x.item.id === l.id);
|
|
282
|
-
if (l.quantity === 0) {
|
|
283
|
-
if (i >= 0)
|
|
284
|
-
body.line_items.splice(i, 1);
|
|
285
|
-
}
|
|
286
|
-
else if (i >= 0)
|
|
287
|
-
body.line_items[i].quantity = l.quantity;
|
|
288
|
-
else
|
|
289
|
-
body.line_items.push({ item: { id: l.id }, quantity: l.quantity });
|
|
290
|
-
}
|
|
291
|
-
if (!body.line_items.length)
|
|
292
|
-
fail('invalid_argument', 'The update leaves the checkout with no items.', `Keep at least one item, or cancel it: c11r ucp checkout cancel ${id}.`);
|
|
293
|
-
const codes = ch.codes ?? [...(current.discounts?.codes ?? []), ...(ch.add_codes ?? [])];
|
|
294
|
-
if (ch.codes || ch.add_codes?.length)
|
|
295
|
-
body.discounts = { codes: [...new Set(codes)] };
|
|
296
|
-
if (ch.address || ch.shipping) {
|
|
297
|
-
const method = body.fulfillment?.methods?.[0] ?? { type: 'shipping', line_item_ids: (current.line_items ?? []).map((l) => l.id) };
|
|
298
|
-
if (ch.address) {
|
|
299
|
-
const prev = selectedDestination(current) ?? {};
|
|
300
|
-
const buyer = (body.buyer ?? {});
|
|
301
|
-
const dest = clean({ ...prev, first_name: buyer.first_name, last_name: buyer.last_name, ...ch.address, id: prev.id ?? 'dest_1' });
|
|
302
|
-
if (dest.address_country)
|
|
303
|
-
dest.address_country = String(dest.address_country).toUpperCase();
|
|
304
|
-
delete dest.type;
|
|
305
|
-
method.destinations = [dest];
|
|
306
|
-
method.selected_destination_id = dest.id;
|
|
307
|
-
}
|
|
308
|
-
if (ch.shipping)
|
|
309
|
-
method.groups = (method.groups?.length ? method.groups : [{ id: 'grp_1' }]).map((g) => ({ ...g, selected_option_id: ch.shipping }));
|
|
310
|
-
body.fulfillment = { methods: [method, ...(body.fulfillment?.methods?.slice(1) ?? [])] };
|
|
311
|
-
}
|
|
312
|
-
let call = await step(origin, () => shopping.updateCheckout(store, id, body));
|
|
313
|
-
// A store that offers shipping options without picking one: take its first, and say which in the result.
|
|
314
|
-
const group = call.body.fulfillment?.methods?.[0]?.groups?.[0];
|
|
315
|
-
if (group && !group.selected_option_id && group.options?.length) {
|
|
316
|
-
const again = replay(call.body);
|
|
317
|
-
again.fulfillment.methods[0].groups[0].selected_option_id = group.options[0].id;
|
|
318
|
-
call = await step(origin, () => shopping.updateCheckout(store, id, again));
|
|
319
|
-
}
|
|
320
|
-
// A store can answer ready_for_complete before the shipping is in the totals (seen once, since
|
|
321
|
-
// fixed on the store host): read it again rather than show the human a total without shipping.
|
|
322
|
-
const ck = call.body;
|
|
323
|
-
if (ck.status === 'ready_for_complete' && ck.fulfillment?.methods?.[0]?.groups?.[0]?.selected_option_id && !(ck.totals ?? []).some((t) => t.type === 'fulfillment')) {
|
|
324
|
-
call = await step(origin, () => shopping.getCheckout(store, id));
|
|
325
|
-
}
|
|
326
|
-
return checkoutView(call.body, origin, call);
|
|
327
|
-
}
|
|
328
|
-
export async function cancelCheckout(origin, id) {
|
|
329
|
-
const store = await open(origin);
|
|
330
|
-
const call = await step(origin, () => shopping.cancelCheckout(store, id));
|
|
331
|
-
return checkoutView(call.body, origin, call);
|
|
332
|
-
}
|
|
333
|
-
export async function completeCheckout(origin, id, decline) {
|
|
334
|
-
const store = await open(origin);
|
|
335
|
-
if (!store.profile.ucp?.payment_handlers?.[SANDBOX_HANDLER]) {
|
|
336
|
-
const handlers = Object.keys(store.profile.ucp?.payment_handlers ?? {});
|
|
337
|
-
fail('payment_handler_unsupported', `The store at ${origin} takes ${handlers.length ? handlers.join(', ') : 'no payment handler this CLI knows'}; this CLI pays only with the ${SANDBOX_HANDLER} test handler today.`, `Give the human the checkout's continue_url to pay there: c11r ucp checkout show ${id}.`);
|
|
338
|
-
}
|
|
339
|
-
const call = await step(origin, () => shopping.completeCheckout(store, id, { payment: { instruments: [sandboxInstrument(decline)] }, signals: { 'dev.ucp.user_agent': 'c11r' } }));
|
|
340
|
-
const ck = call.body;
|
|
341
|
-
if (ck.order?.id)
|
|
342
|
-
remember(ck.order.id, origin);
|
|
343
|
-
return checkoutView(ck, origin, call);
|
|
344
|
-
}
|
|
345
|
-
export async function getOrder(origin, id) {
|
|
346
|
-
const store = await discover(origin);
|
|
347
|
-
const call = await step(origin, () => shopping.getOrder(store, id));
|
|
348
|
-
return orderView(call.body, origin, call);
|
|
349
|
-
}
|
|
350
|
-
export async function ask(origin, question) {
|
|
351
|
-
const store = await discover(origin);
|
|
352
|
-
if (!store.capabilities.has('dev.ucp.common.ask'))
|
|
353
|
-
fail('ask_unsupported', `The store at ${origin} does not answer questions over UCP.`, 'Search its catalog instead: c11r ucp search <query>.');
|
|
354
|
-
const call = await step(origin, () => shopping.ask(store, { query: question }));
|
|
355
|
-
const r = call.body;
|
|
356
|
-
return { store: origin, question, answer: plain(r.answer), links: r.links, messages: r.messages, signature: sign(call) };
|
|
357
|
-
}
|
package/dist/ucp/signing.js
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// HTTP message signatures (RFC 9421) and content digests (RFC 9530) as UCP uses
|
|
2
|
-
// them: ES256 over a signature base, raw r||s encoding, keys published as JWKs.
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
export function contentDigest(body) {
|
|
5
|
-
return `sha-256=:${crypto.createHash('sha256').update(body).digest('base64')}:`;
|
|
6
|
-
}
|
|
7
|
-
/** The inner-list serialization that goes in Signature-Input and in the base. */
|
|
8
|
-
export function serializeParams(p) {
|
|
9
|
-
let s = `(${p.components.map((c) => `"${c}"`).join(' ')})`;
|
|
10
|
-
if (p.created !== undefined)
|
|
11
|
-
s += `;created=${p.created}`;
|
|
12
|
-
s += `;keyid="${p.keyid}"`;
|
|
13
|
-
return s;
|
|
14
|
-
}
|
|
15
|
-
/** The signature base: one line per covered component, then @signature-params. */
|
|
16
|
-
export function signatureBase(values, p) {
|
|
17
|
-
const lines = p.components.map((c) => `"${c}": ${values[c] ?? ''}`);
|
|
18
|
-
lines.push(`"@signature-params": ${serializeParams(p)}`);
|
|
19
|
-
return lines.join('\n');
|
|
20
|
-
}
|
|
21
|
-
export function generateKey(kid) {
|
|
22
|
-
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
23
|
-
const privateJwk = privateKey.export({ format: 'jwk' });
|
|
24
|
-
const publicJwk = { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'ES256' };
|
|
25
|
-
return { privateJwk, publicJwk };
|
|
26
|
-
}
|
|
27
|
-
export function sign(base, privateJwk) {
|
|
28
|
-
const key = crypto.createPrivateKey({ key: privateJwk, format: 'jwk' });
|
|
29
|
-
return crypto.sign('sha256', Buffer.from(base), { key, dsaEncoding: 'ieee-p1363' }).toString('base64');
|
|
30
|
-
}
|
|
31
|
-
export function verify(base, signatureBase64, publicJwk) {
|
|
32
|
-
try {
|
|
33
|
-
const key = crypto.createPublicKey({ key: publicJwk, format: 'jwk' });
|
|
34
|
-
return crypto.verify('sha256', Buffer.from(base), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(signatureBase64, 'base64'));
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
/** Build Signature-Input and Signature headers for a message. */
|
|
41
|
-
export function signHeaders(label, values, p, privateJwk) {
|
|
42
|
-
return {
|
|
43
|
-
'signature-input': `${label}=${serializeParams(p)}`,
|
|
44
|
-
signature: `${label}=:${sign(signatureBase(values, p), privateJwk)}:`,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
/** Parse one signature out of Signature-Input / Signature headers (the first label). */
|
|
48
|
-
export function parseSignature(signatureInput, signature) {
|
|
49
|
-
if (!signatureInput || !signature)
|
|
50
|
-
return null;
|
|
51
|
-
const input = signatureInput.match(/^\s*([a-z0-9_-]+)=\(([^)]*)\)((?:;[a-z]+=[^;]*)*)\s*$/i);
|
|
52
|
-
if (!input)
|
|
53
|
-
return null;
|
|
54
|
-
const [, label, list, paramStr] = input;
|
|
55
|
-
const components = [...list.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
56
|
-
const params = { components, keyid: '' };
|
|
57
|
-
for (const [, k, v] of paramStr.matchAll(/;([a-z]+)=("[^"]*"|[^;]*)/g)) {
|
|
58
|
-
if (k === 'keyid')
|
|
59
|
-
params.keyid = v.replace(/^"|"$/g, '');
|
|
60
|
-
if (k === 'created')
|
|
61
|
-
params.created = Number(v);
|
|
62
|
-
}
|
|
63
|
-
const sig = signature.match(new RegExp(`${label}=:([A-Za-z0-9+/=]+):`));
|
|
64
|
-
if (!sig)
|
|
65
|
-
return null;
|
|
66
|
-
return { label, params, signature: sig[1] };
|
|
67
|
-
}
|