@commercelayer/agent-cli 0.3.1 → 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 +28 -7
- package/dist/api/client.js +59 -0
- package/dist/api/stub/locale.js +52 -0
- package/dist/api/stub/management.js +38 -2
- package/dist/api/stub/orders.js +359 -0
- package/dist/api/stub/search.js +78 -0
- package/dist/api/stub/shopping.js +84 -18
- package/dist/api/stub/theme.js +119 -30
- package/dist/api/stub/ucp.js +10 -6
- package/dist/bin.js +15 -5
- package/dist/catalog.js +40 -4
- package/dist/commands.js +149 -20
- package/dist/languages.js +49 -0
- package/dist/locales.js +43 -0
- package/dist/products.js +15 -5
- package/dist/store.js +95 -9
- package/dist/ucp/shop.js +53 -6
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
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';
|
|
@@ -19,6 +21,10 @@ export const template = (name) => `# Commerce Layer store. This file is the stor
|
|
|
19
21
|
# Every product needs a price in the currency of every market.
|
|
20
22
|
|
|
21
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.
|
|
22
28
|
|
|
23
29
|
markets:
|
|
24
30
|
# One market per region you sell in. countries are ISO 3166 codes.
|
|
@@ -51,7 +57,8 @@ catalog:
|
|
|
51
57
|
# source: sheets # a Google Sheet published as CSV: url is its CSV link
|
|
52
58
|
# source: supabase # a Supabase table: table is its name (default products);
|
|
53
59
|
# # C11R_SUPABASE_URL and C11R_SUPABASE_KEY in .commercelayer/credentials
|
|
54
|
-
# 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
|
|
55
62
|
# url: https://...
|
|
56
63
|
# table: products
|
|
57
64
|
|
|
@@ -151,12 +158,16 @@ export function loadStore() {
|
|
|
151
158
|
}
|
|
152
159
|
const theme = checkTheme(doc.theme);
|
|
153
160
|
checkStorefront(doc.storefront);
|
|
161
|
+
const language = storeLanguage(doc.language);
|
|
154
162
|
const currencies = [...new Set(markets.map((m) => m.currency))];
|
|
155
|
-
const products = checkProducts(sourced, currencies);
|
|
156
|
-
|
|
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 };
|
|
157
168
|
}
|
|
158
169
|
/** The product rules, shared by loadStore and catalog pull: errors name each product's source. */
|
|
159
|
-
export function checkProducts(sourced, currencies) {
|
|
170
|
+
export function checkProducts(sourced, currencies, language = DEFAULT_LANGUAGE) {
|
|
160
171
|
const skus = new Map();
|
|
161
172
|
const products = [];
|
|
162
173
|
for (const { product: p, source } of sourced) {
|
|
@@ -177,11 +188,16 @@ export function checkProducts(sourced, currencies) {
|
|
|
177
188
|
if (p.image_url !== undefined && !isImageRef(String(p.image_url))) {
|
|
178
189
|
fail('invalid_product', `Product ${p.sku} in ${source} has image_url "${p.image_url}".`, IMAGE_HINT);
|
|
179
190
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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.');
|
|
185
201
|
}
|
|
186
202
|
for (const c of currencies) {
|
|
187
203
|
const price = p.prices?.[c];
|
|
@@ -193,6 +209,60 @@ export function checkProducts(sourced, currencies) {
|
|
|
193
209
|
}
|
|
194
210
|
return products;
|
|
195
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
|
+
}
|
|
196
266
|
/** An image reference that is a file in .commercelayer/img/ (img/eth.jpg), not a URL. */
|
|
197
267
|
export const isLocalImage = (url) => !/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//');
|
|
198
268
|
/** The paths the store host serves images at, as the API accepts them. */
|
|
@@ -245,8 +315,21 @@ export function toApplied(store) {
|
|
|
245
315
|
return {
|
|
246
316
|
markets: Object.fromEntries(store.markets.map((m) => [m.code, m])),
|
|
247
317
|
products: Object.fromEntries(store.products.map((p) => [p.sku, p])),
|
|
318
|
+
...(store.language !== DEFAULT_LANGUAGE && { language: store.language }),
|
|
248
319
|
};
|
|
249
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;
|
|
250
333
|
/** JSON with sorted keys and no undefined values, so key order never reads as a change. */
|
|
251
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));
|
|
252
335
|
/** The one definition of "pending changes": what apply would do. plan and status both use it. */
|
|
@@ -273,6 +356,9 @@ export function diff(state, desired) {
|
|
|
273
356
|
if (!applied ? storefrontMode(desired) !== 'hosted' : state.storefront_mode !== undefined && (state.storefront_mode ?? 'hosted') !== storefrontMode(desired)) {
|
|
274
357
|
changes.push({ action: 'update', type: 'store', id: 'storefront_mode' });
|
|
275
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' });
|
|
276
362
|
const after = toApplied(desired);
|
|
277
363
|
for (const kind of ['markets', 'products']) {
|
|
278
364
|
const type = kind.slice(0, -1);
|
package/dist/ucp/shop.js
CHANGED
|
@@ -53,6 +53,23 @@ 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 {
|
|
@@ -60,10 +77,13 @@ function variantView(v) {
|
|
|
60
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
|
};
|
|
@@ -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,
|
|
@@ -224,7 +271,7 @@ 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 });
|
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": [
|