@commercelayer/agent-cli 0.3.1 → 0.5.1
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 +31 -9
- package/dist/api/client.js +74 -1
- package/dist/api/stub/index.js +26 -1
- package/dist/api/stub/locale.js +52 -0
- package/dist/api/stub/management.js +38 -2
- package/dist/api/stub/orders.js +359 -0
- package/dist/api/stub/search.js +78 -0
- package/dist/api/stub/shopping.js +84 -18
- package/dist/api/stub/theme.js +119 -30
- package/dist/api/stub/ucp.js +10 -6
- package/dist/api/transport.js +3 -0
- package/dist/bin.js +18 -7
- package/dist/catalog.js +40 -4
- package/dist/commands.js +155 -25
- package/dist/files.js +14 -2
- package/dist/languages.js +49 -0
- package/dist/locales.js +43 -0
- package/dist/output.js +14 -1
- package/dist/products.js +15 -5
- package/dist/store.js +95 -9
- package/dist/ucp/shop.js +53 -6
- package/dist/version.js +9 -0
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
// One function per command. Each returns the object the CLI prints, or throws a
|
|
2
2
|
// CliError. Every command is safe to re-run.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import { createRequire } from 'node:module';
|
|
5
4
|
import path from 'node:path';
|
|
6
5
|
import { createApi } from './api/client.js';
|
|
7
6
|
import { ignoreCredentials, paths, readCredentials, requireCredentials, writeAgentNotes, writeCredentials } from './files.js';
|
|
8
7
|
import { CliError, fail, progress } from './output.js';
|
|
9
|
-
import { diff, hashStore, loadStore, storefrontMode, storefrontPath, template, toApplied } from './store.js';
|
|
8
|
+
import { diff, hashStore, languageWarnings, loadStore, storefrontMode, storefrontPath, template, toApplied } from './store.js';
|
|
10
9
|
import { storefrontFiles } from './storefront.js';
|
|
11
10
|
import { imageNotes, planAssets, uploadAssets } from './assets.js';
|
|
12
11
|
import { pull } from './catalog.js';
|
|
@@ -19,13 +18,13 @@ import { check } from './ucp/check.js';
|
|
|
19
18
|
import { discover } from './ucp/client.js';
|
|
20
19
|
import * as shop from './ucp/shop.js';
|
|
21
20
|
import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
|
|
22
|
-
|
|
23
|
-
export
|
|
21
|
+
import { LATEST_HINT, VERSION } from './version.js';
|
|
22
|
+
export { VERSION } from './version.js';
|
|
24
23
|
export const HELP = {
|
|
25
24
|
init: 'Create a sandbox store for this repo, with no login: c11r init --name "<business name>" (the folder name when --name is absent). The name becomes the organization\'s name, and the org slug and store hostname derive from it. Writes .commercelayer/store.yml and .commercelayer/credentials, and a section in AGENTS.md and CLAUDE.md (created, or appended once) that points later agents in this repo to the skills: commit them. Reuses the store if store.yml already exists.',
|
|
26
|
-
plan: 'Show what apply would create, update or delete: markets, products, images, and the store\'s name, policies and theme; missing_images lists references with no file in .commercelayer/img/ (apply refuses them), unused_images files nothing references, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku).',
|
|
25
|
+
plan: 'Show what apply would create, update or delete: markets, products, images, and the store\'s name, language, policies and theme; missing_images lists references with no file in .commercelayer/img/ (apply refuses them), unused_images files nothing references, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku). Products may carry other languages: name, description and category as { <ll>: text } maps (the base language, store.yml language:, default en, among them), or name_<ll>, description_<ll>, category_<ll> CSV columns; at most 6 languages. warnings lists a language no country of the store speaks.',
|
|
27
26
|
apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/: markets, products, the storefront mode (storefront.mode: hosted, the default, or agent-only: UCP and the pages it needs, no catalog pages), the theme (its settings, badge included, and the images in .commercelayer/img/ that the theme and products reference, uploaded when new or changed), name and policies. The store\'s website is live at preview_url after apply. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
|
|
28
|
-
'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant, url, table }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category and one price column per market currency, in lowercase (eur, usd). Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV.',
|
|
27
|
+
'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant, url, table }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category, name_<ll>, description_<ll> and category_<ll> for other languages (name_it), and one price column per market currency, in lowercase (eur, usd). A Merchant feed adds other languages with catalog.translations: { it: <feed url> }, merged by id. Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV.',
|
|
29
28
|
'storefront init': 'Custom storefronts only: the hosted theme needs none of this (set theme: in store.yml and apply). Write a static storefront on drop-in.js, no build step: storefront/index.html (the products by category, a minicart that opens on add to cart, the hosted cart and checkout, and a product page at #/p/<sku>), styles.css (theme tokens first) and app.js. Product images are the store\'s, at /img/ (.commercelayer/img/, uploaded by apply); the site\'s own files go in storefront/assets/. Refuses to overwrite unless --force is given.',
|
|
30
29
|
'storefront check': 'Custom storefronts only. Check the published storefront at the preview URL: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides, title, lang, viewport, description, and whether the folder changed since publish. Prints { status, summary, checks: [{ id, kind, status: pass|warn|fail, message, fix, items }] }; exits 1 when a check fails.',
|
|
31
30
|
'storefront publish': 'Custom storefronts only: upload the storefront folder (store.yml storefront.path, default storefront) to the preview URL, where it replaces the hosted theme.',
|
|
@@ -42,12 +41,26 @@ export const HELP = {
|
|
|
42
41
|
'links enable': 'Turn a link on: c11r links enable <id>.',
|
|
43
42
|
'links disable': 'Turn a link off without deleting it: its url stops opening. c11r links disable <id>.',
|
|
44
43
|
'links delete': 'Delete a link from Commerce Layer: c11r links delete <id>. Ask the human first; to pause one, disable it.',
|
|
45
|
-
'orders test': 'Buy from the store over UCP as a shopping agent would, paying with the sandbox handler: discover, search, checkout, address, pay, read the order. Options: --sku <id> --quantity <n> --country <CC> --code <discount> --via-cart --decline. discounts lists what promotions and codes took off; a code that applied nothing is a discount_code_invalid warning in messages.',
|
|
46
|
-
'orders list': '
|
|
47
|
-
'orders show': '
|
|
44
|
+
'orders test': 'Buy from the store over UCP as a shopping agent would, paying with the sandbox handler: discover, search, checkout, address, pay, read the order. Options: --sku <id> --quantity <n> --country <CC> --code <discount> --via-cart --decline. The order is placed, payment authorized; approve and capture it with `c11r orders approve <id> --capture`. discounts lists what promotions and codes took off; a code that applied nothing is a discount_code_invalid warning in messages.',
|
|
45
|
+
'orders list': "The store's orders, live in Commerce Layer, newest first: by default the placed and approved ones, not archived, 25 at most. c11r orders list [--status placed|approved|cancelled|editing|all] [--payment authorized|paid|refunded|partially_refunded|voided|...] [--fulfillment unfulfilled|in_progress|fulfilled|not_required] [--archived [all]] [--limit 1-100]; a comma list takes several (--status placed,cancelled). Returns { count, orders: [{ id, number, status, payment_status, fulfillment_status, placed_at, market, country, currency, total: { amount, currency, major, display }, email, items, shipments: [{ id, number, status, tracking }], archived }], next }. Orders arrive placed with the payment authorized, not taken: they wait for the human to accept them.",
|
|
46
|
+
'orders show': 'One order as the merchant sees it: c11r orders show <number, #number or id>. Returns { order: { ...as in orders list, line_items: [{ sku, name, quantity, unit_price, total }], totals: { subtotal, discount, shipping, taxes, total, codes }, shipping_address, billing_address, shipping_method, payment: { method, source, status, captured, refundable }, transactions: [{ type, amount, succeeded, at }], language, archived_at, ucp: { id, permalink_url } }, next }. next says what the order is waiting for.',
|
|
47
|
+
'orders approve': "Accept a placed order, only once the human said to: c11r orders approve <number> [--capture]. With --capture the authorized payment is taken too; without, take it later with orders capture. Returns { status, order, next } with the order after it. An order that cannot be approved answers invalid_transition: its hint lists what it allows now.",
|
|
48
|
+
'orders capture': 'Take the authorized payment of an approved order, once the human said to: c11r orders capture <number>. Returns { status, order, next }; invalid_transition when it is not approved or already captured.',
|
|
49
|
+
'orders cancel': "Cancel an order the human does not want, before it ships: c11r orders cancel <number>. An authorized payment is voided. A shipped order cannot be cancelled, and a captured one is refunded instead (orders refund): both answer invalid_transition, with what is allowed in the hint. Returns { status, order, next }.",
|
|
50
|
+
'orders refund': "Give money back on a captured order, only the amount the human said: c11r orders refund <number> [--amount <n>]. --amount is in major units (4.50); without it, everything still refundable. Refunding the last of the payment cancels the order. Returns { status, order, next }; refund_exceeds when the amount is more than payment.refundable.",
|
|
51
|
+
'orders archive': 'Hide an order from orders list (any status), for orders that need nothing more: c11r orders archive <number>. Idempotent. orders list --archived shows them.',
|
|
52
|
+
'orders unarchive': 'Bring an archived order back into orders list: c11r orders unarchive <number>. Idempotent.',
|
|
53
|
+
'fulfillment list': "The store's shipments, live in Commerce Layer, newest first: by default the ones not shipped yet (upcoming, on_hold, picking, packing, ready_to_ship). c11r fulfillment list [--status upcoming|on_hold|picking|packing|ready_to_ship|shipped|delivered|cancelled|all] [--limit 1-100]. Returns { count, shipments: [{ id, number (1001/S/001), status, order: { id, number, status }, country, shipping_method, items, tracking: { carrier, number, url } | null, dates }], next }.",
|
|
54
|
+
'fulfillment show': 'One shipment, with what goes in it and where: c11r fulfillment show <shipment number or id, or the order number when it has one shipment>. Returns { shipment: { ...as in fulfillment list, line_items: [{ sku, name, quantity }], address, parcels }, next }.',
|
|
55
|
+
'fulfillment hold': 'Put a shipment on hold, for an order the human wants to wait on: c11r fulfillment hold <shipment>. Works on a placed order too. Returns { status, shipment, next }.',
|
|
56
|
+
'fulfillment pick': 'Mark a shipment as being picked: c11r fulfillment pick <shipment>. Needs the order approved. on_hold, picking, packing and ready_to_ship move freely between each other. Returns { status, shipment, next }; invalid_transition says what is allowed now.',
|
|
57
|
+
'fulfillment pack': 'Mark a shipment as being packed: c11r fulfillment pack <shipment>. Needs the order approved. Returns { status, shipment, next }.',
|
|
58
|
+
'fulfillment ready': 'Mark a shipment ready to ship: c11r fulfillment ready <shipment>. Needs the order approved. Returns { status, shipment, next }.',
|
|
59
|
+
'fulfillment ship': "Mark a shipment shipped, with the carrier's tracking when the human has it: c11r fulfillment ship <shipment> [--carrier <name>] [--tracking <number>] [--tracking-url <https url>]. Needs the order approved; works from upcoming or any step before. Ask the human for the tracking, never invent it. Returns { status, shipment, next }.",
|
|
60
|
+
'fulfillment deliver': 'Mark a shipped shipment delivered: c11r fulfillment deliver <shipment>. Returns { status, shipment, next }.',
|
|
48
61
|
'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
|
|
49
62
|
'ucp check': 'Walk every UCP capability the store advertises and report what passes. Places and pays one real test order on the way. Exits 1 when a check fails.',
|
|
50
|
-
'ucp search': "Buy for the human, step 1: search a store's catalog over UCP. c11r ucp search [<
|
|
63
|
+
'ucp search': "Buy for the human, step 1: search a store's catalog over UCP. c11r ucp search [<words>] [--category <name>] [--min-price <n>] [--max-price <n>] [--country CC] [--store <host>]. Search with the words that matter, in any order (\"dark roast coffee\" or \"coffee dark\"): the store ranks products with every word first; no words lists the whole catalog (up to 50). --category narrows to a category or its subcategories (\"Coffee\" includes \"Coffee > Single origin\"), repeat it for any of several; --min-price and --max-price are in major units of the store's currency (15 means 15.00); --country is the human's country: prices, shipping and availability for it. Returns { store, query, prices: { market, market_name, currency, countries, tax_included }, filters, count, total_count, has_more, products: [{ id, title, description, url, image_url, categories: [\"Coffee > Single origin\"], partial, matched_on, variants: [{ id, sku, title, price: { amount, currency, major, display }, list_price, available, availability, shipping: { to, option, price, earliest, latest, ships_to_country } }] }], messages, signature }. list_price is the price before a sale, only when there is one; partial: true marks a product with only some of the words. Show the human the titles, categories and price.display (amount is in minor units: never show it), with shipping.price.display and the arrival dates when they ask what it costs delivered; let them pick, then c11r ucp checkout create --item <variant id>. Read messages and pass on what they say: with no results or a partial match they list the store's categories, and prices says which market the prices are for. --store is any store that speaks UCP (hostname or https origin); the default is this folder's store.",
|
|
51
64
|
'ucp product': 'Look at one product over UCP, with every variant and the full description: c11r ucp product <product id> [--country CC] [--store <host>]. Returns { store, product: { id, title, description, url, image_url, variants: [...] }, signature }, variants as in ucp search.',
|
|
52
65
|
'ucp checkout create': "Buy for the human, step 2: open a checkout with the items the human picked. c11r ucp checkout create --item <variant id>[:<qty>] [--item ...] [--code <discount>] [--country CC] [--store <host>]. Returns the checkout: { id, status, store, currency, line_items: [{ id, item_id, title, quantity, unit_price, total }], buyer, ship_to, shipping, discounts, codes, totals: [{ type, label, amount, major, display }], total, messages, continue_url, expires_at, links, signature }. status is incomplete until the buyer's email and shipping address are given: messages say what is missing. Ask the human for them, never invent them, then c11r ucp checkout update <id>. The id is remembered with its store: later steps need no --store.",
|
|
53
66
|
'ucp checkout update': "Buy for the human, step 3: give the buyer's details, change items or codes. c11r ucp checkout update <id> [--email --first-name --last-name --phone] [--street --city --postal-code --region --country] [--item <variant id>:<qty> (0 removes)] [--code <discount> (adds)] [--shipping <option id>]; or the same as YAML or JSON on stdin or -f <file>: { buyer: { email, first_name, last_name, phone_number }, address: { street_address, address_locality, postal_code, address_region, address_country }, items: [\"ID:2\"], codes: [...] (replaces), shipping }. Only what is given changes. Returns the checkout as in create: status ready_for_complete once nothing is missing, with shipping and the total including it; or incomplete with messages (address_undeliverable says where the store ships). Show the human the line items, shipping and total.display, ask for an explicit yes, then c11r ucp checkout complete <id>.",
|
|
@@ -56,13 +69,13 @@ export const HELP = {
|
|
|
56
69
|
'ucp checkout complete': "Buy for the human, step 4, only after the human said yes to the total: pay the checkout. c11r ucp checkout complete <id> [--decline] [--store <host>]. Pays with the ai.commercelayer.sandbox test handler (no money moves): only stores that advertise it can be paid; --decline uses the declined test card. Returns the checkout with status completed and order: { id, label, permalink_url }, plus payment_handler. Tell the human the order label and give them permalink_url. A store without that handler (any store taking real payments) answers payment_handler_unsupported: give the human continue_url to pay there. A declined payment leaves status ready_for_complete with a payment_failed message.",
|
|
57
70
|
'ucp order': 'Read an order this CLI placed: c11r ucp order <id> [--store <host>]. Returns { id, label, permalink_url, store, checkout_id, currency, line_items: [{ item_id, title, quantity, status, total }], fulfillment: [{ method, destination, description }], events, totals, signature }.',
|
|
58
71
|
'ucp ask': 'Ask a store a question in plain language over UCP (shipping, returns, what it sells): c11r ucp ask "<question>" [--store <host>]. Returns { store, question, answer, links, messages, signature }. The answer is the store\'s: quote it, do not embellish it. Ask only answers; buy with ucp checkout.',
|
|
59
|
-
status: 'Show the sandbox, what is applied, and whether the store files have changes not yet applied (the same changes plan lists).',
|
|
72
|
+
status: 'Show the sandbox, what is applied, and whether the store files have changes not yet applied (the same changes plan lists). search is the store\'s semantic search index: { model, status: indexing|ready|failed|too_large, vectors, embedded, updated_at, error }, or null before it first runs; until it is ready the store\'s search matches words only.',
|
|
60
73
|
claim: 'Start keeping this sandbox store: returns a URL and a code for the human to approve in the browser.',
|
|
61
|
-
help: 'Describe the commands. `c11r help <command>` for one command, `c11r help <group>` (catalog, orders, storefront, ucp) for a group.',
|
|
74
|
+
help: 'Describe the commands. `c11r help <command>` for one command, `c11r help <group>` (catalog, fulfillment, links, orders, promotions, storefront, ucp) for a group.',
|
|
62
75
|
};
|
|
63
76
|
export function help(name) {
|
|
64
77
|
if (name && !HELP[name]) {
|
|
65
|
-
// A group (catalog, orders, storefront, ucp): its commands.
|
|
78
|
+
// A group (catalog, fulfillment, links, orders, promotions, storefront, ucp): its commands.
|
|
66
79
|
const group = Object.entries(HELP).filter(([k]) => k.startsWith(`${name} `));
|
|
67
80
|
if (group.length)
|
|
68
81
|
return { group: name, commands: Object.fromEntries(group) };
|
|
@@ -73,7 +86,7 @@ export function help(name) {
|
|
|
73
86
|
const showsFormat = name === 'apply' || name === 'plan';
|
|
74
87
|
return { command: name, description: HELP[name], store_format: showsFormat ? template('<name>') : undefined };
|
|
75
88
|
}
|
|
76
|
-
return { version: VERSION, commands: HELP, note: 'Every command prints JSON. The store format is documented in the comments of .commercelayer/store.yml; large catalogs go in .commercelayer/products/ as .yml or .csv files.' };
|
|
89
|
+
return { version: VERSION, latest_hint: LATEST_HINT, commands: HELP, note: 'Every command prints JSON. The store format is documented in the comments of .commercelayer/store.yml; large catalogs go in .commercelayer/products/ as .yml or .csv files.' };
|
|
77
90
|
}
|
|
78
91
|
export async function init(values) {
|
|
79
92
|
const existing = readCredentials();
|
|
@@ -118,6 +131,7 @@ export async function plan() {
|
|
|
118
131
|
changes,
|
|
119
132
|
deletes: changes.filter((c) => c.action === 'delete' && c.type !== 'asset').length,
|
|
120
133
|
...imageNotes(assets),
|
|
134
|
+
warnings: languageWarnings(desired).length ? languageWarnings(desired) : undefined,
|
|
121
135
|
next: assets.missing.length ? 'Put the missing images in .commercelayer/img/ (img/<file> in store.yml is .commercelayer/img/<file>), or fix the references: apply refuses until they are there.' : undefined,
|
|
122
136
|
};
|
|
123
137
|
}
|
|
@@ -281,7 +295,8 @@ export async function ordersTest(flags, values) {
|
|
|
281
295
|
fail('invalid_argument', '--quantity must be a positive integer.', 'Example: --quantity 2.');
|
|
282
296
|
const p = await buy(origin, { sku: values.sku, quantity, country: values.country, code: values.code, viaCart: flags.has('--via-cart'), decline: flags.has('--decline') });
|
|
283
297
|
return {
|
|
284
|
-
|
|
298
|
+
// The store places the order with its payment authorized; the merchant approves and captures it.
|
|
299
|
+
status: p.checkout.status === 'completed' ? 'placed' : p.checkout.status,
|
|
285
300
|
order: p.order,
|
|
286
301
|
// What the promotions took off, in minor units like the totals; a code that applied nothing is in messages.
|
|
287
302
|
discounts: (p.checkout.discounts ?? p.ready.discounts)?.applied?.length ? (p.checkout.discounts ?? p.ready.discounts).applied : undefined,
|
|
@@ -289,17 +304,114 @@ export async function ordersTest(flags, values) {
|
|
|
289
304
|
messages: p.checkout.messages ?? p.ready.messages,
|
|
290
305
|
payment_handler: 'ai.commercelayer.sandbox (test payments only)',
|
|
291
306
|
steps: p.steps,
|
|
307
|
+
next: p.checkout.status === 'completed' ? `Placed, payment authorized: approve and capture it with c11r orders approve ${String(p.order?.label ?? p.order?.id).replace(/^#/, '')} --capture, then ship it with c11r fulfillment ship.` : undefined,
|
|
292
308
|
};
|
|
293
309
|
}
|
|
294
|
-
|
|
310
|
+
// Orders and shipments, live in Commerce Layer through the API. An order lands placed with its
|
|
311
|
+
// payment authorized; the merchant approves, captures, ships. Each answer says what comes next.
|
|
312
|
+
const LIST_LIMIT = (raw) => {
|
|
313
|
+
if (raw === undefined)
|
|
314
|
+
return undefined;
|
|
315
|
+
const n = Number(raw);
|
|
316
|
+
if (!Number.isInteger(n) || n < 1 || n > 100)
|
|
317
|
+
fail('invalid_argument', `--limit ${raw} is not a number from 1 to 100.`, 'Example: --limit 50.');
|
|
318
|
+
return String(n);
|
|
319
|
+
};
|
|
320
|
+
function orderNext(o) {
|
|
321
|
+
const n = o.number;
|
|
322
|
+
const shipment = o.shipments?.[0];
|
|
323
|
+
if (o.status === 'cancelled')
|
|
324
|
+
return `Order ${n} is cancelled (payment ${o.payment_status}). Nothing more to do; archive it to keep the list short: c11r orders archive ${n}.`;
|
|
325
|
+
if (o.status === 'placed')
|
|
326
|
+
return `Order ${n} is placed and its payment is authorized, not taken. When the human accepts it, approve it and take the payment: c11r orders approve ${n} --capture (without --capture to take it later). If they don't want it: c11r orders cancel ${n}.`;
|
|
327
|
+
if (o.payment_status === 'authorized')
|
|
328
|
+
return `Order ${n} is approved; the payment is authorized but not taken. Take it when the human says so: c11r orders capture ${n}.`;
|
|
329
|
+
if (o.fulfillment_status === 'fulfilled')
|
|
330
|
+
return `Order ${n} has shipped${shipment?.tracking?.number ? ` (tracking ${shipment.tracking.number})` : ''}. Mark it delivered when it arrives: c11r fulfillment deliver ${shipment?.number ?? n}; refund all or part if the human asks: c11r orders refund ${n} [--amount <n>].`;
|
|
331
|
+
return `Order ${n} is approved and paid (${o.total?.display}). Ship it: c11r fulfillment ship ${shipment?.number ?? n} [--carrier <name> --tracking <number> --tracking-url <url>], or move it through picking and packing first (c11r fulfillment pick, pack, ready).`;
|
|
332
|
+
}
|
|
333
|
+
export async function ordersList(values, flags) {
|
|
295
334
|
const api = createApi(requireCredentials());
|
|
296
|
-
|
|
335
|
+
const archived = values.archived ?? (flags.has('--archived') ? 'true' : undefined);
|
|
336
|
+
if (archived !== undefined && !['true', 'false', 'all'].includes(archived))
|
|
337
|
+
fail('invalid_argument', `--archived ${archived} is not a choice.`, 'Use --archived for only the archived orders, or --archived all for every order.');
|
|
338
|
+
const query = { status: values.status, payment: values.payment, fulfillment: values.fulfillment, archived, limit: LIST_LIMIT(values.limit) };
|
|
339
|
+
const { orders } = await api.listMerchantOrders(query);
|
|
340
|
+
const waiting = orders.filter((o) => o.status === 'placed').length;
|
|
341
|
+
return {
|
|
342
|
+
count: orders.length,
|
|
343
|
+
orders,
|
|
344
|
+
next: orders.length
|
|
345
|
+
? `Show the human the orders (number, status, payment_status, total.display, email).${waiting ? ` ${waiting} placed order(s) wait for the human to approve them: c11r orders show <number> for one, then c11r orders approve <number> --capture once they say so.` : ''}`
|
|
346
|
+
: 'No orders match. Without --status the list has placed and approved orders only: --status all shows every one, --archived the archived ones.',
|
|
347
|
+
};
|
|
297
348
|
}
|
|
298
349
|
export async function ordersShow(id) {
|
|
299
350
|
if (!id)
|
|
300
|
-
fail('missing_argument', 'orders show needs an order id or number.', 'Run `c11r orders list` to see the orders, then `c11r orders show <
|
|
301
|
-
const
|
|
302
|
-
return { order:
|
|
351
|
+
fail('missing_argument', 'orders show needs an order id or number.', 'Run `c11r orders list` to see the orders, then `c11r orders show <number>`.');
|
|
352
|
+
const order = await createApi(requireCredentials()).getMerchantOrder(id);
|
|
353
|
+
return { order, next: orderNext(order) };
|
|
354
|
+
}
|
|
355
|
+
export async function ordersAct(action, id, values, flags) {
|
|
356
|
+
if (!id)
|
|
357
|
+
fail('missing_argument', `orders ${action} needs an order id or number.`, `Run \`c11r orders list\` to see the orders, then \`c11r orders ${action} <number>\`.`);
|
|
358
|
+
const body = {};
|
|
359
|
+
if (action === 'approve' && flags.has('--capture'))
|
|
360
|
+
body.capture = true;
|
|
361
|
+
if (action === 'refund' && values.amount !== undefined) {
|
|
362
|
+
const amount = Number(values.amount);
|
|
363
|
+
if (values.amount.trim() === '' || !Number.isFinite(amount) || amount <= 0)
|
|
364
|
+
fail('invalid_argument', `--amount ${values.amount} is not an amount.`, 'Give it in major units, for example --amount 4.50; leave it out to refund everything still refundable.');
|
|
365
|
+
body.amount = amount;
|
|
366
|
+
}
|
|
367
|
+
const order = await createApi(requireCredentials()).orderAction(id, action, body);
|
|
368
|
+
return { status: action === 'archive' ? 'archived' : action === 'unarchive' ? 'unarchived' : order.status, order, next: orderNext(order) };
|
|
369
|
+
}
|
|
370
|
+
function shipmentNext(s) {
|
|
371
|
+
const n = s.number;
|
|
372
|
+
if (s.status === 'delivered')
|
|
373
|
+
return `Shipment ${n} is delivered. Nothing more to do.`;
|
|
374
|
+
if (s.status === 'cancelled')
|
|
375
|
+
return `Shipment ${n} is cancelled with its order ${s.order?.number}.`;
|
|
376
|
+
if (s.status === 'shipped')
|
|
377
|
+
return `Shipment ${n} is on its way${s.tracking?.number ? ` (tracking ${s.tracking.number})` : ''}. Mark it delivered when it arrives: c11r fulfillment deliver ${n}.`;
|
|
378
|
+
if (s.order?.status === 'placed')
|
|
379
|
+
return `Shipment ${n} waits for its order ${s.order.number} to be approved: c11r orders approve ${s.order.number} --capture, once the human accepts it. It can be held meanwhile: c11r fulfillment hold ${n}.`;
|
|
380
|
+
return `Shipment ${n} is ${s.status.replace(/_/g, ' ')}. Ship it when it leaves: c11r fulfillment ship ${n} [--carrier <name> --tracking <number> --tracking-url <url>]; or move it on: c11r fulfillment pick, pack, ready or hold ${n}.`;
|
|
381
|
+
}
|
|
382
|
+
export async function fulfillmentList(values) {
|
|
383
|
+
const { shipments } = await createApi(requireCredentials()).listShipments({ status: values.status, limit: LIST_LIMIT(values.limit) });
|
|
384
|
+
return {
|
|
385
|
+
count: shipments.length,
|
|
386
|
+
shipments,
|
|
387
|
+
next: shipments.length
|
|
388
|
+
? 'Show the human the shipments (number, status, order.number, country, items). c11r fulfillment show <number> for the address and items.'
|
|
389
|
+
: 'No shipments match. Without --status the list has the ones not shipped yet: --status shipped, delivered or all for the others.',
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
export async function fulfillmentShow(id) {
|
|
393
|
+
if (!id)
|
|
394
|
+
fail('missing_argument', 'fulfillment show needs a shipment id or number, or an order number.', 'Run `c11r fulfillment list` to see the shipments.');
|
|
395
|
+
const shipment = await createApi(requireCredentials()).getShipment(id);
|
|
396
|
+
return { shipment, next: shipmentNext(shipment) };
|
|
397
|
+
}
|
|
398
|
+
export async function fulfillmentAct(action, id, values) {
|
|
399
|
+
if (!id)
|
|
400
|
+
fail('missing_argument', `fulfillment ${action} needs a shipment id or number, or an order number.`, 'Run `c11r fulfillment list` to see the shipments.');
|
|
401
|
+
const body = {};
|
|
402
|
+
if (action === 'ship') {
|
|
403
|
+
if (values.carrier !== undefined)
|
|
404
|
+
body.carrier = values.carrier;
|
|
405
|
+
if (values.tracking !== undefined)
|
|
406
|
+
body.tracking = values.tracking;
|
|
407
|
+
if (values['tracking-url'] !== undefined) {
|
|
408
|
+
if (!/^https:\/\/\S+$/.test(values['tracking-url']))
|
|
409
|
+
fail('invalid_argument', `--tracking-url ${values['tracking-url']} is not an https address.`, "Give the carrier's tracking page, starting with https://.");
|
|
410
|
+
body.tracking_url = values['tracking-url'];
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const shipment = await createApi(requireCredentials()).shipmentAction(id, action, body);
|
|
414
|
+
return { status: shipment.status, shipment, next: shipmentNext(shipment) };
|
|
303
415
|
}
|
|
304
416
|
export async function ucpProfile(flags) {
|
|
305
417
|
if (flags.has('--agent'))
|
|
@@ -313,7 +425,7 @@ export async function ucpCheck() {
|
|
|
313
425
|
const report = await check(origin);
|
|
314
426
|
return {
|
|
315
427
|
...report,
|
|
316
|
-
note: report.order ? `The checkout checks placed
|
|
428
|
+
note: report.order ? `The checkout checks placed a real test order (${report.order.label ?? report.order.id}) with the sandbox handler, payment authorized; it appears in \`c11r orders list\` like one from \`orders test\`.` : undefined,
|
|
317
429
|
next: report.status === 'pass' ? 'Any shopping agent can buy from this store.' : 'Fix the failed checks and run `c11r ucp check` again.',
|
|
318
430
|
};
|
|
319
431
|
}
|
|
@@ -349,6 +461,8 @@ export async function status() {
|
|
|
349
461
|
markets: s.applied ? Object.keys(s.applied.markets) : [],
|
|
350
462
|
products: s.applied ? Object.keys(s.applied.products).length : 0,
|
|
351
463
|
orders: s.orders.map((o) => ({ id: o.id, label: o.label })),
|
|
464
|
+
// Semantic search: until it is ready, the store's search matches words only.
|
|
465
|
+
search: s.search ? { model: s.search.model, status: s.search.status, vectors: s.search.vectors, embedded: s.search.embedded, updated_at: s.search.updated_at, error: s.search.error } : null,
|
|
352
466
|
ucp_profile: `${s.preview_url}/.well-known/ucp`,
|
|
353
467
|
next: s.apply?.status === 'in_progress' ? 'An apply is running: run `c11r status` again to follow it, or `c11r apply` to wait for it.' : storeError ? 'Fix the store files (see store_error), then run `c11r plan`.' : pending.length ? 'Run `c11r plan` to see the changes, then `c11r apply`.' : undefined,
|
|
354
468
|
};
|
|
@@ -384,7 +498,7 @@ const needId = (id, command) => {
|
|
|
384
498
|
};
|
|
385
499
|
const checkoutNext = (ck) => {
|
|
386
500
|
if (ck.status === 'completed')
|
|
387
|
-
return `
|
|
501
|
+
return `Placed. Tell the human: order ${ck.order?.label ?? ck.order?.id}, and give them ${ck.order?.permalink_url}.`;
|
|
388
502
|
if (ck.status === 'canceled')
|
|
389
503
|
return 'Canceled. Open a new one with c11r ucp checkout create --item <variant id> if the human wants to buy.';
|
|
390
504
|
if (ck.status === 'ready_for_complete') {
|
|
@@ -396,9 +510,25 @@ const checkoutNext = (ck) => {
|
|
|
396
510
|
const errors = (ck.messages ?? []).filter((m) => m.type === 'error').map((m) => m.content);
|
|
397
511
|
return `Still needed: ${errors.join(' ') || 'see messages'} Ask the human (never invent buyer details), then run c11r ucp checkout update ${ck.id} with what they said.`;
|
|
398
512
|
};
|
|
399
|
-
export async function ucpSearch(query, values) {
|
|
400
|
-
const
|
|
401
|
-
|
|
513
|
+
export async function ucpSearch(query, values, lists = {}) {
|
|
514
|
+
const price = (flag) => {
|
|
515
|
+
const raw = values[flag];
|
|
516
|
+
if (raw === undefined)
|
|
517
|
+
return undefined;
|
|
518
|
+
const n = Number(raw);
|
|
519
|
+
if (raw.trim() === '' || !Number.isFinite(n) || n < 0)
|
|
520
|
+
fail('invalid_argument', `--${flag} ${raw} is not a price.`, `Give it in major units, for example --${flag} 15 or --${flag} 9.90.`);
|
|
521
|
+
return n;
|
|
522
|
+
};
|
|
523
|
+
const min = price('min-price');
|
|
524
|
+
const max = price('max-price');
|
|
525
|
+
if (min !== undefined && max !== undefined && min > max)
|
|
526
|
+
fail('invalid_argument', `--min-price ${min} is above --max-price ${max}.`, 'Swap them.');
|
|
527
|
+
const r = await shop.search(await shopOrigin(values), query, { country: values.country, categories: lists.category, min, max });
|
|
528
|
+
const next = r.count
|
|
529
|
+
? 'Show the human the products (title, categories, price.display, and list_price.display when it is on sale), let them pick, then c11r ucp checkout create --item <variant id>[:<qty>] (variants[].id).'
|
|
530
|
+
: 'Nothing matched: read messages (the store may list its categories there), then try other words, a --category, wider prices, or no query for the whole catalog.';
|
|
531
|
+
return { ...r, next };
|
|
402
532
|
}
|
|
403
533
|
export async function ucpProduct(id, values) {
|
|
404
534
|
if (!id)
|
package/dist/files.js
CHANGED
|
@@ -63,18 +63,30 @@ export function ignoreCredentials() {
|
|
|
63
63
|
}
|
|
64
64
|
/** The note that sends a future agent in this repo to the skills. No skill names: it never goes stale. */
|
|
65
65
|
const AGENT_NOTE_HEADING = '# Store on Commerce Layer';
|
|
66
|
+
const SKILLS = 'Before any task about the store (products, prices, markets, storefront, orders, promotions, agents), fetch `https://commercelayer.ai/skills.md` with `curl -fsSL` and load the skill for the task from `skills/<name>.md`; follow it rather than calling the Commerce Layer API directly. If `.commercelayer/store.yml` is missing, start from `https://commercelayer.ai/start.md`.';
|
|
66
67
|
export const AGENT_NOTE = `${AGENT_NOTE_HEADING}
|
|
67
|
-
This store is managed with \`c11r
|
|
68
|
+
This store is managed with \`c11r\`, run as \`npx -y @commercelayer/agent-cli@latest\`, configured in \`.commercelayer/store.yml\`. Always use that form, even if a \`c11r\` binary is installed: it stays on the current version. ${SKILLS}
|
|
69
|
+
`;
|
|
70
|
+
/** The note CLIs before 0.5.1 wrote: replaced by the current one when a file has it word for word. */
|
|
71
|
+
const OLD_AGENT_NOTE = `${AGENT_NOTE_HEADING}
|
|
72
|
+
This store is managed with \`c11r\` (\`npx @commercelayer/agent-cli\`), configured in \`.commercelayer/store.yml\`. ${SKILLS}
|
|
68
73
|
`;
|
|
69
74
|
/**
|
|
70
75
|
* Write the note to AGENTS.md and CLAUDE.md (Claude Code reads that name) in the repo root:
|
|
71
76
|
* created when absent, appended as a section when the file has other content, left alone
|
|
72
|
-
* when the section is already there
|
|
77
|
+
* when the section is already there (replaced when it is an older CLI's, word for word).
|
|
78
|
+
* Returns the files written.
|
|
73
79
|
*/
|
|
74
80
|
export function writeAgentNotes() {
|
|
75
81
|
const written = [];
|
|
76
82
|
for (const file of ['AGENTS.md', 'CLAUDE.md']) {
|
|
77
83
|
const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
84
|
+
// A note an older CLI wrote, untouched since: bring it up to date. An edited one stays as it is.
|
|
85
|
+
if (before.includes(OLD_AGENT_NOTE)) {
|
|
86
|
+
fs.writeFileSync(file, before.replace(OLD_AGENT_NOTE, AGENT_NOTE));
|
|
87
|
+
written.push(file);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
78
90
|
if (before.split('\n').some((line) => line.trim() === AGENT_NOTE_HEADING))
|
|
79
91
|
continue;
|
|
80
92
|
fs.writeFileSync(file, before.trim() ? `${before.replace(/\s*$/, '')}\n\n${AGENT_NOTE}` : AGENT_NOTE);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Languages: the store has one base language (store.yml `language:`, default en) and products
|
|
2
|
+
// may carry name, description and category in others, as a { <ll>: text } map in YAML or as
|
|
3
|
+
// name_<ll>, description_<ll>, category_<ll> columns in CSV and the catalog sources. They are
|
|
4
|
+
// normalised into the base-language strings plus translations: { <ll>: { name, ... } }.
|
|
5
|
+
/** ISO 639-1: the two-letter language codes. */
|
|
6
|
+
export const ISO_639_1 = new Set('aa ab ae af ak am an ar as av ay az ba be bg bi bm bn bo br bs ca ce ch co cr cs cu cv cy da de dv dz ee el en eo es et eu fa ff fi fj fo fr fy ga gd gl gn gu gv ha he hi ho hr ht hu hy hz ia id ie ig ii ik io is it iu ja jv ka kg ki kj kk kl km kn ko kr ks ku kv kw ky la lb lg li ln lo lt lu lv mg mh mi mk ml mn mr ms mt my na nb nd ne ng nl nn no nr nv ny oc oj om or os pa pi pl ps pt qu rm rn ro ru rw sa sc sd se sg si sk sl sm sn so sq sr ss st su sv sw ta te tg th ti tk tl tn to tr ts tt tw ty ug uk ur uz ve vi vo wa wo xh yi yo za zh zu'.split(' '));
|
|
7
|
+
export const DEFAULT_LANGUAGE = 'en';
|
|
8
|
+
/** The product fields that can be translated. */
|
|
9
|
+
export const TRANSLATED = ['name', 'description', 'category'];
|
|
10
|
+
/** A column name_it, description_fr, category_de: the field and the language, or null. */
|
|
11
|
+
export function translatedColumn(header) {
|
|
12
|
+
const m = header.trim().toLowerCase().match(/^(name|description|category)_([a-z]{2})$/);
|
|
13
|
+
return m ? { field: m[1], language: m[2] } : null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The key a plain column (name, description, category) goes under when a product also has
|
|
17
|
+
* translated columns: it is the base language's text, whatever the base language is.
|
|
18
|
+
*/
|
|
19
|
+
export const PLAIN = '';
|
|
20
|
+
/** Put one language's text of a field on a product being read from a table: the field becomes a map. */
|
|
21
|
+
export function setTranslated(product, field, language, value) {
|
|
22
|
+
const current = product[field];
|
|
23
|
+
const map = current && typeof current === 'object' ? current : current === undefined || current === '' ? {} : { [PLAIN]: String(current) };
|
|
24
|
+
map[language] = value;
|
|
25
|
+
product[field] = map;
|
|
26
|
+
}
|
|
27
|
+
/** Put the plain column's text on a product, keeping any translations already read. */
|
|
28
|
+
export function setPlain(product, field, value) {
|
|
29
|
+
const current = product[field];
|
|
30
|
+
if (current && typeof current === 'object')
|
|
31
|
+
current[PLAIN] = value;
|
|
32
|
+
else
|
|
33
|
+
product[field] = value;
|
|
34
|
+
}
|
|
35
|
+
/** The languages in a set of translations, sorted, and the fields each column set needs, for CSV output. */
|
|
36
|
+
export function translationColumns(all) {
|
|
37
|
+
const columns = new Set();
|
|
38
|
+
for (const t of all)
|
|
39
|
+
for (const [language, fields] of Object.entries(t ?? {}))
|
|
40
|
+
for (const f of TRANSLATED)
|
|
41
|
+
if (fields[f] !== undefined)
|
|
42
|
+
columns.add(`${f}_${language}`);
|
|
43
|
+
const order = (c) => [c.slice(-2), TRANSLATED.indexOf(c.slice(0, -3))];
|
|
44
|
+
return [...columns].sort((a, b) => {
|
|
45
|
+
const [la, fa] = order(a);
|
|
46
|
+
const [lb, fb] = order(b);
|
|
47
|
+
return la < lb ? -1 : la > lb ? 1 : fa - fb;
|
|
48
|
+
});
|
|
49
|
+
}
|
package/dist/locales.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Mirrored byte for byte from the API's src/api/locales.ts at 5cee8ba (TABLE, languageOf, localesOf):
|
|
2
|
+
// keep it in step. The API derives a store's locales; the CLI needs them offline for plan and the stub.
|
|
3
|
+
const TABLE = `
|
|
4
|
+
AD:ca AE:ar AF:fa AG:en AI:en AL:sq AM:hy AO:pt AQ:en AR:es AS:en AT:de AU:en AW:nl AX:sv AZ:az
|
|
5
|
+
BA:bs BB:en BD:bn BE:nl BF:fr BG:bg BH:ar BI:fr BJ:fr BL:fr BM:en BN:ms BO:es BQ:nl BR:pt BS:en BT:dz BV:no BW:en BY:be BZ:en
|
|
6
|
+
CA:en CC:en CD:fr CF:fr CG:fr CH:de CI:fr CK:en CL:es CM:fr CN:zh CO:es CR:es CU:es CV:pt CW:nl CX:en CY:el CZ:cs
|
|
7
|
+
DE:de DJ:fr DK:da DM:en DO:es DZ:ar EC:es EE:et EG:ar EH:ar ER:ti ES:es ET:am FI:fi FJ:en FK:en FM:en FO:fo FR:fr
|
|
8
|
+
GA:fr GB:en GD:en GE:ka GF:fr GG:en GH:en GI:en GL:kl GM:en GN:fr GP:fr GQ:es GR:el GS:en GT:es GU:en GW:pt GY:en
|
|
9
|
+
HK:zh HM:en HN:es HR:hr HT:fr HU:hu ID:id IE:en IL:he IM:en IN:en IO:en IQ:ar IR:fa IS:is IT:it JE:en JM:en JO:ar JP:ja
|
|
10
|
+
KE:en KG:ky KH:km KI:en KM:ar KN:en KP:ko KR:ko KW:ar KY:en KZ:kk LA:lo LB:ar LC:en LI:de LK:si LR:en LS:en LT:lt LU:fr LV:lv LY:ar
|
|
11
|
+
MA:ar MC:fr MD:ro ME:sr MF:fr MG:mg MH:en MK:mk ML:fr MM:my MN:mn MO:zh MP:en MQ:fr MR:ar MS:en MT:mt MU:en MV:dv MW:en MX:es MY:ms MZ:pt
|
|
12
|
+
NA:en NC:fr NE:fr NF:en NG:en NI:es NL:nl NO:no NP:ne NR:en NU:en NZ:en OM:ar
|
|
13
|
+
PA:es PE:es PF:fr PG:en PH:en PK:ur PL:pl PM:fr PN:en PR:es PS:ar PT:pt PW:en PY:es QA:ar RE:fr RO:ro RS:sr RU:ru RW:rw
|
|
14
|
+
SA:ar SB:en SC:en SD:ar SE:sv SG:en SH:en SI:sl SJ:no SK:sk SL:en SM:it SN:fr SO:so SR:nl SS:en ST:pt SV:es SX:nl SY:ar SZ:en
|
|
15
|
+
TC:en TD:fr TF:fr TG:fr TH:th TJ:tg TK:en TL:pt TM:tk TN:ar TO:en TR:tr TT:en TV:en TW:zh TZ:sw
|
|
16
|
+
UA:uk UG:en UM:en US:en UY:es UZ:uz VA:it VC:en VE:es VG:en VI:en VN:vi VU:en WF:fr WS:en YE:ar YT:fr ZA:en ZM:en ZW:en
|
|
17
|
+
`;
|
|
18
|
+
/** Country code → its language. */
|
|
19
|
+
export const COUNTRY_LANGUAGES = Object.freeze(Object.fromEntries(TABLE.trim().split(/\s+/).map((pair) => pair.split(':'))));
|
|
20
|
+
/** A country's language: the table's, else en. */
|
|
21
|
+
export const languageOf = (country) => COUNTRY_LANGUAGES[country.toUpperCase()] ?? 'en';
|
|
22
|
+
/** The store's base language, in which plain names and descriptions are written. */
|
|
23
|
+
export const baseLanguage = (applied) => applied?.language ?? 'en';
|
|
24
|
+
export const LANGUAGE_LIMIT = 6;
|
|
25
|
+
/**
|
|
26
|
+
* The store's locales: one per country, in the order of its markets and their
|
|
27
|
+
* countries. A country listed in two markets is the first market's.
|
|
28
|
+
*/
|
|
29
|
+
export function localesOf(applied) {
|
|
30
|
+
const out = [];
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
for (const m of Object.values(applied?.markets ?? {})) {
|
|
33
|
+
for (const raw of m.countries) {
|
|
34
|
+
const country = raw.toUpperCase();
|
|
35
|
+
if (seen.has(country))
|
|
36
|
+
continue;
|
|
37
|
+
seen.add(country);
|
|
38
|
+
const language = languageOf(country);
|
|
39
|
+
out.push({ country, language, market: m.code, path: `${country.toLowerCase()}-${language}` });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
package/dist/output.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// A failure is { error: { code, message, hint } } with exit code 1. The hint is
|
|
3
3
|
// the fix: an agent reads it and acts on it before trying anything else.
|
|
4
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.
|
|
5
6
|
export class CliError extends Error {
|
|
6
7
|
code;
|
|
7
8
|
hint;
|
|
@@ -17,6 +18,18 @@ export function fail(code, message, hint) {
|
|
|
17
18
|
export function progress(data) {
|
|
18
19
|
process.stderr.write(JSON.stringify(data) + '\n');
|
|
19
20
|
}
|
|
21
|
+
const notices = [];
|
|
22
|
+
/** Keep a warning to print with the result, once per code. */
|
|
23
|
+
export function notice(n) {
|
|
24
|
+
if (!notices.some((x) => x.code === n.code))
|
|
25
|
+
notices.push(n);
|
|
26
|
+
}
|
|
20
27
|
export function print(data) {
|
|
21
|
-
|
|
28
|
+
let out = data;
|
|
29
|
+
if (notices.length && data && typeof data === 'object' && !Array.isArray(data)) {
|
|
30
|
+
const own = data.warnings;
|
|
31
|
+
// A command's own warnings are strings or objects; the API's are added after them.
|
|
32
|
+
out = { ...data, warnings: [...(Array.isArray(own) ? own : own === undefined ? [] : [own]), ...notices] };
|
|
33
|
+
}
|
|
34
|
+
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
22
35
|
}
|
package/dist/products.js
CHANGED
|
@@ -7,7 +7,8 @@ import path from 'node:path';
|
|
|
7
7
|
import YAML from 'yaml';
|
|
8
8
|
import { paths } from './files.js';
|
|
9
9
|
import { fail } from './output.js';
|
|
10
|
-
|
|
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. */
|
|
11
12
|
export const CSV_COLUMNS = ['sku', 'name', 'description', 'image_url', 'category'];
|
|
12
13
|
/** The product files, in a stable order. */
|
|
13
14
|
export function productFiles() {
|
|
@@ -98,10 +99,13 @@ function fromCsv(file, text) {
|
|
|
98
99
|
const lower = h.toLowerCase();
|
|
99
100
|
if (CSV_COLUMNS.includes(lower))
|
|
100
101
|
return { field: lower };
|
|
102
|
+
const t = translatedColumn(h);
|
|
103
|
+
if (t)
|
|
104
|
+
return { translated: t.field, language: t.language };
|
|
101
105
|
if (/^[A-Z]{3}$/.test(h))
|
|
102
106
|
return { currency: h };
|
|
103
107
|
const why = lower === 'stock' ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : '';
|
|
104
|
-
fail('invalid_product', `${file} has a column "${h}" that is not a product field.`, `${why}CSV columns are ${CSV_COLUMNS.join(', ')} and one column per currency with the price, named by its ISO 4217 code (EUR, USD, ...). Remove or rename the column.`);
|
|
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.`);
|
|
105
109
|
});
|
|
106
110
|
if (!columns.some((c) => 'field' in c && c.field === 'sku'))
|
|
107
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.`);
|
|
@@ -119,6 +123,10 @@ function fromCsv(file, text) {
|
|
|
119
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.');
|
|
120
124
|
product.prices[col.currency] = price;
|
|
121
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);
|
|
122
130
|
else
|
|
123
131
|
product[col.field] = value;
|
|
124
132
|
});
|
|
@@ -130,12 +138,14 @@ const csvCell = (v) => {
|
|
|
130
138
|
const t = v === undefined ? '' : String(v);
|
|
131
139
|
return /[",\r\n]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t;
|
|
132
140
|
};
|
|
133
|
-
/** Products as CSV in the shape fromCsv reads: the fixed columns, then one per currency, rows by sku. Same products, same bytes. */
|
|
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. */
|
|
134
142
|
export function toCsv(products) {
|
|
135
143
|
const currencies = [...new Set(products.flatMap((p) => Object.keys(p.prices ?? {})))].sort();
|
|
144
|
+
const translated = translationColumns(products.map((p) => p.translations));
|
|
136
145
|
const rows = [...products].sort((a, b) => (a.sku < b.sku ? -1 : a.sku > b.sku ? 1 : 0));
|
|
137
|
-
const lines = [[...CSV_COLUMNS, ...currencies].join(',')];
|
|
146
|
+
const lines = [[...CSV_COLUMNS, ...translated, ...currencies].join(',')];
|
|
147
|
+
const translation = (p, column) => p.translations?.[column.slice(-2)]?.[column.slice(0, -3)];
|
|
138
148
|
for (const p of rows)
|
|
139
|
-
lines.push([...CSV_COLUMNS.map((c) => csvCell(p[c])), ...currencies.map((c) => csvCell(p.prices?.[c]))].join(','));
|
|
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(','));
|
|
140
150
|
return lines.join('\n') + '\n';
|
|
141
151
|
}
|