@commercelayer/agent-cli 0.8.0 → 1.0.0-dev.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.
Files changed (67) hide show
  1. package/README.md +95 -215
  2. package/dist/argv.js +214 -0
  3. package/dist/bin.js +15 -178
  4. package/dist/builtins/auth.js +169 -0
  5. package/dist/builtins/extensions.js +123 -0
  6. package/dist/builtins/index.js +8 -0
  7. package/dist/builtins/loops.js +215 -0
  8. package/dist/builtins/sandbox.js +186 -0
  9. package/dist/config.js +211 -0
  10. package/dist/confirm.js +56 -0
  11. package/dist/engine.js +173 -0
  12. package/dist/errors.js +146 -0
  13. package/dist/help.js +78 -0
  14. package/dist/http.js +117 -0
  15. package/dist/login.js +133 -0
  16. package/dist/resources.js +41 -0
  17. package/dist/schema.js +152 -0
  18. package/dist/schemas.js +72 -0
  19. package/dist/secrets.js +189 -0
  20. package/dist/version.js +4 -8
  21. package/package.json +4 -9
  22. package/dist/api/client.js +0 -190
  23. package/dist/api/server.js +0 -26
  24. package/dist/api/stub/db.js +0 -37
  25. package/dist/api/stub/http.js +0 -18
  26. package/dist/api/stub/index.js +0 -52
  27. package/dist/api/stub/locale.js +0 -52
  28. package/dist/api/stub/management.js +0 -409
  29. package/dist/api/stub/messages.js +0 -126
  30. package/dist/api/stub/orders.js +0 -625
  31. package/dist/api/stub/search.js +0 -78
  32. package/dist/api/stub/shopping.js +0 -673
  33. package/dist/api/stub/theme.js +0 -197
  34. package/dist/api/stub/ucp.js +0 -377
  35. package/dist/api/transport.js +0 -57
  36. package/dist/api/types.js +0 -4
  37. package/dist/assets.js +0 -100
  38. package/dist/catalog.js +0 -366
  39. package/dist/check.js +0 -187
  40. package/dist/commands.js +0 -1241
  41. package/dist/cursor.js +0 -31
  42. package/dist/files.js +0 -98
  43. package/dist/inbox/cursor.js +0 -11
  44. package/dist/inbox/fake.js +0 -95
  45. package/dist/inbox/imap.js +0 -139
  46. package/dist/inbox/mailbox.js +0 -25
  47. package/dist/inbox/message.js +0 -88
  48. package/dist/inspect.js +0 -230
  49. package/dist/languages.js +0 -49
  50. package/dist/links.js +0 -118
  51. package/dist/locales.js +0 -43
  52. package/dist/ops.js +0 -190
  53. package/dist/orderRequest.js +0 -141
  54. package/dist/output.js +0 -39
  55. package/dist/products.js +0 -151
  56. package/dist/promotions.js +0 -291
  57. package/dist/store.js +0 -379
  58. package/dist/storefront.js +0 -285
  59. package/dist/ucp/buy.js +0 -90
  60. package/dist/ucp/check.js +0 -360
  61. package/dist/ucp/client.js +0 -129
  62. package/dist/ucp/images.js +0 -62
  63. package/dist/ucp/platform.js +0 -90
  64. package/dist/ucp/shop.js +0 -357
  65. package/dist/ucp/signing.js +0 -67
  66. package/dist/web.js +0 -316
  67. package/dist/website.js +0 -390
package/dist/store.js DELETED
@@ -1,379 +0,0 @@
1
- // .commercelayer/store.yml is the store: the desired state, committed in the repo.
2
- // Products can also live in files under .commercelayer/products/ (see products.ts).
3
- // This module reads and validates the whole thing, and diffs it against what the
4
- // API has applied.
5
- import crypto from 'node:crypto';
6
- import fs from 'node:fs';
7
- import YAML from 'yaml';
8
- import { DEFAULT_LANGUAGE, ISO_639_1, PLAIN, TRANSLATED } from './languages.js';
9
- import { localesOf } from './locales.js';
10
- import { fail } from './output.js';
11
- import { paths } from './files.js';
12
- import { productFiles, readProductFile } from './products.js';
13
- /** The keys a product may have. Stock is not one: inventory lives in Commerce Layer. */
14
- export const PRODUCT_KEYS = ['sku', 'name', 'description', 'image_url', 'category', 'prices'];
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';
18
- export const template = (name) => `# Commerce Layer store. This file is the store: edit it, then run
19
- # c11r plan to see what will change
20
- # c11r apply to make the change
21
- # Every product needs a price in the currency of every market.
22
-
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.
28
-
29
- markets:
30
- # One market per region you sell in. countries are ISO 3166 codes.
31
- # shipping is optional: without it orders ship free as "Standard".
32
- # - code: eu
33
- # name: Europe
34
- # countries: [IT, FR, DE, ES]
35
- # currency: EUR
36
- # shipping: { method: Standard, price: 5.00, days: 3-5 }
37
-
38
- products:
39
- # Products go here, or in files under .commercelayer/products/, merged by sku:
40
- # <name>.yml a list of products in this same shape
41
- # <name>.csv one product per row, columns: sku, name, description, image_url,
42
- # category, and one column per currency (EUR, USD, ...) with the price
43
- # image_url is an https:// URL or a file in .commercelayer/img/, written img/<file>.
44
- # category is optional, a path from the top level: Coffee > Single origin.
45
- # Use the folder for anything beyond a handful of products.
46
- # sku is unique and stays the same when you rename a product.
47
- # - sku: COFFEE-ETH-250
48
- # name: Ethiopia Yirgacheffe, 250 g
49
- # description: Washed, floral, bright.
50
- # category: Coffee > Single origin
51
- # prices: { EUR: 14.50 }
52
- # Stock is not part of the catalog: inventory is tracked live in Commerce Layer.
53
-
54
- catalog:
55
- # Where \`c11r catalog pull\` reads the products from, if they live in a tool the
56
- # human edits. Pull writes them to .commercelayer/products/catalog.csv.
57
- # source: sheets # a Google Sheet published as CSV: url is its CSV link
58
- # source: supabase # a Supabase table: table is its name (default products);
59
- # # C11R_SUPABASE_URL and C11R_SUPABASE_KEY in .commercelayer/credentials
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
62
- # source: website # a brand's website, for a demo store: url is the site; limit is how
63
- # # many products (default 100, at most 500). Images stay on the site.
64
- # url: https://...
65
- # table: products
66
-
67
- theme:
68
- # The store's website: a theme the platform renders and keeps up to date.
69
- # Every setting is optional. Images live in .commercelayer/img/ and apply uploads them.
70
- # color: "#2f5d50" # the accent: buttons, links, the hero band
71
- # logo: img/logo.svg # or an https:// URL; without it the name is the logo
72
- # hero: img/hero.jpg # the home page's wide image; without it a band in the accent
73
- # tagline: Small-batch coffee, roasted in Milan # at most 120 characters
74
- # badge: Demo # a short label next to the logo, at most 20 characters
75
-
76
- storefront:
77
- # mode: hosted # hosted (default): the website for humans, and UCP for agents.
78
- # # agent-only: the store host serves only UCP and the pages it
79
- # # needs (checkout, order, policy pages), no catalog pages.
80
- # Only for a custom storefront (your own web app in this repo, published with
81
- # c11r storefront publish): it replaces the theme. Its folder, from the repo root.
82
- # path: storefront
83
-
84
- policies:
85
- # Pages shopping agents show buyers before they pay. Without them the store
86
- # serves default sandbox pages at <preview_url>/policies/<name>.
87
- # privacy_policy: https://example.com/privacy
88
- # terms_of_service: https://example.com/terms
89
- # returns: https://example.com/returns
90
- `;
91
- export function readStoreFile() {
92
- return fs.readFileSync(paths.store, 'utf8');
93
- }
94
- /** storefront.path from store.yml without validating the rest, default "storefront". */
95
- export function storefrontPath() {
96
- try {
97
- const doc = YAML.parse(readStoreFile()) ?? {};
98
- return doc.storefront?.path ?? 'storefront';
99
- }
100
- catch {
101
- return 'storefront';
102
- }
103
- }
104
- /** Every file the store is read from: store.yml and the product files. */
105
- export function storeFiles() {
106
- return [paths.store, ...productFiles()];
107
- }
108
- /** A short digest of all the store's files, as they are on disk. The API keeps the one it applied. */
109
- export function hashStore() {
110
- const h = crypto.createHash('sha256');
111
- for (const file of storeFiles())
112
- h.update(`${file}\n`).update(fs.readFileSync(file)).update('\n');
113
- return h.digest('hex').slice(0, 12);
114
- }
115
- /** Parse and validate store.yml and the product files, or fail with the fix in the hint. */
116
- export function loadStore() {
117
- let doc;
118
- try {
119
- doc = YAML.parse(readStoreFile()) ?? {};
120
- }
121
- catch (e) {
122
- const reason = e.message.split('\n')[0];
123
- fail('invalid_yaml', `store.yml is not valid YAML: ${reason}`, `Fix the YAML syntax in ${paths.store} and run the command again.`);
124
- }
125
- const markets = (doc.markets ?? []);
126
- if (!Array.isArray(doc.products ?? []))
127
- fail('invalid_store', 'products in store.yml is not a list.', 'Write products as a YAML list under `products:`, or move them to files under .commercelayer/products/.');
128
- const sourced = (doc.products ?? []).map((product) => ({ product, source: paths.store }));
129
- for (const file of productFiles())
130
- sourced.push(...readProductFile(file));
131
- if (!doc.name)
132
- fail('invalid_store', 'store.yml has no name.', `Add \`name: <store name>\` at the top of ${paths.store}.`);
133
- if (markets.length === 0)
134
- fail('invalid_store', 'store.yml has no markets.', 'Add at least one market under `markets:`, with code, name, countries and currency. See the comments in store.yml.');
135
- if (sourced.length === 0)
136
- fail('invalid_store', 'The store has no products.', `Add at least one product, with sku, name and prices: under \`products:\` in ${paths.store}, or in a .yml or .csv file under ${paths.products}/. See the comments in store.yml.`);
137
- const codes = new Set();
138
- for (const m of markets) {
139
- for (const key of ['code', 'name', 'countries', 'currency']) {
140
- if (!m[key])
141
- fail('invalid_market', `Market ${m.code ?? '(no code)'} has no ${key}.`, `Add \`${key}\` to the market in ${paths.store}.`);
142
- }
143
- if (!/^[A-Z]{3}$/.test(m.currency))
144
- fail('invalid_market', `Market ${m.code} has currency "${m.currency}".`, 'Use an ISO 4217 code in capitals, such as EUR or USD.');
145
- if (codes.has(m.code))
146
- fail('invalid_market', `Two markets have code ${m.code}.`, 'Give every market a unique code.');
147
- codes.add(m.code);
148
- if (m.shipping !== undefined) {
149
- if (!m.shipping.method || typeof m.shipping.price !== 'number' || m.shipping.price < 0) {
150
- fail('invalid_market', `Market ${m.code} has an invalid shipping entry.`, 'Use `shipping: { method: <name>, price: <amount in the market currency>, days: <estimate> }`, or remove it for free Standard shipping.');
151
- }
152
- }
153
- }
154
- if (doc.promotions !== undefined)
155
- fail('invalid_store', 'store.yml has promotions:, which is not part of the store files.', 'Promotions are live settings in Commerce Layer: create each one with `c11r promotions create -f <file>` (the same entry as YAML or JSON), then remove promotions: from store.yml.');
156
- const policies = (doc.policies ?? {});
157
- for (const [key, url] of Object.entries(policies)) {
158
- if (url && !/^https?:\/\//.test(String(url)))
159
- 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.');
160
- }
161
- const theme = checkTheme(doc.theme);
162
- checkStorefront(doc.storefront);
163
- const language = storeLanguage(doc.language);
164
- const currencies = [...new Set(markets.map((m) => m.currency))];
165
- const products = checkProducts(sourced, currencies, language);
166
- const languages = storeLanguages({ language, products });
167
- if (languages.length > MAX_LANGUAGES)
168
- 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.`);
169
- return { name: doc.name, language, markets, products, storefront: doc.storefront, policies, theme };
170
- }
171
- /** The product rules, shared by loadStore and catalog pull: errors name each product's source. */
172
- export function checkProducts(sourced, currencies, language = DEFAULT_LANGUAGE) {
173
- const skus = new Map();
174
- const products = [];
175
- for (const { product: p, source } of sourced) {
176
- if (!p || typeof p !== 'object')
177
- fail('invalid_product', `${source} has an entry that is not a product.`, 'Every product is a mapping with sku, name and prices.');
178
- if (!p.sku || !p.name)
179
- fail('invalid_product', `A product in ${source} has no ${p.sku ? 'name' : 'sku'}${p.sku ? ` (sku ${p.sku})` : ''}.`, 'Every product needs a sku and a name.');
180
- p.sku = String(p.sku);
181
- const seen = skus.get(p.sku);
182
- if (seen)
183
- fail('invalid_product', `Two products have sku ${p.sku}: in ${seen} and ${source}.`, 'Give every product a unique sku, or remove the duplicate.');
184
- skus.set(p.sku, source);
185
- const unknown = Object.keys(p).filter((k) => !PRODUCT_KEYS.includes(k));
186
- if (unknown.length) {
187
- const why = unknown.includes('stock') ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : '';
188
- fail('invalid_product', `Product ${p.sku} in ${source} has ${unknown.length === 1 ? 'a field' : 'fields'} not in the catalog: ${unknown.join(', ')}.`, `${why}A product has ${PRODUCT_KEYS.join(', ')}. Remove the other fields.`);
189
- }
190
- if (p.image_url !== undefined && !isImageRef(String(p.image_url))) {
191
- fail('invalid_product', `Product ${p.sku} in ${source} has image_url "${p.image_url}".`, IMAGE_HINT);
192
- }
193
- translate(p, source, language);
194
- if (p.category !== undefined)
195
- p.category = categoryPath(p.category, `Product ${p.sku} in ${source} has category "${p.category}".`);
196
- for (const [ll, t] of Object.entries(p.translations ?? {})) {
197
- if (t.category === undefined)
198
- continue;
199
- t.category = categoryPath(t.category, `Product ${p.sku} in ${source} has category.${ll} "${t.category}".`);
200
- const levels = (c) => c.split(' > ').length;
201
- if (levels(t.category) !== levels(p.category))
202
- 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.');
203
- }
204
- for (const c of currencies) {
205
- const price = p.prices?.[c];
206
- if (typeof price !== 'number' || price < 0) {
207
- fail('missing_price', `Product ${p.sku} in ${source} has no price in ${c}.`, `Add the ${c} price to the product (\`${c}: <amount>\` under prices in YAML, a ${c} column in CSV or in the catalog source); every product needs a price in each market's currency (${currencies.join(', ')}).`);
208
- }
209
- }
210
- products.push(p);
211
- }
212
- return products;
213
- }
214
- /** store.yml `language:`, validated: the base language, default en. */
215
- export function storeLanguage(raw) {
216
- if (raw === undefined || raw === null)
217
- return DEFAULT_LANGUAGE;
218
- const ll = String(raw);
219
- if (!ISO_639_1.has(ll))
220
- 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.');
221
- return ll;
222
- }
223
- const categoryPath = (value, message) => {
224
- const parts = typeof value === 'string' ? value.split('>').map((c) => c.trim()) : [''];
225
- if (parts.some((c) => !c))
226
- 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.');
227
- return parts.join(' > ');
228
- };
229
- /**
230
- * name, description and category as a string (the base language) or a { <ll>: text } map, from
231
- * YAML or from name_<ll> columns: the base language's text stays on the product, the others move
232
- * to translations, sorted by language.
233
- */
234
- function translate(p, source, language) {
235
- const raw = p;
236
- if (raw.translations !== undefined)
237
- 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.');
238
- const out = {};
239
- for (const field of TRANSLATED) {
240
- const v = raw[field];
241
- if (v === undefined || v === null || typeof v !== 'object')
242
- continue;
243
- if (Array.isArray(v))
244
- 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: ... }.`);
245
- const map = v;
246
- for (const [ll, text] of Object.entries(map)) {
247
- if (ll !== PLAIN && !ISO_639_1.has(ll))
248
- 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, ...');
249
- if (typeof text !== 'string' && typeof text !== 'number')
250
- fail('invalid_product', `Product ${p.sku} in ${source}: ${field}.${ll} is not text.`, `Write each language's ${field} as plain text.`);
251
- }
252
- const plain = map[PLAIN];
253
- const base = map[language];
254
- if (plain !== undefined && base !== undefined && String(plain) !== String(base))
255
- 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.`);
256
- const text = base ?? plain;
257
- if (text === undefined || String(text).trim() === '')
258
- 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.`);
259
- raw[field] = String(text);
260
- for (const [ll, t] of Object.entries(map))
261
- if (ll !== PLAIN && ll !== language && String(t).trim() !== '')
262
- (out[ll] ??= {})[field] = String(t).trim();
263
- }
264
- const languages = Object.keys(out).sort();
265
- if (languages.length)
266
- p.translations = Object.fromEntries(languages.map((ll) => [ll, out[ll]]));
267
- }
268
- /** An image reference that is a file in .commercelayer/img/ (img/eth.jpg), not a URL. */
269
- export const isLocalImage = (url) => !/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//');
270
- /** The paths the store host serves images at, as the API accepts them. */
271
- export const IMAGE_PATH = /^img\/[A-Za-z0-9._-][A-Za-z0-9._/-]*\.(png|jpg|jpeg|webp|gif|avif|svg)$/;
272
- /** image_url, logo, hero: an https URL, or img/<file> for a file in .commercelayer/img/. */
273
- export const isImageRef = (url) => isLocalImage(url) ? IMAGE_PATH.test(url) && !url.includes('..') && !url.includes('//') : /^https?:\/\/\S+$/.test(url);
274
- 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).';
275
- /** The storefront block of store.yml: path, and mode hosted or agent-only. */
276
- function checkStorefront(raw) {
277
- if (raw === undefined || raw === null)
278
- return;
279
- 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.';
280
- if (typeof raw !== 'object' || Array.isArray(raw))
281
- fail('invalid_storefront', 'storefront in store.yml is not a mapping.', hint);
282
- const s = raw;
283
- const unknown = Object.keys(s).filter((k) => !['mode', 'path'].includes(k));
284
- if (unknown.length)
285
- fail('invalid_storefront', `storefront in store.yml has ${unknown.join(', ')}, which it does not take.`, hint);
286
- if (s.mode !== undefined && s.mode !== null && !STOREFRONT_MODES.includes(s.mode))
287
- 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).');
288
- if (s.path !== undefined && s.path !== null && typeof s.path !== 'string')
289
- fail('invalid_storefront', 'storefront.path is not a folder name.', hint);
290
- }
291
- /** The theme block of store.yml, validated: the theme would ignore bad values, so say so here. */
292
- function checkTheme(raw) {
293
- if (raw === undefined || raw === null)
294
- return undefined;
295
- if (typeof raw !== 'object' || Array.isArray(raw))
296
- 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.');
297
- const t = raw;
298
- const unknown = Object.keys(t).filter((k) => !THEME_KEYS.includes(k));
299
- if (unknown.length)
300
- fail('invalid_theme', `theme in store.yml has ${unknown.length === 1 ? 'a setting' : 'settings'} the theme does not have: ${unknown.join(', ')}.`, `The theme's settings are ${THEME_KEYS.join(', ')}. A custom storefront's folder goes under storefront.path.`);
301
- const theme = {};
302
- for (const k of THEME_KEYS)
303
- if (t[k] !== undefined && t[k] !== null)
304
- theme[k] = String(t[k]);
305
- if (theme.color !== undefined && !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(theme.color))
306
- fail('invalid_theme', `theme.color is "${theme.color}".`, 'Use a hex colour in quotes, #rgb or #rrggbb, such as "#2f5d50".');
307
- for (const k of ['logo', 'hero'])
308
- if (theme[k] !== undefined && !isImageRef(theme[k]))
309
- fail('invalid_theme', `theme.${k} is "${theme[k]}".`, IMAGE_HINT);
310
- if (theme.tagline !== undefined && (theme.tagline.length > 120 || /[\r\n<>]/.test(theme.tagline)))
311
- 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.');
312
- if (theme.badge !== undefined && (theme.badge.length > 20 || !theme.badge.trim() || /[\r\n<>]/.test(theme.badge)))
313
- 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 < >.');
314
- return theme;
315
- }
316
- export function toApplied(store) {
317
- return {
318
- markets: Object.fromEntries(store.markets.map((m) => [m.code, m])),
319
- products: Object.fromEntries(store.products.map((p) => [p.sku, p])),
320
- ...(store.language !== DEFAULT_LANGUAGE && { language: store.language }),
321
- };
322
- }
323
- /** The store's languages: the base one first, then every language a product is translated into. */
324
- export const storeLanguages = (store) => [store.language, ...[...new Set(store.products.flatMap((p) => Object.keys(p.translations ?? {})))].sort()];
325
- /** Translations in a language no country of the store speaks: kept, but no page shows them. */
326
- export function languageWarnings(store) {
327
- const spoken = new Set(localesOf(toApplied(store)).map((l) => l.language));
328
- return storeLanguages(store)
329
- .slice(1)
330
- .filter((ll) => !spoken.has(ll))
331
- .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.`);
332
- }
333
- /** At most this many languages, base included: the API's limit. */
334
- export const MAX_LANGUAGES = 6;
335
- /** JSON with sorted keys and no undefined values, so key order never reads as a change. */
336
- 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));
337
- /** The one definition of "pending changes": what apply would do. plan and status both use it. */
338
- export function diff(state, desired) {
339
- const changes = [];
340
- const { applied } = state;
341
- if (applied) {
342
- if (state.name !== undefined && state.name !== null && state.name !== desired.name)
343
- changes.push({ action: 'update', type: 'store', id: 'name' });
344
- if (state.policies !== undefined && canonical(state.policies) !== canonical(desired.policies))
345
- changes.push({ action: 'update', type: 'store', id: 'policies' });
346
- }
347
- // The theme is listed like a resource: a create when the store has none yet (first apply
348
- // included), an update when it changes. An API that does not know themes (undefined) is skipped.
349
- const hasTheme = (t) => Boolean(t && Object.keys(t).length);
350
- if (!applied || state.theme !== undefined) {
351
- const before = applied ? state.theme : null;
352
- if (!hasTheme(before) && hasTheme(desired.theme))
353
- changes.push({ action: 'create', type: 'store', id: 'theme' });
354
- else if (hasTheme(before) && canonical(before) !== canonical(desired.theme))
355
- changes.push({ action: 'update', type: 'store', id: 'theme' });
356
- }
357
- // The mode: a change from what the API holds (hosted before the first apply). An API that does not know it is skipped.
358
- if (!applied ? storefrontMode(desired) !== 'hosted' : state.storefront_mode !== undefined && (state.storefront_mode ?? 'hosted') !== storefrontMode(desired)) {
359
- changes.push({ action: 'update', type: 'store', id: 'storefront_mode' });
360
- }
361
- // The base language: a change from what is applied (en before the first apply, and when absent).
362
- if ((applied?.language ?? DEFAULT_LANGUAGE) !== desired.language)
363
- changes.push({ action: 'update', type: 'store', id: 'language' });
364
- const after = toApplied(desired);
365
- for (const kind of ['markets', 'products']) {
366
- const type = kind.slice(0, -1);
367
- const before = applied?.[kind] ?? {};
368
- for (const id of Object.keys(after[kind])) {
369
- if (!(id in before))
370
- changes.push({ action: 'create', type, id });
371
- else if (canonical(before[id]) !== canonical(after[kind][id]))
372
- changes.push({ action: 'update', type, id });
373
- }
374
- for (const id of Object.keys(before))
375
- if (!(id in after[kind]))
376
- changes.push({ action: 'delete', type, id });
377
- }
378
- return changes;
379
- }