@commercelayer/agent-cli 0.3.0 → 0.5.0
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 +37 -11
- package/dist/api/client.js +59 -0
- package/dist/api/stub/locale.js +52 -0
- package/dist/api/stub/management.js +43 -3
- package/dist/api/stub/orders.js +359 -0
- package/dist/api/stub/search.js +78 -0
- package/dist/api/stub/shopping.js +85 -18
- package/dist/api/stub/theme.js +121 -31
- package/dist/api/stub/ucp.js +20 -7
- package/dist/bin.js +15 -5
- package/dist/catalog.js +40 -4
- package/dist/commands.js +162 -32
- package/dist/languages.js +49 -0
- package/dist/locales.js +43 -0
- package/dist/products.js +15 -5
- package/dist/store.js +126 -11
- package/dist/ucp/shop.js +66 -19
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -5,18 +5,26 @@
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import YAML from 'yaml';
|
|
8
|
+
import { DEFAULT_LANGUAGE, ISO_639_1, PLAIN, TRANSLATED } from './languages.js';
|
|
9
|
+
import { localesOf } from './locales.js';
|
|
8
10
|
import { fail } from './output.js';
|
|
9
11
|
import { paths } from './files.js';
|
|
10
12
|
import { productFiles, readProductFile } from './products.js';
|
|
11
13
|
/** The keys a product may have. Stock is not one: inventory lives in Commerce Layer. */
|
|
12
14
|
export const PRODUCT_KEYS = ['sku', 'name', 'description', 'image_url', 'category', 'prices'];
|
|
13
|
-
export const THEME_KEYS = ['color', 'logo', 'hero', 'tagline'];
|
|
15
|
+
export const THEME_KEYS = ['color', 'logo', 'hero', 'tagline', 'badge'];
|
|
16
|
+
export const STOREFRONT_MODES = ['hosted', 'agent-only'];
|
|
17
|
+
export const storefrontMode = (s) => s.storefront?.mode ?? 'hosted';
|
|
14
18
|
export const template = (name) => `# Commerce Layer store. This file is the store: edit it, then run
|
|
15
19
|
# c11r plan to see what will change
|
|
16
20
|
# c11r apply to make the change
|
|
17
21
|
# Every product needs a price in the currency of every market.
|
|
18
22
|
|
|
19
23
|
name: ${name}
|
|
24
|
+
# language: en # the products' language, ISO 639-1 (default en). Other languages
|
|
25
|
+
# # go in the products: name: { en: Ethiopia, it: Etiopia }, and the same
|
|
26
|
+
# # for description and category; in CSV, name_it, description_it, category_it.
|
|
27
|
+
# # The website has a page per country, in that country's language.
|
|
20
28
|
|
|
21
29
|
markets:
|
|
22
30
|
# One market per region you sell in. countries are ISO 3166 codes.
|
|
@@ -49,7 +57,8 @@ catalog:
|
|
|
49
57
|
# source: sheets # a Google Sheet published as CSV: url is its CSV link
|
|
50
58
|
# source: supabase # a Supabase table: table is its name (default products);
|
|
51
59
|
# # C11R_SUPABASE_URL and C11R_SUPABASE_KEY in .commercelayer/credentials
|
|
52
|
-
# source: merchant # a Google Merchant Center feed, RSS (XML) or TSV: url is the feed
|
|
60
|
+
# source: merchant # a Google Merchant Center feed, RSS (XML) or TSV: url is the feed;
|
|
61
|
+
# # translations: { it: <the it feed url> } adds other languages, by id
|
|
53
62
|
# url: https://...
|
|
54
63
|
# table: products
|
|
55
64
|
|
|
@@ -60,8 +69,12 @@ theme:
|
|
|
60
69
|
# logo: img/logo.svg # or an https:// URL; without it the name is the logo
|
|
61
70
|
# hero: img/hero.jpg # the home page's wide image; without it a band in the accent
|
|
62
71
|
# tagline: Small-batch coffee, roasted in Milan # at most 120 characters
|
|
72
|
+
# badge: Demo # a short label next to the logo, at most 20 characters
|
|
63
73
|
|
|
64
74
|
storefront:
|
|
75
|
+
# mode: hosted # hosted (default): the website for humans, and UCP for agents.
|
|
76
|
+
# # agent-only: the store host serves only UCP and the pages it
|
|
77
|
+
# # needs (checkout, order, policy pages), no catalog pages.
|
|
65
78
|
# Only for a custom storefront (your own web app in this repo, published with
|
|
66
79
|
# c11r storefront publish): it replaces the theme. Its folder, from the repo root.
|
|
67
80
|
# path: storefront
|
|
@@ -144,12 +157,17 @@ export function loadStore() {
|
|
|
144
157
|
fail('invalid_store', `policies.${key} is not a URL.`, 'Policy entries are full URLs starting with https://, or left out to use the default sandbox pages.');
|
|
145
158
|
}
|
|
146
159
|
const theme = checkTheme(doc.theme);
|
|
160
|
+
checkStorefront(doc.storefront);
|
|
161
|
+
const language = storeLanguage(doc.language);
|
|
147
162
|
const currencies = [...new Set(markets.map((m) => m.currency))];
|
|
148
|
-
const products = checkProducts(sourced, currencies);
|
|
149
|
-
|
|
163
|
+
const products = checkProducts(sourced, currencies, language);
|
|
164
|
+
const languages = storeLanguages({ language, products });
|
|
165
|
+
if (languages.length > MAX_LANGUAGES)
|
|
166
|
+
fail('invalid_store', `The store has ${languages.length} languages: ${languages.join(', ')}.`, `A store has at most ${MAX_LANGUAGES} languages, the base one (${language}) included. Drop the translations of the others.`);
|
|
167
|
+
return { name: doc.name, language, markets, products, storefront: doc.storefront, policies, theme };
|
|
150
168
|
}
|
|
151
169
|
/** The product rules, shared by loadStore and catalog pull: errors name each product's source. */
|
|
152
|
-
export function checkProducts(sourced, currencies) {
|
|
170
|
+
export function checkProducts(sourced, currencies, language = DEFAULT_LANGUAGE) {
|
|
153
171
|
const skus = new Map();
|
|
154
172
|
const products = [];
|
|
155
173
|
for (const { product: p, source } of sourced) {
|
|
@@ -170,11 +188,16 @@ export function checkProducts(sourced, currencies) {
|
|
|
170
188
|
if (p.image_url !== undefined && !isImageRef(String(p.image_url))) {
|
|
171
189
|
fail('invalid_product', `Product ${p.sku} in ${source} has image_url "${p.image_url}".`, IMAGE_HINT);
|
|
172
190
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
191
|
+
translate(p, source, language);
|
|
192
|
+
if (p.category !== undefined)
|
|
193
|
+
p.category = categoryPath(p.category, `Product ${p.sku} in ${source} has category "${p.category}".`);
|
|
194
|
+
for (const [ll, t] of Object.entries(p.translations ?? {})) {
|
|
195
|
+
if (t.category === undefined)
|
|
196
|
+
continue;
|
|
197
|
+
t.category = categoryPath(t.category, `Product ${p.sku} in ${source} has category.${ll} "${t.category}".`);
|
|
198
|
+
const levels = (c) => c.split(' > ').length;
|
|
199
|
+
if (levels(t.category) !== levels(p.category))
|
|
200
|
+
fail('invalid_product', `Product ${p.sku} in ${source} has category.${ll} "${t.category}", with ${levels(t.category)} levels; "${p.category}" has ${levels(p.category)}.`, 'A translated category names the same levels as the base one, each translated: "Coffee > Single origin" is "Caffè > Monorigine" in it.');
|
|
178
201
|
}
|
|
179
202
|
for (const c of currencies) {
|
|
180
203
|
const price = p.prices?.[c];
|
|
@@ -186,6 +209,60 @@ export function checkProducts(sourced, currencies) {
|
|
|
186
209
|
}
|
|
187
210
|
return products;
|
|
188
211
|
}
|
|
212
|
+
/** store.yml `language:`, validated: the base language, default en. */
|
|
213
|
+
export function storeLanguage(raw) {
|
|
214
|
+
if (raw === undefined || raw === null)
|
|
215
|
+
return DEFAULT_LANGUAGE;
|
|
216
|
+
const ll = String(raw);
|
|
217
|
+
if (!ISO_639_1.has(ll))
|
|
218
|
+
fail('invalid_store', `language in store.yml is "${ll}".`, 'Use the ISO 639-1 code of the language the products are written in, in lower case: en, it, fr, de, es, ... Leave it out for en.');
|
|
219
|
+
return ll;
|
|
220
|
+
}
|
|
221
|
+
const categoryPath = (value, message) => {
|
|
222
|
+
const parts = typeof value === 'string' ? value.split('>').map((c) => c.trim()) : [''];
|
|
223
|
+
if (parts.some((c) => !c))
|
|
224
|
+
fail('invalid_product', message, 'A category is a path of names from the top level, separated by " > ", such as "Coffee > Single origin". Leave it out for no category.');
|
|
225
|
+
return parts.join(' > ');
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* name, description and category as a string (the base language) or a { <ll>: text } map, from
|
|
229
|
+
* YAML or from name_<ll> columns: the base language's text stays on the product, the others move
|
|
230
|
+
* to translations, sorted by language.
|
|
231
|
+
*/
|
|
232
|
+
function translate(p, source, language) {
|
|
233
|
+
const raw = p;
|
|
234
|
+
if (raw.translations !== undefined)
|
|
235
|
+
fail('invalid_product', `Product ${p.sku} in ${source} has translations.`, 'Write the other languages in the fields themselves: name: { en: Ethiopia, it: Etiopia }, or name_it columns in CSV.');
|
|
236
|
+
const out = {};
|
|
237
|
+
for (const field of TRANSLATED) {
|
|
238
|
+
const v = raw[field];
|
|
239
|
+
if (v === undefined || v === null || typeof v !== 'object')
|
|
240
|
+
continue;
|
|
241
|
+
if (Array.isArray(v))
|
|
242
|
+
fail('invalid_product', `Product ${p.sku} in ${source} has ${field} as a list.`, `Write ${field} as text, or as a map of language codes to text: ${field}: { ${language}: ..., it: ... }.`);
|
|
243
|
+
const map = v;
|
|
244
|
+
for (const [ll, text] of Object.entries(map)) {
|
|
245
|
+
if (ll !== PLAIN && !ISO_639_1.has(ll))
|
|
246
|
+
fail('invalid_product', `Product ${p.sku} in ${source}: ${field}.${ll} is not a language.`, 'Use ISO 639-1 codes in lower case for the languages: en, it, fr, de, es, ...');
|
|
247
|
+
if (typeof text !== 'string' && typeof text !== 'number')
|
|
248
|
+
fail('invalid_product', `Product ${p.sku} in ${source}: ${field}.${ll} is not text.`, `Write each language's ${field} as plain text.`);
|
|
249
|
+
}
|
|
250
|
+
const plain = map[PLAIN];
|
|
251
|
+
const base = map[language];
|
|
252
|
+
if (plain !== undefined && base !== undefined && String(plain) !== String(base))
|
|
253
|
+
fail('invalid_product', `Product ${p.sku} in ${source} has ${field} and ${field}_${language} with different texts.`, `${language} is the store's base language: the plain ${field} column is its text. Keep one of the two.`);
|
|
254
|
+
const text = base ?? plain;
|
|
255
|
+
if (text === undefined || String(text).trim() === '')
|
|
256
|
+
fail('invalid_product', `Product ${p.sku} in ${source} has ${field} in ${Object.keys(map).filter((k) => k !== PLAIN).join(', ')} but not in ${language}, the store's base language.`, `Add the ${language} text (${field}: { ${language}: ... }), or set language: in store.yml to the products' base language.`);
|
|
257
|
+
raw[field] = String(text);
|
|
258
|
+
for (const [ll, t] of Object.entries(map))
|
|
259
|
+
if (ll !== PLAIN && ll !== language && String(t).trim() !== '')
|
|
260
|
+
(out[ll] ??= {})[field] = String(t).trim();
|
|
261
|
+
}
|
|
262
|
+
const languages = Object.keys(out).sort();
|
|
263
|
+
if (languages.length)
|
|
264
|
+
p.translations = Object.fromEntries(languages.map((ll) => [ll, out[ll]]));
|
|
265
|
+
}
|
|
189
266
|
/** An image reference that is a file in .commercelayer/img/ (img/eth.jpg), not a URL. */
|
|
190
267
|
export const isLocalImage = (url) => !/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//');
|
|
191
268
|
/** The paths the store host serves images at, as the API accepts them. */
|
|
@@ -193,12 +270,28 @@ export const IMAGE_PATH = /^img\/[A-Za-z0-9._-][A-Za-z0-9._/-]*\.(png|jpg|jpeg|w
|
|
|
193
270
|
/** image_url, logo, hero: an https URL, or img/<file> for a file in .commercelayer/img/. */
|
|
194
271
|
export const isImageRef = (url) => isLocalImage(url) ? IMAGE_PATH.test(url) && !url.includes('..') && !url.includes('//') : /^https?:\/\/\S+$/.test(url);
|
|
195
272
|
const IMAGE_HINT = 'Use a full https:// URL, or img/<file> for a file in .commercelayer/img/ (letters, digits, . _ - and /; extension png, jpg, jpeg, webp, gif, avif or svg, in lowercase).';
|
|
273
|
+
/** The storefront block of store.yml: path, and mode hosted or agent-only. */
|
|
274
|
+
function checkStorefront(raw) {
|
|
275
|
+
if (raw === undefined || raw === null)
|
|
276
|
+
return;
|
|
277
|
+
const hint = 'Write `storefront:` with `mode: hosted` (the default) or `mode: agent-only`, and `path:` for a custom storefront. See the comments in store.yml.';
|
|
278
|
+
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
279
|
+
fail('invalid_storefront', 'storefront in store.yml is not a mapping.', hint);
|
|
280
|
+
const s = raw;
|
|
281
|
+
const unknown = Object.keys(s).filter((k) => !['mode', 'path'].includes(k));
|
|
282
|
+
if (unknown.length)
|
|
283
|
+
fail('invalid_storefront', `storefront in store.yml has ${unknown.join(', ')}, which it does not take.`, hint);
|
|
284
|
+
if (s.mode !== undefined && s.mode !== null && !STOREFRONT_MODES.includes(s.mode))
|
|
285
|
+
fail('invalid_storefront', `storefront.mode is "${String(s.mode)}".`, 'Use hosted (a website for humans, and UCP) or agent-only (UCP and the pages it needs, no catalog pages).');
|
|
286
|
+
if (s.path !== undefined && s.path !== null && typeof s.path !== 'string')
|
|
287
|
+
fail('invalid_storefront', 'storefront.path is not a folder name.', hint);
|
|
288
|
+
}
|
|
196
289
|
/** The theme block of store.yml, validated: the theme would ignore bad values, so say so here. */
|
|
197
290
|
function checkTheme(raw) {
|
|
198
291
|
if (raw === undefined || raw === null)
|
|
199
292
|
return undefined;
|
|
200
293
|
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
201
|
-
fail('invalid_theme', 'theme in store.yml is not a mapping.', 'Write `theme:` with color, logo, hero and
|
|
294
|
+
fail('invalid_theme', 'theme in store.yml is not a mapping.', 'Write `theme:` with color, logo, hero, tagline and badge under it, each optional. See the comments in store.yml.');
|
|
202
295
|
const t = raw;
|
|
203
296
|
const unknown = Object.keys(t).filter((k) => !THEME_KEYS.includes(k));
|
|
204
297
|
if (unknown.length)
|
|
@@ -214,14 +307,29 @@ function checkTheme(raw) {
|
|
|
214
307
|
fail('invalid_theme', `theme.${k} is "${theme[k]}".`, IMAGE_HINT);
|
|
215
308
|
if (theme.tagline !== undefined && (theme.tagline.length > 120 || /[\r\n<>]/.test(theme.tagline)))
|
|
216
309
|
fail('invalid_theme', `theme.tagline is ${theme.tagline.length > 120 ? `${theme.tagline.length} characters long` : 'not plain text on one line'}.`, 'Write the tagline as one line of plain text, at most 120 characters.');
|
|
310
|
+
if (theme.badge !== undefined && (theme.badge.length > 20 || !theme.badge.trim() || /[\r\n<>]/.test(theme.badge)))
|
|
311
|
+
fail('invalid_theme', `theme.badge is ${theme.badge.length > 20 ? `${theme.badge.length} characters long` : `"${theme.badge}"`}.`, 'Use a short plain-text label of at most 20 characters, such as Demo, with no line breaks or < >.');
|
|
217
312
|
return theme;
|
|
218
313
|
}
|
|
219
314
|
export function toApplied(store) {
|
|
220
315
|
return {
|
|
221
316
|
markets: Object.fromEntries(store.markets.map((m) => [m.code, m])),
|
|
222
317
|
products: Object.fromEntries(store.products.map((p) => [p.sku, p])),
|
|
318
|
+
...(store.language !== DEFAULT_LANGUAGE && { language: store.language }),
|
|
223
319
|
};
|
|
224
320
|
}
|
|
321
|
+
/** The store's languages: the base one first, then every language a product is translated into. */
|
|
322
|
+
export const storeLanguages = (store) => [store.language, ...[...new Set(store.products.flatMap((p) => Object.keys(p.translations ?? {})))].sort()];
|
|
323
|
+
/** Translations in a language no country of the store speaks: kept, but no page shows them. */
|
|
324
|
+
export function languageWarnings(store) {
|
|
325
|
+
const spoken = new Set(localesOf(toApplied(store)).map((l) => l.language));
|
|
326
|
+
return storeLanguages(store)
|
|
327
|
+
.slice(1)
|
|
328
|
+
.filter((ll) => !spoken.has(ll))
|
|
329
|
+
.map((ll) => `Products are translated into ${ll}, which no country of the store speaks (${[...spoken].join(', ') || 'none'}): no page shows it, but shopping agents can ask for it over UCP.`);
|
|
330
|
+
}
|
|
331
|
+
/** At most this many languages, base included: the API's limit. */
|
|
332
|
+
export const MAX_LANGUAGES = 6;
|
|
225
333
|
/** JSON with sorted keys and no undefined values, so key order never reads as a change. */
|
|
226
334
|
export const canonical = (v) => JSON.stringify(v ?? {}, (_k, x) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x));
|
|
227
335
|
/** The one definition of "pending changes": what apply would do. plan and status both use it. */
|
|
@@ -244,6 +352,13 @@ export function diff(state, desired) {
|
|
|
244
352
|
else if (hasTheme(before) && canonical(before) !== canonical(desired.theme))
|
|
245
353
|
changes.push({ action: 'update', type: 'store', id: 'theme' });
|
|
246
354
|
}
|
|
355
|
+
// The mode: a change from what the API holds (hosted before the first apply). An API that does not know it is skipped.
|
|
356
|
+
if (!applied ? storefrontMode(desired) !== 'hosted' : state.storefront_mode !== undefined && (state.storefront_mode ?? 'hosted') !== storefrontMode(desired)) {
|
|
357
|
+
changes.push({ action: 'update', type: 'store', id: 'storefront_mode' });
|
|
358
|
+
}
|
|
359
|
+
// The base language: a change from what is applied (en before the first apply, and when absent).
|
|
360
|
+
if ((applied?.language ?? DEFAULT_LANGUAGE) !== desired.language)
|
|
361
|
+
changes.push({ action: 'update', type: 'store', id: 'language' });
|
|
247
362
|
const after = toApplied(desired);
|
|
248
363
|
for (const kind of ['markets', 'products']) {
|
|
249
364
|
const type = kind.slice(0, -1);
|
package/dist/ucp/shop.js
CHANGED
|
@@ -53,17 +53,37 @@ export function money(amount, currency) {
|
|
|
53
53
|
return { amount, currency, major, display: `${amount < 0 ? '-' : ''}${currency} ${Math.abs(major).toFixed(decimals)}` };
|
|
54
54
|
}
|
|
55
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
|
+
}
|
|
56
73
|
function variantView(v) {
|
|
57
74
|
const shipping = v.fulfillment?.methods?.map((m) => plain(m.description)).filter(Boolean).join('; ');
|
|
58
75
|
return {
|
|
59
76
|
id: v.id,
|
|
60
|
-
sku: v.sku
|
|
77
|
+
sku: v.sku,
|
|
61
78
|
title: v.title,
|
|
62
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,
|
|
63
82
|
available: v.availability?.available ?? undefined,
|
|
64
83
|
availability: v.availability?.status ?? undefined,
|
|
65
84
|
image_url: v.media?.find((m) => m.type === 'image')?.url,
|
|
66
85
|
ships: shipping || undefined,
|
|
86
|
+
shipping: shippingView(v.fulfillment?.methods?.[0], v.price?.currency),
|
|
67
87
|
};
|
|
68
88
|
}
|
|
69
89
|
export function productView(p, full = false) {
|
|
@@ -76,6 +96,10 @@ export function productView(p, full = false) {
|
|
|
76
96
|
description: full || !description || description.length <= 200 ? description : `${description.slice(0, 197)}...`,
|
|
77
97
|
url: p.url,
|
|
78
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,
|
|
79
103
|
// A search lists a variant's image only when it is not the product's.
|
|
80
104
|
variants: full ? variants : variants.map((v) => ({ ...v, image_url: v.image_url === image ? undefined : v.image_url })),
|
|
81
105
|
};
|
|
@@ -102,7 +126,7 @@ export function checkoutView(ck, origin, call) {
|
|
|
102
126
|
currency: cur,
|
|
103
127
|
line_items: (ck.line_items ?? []).map((l) => ({
|
|
104
128
|
id: l.id,
|
|
105
|
-
|
|
129
|
+
item_id: l.item?.id,
|
|
106
130
|
title: l.item?.title,
|
|
107
131
|
quantity: l.quantity,
|
|
108
132
|
unit_price: l.item?.price === undefined ? undefined : money(l.item.price, cur),
|
|
@@ -134,7 +158,7 @@ export function orderView(o, origin, call) {
|
|
|
134
158
|
checkout_id: o.checkout_id,
|
|
135
159
|
currency: cur,
|
|
136
160
|
line_items: (o.line_items ?? []).map((l) => ({
|
|
137
|
-
|
|
161
|
+
item_id: l.item?.id,
|
|
138
162
|
title: l.item?.title,
|
|
139
163
|
quantity: l.quantity?.total ?? l.quantity,
|
|
140
164
|
status: l.status,
|
|
@@ -160,15 +184,15 @@ async function step(origin, run) {
|
|
|
160
184
|
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>.`);
|
|
161
185
|
if (code === 'identity_required' || e.call.status === 401)
|
|
162
186
|
fail('identity_required', e.message, 'Orders are readable by the platform that placed them: this CLI can only read orders it placed.');
|
|
163
|
-
fail('ucp_error', e.message, `The store at ${origin} refused it: tell the human what it said. The
|
|
187
|
+
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.`);
|
|
164
188
|
}
|
|
165
189
|
}
|
|
166
|
-
/** --item
|
|
190
|
+
/** --item ID or ID:QTY, the variant id. */
|
|
167
191
|
export function parseItem(raw) {
|
|
168
192
|
const m = String(raw).match(/^(.+?)(?::(\d+))?$/);
|
|
169
193
|
if (!m || !m[1].trim())
|
|
170
|
-
fail('invalid_argument', `--item ${raw} is not
|
|
171
|
-
return {
|
|
194
|
+
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.');
|
|
195
|
+
return { id: m[1].trim(), quantity: m[2] === undefined ? 1 : Number(m[2]) };
|
|
172
196
|
}
|
|
173
197
|
export async function open(origin) {
|
|
174
198
|
const store = await discover(origin);
|
|
@@ -176,17 +200,40 @@ export async function open(origin) {
|
|
|
176
200
|
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.');
|
|
177
201
|
return store;
|
|
178
202
|
}
|
|
179
|
-
export async function search(origin, query,
|
|
203
|
+
export async function search(origin, query, f = {}) {
|
|
180
204
|
const store = await discover(origin);
|
|
181
|
-
const body = {
|
|
182
|
-
if (
|
|
183
|
-
body.
|
|
205
|
+
const body = { pagination: { limit: 50 } };
|
|
206
|
+
if (query)
|
|
207
|
+
body.query = query;
|
|
208
|
+
if (f.country)
|
|
209
|
+
body.context = { address_country: f.country.toUpperCase() };
|
|
210
|
+
const filters = {};
|
|
211
|
+
if (f.categories?.length)
|
|
212
|
+
filters.categories = f.categories;
|
|
213
|
+
if (f.min !== undefined || f.max !== undefined) {
|
|
214
|
+
// filters.price is in minor units of context.currency: ask the store which currency it prices in here.
|
|
215
|
+
const probe = await step(origin, () => shopping.search(store, { ...body, ...(f.categories?.length ? { filters } : {}), pagination: { limit: 1 } }));
|
|
216
|
+
const currency = probe.body.products?.[0]?.variants?.[0]?.price?.currency;
|
|
217
|
+
if (currency) {
|
|
218
|
+
body.context = { ...body.context, currency };
|
|
219
|
+
filters.price = { ...(f.min !== undefined ? { min: Math.round(f.min * minorUnits(currency)) } : {}), ...(f.max !== undefined ? { max: Math.round(f.max * minorUnits(currency)) } : {}) };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (Object.keys(filters).length)
|
|
223
|
+
body.filters = filters;
|
|
184
224
|
const call = await step(origin, () => shopping.search(store, body));
|
|
185
225
|
const r = call.body;
|
|
186
|
-
|
|
226
|
+
let products = r.products ?? [];
|
|
227
|
+
// A store that ignores filters.price still gets the bounds applied here.
|
|
228
|
+
if (filters.price)
|
|
229
|
+
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)));
|
|
230
|
+
// Which market, currency and countries the prices are for: the store says it per variant, the same for all.
|
|
231
|
+
const prices = products.flatMap((p) => p.variants ?? []).find((v) => v.metadata?.prices)?.metadata.prices;
|
|
187
232
|
return {
|
|
188
233
|
store: origin,
|
|
189
234
|
query,
|
|
235
|
+
prices: prices ? { market: prices.market, market_name: prices.market_name, currency: prices.currency, countries: prices.countries, tax_included: prices.tax_included } : undefined,
|
|
236
|
+
filters: Object.keys(filters).length ? { categories: filters.categories, min_price: f.min, max_price: f.max, currency: body.context?.currency } : undefined,
|
|
190
237
|
count: products.length,
|
|
191
238
|
total_count: r.pagination?.total_count,
|
|
192
239
|
has_more: r.pagination?.has_next_page || undefined,
|
|
@@ -205,7 +252,7 @@ export async function product(origin, id, country) {
|
|
|
205
252
|
}
|
|
206
253
|
export async function createCheckout(origin, items, codes, country) {
|
|
207
254
|
const store = await open(origin);
|
|
208
|
-
const body = { line_items: items.map((l) => ({ item: { id: l.
|
|
255
|
+
const body = { line_items: items.map((l) => ({ item: { id: l.id }, quantity: l.quantity })) };
|
|
209
256
|
if (country)
|
|
210
257
|
body.context = { address_country: country.toUpperCase() };
|
|
211
258
|
if (codes.length)
|
|
@@ -224,12 +271,12 @@ export async function updateCheckout(origin, id, ch) {
|
|
|
224
271
|
const store = await open(origin);
|
|
225
272
|
const current = (await step(origin, () => shopping.getCheckout(store, id))).body;
|
|
226
273
|
if (current.status === 'completed' || current.status === 'canceled')
|
|
227
|
-
fail('checkout_closed', `The checkout ${id} is ${current.status}.`, current.status === 'completed' ? `It is
|
|
274
|
+
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>.');
|
|
228
275
|
const body = replay(current);
|
|
229
276
|
if (ch.buyer)
|
|
230
277
|
body.buyer = clean({ ...(current.buyer ?? {}), ...ch.buyer });
|
|
231
278
|
for (const l of ch.items ?? []) {
|
|
232
|
-
const i = body.line_items.findIndex((x) => x.item.id === l.
|
|
279
|
+
const i = body.line_items.findIndex((x) => x.item.id === l.id);
|
|
233
280
|
if (l.quantity === 0) {
|
|
234
281
|
if (i >= 0)
|
|
235
282
|
body.line_items.splice(i, 1);
|
|
@@ -237,7 +284,7 @@ export async function updateCheckout(origin, id, ch) {
|
|
|
237
284
|
else if (i >= 0)
|
|
238
285
|
body.line_items[i].quantity = l.quantity;
|
|
239
286
|
else
|
|
240
|
-
body.line_items.push({ item: { id: l.
|
|
287
|
+
body.line_items.push({ item: { id: l.id }, quantity: l.quantity });
|
|
241
288
|
}
|
|
242
289
|
if (!body.line_items.length)
|
|
243
290
|
fail('invalid_argument', 'The update leaves the checkout with no items.', `Keep at least one item, or cancel it: c11r ucp checkout cancel ${id}.`);
|
|
@@ -268,8 +315,8 @@ export async function updateCheckout(origin, id, ch) {
|
|
|
268
315
|
again.fulfillment.methods[0].groups[0].selected_option_id = group.options[0].id;
|
|
269
316
|
call = await step(origin, () => shopping.updateCheckout(store, id, again));
|
|
270
317
|
}
|
|
271
|
-
// A store can answer ready_for_complete before the shipping is in the totals (seen once
|
|
272
|
-
//
|
|
318
|
+
// A store can answer ready_for_complete before the shipping is in the totals (seen once, since
|
|
319
|
+
// fixed on the store host): read it again rather than show the human a total without shipping.
|
|
273
320
|
const ck = call.body;
|
|
274
321
|
if (ck.status === 'ready_for_complete' && ck.fulfillment?.methods?.[0]?.groups?.[0]?.selected_option_id && !(ck.totals ?? []).some((t) => t.type === 'fulfillment')) {
|
|
275
322
|
call = await step(origin, () => shopping.getCheckout(store, id));
|
|
@@ -285,7 +332,7 @@ export async function completeCheckout(origin, id, decline) {
|
|
|
285
332
|
const store = await open(origin);
|
|
286
333
|
if (!store.profile.ucp?.payment_handlers?.[SANDBOX_HANDLER]) {
|
|
287
334
|
const handlers = Object.keys(store.profile.ucp?.payment_handlers ?? {});
|
|
288
|
-
fail('payment_handler_unsupported', `The store at ${origin} takes ${handlers.length ? handlers.join(', ') : 'no payment handler this CLI knows'}; this CLI
|
|
335
|
+
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}.`);
|
|
289
336
|
}
|
|
290
337
|
const call = await step(origin, () => shopping.completeCheckout(store, id, { payment: { instruments: [sandboxInstrument(decline)] }, signals: { 'dev.ucp.user_agent': 'c11r' } }));
|
|
291
338
|
const ck = call.body;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commercelayer/agent-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
|
|
5
5
|
"homepage": "https://commercelayer.ai",
|
|
6
6
|
"keywords": [
|