@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
@@ -1,141 +0,0 @@
1
- // An order request from an email or a message (orders create), checked before it is sent: the
2
- // same rules the API applies, so a request missing what only the human knows fails here, naming
3
- // the path. Nothing is ever made up: no placeholder names, phones or addresses.
4
- import { fail } from './output.js';
5
- export const MAX_ITEMS = 25;
6
- const KEYS = ['source', 'customer', 'ship_to', 'bill_to', 'items', 'notes', 'reference', 'language', 'place', 'force'];
7
- const ASK = 'Ask the human for it; never invent it.';
8
- const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
9
- const bad = (path, what, hint = ASK) => fail('invalid_argument', `${path}: ${what}`, hint, path);
10
- /** A text field: absent is undefined; present, it is a non-empty string of at most max characters. */
11
- function text(o, key, path, opts = {}) {
12
- const v = o[key];
13
- if (v === undefined || v === null || (typeof v === 'string' && !v.trim())) {
14
- if (opts.required)
15
- bad(`${path}.${key}`, 'is missing.');
16
- return undefined;
17
- }
18
- if (typeof v !== 'string' && typeof v !== 'number')
19
- bad(`${path}.${key}`, `is ${JSON.stringify(v)}, not text.`, 'Give it as text.');
20
- const s = String(v).trim();
21
- // A dash or a question mark stands for "unknown": the human has to say it.
22
- if (/^[-?.]+$/.test(s))
23
- bad(`${path}.${key}`, `is "${s}", a placeholder.`);
24
- if (s.length > (opts.max ?? 255))
25
- bad(`${path}.${key}`, `is longer than ${opts.max ?? 255} characters.`, 'Shorten it.');
26
- return s;
27
- }
28
- function unknownKeys(o, allowed, path) {
29
- const extra = Object.keys(o).filter((k) => !allowed.includes(k));
30
- if (extra.length)
31
- bad(`${path ? `${path}.` : ''}${extra[0]}`, `is not a field${path ? ` of ${path}` : ''}.`, `The fields are ${allowed.join(', ')}.`);
32
- }
33
- function address(o, path, withNames) {
34
- if (!isObj(o))
35
- return bad(path, 'is missing: the address the order ships to.');
36
- const fields = ['line_1', 'line_2', 'city', 'postal_code', 'region', 'country', 'phone', ...(withNames ? ['first_name', 'last_name', 'company'] : [])];
37
- unknownKeys(o, fields, path);
38
- const country = text(o, 'country', path, { required: true }).toUpperCase();
39
- if (!/^[A-Z]{2}$/.test(country))
40
- bad(`${path}.country`, `is "${country}", not an ISO 3166 country code.`, 'Give the two-letter code: IT, FR, US.');
41
- const out = {
42
- line_1: text(o, 'line_1', path, { required: true }),
43
- line_2: text(o, 'line_2', path),
44
- city: text(o, 'city', path, { required: true }),
45
- postal_code: text(o, 'postal_code', path, { required: true, max: 20 }),
46
- region: text(o, 'region', path, { max: 50 }),
47
- country,
48
- phone: text(o, 'phone', path, { max: 40 }),
49
- ...(withNames ? { first_name: text(o, 'first_name', path), last_name: text(o, 'last_name', path), company: text(o, 'company', path) } : {}),
50
- };
51
- return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== undefined));
52
- }
53
- /** The request, checked and trimmed; invalid_argument naming the path, or too_many_items. */
54
- export function checkOrderRequest(raw) {
55
- if (!isObj(raw))
56
- return bad('request', 'is not a set of fields.', `Give it as YAML or JSON with ${KEYS.join(', ')}.`);
57
- unknownKeys(raw, KEYS, '');
58
- if (!isObj(raw.customer))
59
- bad('customer', 'is missing: who the order is for (email, first_name, last_name or company, phone).');
60
- const c = raw.customer;
61
- unknownKeys(c, ['email', 'first_name', 'last_name', 'company', 'phone'], 'customer');
62
- const email = text(c, 'email', 'customer', { required: true });
63
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
64
- bad('customer.email', `is "${email}", not an email address.`);
65
- const customer = { email, first_name: text(c, 'first_name', 'customer'), last_name: text(c, 'last_name', 'customer'), company: text(c, 'company', 'customer'), phone: text(c, 'phone', 'customer', { max: 40 }) };
66
- if (!customer.company && !(customer.first_name && customer.last_name))
67
- bad(customer.first_name ? 'customer.last_name' : 'customer.first_name', 'is missing: an order needs the first and last name, or a company.');
68
- const ship_to = address(raw.ship_to, 'ship_to', false);
69
- if (!customer.phone && !ship_to.phone)
70
- bad('customer.phone', 'is missing: Commerce Layer needs a phone number for the shipping address.');
71
- const bill_to = raw.bill_to === undefined || raw.bill_to === null ? undefined : address(raw.bill_to, 'bill_to', true);
72
- if (!Array.isArray(raw.items) || raw.items.length === 0)
73
- bad('items', 'is missing: at least one line, { sku, quantity } or { query, quantity }.', 'Give each line a sku, or the words that name the product as query.');
74
- const list = raw.items;
75
- if (list.length > MAX_ITEMS)
76
- fail('too_many_items', `items: ${list.length} lines, more than the ${MAX_ITEMS} one order takes.`, `Split it into orders of at most ${MAX_ITEMS} lines, with the human.`, 'items');
77
- const items = list.map((it, i) => {
78
- const path = `items[${i}]`;
79
- if (!isObj(it))
80
- return bad(path, `is ${JSON.stringify(it)}, not { sku, quantity } or { query, quantity }.`, 'Give each line a sku or a query, and a quantity.');
81
- unknownKeys(it, ['sku', 'query', 'quantity'], path);
82
- const sku = text(it, 'sku', path, { max: 100 });
83
- const query = text(it, 'query', path, { max: 200 });
84
- if (!sku && !query)
85
- bad(path, 'has neither sku nor query.', 'Give the sku, or the words that name the product as query.');
86
- const q = it.quantity ?? 1;
87
- if (typeof q !== 'number' || !Number.isInteger(q) || q < 1 || q > 10_000)
88
- bad(`${path}.quantity`, `is ${JSON.stringify(q)}, not a whole number from 1 to 10,000.`, 'Give how many, as the message says; ask the human if it looks wrong.');
89
- return { ...(sku ? { sku } : {}), ...(query ? { query } : {}), quantity: q };
90
- });
91
- // Required: it makes the request idempotent, so a message never makes two orders.
92
- let source;
93
- {
94
- const how = 'Give where the order came from: --source email:<Message-ID> for an email, --source message:<id> for anything else (the chat message or form id), or source: { kind, id } in the request.';
95
- if (raw.source === undefined || raw.source === null)
96
- bad('source', 'is missing: where the order came from.', how);
97
- if (!isObj(raw.source))
98
- return bad('source', 'is not { kind, id }.', how);
99
- const s = raw.source;
100
- unknownKeys(s, ['kind', 'id', 'from', 'subject', 'received_at'], 'source');
101
- const kind = text(s, 'kind', 'source', { required: true });
102
- if (kind !== 'email' && kind !== 'message')
103
- bad('source.kind', `is "${kind}", not email or message.`, 'Use email for an email, message for anything else (a chat, a form, a call).');
104
- source = { kind: kind, id: text(s, 'id', 'source', { required: true }), from: text(s, 'from', 'source'), subject: text(s, 'subject', 'source', { max: 500 }), received_at: text(s, 'received_at', 'source', { max: 40 }) };
105
- source = Object.fromEntries(Object.entries(source).filter(([, v]) => v !== undefined));
106
- }
107
- const language = text(raw, 'language', 'request', { max: 5 })?.toLowerCase();
108
- if (language && !/^[a-z]{2}$/.test(language))
109
- bad('language', `is "${language}", not an ISO 639-1 code.`, 'Give the two-letter code: en, it, fr.');
110
- for (const k of ['place', 'force'])
111
- if (raw[k] !== undefined && typeof raw[k] !== 'boolean')
112
- bad(k, `is ${JSON.stringify(raw[k])}, not true or false.`, `Write ${k}: true or leave it out.`);
113
- const out = {
114
- source,
115
- customer: Object.fromEntries(Object.entries(customer).filter(([, v]) => v !== undefined)),
116
- ship_to,
117
- ...(bill_to ? { bill_to } : {}),
118
- items,
119
- ...(text(raw, 'notes', 'request', { max: 1000 }) ? { notes: text(raw, 'notes', 'request', { max: 1000 }) } : {}),
120
- ...(text(raw, 'reference', 'request') ? { reference: text(raw, 'reference', 'request') } : {}),
121
- ...(language ? { language } : {}),
122
- ...(raw.place === true ? { place: true } : {}),
123
- ...(raw.force === true ? { force: true } : {}),
124
- };
125
- return out;
126
- }
127
- /** "SKU:3" → { sku, quantity }, "dark roast:2" → { query, quantity }; no ":<n>" is 1. */
128
- export function lineFlag(raw, kind) {
129
- const m = raw.match(/^(.*?)(?::\s*(\d+))?$/s);
130
- const value = m[1].trim();
131
- if (!value)
132
- fail('invalid_argument', `--${kind === 'sku' ? 'item' : 'query'} ${raw} names nothing.`, kind === 'sku' ? 'Use --item <sku>:<quantity>, for example --item ETH:3.' : 'Use --query "<words>:<quantity>", for example --query "dark roast:2".');
133
- return { [kind]: value, quantity: m[2] === undefined ? 1 : Number(m[2]) };
134
- }
135
- /** "email:<id>" or "message:<id>": the kind is everything before the first colon. */
136
- export function sourceFlag(raw) {
137
- const i = raw.indexOf(':');
138
- if (i < 1 || !raw.slice(i + 1).trim())
139
- fail('invalid_argument', `--source ${raw} is not kind:id.`, 'Use --source email:<Message-ID> for an email, --source message:<id> for anything else.');
140
- return { kind: raw.slice(0, i), id: raw.slice(i + 1).trim() };
141
- }
package/dist/output.js DELETED
@@ -1,39 +0,0 @@
1
- // Everything the CLI says is JSON on stdout: one object per run, pretty-printed.
2
- // A failure is { error: { code, message, hint } } with exit code 1. The hint is
3
- // the fix: an agent reads it and acts on it before trying anything else.
4
- // A command that waits (apply) writes progress to stderr, one JSON object per line.
5
- // Warnings the API sends about the CLI itself (cli_outdated) go into whatever is printed.
6
- export class CliError extends Error {
7
- code;
8
- hint;
9
- path;
10
- constructor(code, message, hint,
11
- /** The field of a request the error is about: customer.phone, items[0].quantity. */
12
- path) {
13
- super(message);
14
- this.code = code;
15
- this.hint = hint;
16
- this.path = path;
17
- }
18
- }
19
- export function fail(code, message, hint, path) {
20
- throw new CliError(code, message, hint, path);
21
- }
22
- export function progress(data) {
23
- process.stderr.write(JSON.stringify(data) + '\n');
24
- }
25
- const notices = [];
26
- /** Keep a warning to print with the result, once per code. */
27
- export function notice(n) {
28
- if (!notices.some((x) => x.code === n.code))
29
- notices.push(n);
30
- }
31
- export function print(data) {
32
- let out = data;
33
- if (notices.length && data && typeof data === 'object' && !Array.isArray(data)) {
34
- const own = data.warnings;
35
- // A command's own warnings are strings or objects; the API's are added after them.
36
- out = { ...data, warnings: [...(Array.isArray(own) ? own : own === undefined ? [] : [own]), ...notices] };
37
- }
38
- process.stdout.write(JSON.stringify(out, null, 2) + '\n');
39
- }
package/dist/products.js DELETED
@@ -1,151 +0,0 @@
1
- // The catalog can be large, so it lives in files under .commercelayer/products/
2
- // next to (or instead of) the products list in store.yml. Every *.yml file is a
3
- // list of products in the store.yml shape; every *.csv file is one product per
4
- // row. All files are merged by sku, and errors name the file they come from.
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import YAML from 'yaml';
8
- import { paths } from './files.js';
9
- import { fail } from './output.js';
10
- import { setPlain, setTranslated, translatedColumn, translationColumns, TRANSLATED } from './languages.js';
11
- /** The fixed CSV columns; name_<ll>, description_<ll> and category_<ll> are translations; any other column named by an ISO 4217 code is a price. */
12
- export const CSV_COLUMNS = ['sku', 'name', 'description', 'image_url', 'category'];
13
- /** The product files, in a stable order. */
14
- export function productFiles() {
15
- if (!fs.existsSync(paths.products))
16
- return [];
17
- return fs
18
- .readdirSync(paths.products)
19
- .filter((f) => /\.(ya?ml|csv)$/i.test(f))
20
- .sort()
21
- .map((f) => path.join(paths.products, f));
22
- }
23
- export function readProductFile(file) {
24
- const text = fs.readFileSync(file, 'utf8');
25
- const products = /\.csv$/i.test(file) ? fromCsv(file, text) : fromYaml(file, text);
26
- return products.map((product) => ({ product, source: file }));
27
- }
28
- function fromYaml(file, text) {
29
- let doc;
30
- try {
31
- doc = YAML.parse(text);
32
- }
33
- catch (e) {
34
- const reason = e.message.split('\n')[0];
35
- fail('invalid_yaml', `${file} is not valid YAML: ${reason}`, `Fix the YAML syntax in ${file} and run the command again.`);
36
- }
37
- if (doc === null || doc === undefined)
38
- return [];
39
- if (Array.isArray(doc))
40
- return doc;
41
- if (typeof doc === 'object') {
42
- const obj = doc;
43
- if (Array.isArray(obj.products))
44
- return obj.products;
45
- if ('sku' in obj)
46
- return [obj];
47
- }
48
- fail('invalid_product', `${file} is not a list of products.`, 'A product file is a YAML list of products, each with sku, name and prices, in the same shape as products in store.yml.');
49
- }
50
- /** RFC 4180: quoted cells, doubled quotes, CRLF or LF. Blank lines are skipped. */
51
- export function parseCsv(text) {
52
- const rows = [];
53
- let row = [];
54
- let cell = '';
55
- let quoted = false;
56
- for (let i = 0; i < text.length; i++) {
57
- const ch = text[i];
58
- if (quoted) {
59
- if (ch === '"') {
60
- if (text[i + 1] === '"') {
61
- cell += '"';
62
- i++;
63
- }
64
- else
65
- quoted = false;
66
- }
67
- else
68
- cell += ch;
69
- }
70
- else if (ch === '"')
71
- quoted = true;
72
- else if (ch === ',') {
73
- row.push(cell);
74
- cell = '';
75
- }
76
- else if (ch === '\n' || ch === '\r') {
77
- if (ch === '\r' && text[i + 1] === '\n')
78
- i++;
79
- row.push(cell);
80
- rows.push(row);
81
- row = [];
82
- cell = '';
83
- }
84
- else
85
- cell += ch;
86
- }
87
- if (cell !== '' || row.length) {
88
- row.push(cell);
89
- rows.push(row);
90
- }
91
- return rows.filter((r) => !(r.length === 1 && r[0].trim() === ''));
92
- }
93
- function fromCsv(file, text) {
94
- const rows = parseCsv(text.replace(/^/, ''));
95
- if (rows.length === 0)
96
- return [];
97
- const header = rows[0].map((h) => h.trim());
98
- const columns = header.map((h) => {
99
- const lower = h.toLowerCase();
100
- if (CSV_COLUMNS.includes(lower))
101
- return { field: lower };
102
- const t = translatedColumn(h);
103
- if (t)
104
- return { translated: t.field, language: t.language };
105
- if (/^[A-Z]{3}$/.test(h))
106
- return { currency: h };
107
- const why = lower === 'stock' ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : '';
108
- fail('invalid_product', `${file} has a column "${h}" that is not a product field.`, `${why}CSV columns are ${CSV_COLUMNS.join(', ')}, name_<ll>, description_<ll> and category_<ll> for other languages (name_it), and one column per currency with the price, named by its ISO 4217 code (EUR, USD, ...). Remove or rename the column.`);
109
- });
110
- if (!columns.some((c) => 'field' in c && c.field === 'sku'))
111
- fail('invalid_product', `${file} has no sku column.`, `The first line of a product CSV names its columns: ${CSV_COLUMNS.join(', ')} and one per currency.`);
112
- const products = [];
113
- rows.slice(1).forEach((cells, i) => {
114
- const line = `${file}:${i + 2}`;
115
- const product = { sku: '', name: '', prices: {} };
116
- columns.forEach((col, j) => {
117
- const value = (cells[j] ?? '').trim();
118
- if (value === '')
119
- return;
120
- if ('currency' in col) {
121
- const price = Number(value);
122
- if (!Number.isFinite(price))
123
- fail('invalid_product', `${line}: ${col.currency} price "${value}" is not a number.`, 'Write prices as plain decimals, such as 14.50, with no currency sign or thousands separator.');
124
- product.prices[col.currency] = price;
125
- }
126
- else if ('translated' in col)
127
- setTranslated(product, col.translated, col.language, value);
128
- else if (TRANSLATED.includes(col.field))
129
- setPlain(product, col.field, value);
130
- else
131
- product[col.field] = value;
132
- });
133
- products.push(product);
134
- });
135
- return products;
136
- }
137
- const csvCell = (v) => {
138
- const t = v === undefined ? '' : String(v);
139
- return /[",\r\n]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t;
140
- };
141
- /** Products as CSV in the shape fromCsv reads: the fixed columns, the translations, then one per currency, rows by sku. Same products, same bytes. */
142
- export function toCsv(products) {
143
- const currencies = [...new Set(products.flatMap((p) => Object.keys(p.prices ?? {})))].sort();
144
- const translated = translationColumns(products.map((p) => p.translations));
145
- const rows = [...products].sort((a, b) => (a.sku < b.sku ? -1 : a.sku > b.sku ? 1 : 0));
146
- const lines = [[...CSV_COLUMNS, ...translated, ...currencies].join(',')];
147
- const translation = (p, column) => p.translations?.[column.slice(-2)]?.[column.slice(0, -3)];
148
- for (const p of rows)
149
- lines.push([...CSV_COLUMNS.map((c) => csvCell(p[c])), ...translated.map((c) => csvCell(translation(p, c))), ...currencies.map((c) => csvCell(p.prices?.[c]))].join(','));
150
- return lines.join('\n') + '\n';
151
- }
@@ -1,291 +0,0 @@
1
- // promotions: in store.yml. The CLI validates the YAML sugar (single-key rules and actions,
2
- // categories, date-only days) and sends one normalized object per promotion in PUT /resources;
3
- // the API maps each to Commerce Layer promotions. Amounts stay in major units, like prices;
4
- // flex rules pass through as written (Rules Engine syntax, cents).
5
- import { fail } from './output.js';
6
- /** Commerce Layer needs both dates: without them the promotion runs from 2000 to 2100. */
7
- export const DEFAULT_STARTS = '2000-01-01T00:00:00Z';
8
- export const DEFAULT_ENDS = '2100-01-01T00:00:00Z';
9
- /** Promotions without a coupon that can be active at once: Commerce Layer's limit. */
10
- export const ACTIVE_LIMIT = 10;
11
- const KEYS = ['id', 'name', 'market', 'starts', 'ends', 'limit', 'priority', 'exclusive', 'enabled', 'rules', 'actions', 'flex'];
12
- const RULES = ['coupon', 'order_amount', 'skus', 'category'];
13
- const ACTIONS = ['percentage', 'fixed_amount', 'free_shipping', 'fixed_price', 'free_gift', 'buy_x_pay_y'];
14
- const SHAPE_HINT = 'See the promotions skill for the shape: https://commercelayer.ai/skills/promotions.md';
15
- const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
16
- const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
17
- const isInt = (v, min) => Number.isInteger(v) && v >= min;
18
- const list = (v) => (Array.isArray(v) ? v : [v]);
19
- /** A date or a date-time, as ISO UTC. A bare day starts at 00:00:00Z and ends at 23:59:59Z. */
20
- export function isoDate(v, end, bad) {
21
- const s = v instanceof Date ? v.toISOString().replace(/\.000Z$/, 'Z') : String(v);
22
- const hint = 'Write a day, 2026-12-01, or a time in UTC, 2026-12-01T09:00:00Z.';
23
- if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
24
- if (Number.isNaN(Date.parse(s)))
25
- bad(`"${s}" is not a date.`, hint);
26
- return `${s}T${end ? '23:59:59' : '00:00:00'}Z`;
27
- }
28
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(s) || Number.isNaN(Date.parse(s)))
29
- bad(`"${s}" is not a date.`, hint);
30
- return new Date(s).toISOString().replace(/\.000Z$/, 'Z');
31
- }
32
- /**
33
- * The normalized shape (what list and show print) back to the YAML shorthand, so a promotion
34
- * read with show can be edited and sent to update: rules as an object, actions with a type.
35
- */
36
- function fromListed(p) {
37
- const out = { ...p };
38
- if (isObj(p.rules)) {
39
- const r = p.rules;
40
- out.rules = [
41
- ...(r.coupons !== undefined ? [{ coupon: r.coupons }] : []),
42
- ...(r.order_amount !== undefined ? [{ order_amount: r.order_amount }] : []),
43
- ...(r.skus !== undefined ? [{ skus: r.skus }] : []),
44
- ...Object.keys(r).filter((k) => !['coupons', 'order_amount', 'skus'].includes(k)).map((k) => ({ [k]: r[k] })),
45
- ];
46
- }
47
- if (Array.isArray(p.actions)) {
48
- out.actions = p.actions.map((a) => {
49
- if (!isObj(a) || typeof a.type !== 'string')
50
- return a;
51
- const { type, ...rest } = a;
52
- if (type === 'free_shipping')
53
- return 'free_shipping';
54
- if (type === 'percentage' || type === 'fixed_amount') {
55
- const { value, ...targets } = rest;
56
- return { [type]: value, ...targets };
57
- }
58
- return { [type]: rest };
59
- });
60
- }
61
- return out;
62
- }
63
- /**
64
- * Validate one promotion spec (YAML or JSON, as given to promotions create or update) and
65
- * normalize it to the wire shape, or fail invalid_promotion naming the path inside the spec.
66
- * Categories become their products' skus as they are now, from store.yml.
67
- */
68
- export function checkPromotion(raw, markets, products) {
69
- const skus = new Set(products.map((p) => p.sku));
70
- const currencies = new Set(markets.map((m) => m.currency));
71
- const at = 'promotion';
72
- const bad = (msg, hint, path = at) => fail('invalid_promotion', `${path}: ${msg}`, hint);
73
- {
74
- const p = raw;
75
- if (!isObj(p))
76
- bad('is not a map.', `A promotion is a map with id, rules and actions. ${SHAPE_HINT}`);
77
- const pr = fromListed(p);
78
- if (typeof pr.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(pr.id))
79
- bad(`id is ${pr.id === undefined ? 'missing' : `"${String(pr.id)}"`}.`, 'Give the promotion an id of lowercase letters, digits and dashes, such as welcome10: the handle for show, update, enable, disable and delete.');
80
- const id = pr.id;
81
- for (const k of Object.keys(pr))
82
- if (!KEYS.includes(k))
83
- bad(`unknown key ${k}.`, `A promotion's keys are ${KEYS.join(', ')}.`);
84
- const promo = { id, name: pr.name === undefined ? id : String(pr.name), starts: DEFAULT_STARTS, ends: DEFAULT_ENDS, exclusive: false, enabled: true };
85
- if (!promo.name.trim() || promo.name.length > 255)
86
- bad('name is empty or longer than 255 characters.', 'Write a short name the human will recognize in the dashboard.');
87
- let currency;
88
- if (pr.market !== undefined) {
89
- const m = markets.find((x) => x.code === pr.market);
90
- if (!m)
91
- bad(`market ${String(pr.market)} is not in markets.`, `Use the code of one of the store's markets: ${markets.map((x) => x.code).join(', ')}.`);
92
- promo.market = m.code;
93
- currency = m.currency;
94
- }
95
- if (pr.starts !== undefined)
96
- promo.starts = isoDate(pr.starts, false, (m, h) => bad(`starts ${m}`, h));
97
- if (pr.ends !== undefined)
98
- promo.ends = isoDate(pr.ends, true, (m, h) => bad(`ends ${m}`, h));
99
- if (Date.parse(promo.ends) <= Date.parse(promo.starts))
100
- bad('ends is not after starts.', 'Make the end date later than the start date.');
101
- if (pr.limit !== undefined) {
102
- if (!isInt(pr.limit, 1))
103
- bad(`limit is ${String(pr.limit)}.`, 'limit is the total number of uses, a whole number of 1 or more.');
104
- promo.limit = pr.limit;
105
- }
106
- if (pr.priority !== undefined) {
107
- if (!isInt(pr.priority, 1))
108
- bad(`priority is ${String(pr.priority)}.`, 'priority is a whole number of 1 or more; the lowest runs first.');
109
- promo.priority = pr.priority;
110
- }
111
- for (const k of ['exclusive', 'enabled']) {
112
- if (pr[k] !== undefined && typeof pr[k] !== 'boolean')
113
- bad(`${k} is ${String(pr[k])}.`, `${k} is true or false.`);
114
- if (pr[k] !== undefined)
115
- promo[k] = pr[k];
116
- }
117
- if (pr.flex !== undefined) {
118
- if (pr.rules !== undefined || pr.actions !== undefined)
119
- bad('has flex and rules or actions.', 'A flex promotion keeps its rules and actions inside flex: remove the other rules and actions, or remove flex.');
120
- const f = pr.flex;
121
- if (!isObj(f) || !Array.isArray(f.rules) || f.rules.length === 0)
122
- bad('flex has no rules.', 'Write flex: { rules: [...] }, each rule with conditions and actions. See docs.commercelayer.io/rules-engine.');
123
- f.rules.forEach((r, j) => {
124
- const path = `flex.rules[${j}]`;
125
- if (!isObj(r) || !Array.isArray(r.conditions) || !r.conditions.length || !Array.isArray(r.actions) || !r.actions.length)
126
- bad('needs a list of conditions and a list of actions.', 'Each flex rule has conditions (field, matcher, value) and actions (type, selector, value).', path);
127
- });
128
- promo.flex = f;
129
- return promo;
130
- }
131
- // Rules: a list of single-key maps; none means every order.
132
- const rules = {};
133
- let ruleSkus;
134
- const seen = new Set();
135
- const resolve = (v, kind, path) => {
136
- if (kind === 'category') {
137
- const name = String(v).split('>').map((s) => s.trim()).filter(Boolean).join(' > ');
138
- const found = products.filter((x) => x.category === name || x.category?.startsWith(`${name} > `)).map((x) => x.sku);
139
- if (!name || !found.length)
140
- 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'}.`, path);
141
- return found.sort();
142
- }
143
- const l = list(v).map(String);
144
- if (!l.length)
145
- bad('skus is empty.', 'List one or more SKUs.', path);
146
- for (const s of l)
147
- if (!skus.has(s))
148
- bad(`${s} is not a product.`, 'Use SKUs of the store\'s products.', path);
149
- return [...new Set(l)].sort();
150
- };
151
- const rawRules = pr.rules === undefined ? [] : pr.rules;
152
- if (!Array.isArray(rawRules))
153
- bad('rules is not a list.', `Write rules as a list: rules: [{ coupon: CODE }]. ${SHAPE_HINT}`);
154
- rawRules.forEach((r, j) => {
155
- const path = `rules[${j}]`;
156
- if (!isObj(r) || Object.keys(r).length !== 1)
157
- bad('is not a single rule.', `Each rule is one of ${RULES.join(', ')}, written as a one-key map: { coupon: CODE }.`, path);
158
- const [k, v] = Object.entries(r)[0];
159
- if (!RULES.includes(k))
160
- bad(`unknown rule ${k}.`, `Rules are ${RULES.join(', ')}. For anything else, write a flex promotion.`, path);
161
- const kind = k === 'category' ? 'skus' : k;
162
- if (seen.has(kind))
163
- bad(`has two ${kind === 'skus' ? 'skus or category' : k} rules.`, kind === 'skus' ? 'Use skus or category once per promotion: list every SKU in one rule, or make two promotions.' : `Use one ${k} rule per promotion; a coupon rule takes a list of codes.`, path);
164
- seen.add(kind);
165
- if (k === 'coupon') {
166
- const l = list(v).map(String);
167
- for (const c of l) {
168
- if (!/^[A-Za-z0-9_-]{8,40}$/.test(c))
169
- bad(`coupon "${c}" is not 8 to 40 letters, digits, - or _.`, 'Commerce Layer codes have 8 to 40 characters: WELCOME10, not WELCOME.', path);
170
- }
171
- if (!l.length)
172
- bad('coupon has no code.', 'Write coupon: CODE, or a list of codes.', path);
173
- if (new Set(l).size > 20)
174
- bad(`coupon has ${new Set(l).size} codes.`, 'A promotion takes at most 20 codes: split them across promotions.', path);
175
- rules.coupons = [...new Set(l)];
176
- }
177
- else if (k === 'order_amount') {
178
- if (isObj(v) && v.max !== undefined)
179
- bad('order_amount has a max.', 'Commerce Layer promotions match a minimum order amount only: remove max, or write a flex promotion.', path);
180
- if (!isObj(v) || Object.keys(v).some((x) => x !== 'min') || !isNum(v.min) || v.min <= 0)
181
- bad('order_amount needs min, a positive amount.', 'Write order_amount: { min: 50 }, the minimum items total, in the market currency, in major units.', path);
182
- rules.order_amount = { min: v.min };
183
- }
184
- else {
185
- ruleSkus = resolve(v, k, path);
186
- rules.skus = ruleSkus;
187
- }
188
- });
189
- // Actions: one or more, a single-key map or the bare free_shipping.
190
- if (!Array.isArray(pr.actions) || pr.actions.length === 0)
191
- bad('has no actions.', `Add actions: what the promotion does, such as actions: [{ percentage: 10 }]. ${SHAPE_HINT}`);
192
- const actions = [];
193
- const types = new Set();
194
- let needsCurrency = Boolean(rules.order_amount);
195
- pr.actions.forEach((a, j) => {
196
- const path = `actions[${j}]`;
197
- const entry = a === 'free_shipping' ? { free_shipping: true } : a;
198
- if (!isObj(entry))
199
- bad('is not an action.', `Each action is one of ${ACTIONS.join(', ')}: { percentage: 10 }, or free_shipping.`, path);
200
- const [k, v] = Object.entries(entry)[0] ?? [];
201
- if (!ACTIONS.includes(k))
202
- bad(`unknown action ${k ?? '(none)'}.`, `Actions are ${ACTIONS.join(', ')}. For anything else, write a flex promotion.`, path);
203
- if (types.has(k))
204
- bad(`has two ${k} actions.`, 'Use each action once per promotion.', path);
205
- types.add(k);
206
- const extra = Object.keys(entry).filter((x) => x !== k);
207
- // Targets: skus or category next to the action, or inside its map; else the rule's.
208
- const target = (o, required) => {
209
- if (o.skus !== undefined && o.category !== undefined)
210
- bad('has skus and category.', 'Use one of them.', path);
211
- if (o.skus !== undefined)
212
- return resolve(o.skus, 'skus', path);
213
- if (o.category !== undefined)
214
- return resolve(o.category, 'category', path);
215
- if (required && !ruleSkus)
216
- bad(`${k} needs the SKUs it applies to.`, `Add skus: [...] or category: ... to the action, or a skus or category rule.`, path);
217
- return ruleSkus;
218
- };
219
- const only = (o, allowed) => {
220
- for (const x of Object.keys(o))
221
- if (!allowed.includes(x))
222
- bad(`unknown key ${x} in ${k}.`, `${k} takes ${allowed.join(', ')}.`, path);
223
- };
224
- if (k === 'percentage') {
225
- only(entry, ['percentage', 'skus', 'category']);
226
- if (!isInt(v, 1) || v > 100)
227
- bad(`percentage is ${String(v)}.`, 'percentage is a whole number from 1 to 100: 10 is 10% off. To make shipping free, use free_shipping.', path);
228
- const t = target(entry, false);
229
- actions.push({ type: 'percentage', value: v, ...(t ? { skus: t } : {}) });
230
- }
231
- else if (k === 'fixed_amount') {
232
- only(entry, ['fixed_amount']);
233
- if (!isNum(v) || v <= 0)
234
- bad(`fixed_amount is ${String(v)}.`, 'fixed_amount is the amount off the order, in major units: 5 is 5 euros.', path);
235
- actions.push({ type: 'fixed_amount', value: v });
236
- needsCurrency = true;
237
- }
238
- else if (k === 'free_shipping') {
239
- if (extra.length || (v !== true && v !== null && !(isObj(v) && !Object.keys(v).length)))
240
- bad('free_shipping takes no settings.', 'Write free_shipping on its own.', path);
241
- actions.push({ type: 'free_shipping' });
242
- }
243
- else {
244
- if (extra.length)
245
- bad(`${k} has keys next to it: ${extra.join(', ')}.`, `Put them inside: ${k}: { ... }.`, path);
246
- if (!isObj(v))
247
- bad(`${k} is not a map.`, SHAPE_HINT, path);
248
- const o = v;
249
- if (k === 'fixed_price') {
250
- only(o, ['price', 'skus', 'category']);
251
- if (!isNum(o.price) || o.price <= 0)
252
- bad(`fixed_price.price is ${String(o.price)}.`, 'price is the price the SKUs sell at, in major units: 9.90.', path);
253
- actions.push({ type: 'fixed_price', price: o.price, skus: target(o, true) });
254
- needsCurrency = true;
255
- }
256
- else if (k === 'free_gift') {
257
- only(o, ['skus', 'category', 'quantity']);
258
- if (o.skus === undefined && o.category === undefined)
259
- bad('free_gift needs the SKUs given away.', 'Write free_gift: { skus: [GIFT-SKU], quantity: 1 }.', path);
260
- if (o.quantity !== undefined && !isInt(o.quantity, 1))
261
- bad(`free_gift.quantity is ${String(o.quantity)}.`, 'quantity is a whole number of 1 or more.', path);
262
- const gifts = target(o, true);
263
- if (gifts.length > 25)
264
- bad(`free_gift has ${gifts.length} skus.`, 'A free gift takes at most 25 SKUs: narrow the list or the category.', path);
265
- actions.push({ type: 'free_gift', skus: gifts, quantity: o.quantity ?? 1 });
266
- }
267
- else {
268
- only(o, ['x', 'y', 'cheapest_free', 'skus', 'category']);
269
- if (!isInt(o.x, 2) || !isInt(o.y, 1) || o.y >= o.x)
270
- bad(`buy_x_pay_y is x ${String(o.x)}, y ${String(o.y)}.`, 'x and y are whole numbers with x above y and y at least 1: { x: 3, y: 2 } is 3 for 2.', path);
271
- if (o.cheapest_free !== undefined && typeof o.cheapest_free !== 'boolean')
272
- bad('cheapest_free is not true or false.', 'cheapest_free is true or false.', path);
273
- actions.push({ type: 'buy_x_pay_y', x: o.x, y: o.y, cheapest_free: o.cheapest_free ?? false, skus: target(o, true) });
274
- }
275
- }
276
- });
277
- if (needsCurrency && !currency && currencies.size > 1)
278
- bad('has an amount but no market, in a store with several currencies.', 'Set market: so the amount has a currency, or use a percentage.');
279
- promo.rules = rules;
280
- promo.actions = actions;
281
- return promo;
282
- }
283
- }
284
- /**
285
- * Commerce Layer promotions active without a coupon: each action of an enabled promotion that
286
- * has not ended is one (the API makes one per action). Commerce Layer applies at most 10.
287
- */
288
- export function activeWithoutCoupon(promotions, now = Date.now()) {
289
- const live = promotions.filter((p) => p.enabled && !p.flex && !p.rules?.coupons && Date.parse(p.ends) > now);
290
- return { count: live.reduce((n, p) => n + (p.actions?.length ?? 1), 0), ids: live.map((p) => p.id) };
291
- }