@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/website.js DELETED
@@ -1,390 +0,0 @@
1
- // catalog pull, source website: the products of a brand's own site, for a demo store. Three ways
2
- // in, tried in order, and the first that yields products wins: Shopify's public products.json,
3
- // WooCommerce's Store API, then the sitemap's product pages and their JSON-LD (schema.org
4
- // Product). Images are hot-linked, never downloaded: they stay on the brand's servers.
5
- import { fail } from './output.js';
6
- import { absoluteUrl, BudgetExceeded, clip, htmlToText, hreflangs, isType, jsonLd, oneLine, platformOf, skuFromHandle, unreachable, Web } from './web.js';
7
- export const WEBSITE_LIMIT = 100;
8
- export const WEBSITE_MAX = 500;
9
- const DESCRIPTION_MAX = 2000;
10
- const SHOPIFY_PAGE = 250;
11
- const WOO_PAGE = 100;
12
- /** Child sitemaps read from a sitemap index, product ones first. */
13
- const MAX_SITEMAPS = 10;
14
- const PRODUCT_PATH = /\/(products?|p|shop|item)\/[^/?#]+/i;
15
- /** What a strategy gathered, and what it left out. */
16
- class Gathered {
17
- limit;
18
- products = [];
19
- skus = new Set();
20
- variantsSkipped = 0;
21
- noPriceSkipped = 0;
22
- duplicatesSkipped = 0;
23
- seen = null;
24
- currency = null;
25
- constructor(limit) {
26
- this.limit = limit;
27
- }
28
- full() {
29
- return this.products.length >= this.limit;
30
- }
31
- add(f) {
32
- if (this.full() || !f.name)
33
- return;
34
- if (f.price === null || !Number.isFinite(f.price) || f.price < 0) {
35
- this.noPriceSkipped++;
36
- return;
37
- }
38
- if (!f.sku || this.skus.has(f.sku)) {
39
- this.duplicatesSkipped++;
40
- return;
41
- }
42
- this.skus.add(f.sku);
43
- this.products.push(f);
44
- }
45
- }
46
- const text = (html) => {
47
- const t = typeof html === 'string' ? clip(htmlToText(html), DESCRIPTION_MAX) : '';
48
- return t || undefined;
49
- };
50
- /** A category path from site names: "Coffee > Beans"; empty levels and stray > dropped. */
51
- const category = (...levels) => {
52
- const parts = levels
53
- .flatMap((l) => String(l ?? '').split('>'))
54
- .map((l) => oneLine(l))
55
- .filter(Boolean);
56
- return parts.length ? parts.join(' > ') : undefined;
57
- };
58
- const currencyCode = (v) => (typeof v === 'string' && /^[A-Za-z]{3}$/.test(v.trim()) ? v.trim().toUpperCase() : null);
59
- async function shopify(web, origin, g) {
60
- let base = `${origin}/products.json`;
61
- let collection = null;
62
- let first = await web.json(`${base}?limit=${SHOPIFY_PAGE}&page=1`);
63
- if (!Array.isArray(first?.body?.products)) {
64
- const fallback = await web.json(`${origin}/collections/all/products.json?limit=${SHOPIFY_PAGE}&page=1`);
65
- if (Array.isArray(fallback?.body?.products)) {
66
- base = `${origin}/collections/all/products.json`;
67
- collection = 'all';
68
- first = fallback;
69
- }
70
- else
71
- return { result: first === null && fallback === null ? 'disallowed' : 'not_found' };
72
- }
73
- const meta = await web.json(`${origin}/meta.json`).catch((e) => {
74
- if (e instanceof BudgetExceeded)
75
- throw e;
76
- return null;
77
- });
78
- g.currency = currencyCode(meta?.body?.currency);
79
- if (typeof meta?.body?.published_products_count === 'number')
80
- g.seen = meta.body.published_products_count;
81
- let listed = 0;
82
- let page = 1;
83
- let body = first.body;
84
- for (;;) {
85
- const products = body.products;
86
- listed += products.length;
87
- for (const p of products) {
88
- if (g.full())
89
- break;
90
- const variants = Array.isArray(p.variants) ? p.variants : [];
91
- const v = variants.find((x) => x?.available !== false) ?? variants[0];
92
- if (variants.length > 1)
93
- g.variantsSkipped += variants.length - 1;
94
- const tags = Array.isArray(p.tags) ? p.tags : typeof p.tags === 'string' ? p.tags.split(',') : [];
95
- const tag = tags.map((t) => t.trim()).find((t) => t && !t.startsWith('_') && !t.includes(':'));
96
- g.add({
97
- sku: String(v?.sku ?? '').trim() || skuFromHandle(String(p.handle ?? '')),
98
- name: oneLine(p.title),
99
- description: text(p.body_html),
100
- image_url: absoluteUrl(p.images?.[0]?.src, origin) ?? undefined,
101
- category: category(p.product_type) ?? category(tag) ?? (collection && collection !== 'all' ? category(collection) : undefined),
102
- price: v && v.price !== undefined && v.price !== null && v.price !== '' ? Number(v.price) : null,
103
- currency: currencyCode(v?.price_currency ?? v?.price?.currency_code),
104
- });
105
- }
106
- if (g.full() || products.length < SHOPIFY_PAGE)
107
- break;
108
- page++;
109
- const next = await web.json(`${base}?limit=${SHOPIFY_PAGE}&page=${page}`);
110
- if (!Array.isArray(next?.body?.products) || next.body.products.length === 0)
111
- break;
112
- body = next.body;
113
- }
114
- g.seen ??= listed;
115
- g.currency ??= g.products.find((p) => p.currency)?.currency ?? null;
116
- return g.products.length ? { result: 'products', gathered: g } : { result: 'no_products' };
117
- }
118
- async function woocommerce(web, origin, g) {
119
- const url = (page) => `${origin}/wp-json/wc/store/v1/products?per_page=${WOO_PAGE}&page=${page}`;
120
- let r = await web.json(url(1));
121
- if (r === null)
122
- return { result: 'disallowed' };
123
- if (!Array.isArray(r.body))
124
- return { result: 'not_found' };
125
- const total = Number(r.page.headers.get('x-wp-total'));
126
- if (Number.isFinite(total) && total > 0)
127
- g.seen = total;
128
- let listed = 0;
129
- for (let page = 1;;) {
130
- const products = r.body;
131
- listed += products.length;
132
- for (const p of products) {
133
- if (g.full())
134
- break;
135
- const prices = p.prices ?? {};
136
- const minor = Number(prices.currency_minor_unit ?? 2);
137
- const raw = prices.price;
138
- const price = raw !== undefined && raw !== null && raw !== '' && Number.isFinite(Number(raw)) ? Number(raw) / 10 ** (Number.isFinite(minor) ? minor : 2) : null;
139
- if (Array.isArray(p.variations) && p.variations.length > 1)
140
- g.variantsSkipped += p.variations.length - 1;
141
- g.add({
142
- sku: String(p.sku ?? '').trim() || skuFromHandle(String(p.slug ?? p.id ?? '')),
143
- name: oneLine(p.name),
144
- description: text(p.description) ?? text(p.short_description),
145
- image_url: absoluteUrl(p.images?.[0]?.src, origin) ?? undefined,
146
- category: category(p.categories?.[0]?.name),
147
- price,
148
- currency: currencyCode(prices.currency_code),
149
- });
150
- }
151
- if (g.full() || products.length < WOO_PAGE)
152
- break;
153
- page++;
154
- const next = await web.json(url(page));
155
- if (!Array.isArray(next?.body) || next.body.length === 0)
156
- break;
157
- r = next;
158
- }
159
- g.seen ??= listed;
160
- g.currency = g.products.find((p) => p.currency)?.currency ?? null;
161
- return g.products.length ? { result: 'products', gathered: g } : { result: 'no_products' };
162
- }
163
- const locs = (xml) => [...xml.matchAll(/<loc>\s*(?:<!\[CDATA\[)?\s*([^<\]]+?)\s*(?:\]\]>)?\s*<\/loc>/gi)].map((m) => m[1].replace(/&amp;/g, '&'));
164
- /** The product pages a site's sitemaps list: robots.txt's Sitemap lines, else /sitemap.xml; an index is opened. */
165
- async function productUrls(web, origin) {
166
- const robots = await web.robotsFor(origin);
167
- const roots = robots.sitemaps.length ? robots.sitemaps.slice(0, 3) : [`${origin}/sitemap.xml`];
168
- const urls = new Set();
169
- let disallowed = true;
170
- const queue = [...roots];
171
- let read = 0;
172
- while (queue.length && read < MAX_SITEMAPS) {
173
- const url = queue.shift();
174
- if (/\.gz($|\?)/i.test(url))
175
- continue;
176
- read++;
177
- const page = await web.get(url, 'application/xml,text/xml;q=0.9,*/*;q=0.5');
178
- if (page === null)
179
- continue;
180
- disallowed = false;
181
- if (!page.ok)
182
- continue;
183
- if (/<sitemapindex\b/i.test(page.text)) {
184
- // Product sitemaps first (Shopify's sitemap_products_1.xml, WordPress's product-sitemap.xml).
185
- const children = locs(page.text).sort((a, b) => Number(/product/i.test(b)) - Number(/product/i.test(a)));
186
- queue.push(...children);
187
- continue;
188
- }
189
- for (const loc of locs(page.text)) {
190
- const u = absoluteUrl(loc, origin);
191
- if (u && new URL(u).origin === origin && PRODUCT_PATH.test(new URL(u).pathname))
192
- urls.add(u);
193
- }
194
- }
195
- return { urls: [...urls], disallowed: disallowed && read > 0 };
196
- }
197
- const first = (v) => (Array.isArray(v) ? v[0] : v);
198
- const imageOf = (v, base) => {
199
- const i = first(v);
200
- const url = typeof i === 'string' ? i : (i?.url ?? i?.contentUrl);
201
- return absoluteUrl(url, base) ?? undefined;
202
- };
203
- /** "1.234,50", "1,234.50", "14,50", "14.50": the last separator before 1 or 2 digits is the decimal one. */
204
- export function amount(raw) {
205
- const s = raw.replace(/[^\d.,-]/g, '');
206
- const m = s.match(/^(-?[\d.,]*?)[.,](\d{1,2})$/);
207
- return m ? Number(`${m[1].replace(/[.,]/g, '') || '0'}.${m[2]}`) : Number(s.replace(/[.,]/g, '') || NaN);
208
- }
209
- /** The price of a schema.org offer: Offer, AggregateOffer (lowPrice), or a priceSpecification. */
210
- function offerPrice(offers) {
211
- const list = (Array.isArray(offers) ? offers : offers ? [offers] : []);
212
- for (const o of list) {
213
- const spec = first(o?.priceSpecification);
214
- const raw = o?.price ?? o?.lowPrice ?? spec?.price;
215
- const n = typeof raw === 'number' ? raw : amount(String(raw ?? ''));
216
- if (raw !== undefined && raw !== null && raw !== '' && Number.isFinite(n))
217
- return { price: n, currency: currencyCode(o?.priceCurrency ?? spec?.priceCurrency) };
218
- if (o?.offers) {
219
- const inner = offerPrice(o.offers);
220
- if (inner.price !== null)
221
- return inner;
222
- }
223
- }
224
- return { price: null, currency: null };
225
- }
226
- /** A product page's schema.org Product (a ProductGroup's first variant), with the breadcrumb trail as category. */
227
- export function productFromPage(html, url) {
228
- const nodes = jsonLd(html);
229
- const group = nodes.find((n) => isType(n, 'ProductGroup'));
230
- const product = nodes.find((n) => isType(n, 'Product')) ?? group;
231
- if (!product)
232
- return null;
233
- const variant = group ? first(group.hasVariant) : undefined;
234
- const offers = offerPrice(product.offers ?? variant?.offers);
235
- const name = oneLine(product.name ?? group?.name);
236
- const crumbs = nodes.find((n) => isType(n, 'BreadcrumbList'));
237
- let trail = [];
238
- if (Array.isArray(crumbs?.itemListElement)) {
239
- trail = [...crumbs.itemListElement]
240
- .sort((a, b) => Number(a?.position ?? 0) - Number(b?.position ?? 0))
241
- .map((e) => oneLine(e?.name ?? e?.item?.name))
242
- .filter(Boolean);
243
- if (trail.length && /^(home|homepage|start|inicio|accueil|startseite)$/i.test(trail[0]))
244
- trail.shift();
245
- if (trail.length && trail[trail.length - 1] === name)
246
- trail.pop();
247
- }
248
- const slug = new URL(url).pathname.split('/').filter(Boolean).pop() ?? '';
249
- return {
250
- sku: String(product.sku ?? variant?.sku ?? product.productID ?? product.mpn ?? '').trim() || skuFromHandle(decodeURIComponent(slug).replace(/\.html?$/i, '')),
251
- name,
252
- description: text(product.description),
253
- image_url: imageOf(product.image ?? variant?.image, url),
254
- category: category(...trail) ?? (typeof product.category === 'string' ? category(product.category) : undefined),
255
- price: offers.price,
256
- currency: offers.currency,
257
- };
258
- }
259
- async function structuredData(web, origin, g) {
260
- const { urls, disallowed } = await productUrls(web, origin);
261
- if (!urls.length)
262
- return { result: disallowed ? 'disallowed' : 'not_found' };
263
- g.seen = urls.length;
264
- const currencies = new Map();
265
- for (const url of urls) {
266
- if (g.full())
267
- break;
268
- let page;
269
- try {
270
- page = await web.get(url);
271
- }
272
- catch (e) {
273
- if (e instanceof BudgetExceeded)
274
- break;
275
- continue;
276
- }
277
- if (!page?.ok)
278
- continue;
279
- const found = productFromPage(page.text, page.url);
280
- if (!found)
281
- continue;
282
- if (found.currency)
283
- currencies.set(found.currency, (currencies.get(found.currency) ?? 0) + 1);
284
- g.add(found);
285
- }
286
- g.currency = [...currencies.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
287
- if (g.products.length)
288
- return { result: 'products', gathered: g };
289
- return { result: web.budgetExhausted ? 'budget_exhausted' : 'no_products' };
290
- }
291
- const STRATEGIES = [
292
- ['shopify', shopify],
293
- ['woocommerce', woocommerce],
294
- ['structured_data', structuredData],
295
- ];
296
- /** The site's products as catalog products in its currency, and how they were found. */
297
- export async function fromWebsite(c, marketCurrencies, currencyFlag) {
298
- const web = new Web();
299
- let origin = c.url;
300
- // The home page: the site's real origin (after a redirect to www), its title, platform and languages.
301
- let home = null;
302
- try {
303
- home = await web.get(`${origin}/`);
304
- }
305
- catch (e) {
306
- if (e instanceof BudgetExceeded)
307
- throw e;
308
- unreachable(origin, e);
309
- }
310
- if (home && home.status === 0)
311
- fail('site_unreachable', `${origin} did not answer within ${Math.round(web.timeout / 1000)} s.`, 'Check the address with the human (open it in a browser), and try again later.');
312
- if (home?.ok)
313
- origin = new URL(home.url).origin;
314
- const html = home?.ok ? home.text : '';
315
- const title = oneLine(html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? '') || null;
316
- const langs = [html.match(/<html\b[^>]*\blang\s*=\s*["']?([a-zA-Z]{2,3})/)?.[1]?.toLowerCase(), ...hreflangs(html, origin).map((h) => h.language)].filter((l) => !!l);
317
- const tried = [];
318
- let won = null;
319
- for (const [strategy, run] of STRATEGIES) {
320
- const g = new Gathered(c.limit);
321
- let outcome;
322
- try {
323
- outcome = await run(web, origin, g);
324
- }
325
- catch (e) {
326
- if (!(e instanceof BudgetExceeded))
327
- throw e;
328
- outcome = g.products.length ? { result: 'products', gathered: g } : { result: 'budget_exhausted' };
329
- }
330
- tried.push({ strategy, result: outcome.result });
331
- if (outcome.result === 'products') {
332
- won = { strategy, gathered: outcome.gathered };
333
- break;
334
- }
335
- if (web.budgetExhausted)
336
- break;
337
- }
338
- if (!won) {
339
- const all = tried.length === STRATEGIES.length && tried.every((t) => t.result === 'disallowed');
340
- const some = tried.filter((t) => t.result === 'disallowed').map((t) => t.strategy);
341
- fail('no_products_found', `Found no products on ${origin}: ${tried.map((t) => `${t.strategy} ${t.result.replace('_', ' ')}`).join(', ')}.`, `${all ? "The site's robots.txt does not let c11r read its products, and c11r does not go around it. " : some.length ? `robots.txt disallows ${some.join(' and ')}. ` : ''}${web.budgetExhausted ? 'The site answered too slowly to finish in 60 s. ' : ''}Ask the human for a product feed or a sheet instead (catalog source merchant or sheets, then c11r catalog pull).`);
342
- }
343
- const g = won.gathered;
344
- const flag = currencyFlag?.trim().toUpperCase();
345
- if (flag !== undefined && !/^[A-Z]{3}$/.test(flag))
346
- fail('invalid_argument', `--currency is "${currencyFlag}".`, 'Use the ISO 4217 code of the prices on the site, such as EUR.');
347
- if (flag && g.currency && flag !== g.currency)
348
- fail('currency_mismatch', `The site says its prices are in ${g.currency}, not ${flag}.`, `Leave out --currency: the site's own currency is ${g.currency}.`);
349
- const currency = g.currency ?? flag;
350
- if (!currency)
351
- fail('currency_unknown', `${origin} does not say which currency its prices are in.`, `Ask the human, then run c11r catalog pull --currency <code> (such as EUR). The prices are taken as they are on the site, never converted.`);
352
- const others = marketCurrencies.filter((m) => m !== currency);
353
- if (marketCurrencies.length && others.length) {
354
- fail('currency_mismatch', `The site's prices are in ${currency}; the store's markets sell in ${marketCurrencies.join(', ')}.`, marketCurrencies.includes(currency)
355
- ? `A website has one currency: the products would have no ${others.join(', ')} price. Keep only ${currency} markets in store.yml for the demo (ask the human), or add those prices from another source.`
356
- : `c11r never converts prices. Ask the human whether the demo store sells in ${currency}: if so, set the market's currency to ${currency} in store.yml, then pull again.`);
357
- }
358
- let otherCurrency = 0;
359
- const products = [];
360
- for (const f of g.products) {
361
- if (f.currency && f.currency !== currency) {
362
- otherCurrency++;
363
- continue;
364
- }
365
- const p = { sku: f.sku, name: f.name, prices: { [currency]: Math.round(f.price * 100) / 100 } };
366
- if (f.description)
367
- p.description = f.description;
368
- if (f.image_url)
369
- p.image_url = f.image_url;
370
- if (f.category)
371
- p.category = f.category;
372
- products.push(p);
373
- }
374
- return {
375
- products,
376
- report: {
377
- strategy: won.strategy,
378
- source_site: { url: origin, title, platform: won.strategy === 'structured_data' ? platformOf(html, home?.headers) : won.strategy, product_count_seen: g.seen, currency, languages: [...new Set(langs)] },
379
- limit: c.limit,
380
- variants_skipped: g.variantsSkipped,
381
- robots_skipped: web.robotsSkipped,
382
- no_price_skipped: g.noPriceSkipped,
383
- duplicates_skipped: g.duplicatesSkipped,
384
- other_currency_skipped: otherCurrency,
385
- tried,
386
- requests: web.requests,
387
- budget_exhausted: web.budgetExhausted,
388
- },
389
- };
390
- }