@commercelayer/agent-cli 0.5.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -6
- package/dist/api/client.js +39 -1
- package/dist/api/stub/http.js +2 -2
- package/dist/api/stub/management.js +19 -2
- package/dist/api/stub/messages.js +126 -0
- package/dist/api/stub/orders.js +286 -24
- package/dist/bin.js +30 -6
- package/dist/catalog.js +61 -19
- package/dist/commands.js +470 -7
- package/dist/cursor.js +31 -0
- package/dist/files.js +5 -3
- package/dist/inbox/cursor.js +11 -0
- package/dist/inbox/fake.js +95 -0
- package/dist/inbox/imap.js +139 -0
- package/dist/inbox/mailbox.js +25 -0
- package/dist/inbox/message.js +88 -0
- package/dist/inspect.js +230 -0
- package/dist/ops.js +190 -0
- package/dist/orderRequest.js +141 -0
- package/dist/output.js +7 -3
- package/dist/store.js +2 -0
- package/dist/web.js +316 -0
- package/dist/website.js +390 -0
- package/package.json +3 -1
package/dist/ops.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
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}`;
|
|
@@ -0,0 +1,141 @@
|
|
|
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
CHANGED
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
export class CliError extends Error {
|
|
7
7
|
code;
|
|
8
8
|
hint;
|
|
9
|
-
|
|
9
|
+
path;
|
|
10
|
+
constructor(code, message, hint,
|
|
11
|
+
/** The field of a request the error is about: customer.phone, items[0].quantity. */
|
|
12
|
+
path) {
|
|
10
13
|
super(message);
|
|
11
14
|
this.code = code;
|
|
12
15
|
this.hint = hint;
|
|
16
|
+
this.path = path;
|
|
13
17
|
}
|
|
14
18
|
}
|
|
15
|
-
export function fail(code, message, hint) {
|
|
16
|
-
throw new CliError(code, message, hint);
|
|
19
|
+
export function fail(code, message, hint, path) {
|
|
20
|
+
throw new CliError(code, message, hint, path);
|
|
17
21
|
}
|
|
18
22
|
export function progress(data) {
|
|
19
23
|
process.stderr.write(JSON.stringify(data) + '\n');
|
package/dist/store.js
CHANGED
|
@@ -59,6 +59,8 @@ catalog:
|
|
|
59
59
|
# # C11R_SUPABASE_URL and C11R_SUPABASE_KEY in .commercelayer/credentials
|
|
60
60
|
# source: merchant # a Google Merchant Center feed, RSS (XML) or TSV: url is the feed;
|
|
61
61
|
# # translations: { it: <the it feed url> } adds other languages, by id
|
|
62
|
+
# source: website # a brand's website, for a demo store: url is the site; limit is how
|
|
63
|
+
# # many products (default 100, at most 500). Images stay on the site.
|
|
62
64
|
# url: https://...
|
|
63
65
|
# table: products
|
|
64
66
|
|