@commercelayer/agent-cli 0.8.1 → 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 -372
  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 -237
  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 -362
  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/inspect.js DELETED
@@ -1,237 +0,0 @@
1
- // c11r storefront inspect <url>: what a brand's website says about itself, for a demo store that
2
- // looks like it: title, languages, the logo, the colours, the currencies and countries it sells
3
- // in, the platform, and whether robots.txt lets catalog pull read its products. Read-only: it
4
- // writes no file and downloads nothing to keep; the logo is used by its URL (hot-linked).
5
- import { fail } from './output.js';
6
- import { absoluteUrl, BudgetExceeded, hreflangs, isType, jsonLd, metas, oneLine, platformOf, robotsAllows, siteOrigin, tags, unreachable, Web } from './web.js';
7
- const MAX_STYLESHEETS = 3;
8
- const MAX_LOGOS = 5;
9
- const MAX_COLORS = 5;
10
- const hex6 = (h) => {
11
- const x = h.replace('#', '').toLowerCase();
12
- return `#${x.length === 3 ? [...x].map((c) => c + c).join('') : x}`;
13
- };
14
- const rgbHex = (r, g, b) => `#${[r, g, b].map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0')).join('')}`;
15
- /** Greys, white and black: a page's text and background, rarely its brand colour. */
16
- const neutral = (hex) => {
17
- const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
18
- return Math.max(r, g, b) - Math.min(r, g, b) < 16;
19
- };
20
- /** Every colour written in CSS: #rgb, #rrggbb and rgb()/rgba(), counted. */
21
- export function cssColors(css, into = new Map()) {
22
- const add = (h) => into.set(h, (into.get(h) ?? 0) + 1);
23
- for (const m of css.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})(?![0-9a-z_-])/gi))
24
- add(hex6(m[1]));
25
- for (const m of css.matchAll(/rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})(?:\s*[,/]\s*([\d.]+%?))?\s*\)/gi)) {
26
- // A transparent colour is an overlay, not a colour of the brand.
27
- if (m[4] !== undefined && parseFloat(m[4]) === 0)
28
- continue;
29
- add(rgbHex(Number(m[1]), Number(m[2]), Number(m[3])));
30
- }
31
- return into;
32
- }
33
- const num = (v) => {
34
- const n = Number(String(v ?? '').replace(/px$/, ''));
35
- return Number.isFinite(n) && n > 0 ? n : undefined;
36
- };
37
- /** Where the header is: the <header> element, else the top of the body. */
38
- const headerOf = (html) => {
39
- const h = html.match(/<header\b[\s\S]*?<\/header>/i)?.[0];
40
- if (h)
41
- return h;
42
- const body = html.search(/<body\b/i);
43
- return html.slice(Math.max(0, body), Math.max(0, body) + 20_000);
44
- };
45
- export function logoCandidates(html, base, siteTitle) {
46
- const out = [];
47
- for (const node of jsonLd(html)) {
48
- if (!['Organization', 'Brand', 'OnlineStore', 'Store', 'Corporation', 'LocalBusiness', 'WebSite'].some((t) => isType(node, t)))
49
- continue;
50
- const l = Array.isArray(node.logo) ? node.logo[0] : node.logo;
51
- const url = absoluteUrl(typeof l === 'string' ? l : (l?.url ?? l?.contentUrl), base);
52
- if (url)
53
- out.push({ url, source: 'json_ld', score: 4, ...(num(l?.width) ? { width: num(l.width) } : {}), ...(num(l?.height) ? { height: num(l.height) } : {}) });
54
- }
55
- const words = (siteTitle ?? '').toLowerCase();
56
- const scan = (region, source, bonus) => {
57
- for (const t of tags(region, 'img')) {
58
- const a = t.attrs;
59
- const src = a.src || a['data-src'] || (a.srcset ?? a['data-srcset'] ?? '').split(',')[0]?.trim().split(/\s+/)[0];
60
- // Shopify themes leave {width} in lazy sources.
61
- const url = absoluteUrl(src?.replace(/\{width\}/g, '600'), base);
62
- if (!url)
63
- continue;
64
- const own = [a.class, a.id, a.alt, src].join(' ').toLowerCase();
65
- // The element around it (a link or a wrapper named logo), not just anything near.
66
- const parents = [...region.slice(Math.max(0, t.index - 600), t.index).matchAll(/<(?!img\b|\/)[a-z][^>]*>/gi)].slice(-2).map((m) => m[0]).join(' ').toLowerCase();
67
- const alt = (a.alt ?? '').trim().toLowerCase();
68
- const score = (own.includes('logo') ? 3 : 0) + (alt && words && (words.includes(alt) || alt.includes(words.split(/\s[|–—-]\s/)[0])) ? 2 : 0) + (parents.includes('logo') ? 1 : 0);
69
- if (score === 0)
70
- continue;
71
- out.push({ url, source, score: score + bonus, ...(a.alt ? { alt: a.alt.trim() } : {}), ...(num(a.width) ? { width: num(a.width) } : {}), ...(num(a.height) ? { height: num(a.height) } : {}) });
72
- }
73
- };
74
- const header = headerOf(html);
75
- scan(header, 'header_img', 1);
76
- scan(html, 'img', 0);
77
- // An inline <svg> logo has no URL to link: say so, so the agent asks for a file or picks another.
78
- for (const t of tags(header, 'svg')) {
79
- const own = [t.attrs.class, t.attrs.id, t.attrs['aria-label']].join(' ').toLowerCase();
80
- const titled = header.slice(t.index, t.index + 400).match(/<title>([^<]*)<\/title>/i)?.[1]?.toLowerCase();
81
- if (!own.includes('logo') && !(titled && words && words.includes(titled.trim())))
82
- continue;
83
- out.push({ url: null, source: 'header_svg', score: 2, note: 'An inline SVG in the header: it has no URL to link. Use another candidate, or ask the human for the logo file (img/logo.svg).' });
84
- break;
85
- }
86
- const seen = new Set();
87
- return out
88
- .sort((a, b) => b.score - a.score)
89
- .filter((l) => {
90
- const key = l.url ?? `svg:${l.source}`;
91
- if (seen.has(key))
92
- return false;
93
- seen.add(key);
94
- return true;
95
- })
96
- .slice(0, MAX_LOGOS)
97
- .map(({ score: _, ...l }) => l);
98
- }
99
- const ISO_CURRENCY = /^[A-Z]{3}$/;
100
- export function currenciesSeen(html, meta) {
101
- const found = new Set();
102
- const add = (c) => typeof c === 'string' && ISO_CURRENCY.test(c.trim().toUpperCase()) && found.add(c.trim().toUpperCase());
103
- add(meta['og:price:currency']);
104
- add(meta['product:price:currency']);
105
- for (const m of html.matchAll(/Shopify\.currency\s*=\s*\{[^}]*"active"\s*:\s*"([A-Za-z]{3})"/g))
106
- add(m[1]);
107
- for (const m of html.matchAll(/"priceCurrency"\s*:\s*"([A-Za-z]{3})"/g))
108
- add(m[1]);
109
- for (const m of html.matchAll(/"currency_code"\s*:\s*"([A-Za-z]{3})"/g))
110
- add(m[1]);
111
- for (const m of html.matchAll(/data-currency(?:-code)?\s*=\s*["']([A-Za-z]{3})["']/g))
112
- add(m[1]);
113
- // A currency selector: <select name="currency"> with ISO codes as values.
114
- for (const s of html.matchAll(/<select\b[^>]*name\s*=\s*["']?currency[^>]*>([\s\S]*?)<\/select>/gi))
115
- for (const o of tags(s[1], 'option'))
116
- add(o.attrs.value);
117
- return [...found].sort();
118
- }
119
- export function countriesSeen(html, base) {
120
- const found = new Set();
121
- const add = (c) => typeof c === 'string' && /^[A-Z]{2}$/.test(c.trim()) && found.add(c.trim());
122
- for (const h of hreflangs(html, base))
123
- if (h.region && /^[A-Z]{2}$/.test(h.region))
124
- found.add(h.region);
125
- for (const m of html.matchAll(/Shopify\.country\s*=\s*"([A-Z]{2})"/g))
126
- add(m[1]);
127
- // A country selector: Shopify's localization form, or a <select name="country...">.
128
- for (const f of html.matchAll(/<form\b[^>]*localization[^>]*>([\s\S]*?)<\/form>/gi))
129
- for (const m of f[1].matchAll(/data-value\s*=\s*["']([A-Z]{2})["']/g))
130
- add(m[1]);
131
- for (const s of html.matchAll(/<select\b[^>]*name\s*=\s*["']?country[^>]*>([\s\S]*?)<\/select>/gi))
132
- for (const o of tags(s[1], 'option'))
133
- add(o.attrs.value);
134
- return [...found].sort();
135
- }
136
- /** The paths catalog pull reads, by strategy: whether robots.txt lets c11r read them. */
137
- const PRODUCT_PATHS = { shopify: ['/products.json', '/collections/all/products.json'], woocommerce: ['/wp-json/wc/store/v1/products'], structured_data: ['/sitemap.xml'] };
138
- export async function inspect(raw) {
139
- if (!raw)
140
- fail('missing_argument', 'storefront inspect needs the website address.', 'Run c11r storefront inspect https://brand.example.');
141
- const origin = siteOrigin(raw, 'The address');
142
- const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
143
- const start = absoluteUrl(withScheme, origin) ?? `${origin}/`;
144
- const web = new Web();
145
- let page;
146
- try {
147
- page = await web.get(start);
148
- }
149
- catch (e) {
150
- if (e instanceof BudgetExceeded)
151
- fail('site_unreachable', `${start} did not answer in time.`, 'Try again later.');
152
- unreachable(start, e);
153
- }
154
- if (page === null)
155
- fail('robots_disallowed', `${start}'s robots.txt does not let c11r read that page.`, 'c11r does not go around robots.txt. Ask the human for the logo, colours and products instead (a feed or a sheet for catalog pull).');
156
- if (page.status === 0)
157
- fail('site_unreachable', `${start} 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.');
158
- if (!page.ok)
159
- fail('site_error', `${start} answered ${page.status}.`, 'Check the address with the human: open it in a browser.');
160
- const html = page.text;
161
- const base = page.url;
162
- const finalOrigin = new URL(base).origin;
163
- const meta = metas(html);
164
- const title = oneLine(html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? '') || null;
165
- const siteName = meta['og:site_name'] || null;
166
- const lang = html.match(/<html\b[^>]*\blang\s*=\s*["']?([A-Za-z]{2,3}(?:[-_][A-Za-z0-9]+)*)/)?.[1] ?? null;
167
- const alternates = hreflangs(html, base);
168
- const languages = [...new Set([lang?.slice(0, 2).toLowerCase(), ...alternates.map((h) => h.language)].filter((l) => !!l))];
169
- const icons = tags(html, 'link')
170
- .filter((t) => /\b(icon|apple-touch-icon|mask-icon)\b/i.test(t.attrs.rel ?? ''))
171
- .map((t) => ({ url: absoluteUrl(t.attrs.href, base), rel: t.attrs.rel, ...(t.attrs.sizes ? { sizes: t.attrs.sizes } : {}), ...(t.attrs.type ? { type: t.attrs.type } : {}) }))
172
- .filter((i) => i.url);
173
- // Colours: theme-color first, then the CSS: inline <style>, style="" and the first linked stylesheets.
174
- const counts = new Map();
175
- for (const m of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi))
176
- cssColors(m[1], counts);
177
- for (const m of html.matchAll(/\sstyle\s*=\s*"([^"]*)"/gi))
178
- cssColors(m[1], counts);
179
- const sheets = tags(html, 'link')
180
- .filter((t) => /\bstylesheet\b/i.test(t.attrs.rel ?? '') && t.attrs.href)
181
- .map((t) => absoluteUrl(t.attrs.href, base))
182
- .filter((u) => !!u)
183
- .slice(0, MAX_STYLESHEETS);
184
- let stylesheetsRead = 0;
185
- for (const url of sheets) {
186
- try {
187
- const css = await web.get(url, 'text/css,*/*;q=0.1');
188
- if (css?.ok) {
189
- cssColors(css.text, counts);
190
- stylesheetsRead++;
191
- }
192
- }
193
- catch (e) {
194
- if (e instanceof BudgetExceeded)
195
- break;
196
- }
197
- }
198
- const themeColor = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(meta['theme-color'] ?? '') ? hex6(meta['theme-color']) : null;
199
- const colors = [
200
- ...(themeColor ? [{ hex: themeColor, source: 'theme_color', count: counts.get(themeColor) ?? 0, neutral: neutral(themeColor) }] : []),
201
- ...[...counts.entries()]
202
- .filter(([h]) => h !== themeColor)
203
- .sort((a, b) => b[1] - a[1])
204
- .map(([hex, count]) => ({ hex, source: 'css', count, neutral: neutral(hex) })),
205
- ].slice(0, MAX_COLORS);
206
- const ogImage = absoluteUrl(meta['og:image:secure_url'] || meta['og:image'], base);
207
- const logos = logoCandidates(html, base, siteName ?? title);
208
- const ogFile = ogImage ? decodeURIComponent(new URL(ogImage).pathname.split('/').pop() ?? '') : '';
209
- const robots = await web.robotsFor(finalOrigin).catch(() => ({ rules: [], sitemaps: [] }));
210
- const paths = Object.fromEntries(Object.values(PRODUCT_PATHS).flat().map((p) => [p, robotsAllows(robots, p)]));
211
- const platform = platformOf(html, page.headers);
212
- const relevant = platform === 'unknown' ? Object.values(PRODUCT_PATHS).flat() : [...PRODUCT_PATHS[platform], ...PRODUCT_PATHS.structured_data];
213
- return {
214
- url: base,
215
- title,
216
- site_name: siteName,
217
- description: meta.description || meta['og:description'] || null,
218
- language: lang,
219
- languages,
220
- theme_color: themeColor,
221
- icons,
222
- og_image: ogImage,
223
- og_image_width: num(meta['og:image:width']) ?? null,
224
- og_image_height: num(meta['og:image:height']) ?? null,
225
- // The hero is a photo: an og:image that is the logo (a candidate, or logo in its file name) is not one.
226
- og_image_is_logo: ogImage ? logos.some((l) => l.url === ogImage) || /logo/i.test(ogFile) : false,
227
- logo_candidates: logos,
228
- colors,
229
- currencies_seen: currenciesSeen(html, meta),
230
- countries_seen: countriesSeen(html, base),
231
- platform,
232
- robots: { products_allowed: relevant.some((p) => paths[p]), paths },
233
- stylesheets_read: stylesheetsRead,
234
- requests: web.requests,
235
- next: 'Pick theme.color from colors (the first one that is not neutral, usually) and theme.logo from logo_candidates (its url, hot-linked: nothing is copied), and show the human what you picked. Then catalog: { source: website, url: <this site> } in store.yml and c11r catalog pull.' + (relevant.some((p) => paths[p]) ? '' : ' robots.txt disallows the paths catalog pull reads: ask the human for a feed or a sheet instead.'),
236
- };
237
- }
package/dist/languages.js DELETED
@@ -1,49 +0,0 @@
1
- // Languages: the store has one base language (store.yml `language:`, default en) and products
2
- // may carry name, description and category in others, as a { <ll>: text } map in YAML or as
3
- // name_<ll>, description_<ll>, category_<ll> columns in CSV and the catalog sources. They are
4
- // normalised into the base-language strings plus translations: { <ll>: { name, ... } }.
5
- /** ISO 639-1: the two-letter language codes. */
6
- export const ISO_639_1 = new Set('aa ab ae af ak am an ar as av ay az ba be bg bi bm bn bo br bs ca ce ch co cr cs cu cv cy da de dv dz ee el en eo es et eu fa ff fi fj fo fr fy ga gd gl gn gu gv ha he hi ho hr ht hu hy hz ia id ie ig ii ik io is it iu ja jv ka kg ki kj kk kl km kn ko kr ks ku kv kw ky la lb lg li ln lo lt lu lv mg mh mi mk ml mn mr ms mt my na nb nd ne ng nl nn no nr nv ny oc oj om or os pa pi pl ps pt qu rm rn ro ru rw sa sc sd se sg si sk sl sm sn so sq sr ss st su sv sw ta te tg th ti tk tl tn to tr ts tt tw ty ug uk ur uz ve vi vo wa wo xh yi yo za zh zu'.split(' '));
7
- export const DEFAULT_LANGUAGE = 'en';
8
- /** The product fields that can be translated. */
9
- export const TRANSLATED = ['name', 'description', 'category'];
10
- /** A column name_it, description_fr, category_de: the field and the language, or null. */
11
- export function translatedColumn(header) {
12
- const m = header.trim().toLowerCase().match(/^(name|description|category)_([a-z]{2})$/);
13
- return m ? { field: m[1], language: m[2] } : null;
14
- }
15
- /**
16
- * The key a plain column (name, description, category) goes under when a product also has
17
- * translated columns: it is the base language's text, whatever the base language is.
18
- */
19
- export const PLAIN = '';
20
- /** Put one language's text of a field on a product being read from a table: the field becomes a map. */
21
- export function setTranslated(product, field, language, value) {
22
- const current = product[field];
23
- const map = current && typeof current === 'object' ? current : current === undefined || current === '' ? {} : { [PLAIN]: String(current) };
24
- map[language] = value;
25
- product[field] = map;
26
- }
27
- /** Put the plain column's text on a product, keeping any translations already read. */
28
- export function setPlain(product, field, value) {
29
- const current = product[field];
30
- if (current && typeof current === 'object')
31
- current[PLAIN] = value;
32
- else
33
- product[field] = value;
34
- }
35
- /** The languages in a set of translations, sorted, and the fields each column set needs, for CSV output. */
36
- export function translationColumns(all) {
37
- const columns = new Set();
38
- for (const t of all)
39
- for (const [language, fields] of Object.entries(t ?? {}))
40
- for (const f of TRANSLATED)
41
- if (fields[f] !== undefined)
42
- columns.add(`${f}_${language}`);
43
- const order = (c) => [c.slice(-2), TRANSLATED.indexOf(c.slice(0, -3))];
44
- return [...columns].sort((a, b) => {
45
- const [la, fa] = order(a);
46
- const [lb, fb] = order(b);
47
- return la < lb ? -1 : la > lb ? 1 : fa - fb;
48
- });
49
- }
package/dist/links.js DELETED
@@ -1,118 +0,0 @@
1
- // Links: shareable URLs that open a hosted microstore for one SKU, a set of SKUs or a
2
- // category, or the hosted checkout for an order. Live configuration in Commerce Layer, made
3
- // with c11r links create; never in store.yml. The CLI validates the spec against store.yml
4
- // (skus, categories, markets) and sends one normalized link; the API makes the CL link.
5
- import { isoDate } from './promotions.js';
6
- import { fail } from './output.js';
7
- const KEYS = ['id', 'name', 'for', 'market', 'markets', 'starts', 'ends', 'params'];
8
- const FOR = ['sku', 'skus', 'category', 'order'];
9
- /** The SKUs one link can sell: the API's limit. */
10
- const MAX_SKUS = 100;
11
- const SHAPE_HINT = 'See the links skill for the shape: https://commercelayer.ai/skills/links.md';
12
- const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
13
- const seconds = (d) => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
14
- /** One calendar month after a date-time, in UTC. */
15
- function monthAfter(iso) {
16
- const d = new Date(iso);
17
- d.setUTCMonth(d.getUTCMonth() + 1);
18
- return seconds(d);
19
- }
20
- /**
21
- * Validate one link spec (YAML or JSON, as given to links create) and normalize it to the
22
- * wire shape, or fail invalid_link naming the path inside the spec. A category becomes its
23
- * products' skus as they are now, from store.yml.
24
- */
25
- export function checkLink(raw, markets, products, now = new Date()) {
26
- const skus = new Set(products.map((p) => p.sku));
27
- const bad = (msg, hint, path = 'link') => fail('invalid_link', `${path}: ${msg}`, hint);
28
- if (!isObj(raw))
29
- bad('is not a map.', `A link is a map with id, name and for. ${SHAPE_HINT}`);
30
- const l = raw;
31
- if (typeof l.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(l.id))
32
- bad(`id is ${l.id === undefined ? 'missing' : `"${String(l.id)}"`}.`, 'Give the link an id of lowercase letters, digits and dashes, such as summer-bundle: the handle for show, enable, disable and delete.');
33
- const id = l.id;
34
- for (const k of Object.keys(l))
35
- if (!KEYS.includes(k))
36
- bad(`unknown key ${k}.`, `A link's keys are ${KEYS.join(', ')}.`);
37
- const link = { id, name: l.name === undefined ? id : String(l.name), for: { sku: '' }, starts: '', ends: '' };
38
- if (!link.name.trim() || link.name.length > 255)
39
- bad('name is empty or longer than 255 characters.', 'Write a short name the human will recognize in the dashboard.');
40
- // for: exactly one of sku, skus, category, order. show prints a category with its skus: those are read again.
41
- const f = l.for;
42
- if (!isObj(f))
43
- bad(f === undefined ? 'has no for.' : 'for is not a map.', `Say what the link sells: for: { sku: <sku> }, { skus: [...] }, { category: "A > B" } or { order: <number> }. ${SHAPE_HINT}`);
44
- const given = Object.keys(f).filter((k) => !('category' in f && k === 'skus'));
45
- if (given.includes('sku_list'))
46
- bad('sku_list is a list made in the dashboard.', 'Name the skus (for: { skus: [...] }) or a category instead.', 'for');
47
- for (const k of given)
48
- if (!FOR.includes(k))
49
- bad(`unknown key ${k}.`, `for takes one of ${FOR.join(', ')}.`, 'for');
50
- if (given.length !== 1)
51
- bad(`has ${given.length ? given.join(' and ') : 'nothing'}.`, 'Give exactly one of sku, skus, category or order: a link sells one thing.', 'for');
52
- const kind = given[0];
53
- const v = f[kind];
54
- if (kind === 'sku') {
55
- if (typeof v !== 'string' || !skus.has(v))
56
- bad(`${String(v)} is not a product.`, "Use the SKU of one of the store's products.", 'for.sku');
57
- link.for = { sku: v };
58
- }
59
- else if (kind === 'skus') {
60
- if (!Array.isArray(v) || !v.length)
61
- bad('is not a list of SKUs.', 'List one or more SKUs: skus: [ETH, COL].', 'for.skus');
62
- if (v.length > MAX_SKUS)
63
- bad(`has ${v.length} SKUs.`, `A link sells at most ${MAX_SKUS} SKUs: make several links.`, 'for.skus');
64
- v.forEach((s, i) => {
65
- if (typeof s !== 'string' || !skus.has(s))
66
- bad(`${String(s)} is not a product.`, "Use SKUs of the store's products.", `for.skus[${i}]`);
67
- });
68
- link.for = { skus: [...new Set(v)] };
69
- }
70
- else if (kind === 'category') {
71
- const name = String(v).split('>').map((s) => s.trim()).filter(Boolean).join(' > ');
72
- const found = products.filter((x) => x.category === name || x.category?.startsWith(`${name} > `)).map((x) => x.sku);
73
- if (!name || !found.length)
74
- bad(`no product is in category "${String(v)}".`, `Use a category of the store's products, written as in the products: ${[...new Set(products.map((x) => x.category).filter(Boolean))].join(', ') || 'none has a category yet'}.`, 'for.category');
75
- if (found.length > MAX_SKUS)
76
- bad(`"${name}" has ${found.length} products.`, `A link sells at most ${MAX_SKUS} SKUs: use a narrower category, or several links.`, 'for.category');
77
- link.for = { category: name, skus: found.sort() };
78
- }
79
- else {
80
- if (!(typeof v === 'string' && v.trim()) && !(Number.isInteger(v) && v > 0))
81
- bad(`${String(v)} is not an order number.`, 'Use the number of a draft or pending order, or the id of a UCP cart or checkout (cart_..., chk_...): the order behind it.', 'for.order');
82
- link.for = { order: String(v).trim().replace(/^#/, '') };
83
- }
84
- // Markets: a code or a list, under market or markets; none means every market. An order has its own.
85
- if (l.market !== undefined && l.markets !== undefined)
86
- bad('has market and markets.', 'Use one of them.');
87
- const m = l.market ?? l.markets;
88
- const path = l.market !== undefined ? 'market' : 'markets';
89
- if (kind === 'order') {
90
- if (m !== undefined)
91
- bad(`${path}: an order link has the order's market.`, 'Remove market: the checkout opens in the market the order was placed in.');
92
- }
93
- else if (m === undefined)
94
- link.markets = markets.map((x) => x.code);
95
- else {
96
- const codes = Array.isArray(m) ? m : [m];
97
- if (!codes.length)
98
- bad('is empty.', 'List one or more market codes, or leave it out for every market.', path);
99
- codes.forEach((c, i) => {
100
- if (!markets.some((x) => x.code === c))
101
- bad(`${String(c)} is not in markets.`, `Use the code of one of the store's markets: ${markets.map((x) => x.code).join(', ')}.`, Array.isArray(m) ? `${path}[${i}]` : path);
102
- });
103
- link.markets = [...new Set(codes)];
104
- }
105
- link.starts = l.starts === undefined ? seconds(now) : isoDate(l.starts, false, (msg, h) => bad(`starts ${msg}`, h));
106
- link.ends = l.ends === undefined ? monthAfter(link.starts) : isoDate(l.ends, true, (msg, h) => bad(`ends ${msg}`, h));
107
- if (Date.parse(link.ends) <= Date.parse(link.starts))
108
- bad('ends is not after starts.', 'Make the end date later than the start date.');
109
- if (l.params !== undefined) {
110
- if (!isObj(l.params))
111
- bad('params is not a map.', 'params is a map of names to text, numbers or true/false, added to the link URL.');
112
- for (const [k, p] of Object.entries(l.params))
113
- if (!['string', 'number', 'boolean'].includes(typeof p))
114
- bad(`${k} is not text, a number or true/false.`, 'params values are plain values, added to the link URL.', `params.${k}`);
115
- link.params = l.params;
116
- }
117
- return link;
118
- }
package/dist/locales.js DELETED
@@ -1,43 +0,0 @@
1
- // Mirrored byte for byte from the API's src/api/locales.ts at 5cee8ba (TABLE, languageOf, localesOf):
2
- // keep it in step. The API derives a store's locales; the CLI needs them offline for plan and the stub.
3
- const TABLE = `
4
- AD:ca AE:ar AF:fa AG:en AI:en AL:sq AM:hy AO:pt AQ:en AR:es AS:en AT:de AU:en AW:nl AX:sv AZ:az
5
- BA:bs BB:en BD:bn BE:nl BF:fr BG:bg BH:ar BI:fr BJ:fr BL:fr BM:en BN:ms BO:es BQ:nl BR:pt BS:en BT:dz BV:no BW:en BY:be BZ:en
6
- CA:en CC:en CD:fr CF:fr CG:fr CH:de CI:fr CK:en CL:es CM:fr CN:zh CO:es CR:es CU:es CV:pt CW:nl CX:en CY:el CZ:cs
7
- DE:de DJ:fr DK:da DM:en DO:es DZ:ar EC:es EE:et EG:ar EH:ar ER:ti ES:es ET:am FI:fi FJ:en FK:en FM:en FO:fo FR:fr
8
- GA:fr GB:en GD:en GE:ka GF:fr GG:en GH:en GI:en GL:kl GM:en GN:fr GP:fr GQ:es GR:el GS:en GT:es GU:en GW:pt GY:en
9
- HK:zh HM:en HN:es HR:hr HT:fr HU:hu ID:id IE:en IL:he IM:en IN:en IO:en IQ:ar IR:fa IS:is IT:it JE:en JM:en JO:ar JP:ja
10
- KE:en KG:ky KH:km KI:en KM:ar KN:en KP:ko KR:ko KW:ar KY:en KZ:kk LA:lo LB:ar LC:en LI:de LK:si LR:en LS:en LT:lt LU:fr LV:lv LY:ar
11
- MA:ar MC:fr MD:ro ME:sr MF:fr MG:mg MH:en MK:mk ML:fr MM:my MN:mn MO:zh MP:en MQ:fr MR:ar MS:en MT:mt MU:en MV:dv MW:en MX:es MY:ms MZ:pt
12
- NA:en NC:fr NE:fr NF:en NG:en NI:es NL:nl NO:no NP:ne NR:en NU:en NZ:en OM:ar
13
- PA:es PE:es PF:fr PG:en PH:en PK:ur PL:pl PM:fr PN:en PR:es PS:ar PT:pt PW:en PY:es QA:ar RE:fr RO:ro RS:sr RU:ru RW:rw
14
- SA:ar SB:en SC:en SD:ar SE:sv SG:en SH:en SI:sl SJ:no SK:sk SL:en SM:it SN:fr SO:so SR:nl SS:en ST:pt SV:es SX:nl SY:ar SZ:en
15
- TC:en TD:fr TF:fr TG:fr TH:th TJ:tg TK:en TL:pt TM:tk TN:ar TO:en TR:tr TT:en TV:en TW:zh TZ:sw
16
- UA:uk UG:en UM:en US:en UY:es UZ:uz VA:it VC:en VE:es VG:en VI:en VN:vi VU:en WF:fr WS:en YE:ar YT:fr ZA:en ZM:en ZW:en
17
- `;
18
- /** Country code → its language. */
19
- export const COUNTRY_LANGUAGES = Object.freeze(Object.fromEntries(TABLE.trim().split(/\s+/).map((pair) => pair.split(':'))));
20
- /** A country's language: the table's, else en. */
21
- export const languageOf = (country) => COUNTRY_LANGUAGES[country.toUpperCase()] ?? 'en';
22
- /** The store's base language, in which plain names and descriptions are written. */
23
- export const baseLanguage = (applied) => applied?.language ?? 'en';
24
- export const LANGUAGE_LIMIT = 6;
25
- /**
26
- * The store's locales: one per country, in the order of its markets and their
27
- * countries. A country listed in two markets is the first market's.
28
- */
29
- export function localesOf(applied) {
30
- const out = [];
31
- const seen = new Set();
32
- for (const m of Object.values(applied?.markets ?? {})) {
33
- for (const raw of m.countries) {
34
- const country = raw.toUpperCase();
35
- if (seen.has(country))
36
- continue;
37
- seen.add(country);
38
- const language = languageOf(country);
39
- out.push({ country, language, market: m.code, path: `${country.toLowerCase()}-${language}` });
40
- }
41
- }
42
- return out;
43
- }
package/dist/ops.js DELETED
@@ -1,190 +0,0 @@
1
- // The merchant agent running the store on a schedule: what is new since the last run, what the
2
- // policy approves and captures, what it holds for the human, what is stuck. The policy is the
3
- // human's, read here and never sent to the API; the cursor (.commercelayer/ops.json) keeps a
4
- // run from reporting the same order twice. Shipping and cancelling stay with the human.
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import YAML from 'yaml';
8
- import { jsonCursor, MAX_SEEN, strings } from './cursor.js';
9
- import { paths } from './files.js';
10
- import { fail } from './output.js';
11
- import { minorUnits } from './ucp/platform.js';
12
- // ---------- the policy
13
- /**
14
- * Where the policy is read from: .commercelayer/ops.yml, the whole file (key null). Committed
15
- * with the store and agreed with the human; never sent to the API.
16
- */
17
- export const POLICY_SOURCE = { file: 'ops.yml', key: null };
18
- export const DEFAULT_POLICY = { auto_approve: false, auto_capture: false, review_above: null, hold_countries: [], stuck_days: 2, transit_days: 7, mail_orders: { auto_place: false, labels: { pending: 'pending', ordered: 'ordered', declined: 'declined' } } };
19
- const POLICY_KEYS = Object.keys(DEFAULT_POLICY);
20
- export const policyPath = () => path.join(paths.dir, POLICY_SOURCE.file);
21
- /** The policy, the defaults where it says nothing; invalid_policy with the fix when it is wrong. */
22
- export function loadPolicy() {
23
- const file = policyPath();
24
- if (!fs.existsSync(file))
25
- return { ...DEFAULT_POLICY, source: null };
26
- let doc;
27
- try {
28
- doc = YAML.parse(fs.readFileSync(file, 'utf8'));
29
- }
30
- catch (e) {
31
- fail('invalid_policy', `${file} is not valid YAML: ${e.message.split('\n')[0]}`, 'Fix the YAML, then run the command again.');
32
- }
33
- const raw = POLICY_SOURCE.key ? doc?.[POLICY_SOURCE.key] : doc;
34
- const where = POLICY_SOURCE.key ? `${POLICY_SOURCE.key} in ${file}` : file;
35
- return { ...checkPolicy(raw, where), source: file };
36
- }
37
- export function checkPolicy(raw, where) {
38
- if (raw === undefined || raw === null)
39
- return { ...DEFAULT_POLICY };
40
- const bad = (what, hint) => fail('invalid_policy', `${where}: ${what}`, hint);
41
- if (typeof raw !== 'object' || Array.isArray(raw))
42
- bad('the policy is not a set of settings.', `Write it as settings, one per line: auto_approve: true, review_above: 500, … (${POLICY_KEYS.join(', ')}).`);
43
- const p = raw;
44
- const unknown = Object.keys(p).filter((k) => !POLICY_KEYS.includes(k));
45
- if (unknown.length)
46
- bad(`${unknown.join(', ')} ${unknown.length === 1 ? 'is not a setting' : 'are not settings'} of the policy.`, `The settings are ${POLICY_KEYS.join(', ')}.`);
47
- const out = { ...DEFAULT_POLICY, mail_orders: { auto_place: false, labels: { ...DEFAULT_POLICY.mail_orders.labels } } };
48
- for (const k of ['auto_approve', 'auto_capture']) {
49
- if (p[k] === undefined)
50
- continue;
51
- if (typeof p[k] !== 'boolean')
52
- bad(`${k} is ${JSON.stringify(p[k])}, not true or false.`, `Write ${k}: true or ${k}: false.`);
53
- out[k] = p[k];
54
- }
55
- if (p.review_above !== undefined && p.review_above !== null) {
56
- if (typeof p.review_above !== 'number' || !Number.isFinite(p.review_above) || p.review_above < 0)
57
- bad(`review_above is ${JSON.stringify(p.review_above)}, not an amount.`, 'Give it in major units of the order\'s currency, for example review_above: 500, or leave it out to hold none for their total.');
58
- out.review_above = p.review_above;
59
- }
60
- if (p.hold_countries !== undefined) {
61
- const list = p.hold_countries;
62
- if (!Array.isArray(list) || list.some((c) => typeof c !== 'string' || !/^[A-Za-z]{2}$/.test(c)))
63
- bad(`hold_countries is ${JSON.stringify(list)}, not a list of country codes.`, 'Write ISO 3166 codes: hold_countries: [US, CA].');
64
- out.hold_countries = list.map((c) => c.toUpperCase());
65
- }
66
- for (const k of ['stuck_days', 'transit_days']) {
67
- if (p[k] === undefined)
68
- continue;
69
- const n = p[k];
70
- if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0)
71
- bad(`${k} is ${JSON.stringify(n)}, not a number of days.`, `Write a number of days above 0, for example ${k}: ${DEFAULT_POLICY[k]}.`);
72
- out[k] = n;
73
- }
74
- if (p.mail_orders !== undefined && p.mail_orders !== null) {
75
- const m = p.mail_orders;
76
- const fix = 'Write it as mail_orders: { auto_place: false, labels: { pending: pending, ordered: ordered, declined: declined } }; every setting is optional.';
77
- if (typeof m !== 'object' || Array.isArray(m))
78
- bad(`mail_orders is ${JSON.stringify(m)}, not a set of settings.`, fix);
79
- const extra = Object.keys(m).filter((k) => k !== 'auto_place' && k !== 'labels');
80
- if (extra.length)
81
- bad(`mail_orders.${extra[0]} is not a setting: mail_orders has auto_place and labels.`, fix);
82
- if (m.auto_place !== undefined && typeof m.auto_place !== 'boolean')
83
- bad(`mail_orders.auto_place is ${JSON.stringify(m.auto_place)}, not true or false.`, fix);
84
- out.mail_orders.auto_place = m.auto_place === true;
85
- if (m.labels !== undefined && m.labels !== null) {
86
- const l = m.labels;
87
- if (typeof l !== 'object' || Array.isArray(l))
88
- bad(`mail_orders.labels is ${JSON.stringify(l)}, not a set of names.`, fix);
89
- const names = Object.keys(DEFAULT_POLICY.mail_orders.labels);
90
- const other = Object.keys(l).filter((k) => !names.includes(k));
91
- if (other.length)
92
- bad(`mail_orders.labels.${other[0]} is not a label: the labels are ${names.join(', ')}.`, fix);
93
- for (const k of names) {
94
- const v = l[k];
95
- if (v === undefined)
96
- continue;
97
- if (typeof v !== 'string' || !v.trim() || v.length > 50 || v.startsWith('\\'))
98
- bad(`mail_orders.labels.${k} is ${JSON.stringify(v)}, not a label name.`, 'A label is a name of 1 to 50 characters, not starting with a backslash (\\Seen and the like are IMAP flags).');
99
- out.mail_orders.labels[k] = v;
100
- }
101
- }
102
- }
103
- return out;
104
- }
105
- export const cursorPath = () => path.join(paths.dir, 'ops.json');
106
- // Machine state, rewritten on every run: kept out of git like the credentials.
107
- const cursor = () => jsonCursor(cursorPath(), (c) => ({ last_run: typeof c?.last_run === 'string' ? c.last_run : null, seen: strings(c?.seen) }));
108
- export const readCursor = () => cursor().read();
109
- export const writeCursor = (c) => cursor().write({ last_run: c.last_run, seen: c.seen.slice(0, MAX_SEEN) });
110
- // ---------- time
111
- const UNITS = { h: 3600_000, d: 86_400_000, w: 7 * 86_400_000 };
112
- /** "24h", "7d", "2w" or an ISO date or time: the instant it means, relative to now. */
113
- export function parseSince(raw, flag, now = Date.now()) {
114
- const rel = raw.trim().match(/^(\d+(?:\.\d+)?)\s*([hdw])$/i);
115
- if (rel)
116
- return new Date(now - Number(rel[1]) * UNITS[rel[2].toLowerCase()]);
117
- const t = Date.parse(raw);
118
- if (!/^\d{4}-\d{2}-\d{2}/.test(raw.trim()) || Number.isNaN(t))
119
- fail('invalid_argument', `--${flag} ${raw} is not a time.`, `Use a span back from now (24h, 7d, 30d) or an ISO date (2026-11-01, 2026-11-01T09:00:00Z).`);
120
- return new Date(t);
121
- }
122
- const DAY = 86_400_000;
123
- const days = (ms) => Math.round((ms / DAY) * 10) / 10;
124
- const brief = (o) => ({ id: o.id, number: o.number, placed_at: o.placed_at, country: o.country, channel: o.channel, email: o.email, items: o.items, total: o.total, payment_status: o.payment_status });
125
- /** Why the policy holds an order for the human, if it does. */
126
- export function holdReason(o, p) {
127
- const reasons = [];
128
- if (p.review_above !== null && o.total.major > p.review_above)
129
- reasons.push(`its total ${o.total.display} is above ${o.currency} ${p.review_above.toFixed(2)} (review_above)`);
130
- if (o.country && p.hold_countries.includes(o.country.toUpperCase()))
131
- reasons.push(`it ships to ${o.country.toUpperCase()} (hold_countries)`);
132
- return reasons.length ? reasons.join(' and ') : undefined;
133
- }
134
- /** What the policy does with a placed order. */
135
- export function decidePlaced(o, p) {
136
- const held = holdReason(o, p);
137
- if (held)
138
- return { order: brief(o), action: 'hold', reason: held };
139
- if (!p.auto_approve)
140
- return { order: brief(o), action: 'wait', reason: 'auto_approve is off: the human approves orders' };
141
- if (p.auto_capture && o.payment_status === 'authorized')
142
- return { order: brief(o), action: 'approve_capture' };
143
- return { order: brief(o), action: 'approve' };
144
- }
145
- /** What the policy does with an approved order whose payment is only authorized. */
146
- export function decideApproved(o, p) {
147
- if (!p.auto_capture)
148
- return { order: brief(o), action: 'wait', reason: 'auto_capture is off: the human says when to take the payment' };
149
- return { order: brief(o), action: 'capture' };
150
- }
151
- /** Picking, packing or ready to ship for too long is stuck; on hold is the merchant's choice, never stuck. */
152
- const WORKING = ['picking', 'packing', 'ready_to_ship'];
153
- /** A stuck shipment as ops prints it, from its row and its age in days. */
154
- export function stuckView(s, age, p) {
155
- const since = s.status_changed_at ?? s.dates?.[`${s.status}_at`] ?? '';
156
- const reason = s.status === 'shipped' ? `shipped ${age} days ago and not delivered (transit_days ${p.transit_days})` : `${s.status.replace(/_/g, ' ')} for ${age} days (stuck_days ${p.stuck_days})`;
157
- return { shipment: { id: s.id, number: s.number, status: s.status, order: s.order, country: s.country, tracking: s.tracking }, days: age, since, reason };
158
- }
159
- /**
160
- * The same as the API's snapshot, for an API without it: shipments picking, packing or ready to
161
- * ship longer than stuck_days, or shipped longer than transit_days, from when they entered it.
162
- */
163
- export function stuckShipments(list, p, now = Date.now()) {
164
- const out = [];
165
- for (const s of list) {
166
- const d = (s.dates ?? {});
167
- const entered = Date.parse(s.status_changed_at ?? d[`${s.status}_at`] ?? '');
168
- if (Number.isNaN(entered))
169
- continue;
170
- const age = days(now - entered);
171
- if ((WORKING.includes(s.status) && now - entered > p.stuck_days * DAY) || (s.status === 'shipped' && now - entered > p.transit_days * DAY))
172
- out.push(stuckView(s, age, p));
173
- }
174
- return out.sort((a, b) => b.days - a.days);
175
- }
176
- // ---------- words
177
- /** "EUR 20.00 + USD 12.00": money in several currencies, summed per currency. */
178
- export function totalsByCurrency(list) {
179
- const by = new Map();
180
- for (const m of list)
181
- by.set(m.currency, (by.get(m.currency) ?? 0) + m.amount);
182
- return [...by].map(([currency, amount]) => {
183
- const unit = minorUnits(currency);
184
- const major = amount / unit;
185
- return { amount, currency, major, display: `${currency} ${major.toFixed(Math.round(Math.log10(unit)))}` };
186
- });
187
- }
188
- export const moneyText = (list) => (list.length ? list.map((m) => m.display).join(' + ') : '0');
189
- export const numbers = (list) => list.map((d) => `#${d.order.number}`).join(', ');
190
- export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;