@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.
- package/README.md +95 -215
- package/dist/argv.js +214 -0
- package/dist/bin.js +15 -178
- package/dist/builtins/auth.js +169 -0
- package/dist/builtins/extensions.js +123 -0
- package/dist/builtins/index.js +8 -0
- package/dist/builtins/loops.js +215 -0
- package/dist/builtins/sandbox.js +186 -0
- package/dist/config.js +211 -0
- package/dist/confirm.js +56 -0
- package/dist/engine.js +173 -0
- package/dist/errors.js +146 -0
- package/dist/help.js +78 -0
- package/dist/http.js +117 -0
- package/dist/login.js +133 -0
- package/dist/resources.js +41 -0
- package/dist/schema.js +152 -0
- package/dist/schemas.js +72 -0
- package/dist/secrets.js +189 -0
- package/dist/version.js +4 -8
- package/package.json +4 -9
- package/dist/api/client.js +0 -190
- package/dist/api/server.js +0 -26
- package/dist/api/stub/db.js +0 -37
- package/dist/api/stub/http.js +0 -18
- package/dist/api/stub/index.js +0 -52
- package/dist/api/stub/locale.js +0 -52
- package/dist/api/stub/management.js +0 -409
- package/dist/api/stub/messages.js +0 -126
- package/dist/api/stub/orders.js +0 -625
- package/dist/api/stub/search.js +0 -78
- package/dist/api/stub/shopping.js +0 -673
- package/dist/api/stub/theme.js +0 -197
- package/dist/api/stub/ucp.js +0 -377
- package/dist/api/transport.js +0 -57
- package/dist/api/types.js +0 -4
- package/dist/assets.js +0 -100
- package/dist/catalog.js +0 -366
- package/dist/check.js +0 -187
- package/dist/commands.js +0 -1241
- package/dist/cursor.js +0 -31
- package/dist/files.js +0 -98
- package/dist/inbox/cursor.js +0 -11
- package/dist/inbox/fake.js +0 -95
- package/dist/inbox/imap.js +0 -139
- package/dist/inbox/mailbox.js +0 -25
- package/dist/inbox/message.js +0 -88
- package/dist/inspect.js +0 -230
- package/dist/languages.js +0 -49
- package/dist/links.js +0 -118
- package/dist/locales.js +0 -43
- package/dist/ops.js +0 -190
- package/dist/orderRequest.js +0 -141
- package/dist/output.js +0 -39
- package/dist/products.js +0 -151
- package/dist/promotions.js +0 -291
- package/dist/store.js +0 -379
- package/dist/storefront.js +0 -285
- package/dist/ucp/buy.js +0 -90
- package/dist/ucp/check.js +0 -360
- package/dist/ucp/client.js +0 -129
- package/dist/ucp/images.js +0 -62
- package/dist/ucp/platform.js +0 -90
- package/dist/ucp/shop.js +0 -357
- package/dist/ucp/signing.js +0 -67
- package/dist/web.js +0 -316
- package/dist/website.js +0 -390
|
@@ -1,409 +0,0 @@
|
|
|
1
|
-
// The management API at api.commercelayer.ai: sandboxes, resources, storefront
|
|
2
|
-
// uploads, orders, claims. The CLI's only private API; the store hosts speak UCP.
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import { canonical, IMAGE_PATH } from '../../store.js';
|
|
5
|
-
import { generateKey } from '../../ucp/signing.js';
|
|
6
|
-
import { localesOf } from '../../locales.js';
|
|
7
|
-
import { publicOrders, readDb, SANDBOX_DOMAIN, writeDb } from './db.js';
|
|
8
|
-
import { bearer, error, json, readJson } from './http.js';
|
|
9
|
-
import { createOrder } from './messages.js';
|
|
10
|
-
import { insights, listOrders, opsSnapshot, listShipments, OrderError, orderAction, shipmentAction, showOrder, showShipment } from './orders.js';
|
|
11
|
-
function publicState(r) {
|
|
12
|
-
const { token: _token, ucp: _ucp, pending: _pending, asset_files: _files, ...state } = r;
|
|
13
|
-
return { ...state, name: r.name ?? null, policies: r.policies ?? null, theme: r.theme ?? null, storefront_mode: r.storefront_mode ?? 'hosted', locales: r.locales ?? [], assets: r.assets ?? {}, apply: r.apply ?? null, orders: publicOrders(r) };
|
|
14
|
-
}
|
|
15
|
-
/** Like Commerce Layer refusing a sku in an import. Only the stub does this, so tests can exercise apply_failed. */
|
|
16
|
-
const REFUSED = /^REFUSED-/;
|
|
17
|
-
/** C11R_STUB_BUSY_READS=n: the first n reads of a running import answer 503 commercelayer_busy, as when Commerce Layer rate-limits. Per process. */
|
|
18
|
-
let busyReads = Number(process.env.C11R_STUB_BUSY_READS) || 0;
|
|
19
|
-
/** Items an import gets through per read: small, so the CLI polls a few times. */
|
|
20
|
-
const ITEMS_PER_READ = 2;
|
|
21
|
-
/**
|
|
22
|
-
* Move a running apply job forward, as the API does on every read: nothing runs in
|
|
23
|
-
* the background. When it ends, the pending resources are installed, or refused.
|
|
24
|
-
*/
|
|
25
|
-
function advance(r) {
|
|
26
|
-
const job = r.apply;
|
|
27
|
-
if (!job || job.status !== 'in_progress' || !r.pending)
|
|
28
|
-
return;
|
|
29
|
-
job.processed = Math.min(job.total, job.processed + ITEMS_PER_READ);
|
|
30
|
-
if (job.processed < job.total)
|
|
31
|
-
return;
|
|
32
|
-
job.step = 'done';
|
|
33
|
-
job.finished_at = new Date().toISOString();
|
|
34
|
-
if (r.pending.refused.length) {
|
|
35
|
-
job.status = 'failed';
|
|
36
|
-
job.errors = r.pending.refused.slice(0, 50).map((sku) => ({ sku, message: 'code is invalid' }));
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
job.status = 'completed';
|
|
40
|
-
r.applied = r.pending.applied;
|
|
41
|
-
r.applied_hash = job.hash;
|
|
42
|
-
}
|
|
43
|
-
delete r.pending;
|
|
44
|
-
}
|
|
45
|
-
function withDefaults(spec) {
|
|
46
|
-
return { ...spec, starts: spec.starts ?? '2000-01-01T00:00:00Z', ends: spec.ends ?? '2100-01-01T00:00:00Z', exclusive: spec.exclusive ?? false, enabled: spec.enabled ?? true };
|
|
47
|
-
}
|
|
48
|
-
/** As the API lists it: the spec plus status and uses. */
|
|
49
|
-
function listed(p) {
|
|
50
|
-
const now = Date.now();
|
|
51
|
-
const status = !p.enabled ? 'disabled' : p.limit !== undefined && p.uses >= p.limit ? 'used_up' : Date.parse(p.ends) <= now ? 'expired' : Date.parse(p.starts) > now ? 'scheduled' : 'active';
|
|
52
|
-
return { ...p, status, managed: true };
|
|
53
|
-
}
|
|
54
|
-
/** The API checks skus against the last applied catalog. */
|
|
55
|
-
function unknownSkus(r, spec) {
|
|
56
|
-
const known = r.applied?.products ?? {};
|
|
57
|
-
const where = [['rules.skus', spec.rules?.skus], ...(spec.actions ?? []).map((a, i) => [`actions[${i}].skus`, 'skus' in a ? a.skus : undefined])];
|
|
58
|
-
for (const [path, list] of where) {
|
|
59
|
-
const i = (list ?? []).findIndex((sku) => !(sku in known));
|
|
60
|
-
if (i >= 0)
|
|
61
|
-
return error(422, 'invalid_promotion', `${path}[${i}]: ${list[i]} is not a product of the store.`, 'Use skus of applied products: run `c11r apply` first if you just added them.');
|
|
62
|
-
}
|
|
63
|
-
return undefined;
|
|
64
|
-
}
|
|
65
|
-
/** As the API lists it: the spec plus the first market's url, every url, and status. */
|
|
66
|
-
function listedLink(l) {
|
|
67
|
-
const { enabled, urls, ...link } = l;
|
|
68
|
-
const now = Date.now();
|
|
69
|
-
const status = !enabled ? 'disabled' : Date.parse(l.ends) <= now ? 'expired' : Date.parse(l.starts) > now ? 'pending' : 'active';
|
|
70
|
-
return { ...link, url: Object.values(urls)[0], urls, status, managed: true };
|
|
71
|
-
}
|
|
72
|
-
/** The API checks a link against the store: applied skus and markets, draft orders. Answers the markets it gets a link in. */
|
|
73
|
-
function linkMarkets(r, spec) {
|
|
74
|
-
const known = r.applied?.products ?? {};
|
|
75
|
-
const f = spec.for;
|
|
76
|
-
const list = 'sku' in f ? [['for.sku', f.sku]] : 'skus' in f ? f.skus.map((s, i) => [`for.skus[${i}]`, s]) : [];
|
|
77
|
-
for (const [path, sku] of list)
|
|
78
|
-
if (!(sku in known))
|
|
79
|
-
return error(422, 'invalid_link', `${path}: unknown sku ${sku}.`, 'Use skus of applied products: run `c11r apply` first if you just added them.');
|
|
80
|
-
if ('order' in f) {
|
|
81
|
-
if (spec.markets)
|
|
82
|
-
return error(422, 'invalid_link', "markets: an order link uses the order's market.", 'Leave markets out.');
|
|
83
|
-
// A UCP cart or checkout id names the draft order behind it, read back as its number (the stub's: the id's suffix).
|
|
84
|
-
const cart = /^cart_/.test(f.order) ? r.ucp.carts[f.order] : undefined;
|
|
85
|
-
// A cart is the same order as the checkout made from it.
|
|
86
|
-
const ucp = cart ? (Object.values(r.ucp.checkouts).find((c) => c._cart_id === f.order && c.status === 'completed') ?? cart) : /^chk_/.test(f.order) ? r.ucp.checkouts[f.order] : undefined;
|
|
87
|
-
if (ucp) {
|
|
88
|
-
const placed = ucp.order?.label;
|
|
89
|
-
if (ucp.status === 'completed' && placed)
|
|
90
|
-
return error(422, 'invalid_link', `for.order: order ${placed.replace(/^#/, '')} is placed: a link opens the checkout of a draft or pending order.`, 'Link a cart or checkout that is not completed.');
|
|
91
|
-
if (ucp.status === 'canceled')
|
|
92
|
-
return error(422, 'invalid_link', `for.order: no order ${f.order}.`, 'The checkout is canceled: link another cart or checkout.');
|
|
93
|
-
const market = Object.values(r.applied?.markets ?? {}).find((m) => m.currency === ucp.currency)?.code ?? Object.keys(r.applied?.markets ?? {})[0];
|
|
94
|
-
spec.for = { order: f.order.replace(/^(cart|chk)_/, '') };
|
|
95
|
-
return [market];
|
|
96
|
-
}
|
|
97
|
-
const draft = r.draft_orders?.[f.order];
|
|
98
|
-
if (draft)
|
|
99
|
-
return [draft.market];
|
|
100
|
-
if (publicOrders(r).some((o) => o.label.replace(/^#/, '') === f.order))
|
|
101
|
-
return error(422, 'invalid_link', `for.order: order ${f.order} is placed: a link opens the checkout of a draft or pending order.`, 'A placed order opens its thank-you page, not a checkout: link a draft or pending order.');
|
|
102
|
-
return error(422, 'invalid_link', `for.order: no order ${f.order}.`, 'Use the number of a draft or pending order in the store.');
|
|
103
|
-
}
|
|
104
|
-
const applied = Object.keys(r.applied?.markets ?? {});
|
|
105
|
-
const markets = spec.markets ?? applied;
|
|
106
|
-
const i = markets.findIndex((m) => !applied.includes(m));
|
|
107
|
-
if (i >= 0)
|
|
108
|
-
return error(422, 'invalid_link', `markets[${i}]: unknown market ${markets[i]}.`, 'Use markets that are applied: run `c11r apply` first if you just added it.');
|
|
109
|
-
return markets;
|
|
110
|
-
}
|
|
111
|
-
export function handleManagement(req) {
|
|
112
|
-
const db = readDb();
|
|
113
|
-
const path = req.url.pathname;
|
|
114
|
-
if (req.method === 'POST' && path === '/sandboxes') {
|
|
115
|
-
// Like Commerce Layer: the organization is named `name`, and its slug is derived
|
|
116
|
-
// from it, suffixed -2, -3, ... when taken. The slug is the org and the store host.
|
|
117
|
-
const body = readJson(req);
|
|
118
|
-
const name = String(body?.name ?? 'store');
|
|
119
|
-
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'store';
|
|
120
|
-
let slug = base;
|
|
121
|
-
for (let n = 2; db[slug]; n++)
|
|
122
|
-
slug = `${base}-${n}`;
|
|
123
|
-
const expires = new Date(Date.now() + 7 * 24 * 3600 * 1000);
|
|
124
|
-
expires.setUTCHours(0, 0, 0, 0);
|
|
125
|
-
const kid = `${slug}-${new Date().toISOString().slice(0, 7)}`;
|
|
126
|
-
const { privateJwk, publicJwk } = generateKey(kid);
|
|
127
|
-
const record = {
|
|
128
|
-
org: slug,
|
|
129
|
-
token: `sbx_${crypto.randomBytes(16).toString('hex')}`,
|
|
130
|
-
sandbox: true,
|
|
131
|
-
...(body?.test === true ? { test: true } : {}),
|
|
132
|
-
claimed: false,
|
|
133
|
-
claim_url: `https://commercelayer.ai/claim/${crypto.randomBytes(6).toString('hex')}`,
|
|
134
|
-
expires_at: expires.toISOString(),
|
|
135
|
-
preview_url: `https://${slug}.${SANDBOX_DOMAIN}`,
|
|
136
|
-
sales_channel_client_id: `sc_${crypto.randomBytes(12).toString('hex')}`,
|
|
137
|
-
storefront: null,
|
|
138
|
-
applied: null,
|
|
139
|
-
applied_hash: null,
|
|
140
|
-
ucp: { kid, private_jwk: privateJwk, public_jwk: publicJwk, carts: {}, checkouts: {}, orders: {}, idempotency: {}, webhooks: [], tokens: {}, codes: {}, files: {} },
|
|
141
|
-
};
|
|
142
|
-
db[slug] = record;
|
|
143
|
-
writeDb(db);
|
|
144
|
-
const { org, token, claim_url, expires_at, preview_url, sales_channel_client_id } = record;
|
|
145
|
-
return json(201, { org, token, claim_url, expires_at, preview_url, sales_channel_client_id });
|
|
146
|
-
}
|
|
147
|
-
const m = path.match(/^\/stores\/([^/]+)(\/.*)?$/);
|
|
148
|
-
if (!m)
|
|
149
|
-
return error(404, 'not_found', `No route ${req.method} ${path}.`, 'Check the command; run `c11r help`.');
|
|
150
|
-
const [, org, rest = ''] = m;
|
|
151
|
-
const record = db[org];
|
|
152
|
-
if (!record || record.token !== bearer(req)) {
|
|
153
|
-
return error(401, 'unauthorized', `No sandbox ${org} for this token.`, 'The sandbox may have expired or the credentials file may be stale. Remove .commercelayer/credentials and run `c11r init` again.');
|
|
154
|
-
}
|
|
155
|
-
if (new Date(record.expires_at) < new Date() && !record.claimed) {
|
|
156
|
-
return error(410, 'expired', `Sandbox ${org} expired on ${record.expires_at}.`, 'Remove .commercelayer/credentials and run `c11r init` to create a new sandbox; store.yml is kept.');
|
|
157
|
-
}
|
|
158
|
-
if (req.method === 'GET' && rest === '') {
|
|
159
|
-
if (record.apply?.status === 'in_progress' && busyReads > 0) {
|
|
160
|
-
busyReads--;
|
|
161
|
-
return error(503, 'commercelayer_busy', 'Commerce Layer is rate-limiting this organization.', 'Wait a minute and try again; never edit store.yml for this.');
|
|
162
|
-
}
|
|
163
|
-
advance(record);
|
|
164
|
-
writeDb(db);
|
|
165
|
-
return json(200, publicState(record));
|
|
166
|
-
}
|
|
167
|
-
if (req.method === 'PUT' && rest === '/resources') {
|
|
168
|
-
advance(record);
|
|
169
|
-
writeDb(db);
|
|
170
|
-
if (record.apply?.status === 'in_progress')
|
|
171
|
-
return error(409, 'apply_in_progress', 'Another apply is still running.', 'Run `c11r status` to follow it, then apply again.');
|
|
172
|
-
const { applied, hash, name, policies, theme, storefront_mode, assets, allow_delete, full } = readJson(req);
|
|
173
|
-
if (storefront_mode != null && storefront_mode !== 'hosted' && storefront_mode !== 'agent-only')
|
|
174
|
-
return error(422, 'invalid_storefront', `storefront_mode is "${storefront_mode}".`, 'Use hosted or agent-only.');
|
|
175
|
-
const before = record.applied?.products ?? {};
|
|
176
|
-
const removed = Object.keys(before).filter((sku) => !(sku in applied.products));
|
|
177
|
-
if (removed.length && !allow_delete)
|
|
178
|
-
return error(409, 'deletes_need_approval', `Apply would delete product ${removed.join(', ')}.`, 'Ask the human whether to delete these. If they agree, run `c11r apply --allow-delete`.');
|
|
179
|
-
// The name, policies and markets apply in the request; products go through an
|
|
180
|
-
// import job (SKUs, prices, then cleanups for removals) when any changed.
|
|
181
|
-
// Locales come from the markets, which apply in the request, as the API derives them.
|
|
182
|
-
record.locales = localesOf(applied);
|
|
183
|
-
record.name = name;
|
|
184
|
-
record.policies = policies ?? {};
|
|
185
|
-
if (theme !== undefined)
|
|
186
|
-
record.theme = theme;
|
|
187
|
-
// Absent or null is hosted, like the API: removing storefront: from store.yml reverts it.
|
|
188
|
-
record.storefront_mode = (storefront_mode ?? 'hosted');
|
|
189
|
-
// Images not in the list are no longer used: dropped now. No list (older CLIs): nothing is dropped.
|
|
190
|
-
if (assets) {
|
|
191
|
-
for (const p of Object.keys(record.assets ?? {})) {
|
|
192
|
-
if (!assets.includes(p)) {
|
|
193
|
-
delete record.assets[p];
|
|
194
|
-
delete record.asset_files[p];
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
// Translations live in applied only: changing them makes no Commerce Layer call, like the API.
|
|
199
|
-
const commerce = (p) => canonical(p && { ...p, translations: undefined });
|
|
200
|
-
const changed = Object.keys(applied.products).filter((sku) => full || !(sku in before) || commerce(before[sku]) !== commerce(applied.products[sku]));
|
|
201
|
-
if (changed.length === 0 && removed.length === 0) {
|
|
202
|
-
record.applied = applied;
|
|
203
|
-
record.applied_hash = hash;
|
|
204
|
-
writeDb(db);
|
|
205
|
-
return json(200, publicState(record));
|
|
206
|
-
}
|
|
207
|
-
record.apply = {
|
|
208
|
-
id: `apply_${crypto.randomBytes(6).toString('hex')}`,
|
|
209
|
-
status: 'in_progress',
|
|
210
|
-
step: 'skus',
|
|
211
|
-
hash,
|
|
212
|
-
processed: 0,
|
|
213
|
-
total: changed.length + removed.length,
|
|
214
|
-
started_at: new Date().toISOString(),
|
|
215
|
-
finished_at: null,
|
|
216
|
-
errors: [],
|
|
217
|
-
};
|
|
218
|
-
record.pending = { applied, refused: changed.filter((sku) => REFUSED.test(sku)) };
|
|
219
|
-
writeDb(db);
|
|
220
|
-
return json(202, publicState(record));
|
|
221
|
-
}
|
|
222
|
-
// Promotions: live, synchronous, keyed by id. Sandboxes have no Rules Engine.
|
|
223
|
-
if (rest === '/promotions' || rest.startsWith('/promotions/')) {
|
|
224
|
-
const store = (record.promotions ??= {});
|
|
225
|
-
const [, , rawId, verb] = rest.split('/');
|
|
226
|
-
const id = rawId === undefined ? undefined : decodeURIComponent(rawId);
|
|
227
|
-
const save = () => writeDb(db);
|
|
228
|
-
const flexRefused = (spec) => (spec.flex ? error(422, 'flex_unavailable', 'Flex promotions need the Rules Engine, which this organization does not have.', 'Tell the human flex promotions need a Commerce Layer plan with the Rules Engine; build the promotion from the standard rules and actions if you can.') : undefined);
|
|
229
|
-
if (id === undefined) {
|
|
230
|
-
if (req.method === 'GET')
|
|
231
|
-
return json(200, { promotions: Object.keys(store).sort().map((k) => listed(store[k])) });
|
|
232
|
-
if (req.method === 'POST') {
|
|
233
|
-
const spec = readJson(req);
|
|
234
|
-
if (store[spec.id])
|
|
235
|
-
return error(409, 'promotion_exists', `There is already a promotion ${spec.id}.`, 'Use `c11r promotions update <id>` to change it, or pick another id.');
|
|
236
|
-
const refused = flexRefused(spec) ?? unknownSkus(record, spec);
|
|
237
|
-
if (refused)
|
|
238
|
-
return refused;
|
|
239
|
-
store[spec.id] = { ...withDefaults(spec), uses: 0 };
|
|
240
|
-
save();
|
|
241
|
-
return json(201, listed(store[spec.id]));
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
else {
|
|
245
|
-
const p = store[id];
|
|
246
|
-
if (!p)
|
|
247
|
-
return error(404, 'not_found', `There is no promotion ${id}.`, 'Run `c11r promotions list`.');
|
|
248
|
-
if (verb === undefined && req.method === 'GET')
|
|
249
|
-
return json(200, listed(p));
|
|
250
|
-
if (verb === undefined && req.method === 'PUT') {
|
|
251
|
-
const spec = readJson(req);
|
|
252
|
-
if (spec.id !== id)
|
|
253
|
-
return error(422, 'invalid_promotion', `id: the body has ${spec.id}, the path ${id}.`, 'An id never changes.');
|
|
254
|
-
const refused = flexRefused(spec) ?? unknownSkus(record, spec);
|
|
255
|
-
if (refused)
|
|
256
|
-
return refused;
|
|
257
|
-
store[id] = { ...withDefaults(spec), uses: p.uses };
|
|
258
|
-
save();
|
|
259
|
-
return json(200, listed(store[id]));
|
|
260
|
-
}
|
|
261
|
-
if (verb === undefined && req.method === 'DELETE') {
|
|
262
|
-
delete store[id];
|
|
263
|
-
save();
|
|
264
|
-
return { status: 204, headers: {}, body: '' };
|
|
265
|
-
}
|
|
266
|
-
if ((verb === 'enable' || verb === 'disable') && req.method === 'POST') {
|
|
267
|
-
p.enabled = verb === 'enable';
|
|
268
|
-
save();
|
|
269
|
-
return json(200, listed(p));
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
// Links: live, synchronous, keyed by id; one Commerce Layer link per market.
|
|
274
|
-
if (rest === '/links' || rest.startsWith('/links/')) {
|
|
275
|
-
const store = (record.links ??= {});
|
|
276
|
-
const [, , rawId, verb] = rest.split('/');
|
|
277
|
-
const id = rawId === undefined ? undefined : decodeURIComponent(rawId);
|
|
278
|
-
const save = () => writeDb(db);
|
|
279
|
-
if (id === undefined) {
|
|
280
|
-
if (req.method === 'GET')
|
|
281
|
-
return json(200, { links: Object.keys(store).sort().map((k) => listedLink(store[k])) });
|
|
282
|
-
if (req.method === 'POST') {
|
|
283
|
-
const spec = readJson(req);
|
|
284
|
-
if (store[spec.id])
|
|
285
|
-
return error(409, 'link_exists', `There is already a link ${spec.id}.`, 'Pick another id, or delete the link and create it again.');
|
|
286
|
-
const markets = linkMarkets(record, spec);
|
|
287
|
-
if (!Array.isArray(markets))
|
|
288
|
-
return markets;
|
|
289
|
-
// One Commerce Layer link per market: a scope with several markets does not open.
|
|
290
|
-
const urls = Object.fromEntries(markets.map((m) => [m, `https://c11.page/${crypto.randomBytes(6).toString('base64url')}`]));
|
|
291
|
-
// An order link reads back with the order's market.
|
|
292
|
-
store[spec.id] = { ...spec, markets, urls, enabled: true, created_at: new Date().toISOString() };
|
|
293
|
-
save();
|
|
294
|
-
return json(201, listedLink(store[spec.id]));
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
else {
|
|
298
|
-
const l = store[id];
|
|
299
|
-
if (!l)
|
|
300
|
-
return error(404, 'not_found', `There is no link ${id}.`, 'Run `c11r links list`.');
|
|
301
|
-
if (verb === undefined && req.method === 'GET')
|
|
302
|
-
return json(200, listedLink(l));
|
|
303
|
-
if (verb === undefined && req.method === 'DELETE') {
|
|
304
|
-
delete store[id];
|
|
305
|
-
save();
|
|
306
|
-
return { status: 204, headers: {}, body: '' };
|
|
307
|
-
}
|
|
308
|
-
if ((verb === 'enable' || verb === 'disable') && req.method === 'POST') {
|
|
309
|
-
l.enabled = verb === 'enable';
|
|
310
|
-
save();
|
|
311
|
-
return json(200, listedLink(l));
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
if (req.method === 'PUT' && rest === '/assets') {
|
|
316
|
-
const { files } = readJson(req);
|
|
317
|
-
let total = 0;
|
|
318
|
-
for (const f of files ?? []) {
|
|
319
|
-
if (!IMAGE_PATH.test(f.path) || f.path.includes('..') || f.path.includes('//'))
|
|
320
|
-
return error(422, 'invalid_asset', `${f.path} is not an image path the store host serves.`, 'Use img/<file> with letters, digits, . _ - and /, and an image extension.');
|
|
321
|
-
const bytes = Buffer.from(f.content_base64, 'base64');
|
|
322
|
-
if (crypto.createHash('sha256').update(bytes).digest('hex') !== f.sha256)
|
|
323
|
-
return error(422, 'asset_mismatch', `${f.path} does not hash to the sha256 sent.`, 'Run `c11r apply` again.');
|
|
324
|
-
if (bytes.length > 5 * 1024 * 1024)
|
|
325
|
-
return error(413, 'asset_too_large', `${f.path} is larger than 5 MB.`, 'Make the image smaller and apply again.');
|
|
326
|
-
total += bytes.length;
|
|
327
|
-
}
|
|
328
|
-
if (total > 25 * 1024 * 1024)
|
|
329
|
-
return error(413, 'upload_too_large', 'The upload is larger than 25 MB.', 'This is a bug in the CLI: it batches uploads. Tell the human, with this output.');
|
|
330
|
-
record.assets ??= {};
|
|
331
|
-
record.asset_files ??= {};
|
|
332
|
-
for (const f of files ?? []) {
|
|
333
|
-
record.assets[f.path] = f.sha256;
|
|
334
|
-
record.asset_files[f.path] = f.content_base64;
|
|
335
|
-
}
|
|
336
|
-
writeDb(db);
|
|
337
|
-
return json(200, { assets: record.assets });
|
|
338
|
-
}
|
|
339
|
-
if (req.method === 'PUT' && rest === '/storefront') {
|
|
340
|
-
const { path: dir, files } = readJson(req);
|
|
341
|
-
if (!files?.some((f) => f.path === 'index.html')) {
|
|
342
|
-
return error(422, 'no_storefront', 'The upload has no index.html.', 'Run `c11r storefront init` to create one, or set storefront.path in store.yml to the folder that has index.html.');
|
|
343
|
-
}
|
|
344
|
-
record.ucp.files = Object.fromEntries(files.map((f) => [f.path, f.content_base64]));
|
|
345
|
-
record.storefront = { path: dir, files: files.length, published_at: new Date().toISOString() };
|
|
346
|
-
writeDb(db);
|
|
347
|
-
return json(200, record.storefront);
|
|
348
|
-
}
|
|
349
|
-
// The merchant's orders and shipments (view=merchant); a bare GET /orders stays the UCP list older CLIs read.
|
|
350
|
-
const merchant = (run, save = false) => {
|
|
351
|
-
try {
|
|
352
|
-
const out = run();
|
|
353
|
-
if (save)
|
|
354
|
-
writeDb(db);
|
|
355
|
-
return json(200, out);
|
|
356
|
-
}
|
|
357
|
-
catch (e) {
|
|
358
|
-
if (e instanceof OrderError)
|
|
359
|
-
return error(e.status, e.code, e.message, e.hint);
|
|
360
|
-
throw e;
|
|
361
|
-
}
|
|
362
|
-
};
|
|
363
|
-
const q = req.url.searchParams;
|
|
364
|
-
const merchantView = q.get('view') === 'merchant';
|
|
365
|
-
if (req.method === 'GET' && rest === '/orders' && merchantView)
|
|
366
|
-
return merchant(() => listOrders(record, q));
|
|
367
|
-
if (req.method === 'POST' && rest === '/orders') {
|
|
368
|
-
try {
|
|
369
|
-
const out = createOrder(record, readJson(req));
|
|
370
|
-
writeDb(db);
|
|
371
|
-
return json(out.status, out.body);
|
|
372
|
-
}
|
|
373
|
-
catch (e) {
|
|
374
|
-
if (e instanceof OrderError)
|
|
375
|
-
return error(e.status, e.code, e.message, e.hint, e.path);
|
|
376
|
-
throw e;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
const act = rest.match(/^\/orders\/([^/]+)\/(place|approve|capture|cancel|refund|archive|unarchive)$/);
|
|
380
|
-
if (req.method === 'POST' && act)
|
|
381
|
-
return merchant(() => orderAction(record, decodeURIComponent(act[1]), act[2], readJson(req) ?? {}), true);
|
|
382
|
-
if (req.method === 'GET' && rest === '/shipments')
|
|
383
|
-
return merchant(() => listShipments(record, q));
|
|
384
|
-
if (req.method === 'GET' && rest === '/insights')
|
|
385
|
-
return merchant(() => insights(record, q));
|
|
386
|
-
if (req.method === 'GET' && rest === '/ops')
|
|
387
|
-
return merchant(() => opsSnapshot(record, q));
|
|
388
|
-
const ship = rest.match(/^\/shipments\/(.+?)(?:\/(hold|pick|pack|ready|ship|deliver))?$/);
|
|
389
|
-
if (ship && req.method === 'POST' && ship[2])
|
|
390
|
-
return merchant(() => shipmentAction(record, decodeURIComponent(ship[1]), ship[2], readJson(req) ?? {}), true);
|
|
391
|
-
if (ship && req.method === 'GET' && !ship[2])
|
|
392
|
-
return merchant(() => showShipment(record, decodeURIComponent(ship[1])));
|
|
393
|
-
if (req.method === 'GET' && rest === '/orders')
|
|
394
|
-
return json(200, publicOrders(record));
|
|
395
|
-
const one = rest.match(/^\/orders\/([^/]+)$/);
|
|
396
|
-
if (req.method === 'GET' && one && merchantView)
|
|
397
|
-
return merchant(() => showOrder(record, decodeURIComponent(one[1])));
|
|
398
|
-
if (req.method === 'GET' && one) {
|
|
399
|
-
const wanted = decodeURIComponent(one[1]);
|
|
400
|
-
const found = publicOrders(record).find((o) => o.id === wanted || o.label === wanted || o.label === `#${wanted}`);
|
|
401
|
-
if (!found)
|
|
402
|
-
return error(404, 'not_found', `There is no order ${wanted}.`, 'Run `c11r orders list` to see the orders.');
|
|
403
|
-
return json(200, found);
|
|
404
|
-
}
|
|
405
|
-
if (req.method === 'POST' && rest === '/claim') {
|
|
406
|
-
return json(200, { verification_url: 'https://commercelayer.ai/device', user_code: 'BXQT-4KMF' });
|
|
407
|
-
}
|
|
408
|
-
return error(404, 'not_found', `No route ${req.method} ${path}.`, 'Check the command; run `c11r help`.');
|
|
409
|
-
}
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
// POST /stores/:org/orders, as the API does it (its messages.ts): an order from an email or a
|
|
2
|
-
// message, made pending (or placed with place: true) from a checked request. Lines by sku are
|
|
3
|
-
// looked up; lines by words go through the store's search (stub/search.ts rank), with the API's
|
|
4
|
-
// confidence rule: every word 0.9, else 0.6 × the share of words (the stub has no embeddings for
|
|
5
|
-
// the 0.4 × cosine part), 0.2 less when the second candidate scores within 10%; 0.75 to match.
|
|
6
|
-
// A line under the bar and no force: needs_review, nothing created. Catalog prices only.
|
|
7
|
-
import { checkOrderRequest } from '../../orderRequest.js';
|
|
8
|
-
import { CliError } from '../../output.js';
|
|
9
|
-
import { money } from '../../ucp/shop.js';
|
|
10
|
-
import { toMinor } from '../../ucp/platform.js';
|
|
11
|
-
import { OrderError, showOrder } from './orders.js';
|
|
12
|
-
import { queryWords, rank } from './search.js';
|
|
13
|
-
const BAR = 0.75;
|
|
14
|
-
const round = (n) => Math.round(n * 100) / 100;
|
|
15
|
-
function resolveLine(products, market, line) {
|
|
16
|
-
const priced = (p) => typeof p.prices?.[market.currency] === 'number';
|
|
17
|
-
const card = (p) => ({ sku: p.sku, name: p.name, price: priced(p) ? money(toMinor(p.prices[market.currency], market.currency), market.currency) : null });
|
|
18
|
-
const requested = line.sku ?? line.query;
|
|
19
|
-
if (line.sku) {
|
|
20
|
-
const p = products.find((x) => x.sku === line.sku);
|
|
21
|
-
if (p)
|
|
22
|
-
return priced(p) ? { requested, quantity: line.quantity, sku: p.sku, name: p.name, confidence: 1, candidates: [card(p)], status: 'matched', product: p } : { requested, quantity: line.quantity, sku: p.sku, name: p.name, confidence: 1, candidates: [], status: 'unavailable' };
|
|
23
|
-
// An unknown sku is searched like words: "ETH-250" may still name a product.
|
|
24
|
-
}
|
|
25
|
-
const ranked = rank(products, requested);
|
|
26
|
-
const sellable = ranked.filter((x) => priced(x.product));
|
|
27
|
-
if (!ranked.length)
|
|
28
|
-
return { requested, quantity: line.quantity, sku: null, name: null, confidence: 0, candidates: [], status: 'not_found' };
|
|
29
|
-
if (!sellable.length)
|
|
30
|
-
return { requested, quantity: line.quantity, sku: ranked[0].product.sku, name: ranked[0].product.name, confidence: 0, candidates: [], status: 'unavailable' };
|
|
31
|
-
const [top, second] = sellable;
|
|
32
|
-
const asked = Math.max(1, queryWords(requested).length);
|
|
33
|
-
let confidence = top.match.all ? 0.9 : 0.6 * (top.match.words.length / asked);
|
|
34
|
-
if (second && second.match.score >= top.match.score * 0.9)
|
|
35
|
-
confidence -= 0.2;
|
|
36
|
-
confidence = round(Math.max(0, confidence));
|
|
37
|
-
const candidates = sellable.slice(0, 3).map((x) => card(x.product));
|
|
38
|
-
if (confidence >= BAR)
|
|
39
|
-
return { requested, quantity: line.quantity, sku: top.product.sku, name: top.product.name, confidence, candidates, status: 'matched', product: top.product };
|
|
40
|
-
return { requested, quantity: line.quantity, sku: null, name: null, confidence, candidates, status: 'ambiguous' };
|
|
41
|
-
}
|
|
42
|
-
export function createOrder(r, body) {
|
|
43
|
-
let req;
|
|
44
|
-
try {
|
|
45
|
-
req = checkOrderRequest(body);
|
|
46
|
-
}
|
|
47
|
-
catch (e) {
|
|
48
|
-
if (e instanceof CliError)
|
|
49
|
-
throw new OrderError(422, e.code, e.message, e.hint, e.path);
|
|
50
|
-
throw e;
|
|
51
|
-
}
|
|
52
|
-
const markets = Object.values(r.applied?.markets ?? {});
|
|
53
|
-
const market = markets.find((m) => m.countries.includes(req.ship_to.country));
|
|
54
|
-
if (!market) {
|
|
55
|
-
const served = [...new Set(markets.flatMap((m) => m.countries))].sort();
|
|
56
|
-
throw new OrderError(422, 'country_not_served', `The store does not ship to ${req.ship_to.country}.`, served.length ? `It ships to ${served.join(', ')}: ask the human what to do with this order.` : 'The store has no markets yet: run c11r apply.', 'ship_to.country');
|
|
57
|
-
}
|
|
58
|
-
// The same source again: the order it made, unless that one was cancelled.
|
|
59
|
-
if (req.source) {
|
|
60
|
-
const existing = Object.values(r.ucp.orders).reverse().find((o) => o._source?.kind === req.source.kind && o._source?.id === req.source.id && o._merchant?.status !== 'cancelled');
|
|
61
|
-
if (existing) {
|
|
62
|
-
const order = showOrder(r, existing.id);
|
|
63
|
-
return { status: 200, body: { status: order.status, order, resolution: [], warnings: [{ code: 'duplicate_source', message: `${req.source.kind} ${req.source.id} already made order ${order.number}.` }] } };
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
const products = Object.values(r.applied?.products ?? {});
|
|
67
|
-
const resolved = req.items.map((it) => resolveLine(products, market, it));
|
|
68
|
-
const resolution = resolved.map(({ product: _p, ...x }) => x);
|
|
69
|
-
const open = resolved.filter((x) => x.status !== 'matched');
|
|
70
|
-
// Two lines of the same sku become one.
|
|
71
|
-
const kept = [];
|
|
72
|
-
for (const x of resolved.filter((y) => y.status === 'matched')) {
|
|
73
|
-
const same = kept.find((k) => k.sku === x.sku);
|
|
74
|
-
if (same)
|
|
75
|
-
same.quantity = same.quantity + x.quantity;
|
|
76
|
-
else
|
|
77
|
-
kept.push({ ...x });
|
|
78
|
-
}
|
|
79
|
-
if ((open.length && !req.force) || !kept.length)
|
|
80
|
-
return { status: 200, body: { status: 'needs_review', order: null, resolution, warnings: [] } };
|
|
81
|
-
const warnings = open.map((x) => ({ code: 'line_dropped', message: `"${x.requested}" (${x.quantity}) was left out: ${x.status.replace('_', ' ')}.` }));
|
|
82
|
-
const orders = r.ucp.orders;
|
|
83
|
-
const id = `ord_${Math.random().toString(16).slice(2, 14)}`;
|
|
84
|
-
const at = new Date().toISOString();
|
|
85
|
-
const cur = market.currency;
|
|
86
|
-
const lines = kept.map((x, i) => {
|
|
87
|
-
const price = toMinor(x.product.prices[cur], cur);
|
|
88
|
-
return { id: `li_${i + 1}`, item: { id: x.product.sku, title: x.product.name, price, ...(x.product.image_url ? { image_url: x.product.image_url } : {}) }, quantity: { original: x.quantity, total: x.quantity, fulfilled: 0 }, totals: [{ type: 'total', amount: price * x.quantity }], status: 'processing' };
|
|
89
|
-
});
|
|
90
|
-
const subtotal = lines.reduce((s, l) => s + l.totals[0].amount, 0);
|
|
91
|
-
const ship = toMinor(market.shipping?.price ?? 0, cur);
|
|
92
|
-
const c = req.customer;
|
|
93
|
-
const to = req.ship_to;
|
|
94
|
-
const destination = { first_name: c.first_name, last_name: c.last_name, company: c.company, street_address: to.line_1, extended_address: to.line_2, address_locality: to.city, postal_code: to.postal_code, address_region: to.region, address_country: to.country, phone: to.phone ?? c.phone };
|
|
95
|
-
const number = `${1001 + Object.keys(orders).length}`;
|
|
96
|
-
const order = {
|
|
97
|
-
id,
|
|
98
|
-
label: `#${number}`,
|
|
99
|
-
permalink_url: `${r.preview_url}/orders/${id}`,
|
|
100
|
-
currency: cur,
|
|
101
|
-
line_items: lines,
|
|
102
|
-
fulfillment: { expectations: [{ id: 'exp_1', line_items: lines.map((l) => ({ id: l.id, quantity: l.quantity.total })), method_type: 'shipping', destination, description: market.shipping?.method ?? 'Standard', fulfillable_on: 'now' }], events: [] },
|
|
103
|
-
adjustments: [],
|
|
104
|
-
totals: [{ type: 'subtotal', display_text: 'Subtotal', amount: subtotal }, { type: 'fulfillment', display_text: 'Shipping', amount: ship }, { type: 'total', display_text: 'Total', amount: subtotal + ship }],
|
|
105
|
-
_platform: 'message',
|
|
106
|
-
_buyer: { email: c.email, first_name: c.first_name, last_name: c.last_name, company: c.company, phone_number: c.phone ?? to.phone },
|
|
107
|
-
_market: market.code,
|
|
108
|
-
_language: req.language,
|
|
109
|
-
_codes: [],
|
|
110
|
-
_source: req.source ?? null,
|
|
111
|
-
_notes: req.notes,
|
|
112
|
-
_reference: req.reference,
|
|
113
|
-
...(req.bill_to ? { _bill_to: { first_name: req.bill_to.first_name, last_name: req.bill_to.last_name, company: req.bill_to.company, street_address: req.bill_to.line_1, extended_address: req.bill_to.line_2, address_locality: req.bill_to.city, postal_code: req.bill_to.postal_code, address_region: req.bill_to.region, address_country: req.bill_to.country, phone: req.bill_to.phone } } : {}),
|
|
114
|
-
_merchant: {
|
|
115
|
-
status: req.place ? 'placed' : 'pending',
|
|
116
|
-
created_at: at,
|
|
117
|
-
placed_at: req.place ? at : null,
|
|
118
|
-
archived_at: null,
|
|
119
|
-
transactions: req.place ? [{ type: 'authorization', amount: subtotal + ship, succeeded: true, at }] : [],
|
|
120
|
-
shipment: { id: `shp_${id.slice(4)}`, number: `${number}/S/001`, status: 'upcoming', tracking: null, dates: { created_at: at, on_hold_at: null, picking_at: null, packing_at: null, ready_to_ship_at: null, shipped_at: null, delivered_at: null } },
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
orders[id] = order;
|
|
124
|
-
const shown = showOrder(r, id);
|
|
125
|
-
return { status: 201, body: { status: shown.status, order: shown, resolution, warnings } };
|
|
126
|
-
}
|