@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,504 @@
|
|
|
1
|
+
// c11r shop (shop-agent-stores): the agent buys for its human from any store that speaks UCP.
|
|
2
|
+
// Built in: the CLI talks to the store directly, needs no organization (state none), and holds no
|
|
3
|
+
// keys (see ucp.ts). Each step is one command, so the agent talks to the human in between:
|
|
4
|
+
//
|
|
5
|
+
// profile, search, get, ask read the store; nothing of the human's is sent.
|
|
6
|
+
// checkout sends the buyer's details, the human's own words, after their yes
|
|
7
|
+
// on exactly what goes to which host. No money moves.
|
|
8
|
+
// order pays with the sandbox handler, after their yes on the total. On a
|
|
9
|
+
// checkout already paid, it answers the order and pays nothing.
|
|
10
|
+
//
|
|
11
|
+
// Only the sandbox handler: a store without it is refused before anything of the human's is sent.
|
|
12
|
+
import { confirm } from '../confirm.js';
|
|
13
|
+
import { fail } from '../errors.js';
|
|
14
|
+
import { envelope } from '../http.js';
|
|
15
|
+
import { parseSchema } from '../schema.js';
|
|
16
|
+
import { call, discover, handlersOf, minorOf, money, mustPay, originOf, sandboxPayment } from '../ucp.js';
|
|
17
|
+
const ADDRESS = [
|
|
18
|
+
{ name: 'street', type: 'string', description: 'Street and number: Via Roma 1.' },
|
|
19
|
+
{ name: 'line_2', type: 'string', description: 'Apartment, floor, care of.' },
|
|
20
|
+
{ name: 'locality', type: 'string', description: 'The city or town.' },
|
|
21
|
+
{ name: 'postal_code', type: 'string', description: 'The postal code.' },
|
|
22
|
+
{ name: 'region', type: 'string', description: 'The state or province, where the country has them: CA, TO.' },
|
|
23
|
+
{ name: 'country', type: 'string', description: 'The country, as a two-letter code: IT.' },
|
|
24
|
+
];
|
|
25
|
+
const BUYER = [
|
|
26
|
+
{ name: 'email', type: 'string', description: 'The buyer\'s email, as the human gave it.' },
|
|
27
|
+
{ name: 'first_name', type: 'string', description: 'The buyer\'s first name, as the human gave it.' },
|
|
28
|
+
{ name: 'last_name', type: 'string', description: 'The buyer\'s last name, as the human gave it.' },
|
|
29
|
+
{ name: 'phone', type: 'string', description: 'The buyer\'s phone number, as the human gave it.' },
|
|
30
|
+
{ name: 'address', type: 'object', properties: ADDRESS, description: 'Where to ship, as the human gave it: --address.street, --address.locality, --address.postal-code, --address.region, --address.country.' },
|
|
31
|
+
];
|
|
32
|
+
const STORE_ID = { accepts: ['host', 'url'], example: 'shop.example.com' };
|
|
33
|
+
export const shop = {
|
|
34
|
+
schema: parseSchema({
|
|
35
|
+
noun: 'shop',
|
|
36
|
+
group: 'shop',
|
|
37
|
+
skill: 'shop-agent-stores',
|
|
38
|
+
kind: 'task',
|
|
39
|
+
description: 'Buying for the human from any store that speaks UCP, as a shopping agent: the CLI talks to the store directly, with no organization. It pays only with the sandbox test handler, where no money moves. The buyer\'s details are the human\'s, never invented, and nothing of theirs is sent or paid without their yes.',
|
|
40
|
+
id: STORE_ID,
|
|
41
|
+
verbs: [
|
|
42
|
+
{ verb: 'profile', method: 'GET', path: '/shop/{id}/profile', state: 'none', live: false, effect: 'Reads the store\'s UCP profile: what it offers this CLI, its payment handlers, and can_pay, whether this CLI can pay there.' },
|
|
43
|
+
{
|
|
44
|
+
verb: 'search',
|
|
45
|
+
method: 'GET',
|
|
46
|
+
path: '/shop/{id}/search',
|
|
47
|
+
state: 'none',
|
|
48
|
+
live: false,
|
|
49
|
+
query: [
|
|
50
|
+
{ name: 'query', type: 'string', description: 'What the human looks for, in their words.' },
|
|
51
|
+
{ name: 'country', type: 'string', description: 'The country to ship to, as a two-letter code: prices and shipping for it.' },
|
|
52
|
+
{ name: 'category', type: 'array', items: 'string', description: 'A category or a parent of one (Coffee for Coffee > Single origin), repeated: any of them matches.' },
|
|
53
|
+
{ name: 'min_price', type: 'number', description: 'The lowest price, in major units of the store\'s currency.' },
|
|
54
|
+
{ name: 'max_price', type: 'number', description: 'The highest price, in major units of the store\'s currency.' },
|
|
55
|
+
{ name: 'in_stock', type: 'boolean', description: 'Only what is in stock.' },
|
|
56
|
+
{ name: 'sort', type: 'enum', values: ['relevance', 'price_asc', 'price_desc'], description: 'The order of the results.' },
|
|
57
|
+
{ name: 'limit', type: 'integer', description: 'How many products, 50 at most. Default 20.' },
|
|
58
|
+
],
|
|
59
|
+
effect: 'Searches the store\'s catalog: products with their variants, prices, availability and images. A variant id is what checkout --item takes.',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
verb: 'get',
|
|
63
|
+
method: 'GET',
|
|
64
|
+
path: '/shop/{id}/get',
|
|
65
|
+
state: 'none',
|
|
66
|
+
live: false,
|
|
67
|
+
query: [
|
|
68
|
+
{ name: 'variant', type: 'array', items: 'string', required: true, description: 'A variant id from search, repeated for several. The first bare value after the store is one.' },
|
|
69
|
+
{ name: 'country', type: 'string', description: 'The country to ship to, as a two-letter code.' },
|
|
70
|
+
],
|
|
71
|
+
effect: 'Looks variants up by id: the full product, its price, availability and how it ships.',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
verb: 'ask',
|
|
75
|
+
method: 'GET',
|
|
76
|
+
path: '/shop/{id}/ask',
|
|
77
|
+
state: 'none',
|
|
78
|
+
live: false,
|
|
79
|
+
query: [{ name: 'question', type: 'string', required: true, description: 'The human\'s question, in their words. The first bare value after the store.' }],
|
|
80
|
+
effect: 'Asks the store a question in plain words, when it answers them over UCP (dev.ucp.common.ask).',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
verb: 'checkout',
|
|
84
|
+
method: 'POST',
|
|
85
|
+
path: '/shop/{id}/checkout',
|
|
86
|
+
state: 'none',
|
|
87
|
+
live: false,
|
|
88
|
+
body: [
|
|
89
|
+
{ name: 'checkout', type: 'string', description: 'A checkout to change or read again, by the id checkout answered. The first bare value after the store. Without it, a new checkout.' },
|
|
90
|
+
{ name: 'item', type: 'array', items: 'string', description: 'A variant id and its quantity, ID=QTY (ID alone is 1), repeated. On a checkout given, QTY 0 takes it out.' },
|
|
91
|
+
...BUYER,
|
|
92
|
+
{ name: 'shipping', type: 'string', description: 'A shipping option id from shipping_options. Without it, the store\'s first.' },
|
|
93
|
+
],
|
|
94
|
+
effect: 'Opens a checkout, or changes one: items, the buyer and where to ship. No money moves. The buyer\'s details go to the store only after the human\'s yes on exactly what is shared and with which host; ask the human for each, never invent one. Answers the totals with shipping, and what the store still needs.',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
verb: 'order',
|
|
98
|
+
method: 'POST',
|
|
99
|
+
path: '/shop/{id}/order',
|
|
100
|
+
state: 'none',
|
|
101
|
+
live: false,
|
|
102
|
+
body: [{ name: 'checkout', type: 'string', required: true, description: 'The checkout to pay, by the id checkout answered. The first bare value after the store.' }],
|
|
103
|
+
effect: 'Pays a checkout that is ready with the sandbox test handler (no money moves), after the human\'s yes on the total, and answers the order. On a checkout already paid, answers its order and pays nothing.',
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
}),
|
|
107
|
+
positional: { get: 'variant', ask: 'question', checkout: 'checkout', order: 'checkout' },
|
|
108
|
+
async run(run) {
|
|
109
|
+
const origin = originOf(run.parsed.id);
|
|
110
|
+
const store = await discover(origin);
|
|
111
|
+
switch (run.verb.name) {
|
|
112
|
+
case 'profile':
|
|
113
|
+
return profile(store);
|
|
114
|
+
case 'search':
|
|
115
|
+
return search(run, store);
|
|
116
|
+
case 'get':
|
|
117
|
+
return get(run, store);
|
|
118
|
+
case 'ask':
|
|
119
|
+
return ask(run, store);
|
|
120
|
+
case 'checkout':
|
|
121
|
+
return checkout(run, store);
|
|
122
|
+
case 'order':
|
|
123
|
+
return order(run, store);
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
const cmd = (store, ...rest) => ['c11r shop', ...rest.slice(0, 1), store.host, ...rest.slice(1)].join(' ');
|
|
129
|
+
const plain = (d) => (typeof d === 'string' ? d : (d?.plain ?? d?.markdown ?? d?.html));
|
|
130
|
+
const drop = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && !v.length)));
|
|
131
|
+
const upper = (v) => (typeof v === 'string' && v ? v.toUpperCase() : undefined);
|
|
132
|
+
/** An answer, with signed: false and a warning when the store did not sign it. */
|
|
133
|
+
function answer(data, c, extra = {}) {
|
|
134
|
+
const unsigned = c?.signature === 'unsigned';
|
|
135
|
+
return envelope(data, {
|
|
136
|
+
...extra,
|
|
137
|
+
warnings: [...(extra.warnings ?? []), ...(unsigned ? [{ code: 'answer_unsigned', message: 'The store did not sign this answer, so it cannot be checked as the store\'s own.' }] : [])],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// ---------- profile
|
|
141
|
+
function profile(store) {
|
|
142
|
+
const { handlers, can_pay } = handlersOf(store);
|
|
143
|
+
return envelope({
|
|
144
|
+
store: store.host,
|
|
145
|
+
name: store.profile.name ?? undefined,
|
|
146
|
+
ucp_version: store.profile.ucp?.version,
|
|
147
|
+
endpoint: store.endpoint,
|
|
148
|
+
capabilities: [...store.capabilities].sort(),
|
|
149
|
+
payment_handlers: handlers,
|
|
150
|
+
can_pay,
|
|
151
|
+
signed: store.keys.length > 0,
|
|
152
|
+
}, {
|
|
153
|
+
next: [{ command: cmd(store, 'search', '--query WORDS'), why: 'what the store sells' }],
|
|
154
|
+
warnings: can_pay ? [] : [{ code: 'cannot_pay_here', message: `This CLI pays only with the sandbox test handler; ${store.host} ${handlers.length ? `takes ${handlers.join(', ')}` : 'takes no payment over UCP'}. The human buys there in person.` }],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// ---------- reading
|
|
158
|
+
function variantView(v) {
|
|
159
|
+
const m = v.fulfillment?.methods?.[0];
|
|
160
|
+
return drop({
|
|
161
|
+
id: v.id,
|
|
162
|
+
sku: v.sku,
|
|
163
|
+
title: v.title,
|
|
164
|
+
price: v.price ? money(v.price.amount, v.price.currency) : undefined,
|
|
165
|
+
list_price: v.list_price && v.list_price.amount !== v.price?.amount ? money(v.list_price.amount, v.list_price.currency) : undefined,
|
|
166
|
+
available: v.availability?.available,
|
|
167
|
+
availability: v.availability?.status,
|
|
168
|
+
image_url: v.media?.find((x) => x.type === 'image')?.url,
|
|
169
|
+
ships_to: m?.ships_to,
|
|
170
|
+
ships_to_country: m?.destination ? m.destination.ships !== false : undefined,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function productView(p, full) {
|
|
174
|
+
const description = plain(p.description);
|
|
175
|
+
return drop({
|
|
176
|
+
id: p.id,
|
|
177
|
+
title: p.title,
|
|
178
|
+
description: full || !description || description.length <= 200 ? description : `${description.slice(0, 197)}...`,
|
|
179
|
+
url: p.url,
|
|
180
|
+
image_url: p.media?.find((x) => x.type === 'image')?.url,
|
|
181
|
+
categories: Array.isArray(p.categories) ? p.categories.map((c) => c.value ?? c) : undefined,
|
|
182
|
+
variants: (p.variants ?? []).map(variantView),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const context = (country) => (upper(country) ? { context: { address_country: upper(country) } } : {});
|
|
186
|
+
async function search(run, store) {
|
|
187
|
+
if (!store.capabilities.has('dev.ucp.shopping.catalog.search'))
|
|
188
|
+
fail('not_supported', `${store.host} offers no catalog search over UCP.`, { next_step: { action: 'human_step', url: store.origin } });
|
|
189
|
+
const v = run.parsed.values;
|
|
190
|
+
const limit = Math.min(Math.max(Number(v.limit ?? 20), 1), 50);
|
|
191
|
+
const body = { pagination: { limit }, ...context(v.country) };
|
|
192
|
+
if (typeof v.query === 'string' && v.query)
|
|
193
|
+
body.query = v.query;
|
|
194
|
+
const filters = {};
|
|
195
|
+
if (Array.isArray(v.category) && v.category.length)
|
|
196
|
+
filters.categories = v.category;
|
|
197
|
+
if (v.in_stock)
|
|
198
|
+
filters.availability = 'in_stock';
|
|
199
|
+
const sort = v.sort && v.sort !== 'relevance' ? v.sort : undefined;
|
|
200
|
+
if (sort)
|
|
201
|
+
body.sort = sort;
|
|
202
|
+
if (v.min_price !== undefined || v.max_price !== undefined) {
|
|
203
|
+
// filters.price is in minor units of the currency the store prices in here: ask it which.
|
|
204
|
+
const probe = await call(store, 'POST', `${store.endpoint}/catalog/search`, { ...body, ...(Object.keys(filters).length ? { filters } : {}), pagination: { limit: 1 } }, 'a catalog search');
|
|
205
|
+
const currency = probe.body?.products?.[0]?.variants?.[0]?.price?.currency;
|
|
206
|
+
if (currency) {
|
|
207
|
+
body.context = { ...(body.context ?? {}), currency };
|
|
208
|
+
filters.price = drop({ min: v.min_price === undefined ? undefined : minorOf(Number(v.min_price), currency), max: v.max_price === undefined ? undefined : minorOf(Number(v.max_price), currency) });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (Object.keys(filters).length)
|
|
212
|
+
body.filters = filters;
|
|
213
|
+
const c = await call(store, 'POST', `${store.endpoint}/catalog/search`, body, 'a catalog search');
|
|
214
|
+
const products = c.body?.products ?? [];
|
|
215
|
+
const first = products[0]?.variants?.[0]?.id;
|
|
216
|
+
return answer(drop({
|
|
217
|
+
store: store.host,
|
|
218
|
+
query: body.query,
|
|
219
|
+
count: products.length,
|
|
220
|
+
total_count: c.body?.pagination?.total_count,
|
|
221
|
+
has_more: c.body?.pagination?.has_next_page || undefined,
|
|
222
|
+
products: products.map((p) => productView(p, false)),
|
|
223
|
+
messages: c.body?.messages,
|
|
224
|
+
}), c, { next: first ? [{ command: cmd(store, 'get', first), why: 'the whole product, and how it ships' }] : [] });
|
|
225
|
+
}
|
|
226
|
+
async function get(run, store) {
|
|
227
|
+
if (!store.capabilities.has('dev.ucp.shopping.catalog.lookup'))
|
|
228
|
+
fail('not_supported', `${store.host} offers no catalog lookup over UCP: search finds products there.`, { next_step: { action: 'fix_request', command: cmd(store, 'search', '--query WORDS') } });
|
|
229
|
+
const ids = run.parsed.values.variant;
|
|
230
|
+
const c = await call(store, 'POST', `${store.endpoint}/catalog/lookup`, { ids, ...context(run.parsed.values.country) }, ids.length > 1 ? `variants ${ids.join(', ')}` : `variant ${ids[0]}`);
|
|
231
|
+
const products = c.body?.products ?? [];
|
|
232
|
+
const found = new Set(products.flatMap((p) => (p.variants ?? []).map((x) => x.id)));
|
|
233
|
+
const missing = ids.filter((id) => !found.has(id));
|
|
234
|
+
if (!products.length)
|
|
235
|
+
fail('not_found', `${store.host} sells no variant ${ids.join(', ')}.`, { path: 'variant', next_step: { action: 'fix_request', command: cmd(store, 'search', '--query WORDS') } });
|
|
236
|
+
return answer(drop({ store: store.host, products: products.map((p) => productView(p, true)), missing }), c, {
|
|
237
|
+
next: [{ command: cmd(store, 'checkout', `--item ${ids[0]}=QTY ${HUMAN_FLAGS}`), why: 'buy it: every detail from the human, never invented' }],
|
|
238
|
+
warnings: missing.length ? [{ code: 'not_found', message: `${store.host} did not answer ${missing.join(', ')}.`, path: 'variant' }] : [],
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async function ask(run, store) {
|
|
242
|
+
if (!store.capabilities.has('dev.ucp.common.ask'))
|
|
243
|
+
fail('not_supported', `${store.host} does not answer questions over UCP: its catalog search may.`, { next_step: { action: 'fix_request', command: cmd(store, 'search', '--query WORDS') } });
|
|
244
|
+
const question = run.parsed.values.question;
|
|
245
|
+
const c = await call(store, 'POST', `${store.commonEndpoint ?? store.endpoint}/ask`, { query: question }, 'a question');
|
|
246
|
+
return answer(drop({ store: store.host, question, answer: plain(c.body?.answer), links: c.body?.links, messages: c.body?.messages }), c);
|
|
247
|
+
}
|
|
248
|
+
function items(raw) {
|
|
249
|
+
return (raw ?? []).map((s) => {
|
|
250
|
+
const m = s.match(/^(.+?)(?:=(\d+))?$/);
|
|
251
|
+
if (!m || !m[1].trim())
|
|
252
|
+
fail('invalid_value', `--item ${s} is not ID or ID=QTY: a variant id from search, as in ETH-250=2.`, { path: 'item' });
|
|
253
|
+
return { id: m[1].trim(), quantity: m[2] === undefined ? 1 : Number(m[2]) };
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/** The buyer and the address as the flags give them, in UCP's names. Only what was given. */
|
|
257
|
+
function given(v) {
|
|
258
|
+
const a = (v.address ?? {});
|
|
259
|
+
const buyer = drop({ email: v.email, first_name: v.first_name, last_name: v.last_name, phone_number: v.phone });
|
|
260
|
+
const address = drop({ street_address: a.street, extended_address: a.line_2, address_locality: a.locality, postal_code: a.postal_code, address_region: upper(a.region), address_country: upper(a.country) });
|
|
261
|
+
return { buyer, address };
|
|
262
|
+
}
|
|
263
|
+
const NEEDED = [
|
|
264
|
+
['--email', (b) => b.email],
|
|
265
|
+
['--first-name', (b) => b.first_name],
|
|
266
|
+
['--last-name', (b) => b.last_name],
|
|
267
|
+
['--phone', (b) => b.phone_number],
|
|
268
|
+
['--address.street', (_, a) => a.street_address],
|
|
269
|
+
['--address.locality', (_, a) => a.address_locality],
|
|
270
|
+
['--address.postal-code', (_, a) => a.postal_code],
|
|
271
|
+
['--address.country', (_, a) => a.address_country],
|
|
272
|
+
];
|
|
273
|
+
/** Every detail a new checkout needs, each a CAPS word the human's value replaces. */
|
|
274
|
+
const HUMAN_FLAGS = '--email EMAIL --first-name FIRST_NAME --last-name LAST_NAME --phone PHONE --address.street STREET --address.locality CITY --address.postal-code POSTAL_CODE --address.country COUNTRY';
|
|
275
|
+
/** The flags that give what a store's messages say is missing, by the UCP field they name. */
|
|
276
|
+
const FIELD_FLAGS = [
|
|
277
|
+
[/\bemail\b/, '--email EMAIL'],
|
|
278
|
+
[/\bfirst_name\b/, '--first-name FIRST_NAME'],
|
|
279
|
+
[/\blast_name\b/, '--last-name LAST_NAME'],
|
|
280
|
+
[/\bphone(_number)?\b/, '--phone PHONE'],
|
|
281
|
+
[/\bstreet_address\b/, '--address.street STREET'],
|
|
282
|
+
[/\bextended_address\b/, '--address.line-2 LINE_2'],
|
|
283
|
+
[/\baddress_locality\b/, '--address.locality CITY'],
|
|
284
|
+
[/\bpostal_code\b/, '--address.postal-code POSTAL_CODE'],
|
|
285
|
+
[/\baddress_region\b/, '--address.region REGION'],
|
|
286
|
+
[/\baddress_country\b/, '--address.country COUNTRY'],
|
|
287
|
+
[/\bselected_option_id\b/, '--shipping OPTION_ID'],
|
|
288
|
+
[/\bline_items\b/, '--item VARIANT=QTY'],
|
|
289
|
+
];
|
|
290
|
+
/** A runnable command for what the store still needs, or null when its messages name no field. */
|
|
291
|
+
function stillNeeds(ck, store) {
|
|
292
|
+
const said = (ck.messages ?? []).map((m) => [m.path, m.code].filter(Boolean).join(' ')).join(' ');
|
|
293
|
+
const flags = FIELD_FLAGS.filter(([re]) => re.test(said)).map(([, f]) => f);
|
|
294
|
+
return flags.length ? cmd(store, 'checkout', ck.id, ...flags) : null;
|
|
295
|
+
}
|
|
296
|
+
/** What the human sees before their details go: every value, in one line. */
|
|
297
|
+
function sharing(buyer, address) {
|
|
298
|
+
const name = [buyer.first_name, buyer.last_name].filter(Boolean).join(' ');
|
|
299
|
+
const place = [[address.postal_code, address.address_locality].filter(Boolean).join(' '), address.address_region, address.address_country].filter(Boolean).join(' ');
|
|
300
|
+
return [name, buyer.email, buyer.phone_number, address.street_address, address.extended_address, place].filter(Boolean).join(', ');
|
|
301
|
+
}
|
|
302
|
+
/** The update payload for a checkout: everything the platform may write, sent whole again. */
|
|
303
|
+
function replay(ck) {
|
|
304
|
+
const out = { line_items: (ck.line_items ?? []).map((l) => ({ id: l.id, item: { id: l.item?.id }, quantity: l.quantity })) };
|
|
305
|
+
for (const k of ['buyer', 'context'])
|
|
306
|
+
if (ck[k] !== undefined)
|
|
307
|
+
out[k] = ck[k];
|
|
308
|
+
if (ck.discounts?.codes?.length)
|
|
309
|
+
out.discounts = { codes: ck.discounts.codes };
|
|
310
|
+
if (ck.fulfillment?.methods) {
|
|
311
|
+
out.fulfillment = {
|
|
312
|
+
methods: ck.fulfillment.methods.map((m) => ({
|
|
313
|
+
id: m.id,
|
|
314
|
+
type: m.type,
|
|
315
|
+
line_item_ids: m.line_item_ids,
|
|
316
|
+
destinations: m.destinations,
|
|
317
|
+
selected_destination_id: m.selected_destination_id ?? null,
|
|
318
|
+
groups: (m.groups ?? []).map((g) => ({ id: g.id, selected_option_id: g.selected_option_id ?? null })),
|
|
319
|
+
})),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
const destinationOf = (ck) => {
|
|
325
|
+
const m = ck.fulfillment?.methods?.[0];
|
|
326
|
+
return m?.destinations?.find((d) => d.id === m.selected_destination_id);
|
|
327
|
+
};
|
|
328
|
+
function checkoutView(ck, store) {
|
|
329
|
+
const cur = ck.currency;
|
|
330
|
+
const group = ck.fulfillment?.methods?.[0]?.groups?.[0];
|
|
331
|
+
const options = group?.options ?? [];
|
|
332
|
+
const option = options.find((o) => o.id === group?.selected_option_id);
|
|
333
|
+
const totalOf = (t) => t?.find((x) => x.type === 'total')?.amount;
|
|
334
|
+
const optionView = (o) => drop({ id: o.id, title: o.title, description: plain(o.description), price: totalOf(o.totals) === undefined ? undefined : money(totalOf(o.totals), cur) });
|
|
335
|
+
const { id: _id, type: _type, ...ship_to } = destinationOf(ck) ?? {};
|
|
336
|
+
const total = totalOf(ck.totals);
|
|
337
|
+
return drop({
|
|
338
|
+
id: ck.id,
|
|
339
|
+
status: ck.status,
|
|
340
|
+
store: store.host,
|
|
341
|
+
currency: cur,
|
|
342
|
+
line_items: (ck.line_items ?? []).map((l) => drop({ item_id: l.item?.id, title: l.item?.title, quantity: l.quantity, unit_price: l.item?.price === undefined ? undefined : money(l.item.price, cur), total: totalOf(l.totals) === undefined ? undefined : money(totalOf(l.totals), cur) })),
|
|
343
|
+
buyer: ck.buyer ? drop(Object.fromEntries(Object.entries(ck.buyer).filter(([k]) => k !== 'consent'))) : undefined,
|
|
344
|
+
ship_to: Object.keys(ship_to).length ? ship_to : undefined,
|
|
345
|
+
shipping: option ? optionView(option) : undefined,
|
|
346
|
+
shipping_options: options.length > 1 ? options.map(optionView) : undefined,
|
|
347
|
+
totals: (ck.totals ?? []).map((t) => drop({ type: t.type, label: t.display_text, ...money(t.amount, cur) })),
|
|
348
|
+
total: total === undefined ? undefined : money(total, cur),
|
|
349
|
+
messages: ck.messages,
|
|
350
|
+
order: ck.order,
|
|
351
|
+
continue_url: ck.continue_url,
|
|
352
|
+
expires_at: ck.expires_at,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
function nextOf(ck, store) {
|
|
356
|
+
if (ck.status === 'ready_for_complete')
|
|
357
|
+
return [{ command: cmd(store, 'order', ck.id), why: `pay ${ck.totals ? money(ck.totals.find((t) => t.type === 'total')?.amount ?? 0, ck.currency).display : 'the total'} with the sandbox test handler, after the human's yes on it` }];
|
|
358
|
+
if (ck.status === 'completed' && ck.order?.id)
|
|
359
|
+
return [{ command: cmd(store, 'order', ck.id), why: 'the order' }];
|
|
360
|
+
const needs = stillNeeds(ck, store);
|
|
361
|
+
return needs ? [{ command: needs, why: 'what the store says in messages is missing, from the human' }] : [];
|
|
362
|
+
}
|
|
363
|
+
async function checkout(run, store) {
|
|
364
|
+
const v = run.parsed.values;
|
|
365
|
+
const id = v.checkout;
|
|
366
|
+
// Nothing of the human's goes to a store this CLI cannot pay at.
|
|
367
|
+
mustPay(store);
|
|
368
|
+
const lines = items(v.item);
|
|
369
|
+
const { buyer, address } = given(v);
|
|
370
|
+
const current = id ? (await call(store, 'GET', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}`, undefined, `checkout ${id}`)).body : null;
|
|
371
|
+
if (current && (current.status === 'completed' || current.status === 'canceled')) {
|
|
372
|
+
if (lines.length || Object.keys(buyer).length || Object.keys(address).length || v.shipping) {
|
|
373
|
+
fail('invalid_transition', `Checkout ${id} is ${current.status}: it takes no changes.`, { details: { status: current.status }, next_step: current.status === 'completed' ? { action: 'fix_request', command: cmd(store, 'order', id) } : { action: 'fix_request', command: cmd(store, 'checkout', `--item VARIANT=QTY ${HUMAN_FLAGS}`) } }, 'rejected');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (!current) {
|
|
377
|
+
if (!lines.length)
|
|
378
|
+
fail('missing_value', 'A new checkout needs at least one --item: a variant id from search, as in --item ETH-250=1.', { path: 'item', next_step: { action: 'fix_request', command: cmd(store, 'search', '--query WORDS') } });
|
|
379
|
+
const missing = NEEDED.filter(([, pick]) => !pick(buyer, address)).map(([f]) => f);
|
|
380
|
+
if (missing.length) {
|
|
381
|
+
fail('missing_value', `A checkout needs the buyer's ${missing.join(', ')}. Ask the human for each: they are the human's, never invented.`, { path: missing[0].replace(/^--/, '').replace(/-/g, '_'), details: { missing }, next_step: { action: 'human_step' } });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// A read: a checkout given with nothing to change.
|
|
385
|
+
if (current && !lines.length && !Object.keys(buyer).length && !Object.keys(address).length && !v.shipping) {
|
|
386
|
+
return envelope(checkoutView(current, store), { next: nextOf(current, store) });
|
|
387
|
+
}
|
|
388
|
+
// What goes to the store of the human's: every value, shown back to them first.
|
|
389
|
+
const shared = Object.keys(buyer).length || Object.keys(address).length ? sharing(buyer, address) : null;
|
|
390
|
+
if (shared && !run.globals.dryRun) {
|
|
391
|
+
await confirm(`Share the buyer's details with ${store.host}`, run.line, { yes: run.globals.yes, prompter: run.prompter, sentence: `Sharing with ${store.host}: ${shared}`, details: { store: store.host, buyer, ship_to: address } });
|
|
392
|
+
}
|
|
393
|
+
const body = current ? replay(current) : { line_items: lines.map((l) => ({ item: { id: l.id }, quantity: l.quantity })), ...context(address.address_country) };
|
|
394
|
+
if (current) {
|
|
395
|
+
for (const l of lines) {
|
|
396
|
+
const i = body.line_items.findIndex((x) => x.item.id === l.id);
|
|
397
|
+
if (l.quantity === 0) {
|
|
398
|
+
if (i >= 0)
|
|
399
|
+
body.line_items.splice(i, 1);
|
|
400
|
+
}
|
|
401
|
+
else if (i >= 0)
|
|
402
|
+
body.line_items[i].quantity = l.quantity;
|
|
403
|
+
else
|
|
404
|
+
body.line_items.push({ item: { id: l.id }, quantity: l.quantity });
|
|
405
|
+
}
|
|
406
|
+
if (!body.line_items.length)
|
|
407
|
+
fail('invalid_value', `That leaves checkout ${id} with no items: keep one, or let it expire.`, { path: 'item' });
|
|
408
|
+
}
|
|
409
|
+
if (Object.keys(buyer).length)
|
|
410
|
+
body.buyer = drop({ ...(body.buyer ?? {}), ...buyer });
|
|
411
|
+
if (Object.keys(address).length || v.shipping) {
|
|
412
|
+
const method = body.fulfillment?.methods?.[0] ?? { type: 'shipping' };
|
|
413
|
+
if (Object.keys(address).length) {
|
|
414
|
+
const prev = (current && destinationOf(current)) ?? {};
|
|
415
|
+
const b = (body.buyer ?? {});
|
|
416
|
+
const dest = drop({ ...prev, first_name: b.first_name, last_name: b.last_name, phone_number: b.phone_number, ...address, id: prev.id ?? 'dest_1' });
|
|
417
|
+
delete dest.type;
|
|
418
|
+
method.destinations = [dest];
|
|
419
|
+
method.selected_destination_id = dest.id;
|
|
420
|
+
if (address.address_country)
|
|
421
|
+
body.context = { ...(body.context ?? {}), address_country: address.address_country };
|
|
422
|
+
}
|
|
423
|
+
if (v.shipping)
|
|
424
|
+
method.groups = (method.groups?.length ? method.groups : [{ id: 'grp_1' }]).map((g) => ({ ...g, selected_option_id: v.shipping }));
|
|
425
|
+
body.fulfillment = { methods: [method, ...(body.fulfillment?.methods?.slice(1) ?? [])] };
|
|
426
|
+
}
|
|
427
|
+
if (run.globals.dryRun)
|
|
428
|
+
return envelope({ dry_run: true, store: store.host, checkout: id ?? null, would_send: body, sharing: shared });
|
|
429
|
+
let c;
|
|
430
|
+
if (current)
|
|
431
|
+
c = await call(store, 'PUT', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}`, body, `checkout ${id}`);
|
|
432
|
+
else {
|
|
433
|
+
// A new checkout: the items first, then the buyer and the address on it, as the store takes them.
|
|
434
|
+
const { buyer: b, fulfillment: f, ...open } = body;
|
|
435
|
+
c = await call(store, 'POST', `${store.endpoint}/checkout-sessions`, open, 'a new checkout');
|
|
436
|
+
const again = replay(c.body);
|
|
437
|
+
if (b)
|
|
438
|
+
again.buyer = b;
|
|
439
|
+
if (f) {
|
|
440
|
+
const m = f.methods[0];
|
|
441
|
+
again.fulfillment = { methods: [{ ...m, line_item_ids: (c.body.line_items ?? []).map((l) => l.id) }] };
|
|
442
|
+
}
|
|
443
|
+
c = await call(store, 'PUT', `${store.endpoint}/checkout-sessions/${encodeURIComponent(c.body.id)}`, again, `checkout ${c.body.id}`);
|
|
444
|
+
}
|
|
445
|
+
// A store that offers shipping options without picking one: its first, said in the answer.
|
|
446
|
+
const group = c.body.fulfillment?.methods?.[0]?.groups?.[0];
|
|
447
|
+
if (group && !group.selected_option_id && group.options?.length) {
|
|
448
|
+
const again = replay(c.body);
|
|
449
|
+
again.fulfillment.methods[0].groups[0].selected_option_id = group.options[0].id;
|
|
450
|
+
c = await call(store, 'PUT', `${store.endpoint}/checkout-sessions/${encodeURIComponent(c.body.id)}`, again, `checkout ${c.body.id}`);
|
|
451
|
+
}
|
|
452
|
+
const ck = c.body;
|
|
453
|
+
return answer(checkoutView(ck, store), c, { next: nextOf(ck, store) });
|
|
454
|
+
}
|
|
455
|
+
// ---------- order
|
|
456
|
+
function orderView(o, store, checkoutId) {
|
|
457
|
+
const cur = o.currency;
|
|
458
|
+
const totalOf = (t) => t?.find((x) => x.type === 'total')?.amount;
|
|
459
|
+
return drop({
|
|
460
|
+
id: o.id,
|
|
461
|
+
label: o.label,
|
|
462
|
+
permalink_url: o.permalink_url,
|
|
463
|
+
store: store.host,
|
|
464
|
+
checkout: checkoutId,
|
|
465
|
+
currency: cur,
|
|
466
|
+
line_items: (o.line_items ?? []).map((l) => drop({ item_id: l.item?.id, title: l.item?.title, quantity: l.quantity?.total ?? l.quantity, status: l.status, total: totalOf(l.totals) === undefined || !cur ? undefined : money(totalOf(l.totals), cur) })),
|
|
467
|
+
fulfillment: (o.fulfillment?.expectations ?? []).map((e) => drop({ method: e.method_type, destination: e.destination, description: plain(e.description) })),
|
|
468
|
+
totals: cur ? (o.totals ?? []).map((t) => drop({ type: t.type, label: t.display_text, ...money(t.amount, cur) })) : undefined,
|
|
469
|
+
total: cur && totalOf(o.totals) !== undefined ? money(totalOf(o.totals), cur) : undefined,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
async function placed(store, ck, paid) {
|
|
473
|
+
const ref = ck.order;
|
|
474
|
+
const c = await call(store, 'GET', `${store.endpoint}/orders/${encodeURIComponent(ref.id)}`, undefined, `order ${ref.id}`);
|
|
475
|
+
return answer({ ...orderView({ label: ref.label, permalink_url: ref.permalink_url, ...c.body }, store, ck.id), paid_now: paid }, c);
|
|
476
|
+
}
|
|
477
|
+
async function order(run, store) {
|
|
478
|
+
const id = run.parsed.values.checkout;
|
|
479
|
+
const ck = (await call(store, 'GET', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}`, undefined, `checkout ${id}`)).body;
|
|
480
|
+
// Paid already: the order, and nothing paid again.
|
|
481
|
+
if (ck.status === 'completed' && ck.order?.id)
|
|
482
|
+
return placed(store, ck, false);
|
|
483
|
+
if (ck.status === 'canceled')
|
|
484
|
+
fail('invalid_transition', `Checkout ${id} was canceled: it cannot be paid.`, { details: { status: ck.status }, next_step: { action: 'fix_request', command: cmd(store, 'checkout', `--item VARIANT=QTY ${HUMAN_FLAGS}`) } }, 'rejected');
|
|
485
|
+
mustPay(store, { continue_url: ck.continue_url ?? null });
|
|
486
|
+
if (ck.status !== 'ready_for_complete') {
|
|
487
|
+
const said = (ck.messages ?? []).map((m) => m.content).filter(Boolean).join(' ');
|
|
488
|
+
fail('invalid_transition', `Checkout ${id} is ${ck.status}, not ready to pay${said ? `: ${said}` : '.'}`, { details: { status: ck.status, messages: ck.messages ?? [] }, next_step: stillNeeds(ck, store) ? { action: 'fix_request', command: stillNeeds(ck, store) } : { action: 'human_step', url: ck.continue_url ?? null } }, 'rejected');
|
|
489
|
+
}
|
|
490
|
+
const totalAmount = (ck.totals ?? []).find((t) => t.type === 'total')?.amount;
|
|
491
|
+
const total = totalAmount === undefined ? null : money(totalAmount, ck.currency);
|
|
492
|
+
const sentence = `Paying ${total?.display ?? 'the total'} to ${store.host} for checkout ${id}, with the sandbox test handler (no money moves)`;
|
|
493
|
+
if (run.globals.dryRun)
|
|
494
|
+
return envelope({ dry_run: true, store: store.host, checkout: id, total, would_pay: true });
|
|
495
|
+
await confirm(`Pay checkout ${id}`, run.line, { yes: run.globals.yes, prompter: run.prompter, sentence, details: { store: store.host, checkout: id, total } });
|
|
496
|
+
const c = await call(store, 'POST', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}/complete`, sandboxBody(), `checkout ${id}`);
|
|
497
|
+
const done = c.body;
|
|
498
|
+
if (done.status !== 'completed' || !done.order?.id) {
|
|
499
|
+
const said = (done.messages ?? []).map((m) => m.content).filter(Boolean).join(' ');
|
|
500
|
+
fail('store_refused', `${store.host} did not take the payment for checkout ${id}${said ? `: ${said}` : '.'}`, { details: { status: done.status, messages: done.messages ?? [] }, next_step: { action: 'human_step', url: done.continue_url ?? null } }, 'rejected');
|
|
501
|
+
}
|
|
502
|
+
return placed(store, done, true);
|
|
503
|
+
}
|
|
504
|
+
const sandboxBody = () => ({ ...sandboxPayment(), signals: { 'dev.ucp.user_agent': 'c11r' } });
|