@commercelayer/agent-cli 0.3.1 → 0.5.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/dist/catalog.js CHANGED
@@ -9,7 +9,8 @@ import YAML from 'yaml';
9
9
  import { credential, paths } from './files.js';
10
10
  import { CliError, fail } from './output.js';
11
11
  import { parseCsv, readProductFile, toCsv } from './products.js';
12
- import { canonical, checkProducts, readStoreFile } from './store.js';
12
+ import { ISO_639_1, setPlain, setTranslated, translatedColumn, TRANSLATED } from './languages.js';
13
+ import { canonical, checkProducts, readStoreFile, storeLanguage } from './store.js';
13
14
  export const SOURCES = ['supabase', 'sheets', 'merchant'];
14
15
  export const catalogFile = path.join(paths.products, 'catalog.csv');
15
16
  const FIELDS = ['sku', 'name', 'description', 'image_url', 'category'];
@@ -33,7 +34,21 @@ function readConfig(doc) {
33
34
  ? 'Set url to the Google Sheet\'s CSV link (File > Share > Publish to web > Comma-separated values), or to the sheet\'s own link if it is shared with anyone who has the link.'
34
35
  : 'Set url to the Merchant Center product feed (RSS/XML or TSV), as Merchant Center fetches it.');
35
36
  }
36
- return { source: c.source, url, table };
37
+ const translations = {};
38
+ if (c.translations !== undefined && c.translations !== null) {
39
+ if (c.source !== 'merchant')
40
+ fail('invalid_catalog', `catalog.translations is for merchant feeds; this source is ${c.source}.`, 'In a sheet or a table, translations are columns: name_it, description_it, category_it.');
41
+ if (typeof c.translations !== 'object' || Array.isArray(c.translations))
42
+ fail('invalid_catalog', 'catalog.translations in store.yml is not a map.', 'Map each language to its feed: translations: { it: https://..., fr: https://... }.');
43
+ for (const [ll, u] of Object.entries(c.translations)) {
44
+ if (!ISO_639_1.has(ll))
45
+ fail('invalid_catalog', `catalog.translations.${ll} is not a language.`, 'Use ISO 639-1 codes in lower case: it, fr, de, ...');
46
+ if (!/^https?:\/\//.test(String(u)))
47
+ fail('invalid_catalog', `catalog.translations.${ll} is not a URL.`, 'Set it to the Merchant Center feed in that language (RSS/XML or TSV).');
48
+ translations[ll] = String(u);
49
+ }
50
+ }
51
+ return { source: c.source, url, table, translations };
37
52
  }
38
53
  async function get(url, what, headers = {}) {
39
54
  try {
@@ -51,13 +66,16 @@ function fromTable(header, rows, where, label, currencies) {
51
66
  const key = h.trim().toLowerCase();
52
67
  if (FIELDS.includes(key))
53
68
  return { field: key };
69
+ const t = translatedColumn(key);
70
+ if (t)
71
+ return { translated: t.field, language: t.language };
54
72
  if (key === '' || IGNORED.includes(key))
55
73
  return null;
56
74
  const code = /^[a-z]{3}$/.test(key);
57
75
  if (code && (currencies.length === 0 || currencies.includes(key.toUpperCase())))
58
76
  return { currency: key.toUpperCase() };
59
77
  const why = key === 'stock' ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : code ? `${key.toUpperCase()} is not the currency of a market in store.yml. ` : '';
60
- fail('invalid_product', `${where} has a column "${h}" that is not a product field.`, `${why}The catalog columns are ${FIELDS.join(', ')} and one price column per market currency, named by its ISO 4217 code in lowercase (${prices}). Ask the human to rename or remove the column.`);
78
+ fail('invalid_product', `${where} has a column "${h}" that is not a product field.`, `${why}The catalog columns are ${FIELDS.join(', ')}, name_<ll>, description_<ll> and category_<ll> for other languages (name_it), and one price column per market currency, named by its ISO 4217 code in lowercase (${prices}). Ask the human to rename or remove the column.`);
61
79
  });
62
80
  if (!columns.some((c) => c && 'field' in c && c.field === 'sku'))
63
81
  fail('invalid_product', `${where} has no sku column.`, `The catalog columns are ${FIELDS.join(', ')} and one price column per market currency (${prices}).`);
@@ -76,6 +94,10 @@ function fromTable(header, rows, where, label, currencies) {
76
94
  fail('invalid_product', `${label(i)}: ${col.currency} price "${v}" is not a number.`, 'Write prices as plain decimals, such as 14.50, with no currency sign or thousands separator.');
77
95
  product.prices[col.currency] = price;
78
96
  }
97
+ else if ('translated' in col)
98
+ setTranslated(product, col.translated, col.language, String(v).trim());
99
+ else if (TRANSLATED.includes(col.field))
100
+ setPlain(product, col.field, String(v).trim());
79
101
  else
80
102
  product[col.field] = String(v).trim();
81
103
  });
@@ -233,6 +255,20 @@ async function fromMerchant(c) {
233
255
  bySku.set(item.id, sourced);
234
256
  out.push(sourced);
235
257
  });
258
+ // A feed per language: title, description and product_type of the same ids, in that language.
259
+ for (const [ll, url] of Object.entries(c.translations)) {
260
+ const res = await get(url, `the ${ll} Merchant feed`);
261
+ if (!res.ok)
262
+ fail('catalog_error', `The ${ll} Merchant feed answered ${res.status}.`, `Check catalog.translations.${ll} in store.yml: it is the feed URL Merchant Center fetches for that language.`);
263
+ for (const item of feedRows(await res.text()).items) {
264
+ const target = item.id ? bySku.get(item.id)?.product : undefined;
265
+ if (!target)
266
+ continue;
267
+ for (const [tag, field] of [['title', 'name'], ['description', 'description'], ['product_type', 'category']])
268
+ if (item[tag])
269
+ setTranslated(target, field, ll, item[tag]);
270
+ }
271
+ }
236
272
  return out;
237
273
  }
238
274
  export async function pull() {
@@ -254,7 +290,7 @@ export async function pull() {
254
290
  const rls = config.source === 'supabase' ? ' If the table has rows, row level security hides them from the anon key: ask the human to add a policy that allows select.' : '';
255
291
  fail('empty_catalog', `The ${config.source} catalog has no products.`, `Ask the human to add products to it, then pull again.${rls}`);
256
292
  }
257
- const products = checkProducts(sourced, currencies);
293
+ const products = checkProducts(sourced, currencies, storeLanguage(doc.language));
258
294
  let before = new Map();
259
295
  if (fs.existsSync(catalogFile)) {
260
296
  try {
package/dist/commands.js CHANGED
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { createApi } from './api/client.js';
7
7
  import { ignoreCredentials, paths, readCredentials, requireCredentials, writeAgentNotes, writeCredentials } from './files.js';
8
8
  import { CliError, fail, progress } from './output.js';
9
- import { diff, hashStore, loadStore, storefrontMode, storefrontPath, template, toApplied } from './store.js';
9
+ import { diff, hashStore, languageWarnings, loadStore, storefrontMode, storefrontPath, template, toApplied } from './store.js';
10
10
  import { storefrontFiles } from './storefront.js';
11
11
  import { imageNotes, planAssets, uploadAssets } from './assets.js';
12
12
  import { pull } from './catalog.js';
@@ -23,9 +23,9 @@ import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
23
23
  export const VERSION = createRequire(import.meta.url)('../package.json').version;
24
24
  export const HELP = {
25
25
  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).',
26
+ 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
27
  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.',
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, 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
29
  '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
30
  '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
31
  '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 +42,26 @@ export const HELP = {
42
42
  'links enable': 'Turn a link on: c11r links enable <id>.',
43
43
  'links disable': 'Turn a link off without deleting it: its url stops opening. c11r links disable <id>.',
44
44
  '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': 'List the orders placed in the store, newest first, as UCP order entities.',
47
- 'orders show': 'Show one order: c11r orders show <id or number>.',
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. 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.',
46
+ '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.",
47
+ '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.',
48
+ '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.",
49
+ '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.',
50
+ '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 }.",
51
+ '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.",
52
+ '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.',
53
+ 'orders unarchive': 'Bring an archived order back into orders list: c11r orders unarchive <number>. Idempotent.',
54
+ '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 }.",
55
+ '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 }.',
56
+ '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 }.',
57
+ '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.',
58
+ 'fulfillment pack': 'Mark a shipment as being packed: c11r fulfillment pack <shipment>. Needs the order approved. Returns { status, shipment, next }.',
59
+ 'fulfillment ready': 'Mark a shipment ready to ship: c11r fulfillment ready <shipment>. Needs the order approved. Returns { status, shipment, next }.',
60
+ '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 }.",
61
+ 'fulfillment deliver': 'Mark a shipped shipment delivered: c11r fulfillment deliver <shipment>. Returns { status, shipment, next }.',
48
62
  'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
49
63
  '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 [<query>] [--country CC] [--store <host>]; no query lists the whole catalog (up to 50). Returns { store, query, count, total_count, has_more, products: [{ id, title, description, url, image_url, variants: [{ id, sku, title, price: { amount, currency, major, display }, available, availability, ships }] }], signature }. Show the human the titles and price.display (amount is in minor units: never show it), let them pick, then c11r ucp checkout create --item <variant id>. --store is any store that speaks UCP (hostname or https origin); the default is this folder's store.",
64
+ '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
65
  '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
66
  '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
67
  '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>.",
@@ -58,11 +72,11 @@ export const HELP = {
58
72
  '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
73
  status: 'Show the sandbox, what is applied, and whether the store files have changes not yet applied (the same changes plan lists).',
60
74
  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.',
75
+ help: 'Describe the commands. `c11r help <command>` for one command, `c11r help <group>` (catalog, fulfillment, links, orders, promotions, storefront, ucp) for a group.',
62
76
  };
63
77
  export function help(name) {
64
78
  if (name && !HELP[name]) {
65
- // A group (catalog, orders, storefront, ucp): its commands.
79
+ // A group (catalog, fulfillment, links, orders, promotions, storefront, ucp): its commands.
66
80
  const group = Object.entries(HELP).filter(([k]) => k.startsWith(`${name} `));
67
81
  if (group.length)
68
82
  return { group: name, commands: Object.fromEntries(group) };
@@ -118,6 +132,7 @@ export async function plan() {
118
132
  changes,
119
133
  deletes: changes.filter((c) => c.action === 'delete' && c.type !== 'asset').length,
120
134
  ...imageNotes(assets),
135
+ warnings: languageWarnings(desired).length ? languageWarnings(desired) : undefined,
121
136
  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
137
  };
123
138
  }
@@ -281,7 +296,8 @@ export async function ordersTest(flags, values) {
281
296
  fail('invalid_argument', '--quantity must be a positive integer.', 'Example: --quantity 2.');
282
297
  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
298
  return {
284
- status: p.checkout.status === 'completed' ? 'paid' : p.checkout.status,
299
+ // The store places the order with its payment authorized; the merchant approves and captures it.
300
+ status: p.checkout.status === 'completed' ? 'placed' : p.checkout.status,
285
301
  order: p.order,
286
302
  // What the promotions took off, in minor units like the totals; a code that applied nothing is in messages.
287
303
  discounts: (p.checkout.discounts ?? p.ready.discounts)?.applied?.length ? (p.checkout.discounts ?? p.ready.discounts).applied : undefined,
@@ -289,17 +305,114 @@ export async function ordersTest(flags, values) {
289
305
  messages: p.checkout.messages ?? p.ready.messages,
290
306
  payment_handler: 'ai.commercelayer.sandbox (test payments only)',
291
307
  steps: p.steps,
308
+ 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
309
  };
293
310
  }
294
- export async function ordersList() {
311
+ // Orders and shipments, live in Commerce Layer through the API. An order lands placed with its
312
+ // payment authorized; the merchant approves, captures, ships. Each answer says what comes next.
313
+ const LIST_LIMIT = (raw) => {
314
+ if (raw === undefined)
315
+ return undefined;
316
+ const n = Number(raw);
317
+ if (!Number.isInteger(n) || n < 1 || n > 100)
318
+ fail('invalid_argument', `--limit ${raw} is not a number from 1 to 100.`, 'Example: --limit 50.');
319
+ return String(n);
320
+ };
321
+ function orderNext(o) {
322
+ const n = o.number;
323
+ const shipment = o.shipments?.[0];
324
+ if (o.status === 'cancelled')
325
+ return `Order ${n} is cancelled (payment ${o.payment_status}). Nothing more to do; archive it to keep the list short: c11r orders archive ${n}.`;
326
+ if (o.status === 'placed')
327
+ 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}.`;
328
+ if (o.payment_status === 'authorized')
329
+ return `Order ${n} is approved; the payment is authorized but not taken. Take it when the human says so: c11r orders capture ${n}.`;
330
+ if (o.fulfillment_status === 'fulfilled')
331
+ 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>].`;
332
+ 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).`;
333
+ }
334
+ export async function ordersList(values, flags) {
295
335
  const api = createApi(requireCredentials());
296
- return { orders: await api.listOrders() };
336
+ const archived = values.archived ?? (flags.has('--archived') ? 'true' : undefined);
337
+ if (archived !== undefined && !['true', 'false', 'all'].includes(archived))
338
+ fail('invalid_argument', `--archived ${archived} is not a choice.`, 'Use --archived for only the archived orders, or --archived all for every order.');
339
+ const query = { status: values.status, payment: values.payment, fulfillment: values.fulfillment, archived, limit: LIST_LIMIT(values.limit) };
340
+ const { orders } = await api.listMerchantOrders(query);
341
+ const waiting = orders.filter((o) => o.status === 'placed').length;
342
+ return {
343
+ count: orders.length,
344
+ orders,
345
+ next: orders.length
346
+ ? `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.` : ''}`
347
+ : 'No orders match. Without --status the list has placed and approved orders only: --status all shows every one, --archived the archived ones.',
348
+ };
297
349
  }
298
350
  export async function ordersShow(id) {
299
351
  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 <id>`.');
301
- const api = createApi(requireCredentials());
302
- return { order: await api.getOrder(id) };
352
+ fail('missing_argument', 'orders show needs an order id or number.', 'Run `c11r orders list` to see the orders, then `c11r orders show <number>`.');
353
+ const order = await createApi(requireCredentials()).getMerchantOrder(id);
354
+ return { order, next: orderNext(order) };
355
+ }
356
+ export async function ordersAct(action, id, values, flags) {
357
+ if (!id)
358
+ fail('missing_argument', `orders ${action} needs an order id or number.`, `Run \`c11r orders list\` to see the orders, then \`c11r orders ${action} <number>\`.`);
359
+ const body = {};
360
+ if (action === 'approve' && flags.has('--capture'))
361
+ body.capture = true;
362
+ if (action === 'refund' && values.amount !== undefined) {
363
+ const amount = Number(values.amount);
364
+ if (values.amount.trim() === '' || !Number.isFinite(amount) || amount <= 0)
365
+ 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.');
366
+ body.amount = amount;
367
+ }
368
+ const order = await createApi(requireCredentials()).orderAction(id, action, body);
369
+ return { status: action === 'archive' ? 'archived' : action === 'unarchive' ? 'unarchived' : order.status, order, next: orderNext(order) };
370
+ }
371
+ function shipmentNext(s) {
372
+ const n = s.number;
373
+ if (s.status === 'delivered')
374
+ return `Shipment ${n} is delivered. Nothing more to do.`;
375
+ if (s.status === 'cancelled')
376
+ return `Shipment ${n} is cancelled with its order ${s.order?.number}.`;
377
+ if (s.status === 'shipped')
378
+ return `Shipment ${n} is on its way${s.tracking?.number ? ` (tracking ${s.tracking.number})` : ''}. Mark it delivered when it arrives: c11r fulfillment deliver ${n}.`;
379
+ if (s.order?.status === 'placed')
380
+ 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}.`;
381
+ 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}.`;
382
+ }
383
+ export async function fulfillmentList(values) {
384
+ const { shipments } = await createApi(requireCredentials()).listShipments({ status: values.status, limit: LIST_LIMIT(values.limit) });
385
+ return {
386
+ count: shipments.length,
387
+ shipments,
388
+ next: shipments.length
389
+ ? 'Show the human the shipments (number, status, order.number, country, items). c11r fulfillment show <number> for the address and items.'
390
+ : 'No shipments match. Without --status the list has the ones not shipped yet: --status shipped, delivered or all for the others.',
391
+ };
392
+ }
393
+ export async function fulfillmentShow(id) {
394
+ if (!id)
395
+ fail('missing_argument', 'fulfillment show needs a shipment id or number, or an order number.', 'Run `c11r fulfillment list` to see the shipments.');
396
+ const shipment = await createApi(requireCredentials()).getShipment(id);
397
+ return { shipment, next: shipmentNext(shipment) };
398
+ }
399
+ export async function fulfillmentAct(action, id, values) {
400
+ if (!id)
401
+ fail('missing_argument', `fulfillment ${action} needs a shipment id or number, or an order number.`, 'Run `c11r fulfillment list` to see the shipments.');
402
+ const body = {};
403
+ if (action === 'ship') {
404
+ if (values.carrier !== undefined)
405
+ body.carrier = values.carrier;
406
+ if (values.tracking !== undefined)
407
+ body.tracking = values.tracking;
408
+ if (values['tracking-url'] !== undefined) {
409
+ if (!/^https:\/\/\S+$/.test(values['tracking-url']))
410
+ fail('invalid_argument', `--tracking-url ${values['tracking-url']} is not an https address.`, "Give the carrier's tracking page, starting with https://.");
411
+ body.tracking_url = values['tracking-url'];
412
+ }
413
+ }
414
+ const shipment = await createApi(requireCredentials()).shipmentAction(id, action, body);
415
+ return { status: shipment.status, shipment, next: shipmentNext(shipment) };
303
416
  }
304
417
  export async function ucpProfile(flags) {
305
418
  if (flags.has('--agent'))
@@ -313,7 +426,7 @@ export async function ucpCheck() {
313
426
  const report = await check(origin);
314
427
  return {
315
428
  ...report,
316
- note: report.order ? `The checkout checks placed and paid a real test order (${report.order.label ?? report.order.id}) with the sandbox handler; it appears in \`c11r orders list\` like one from \`orders test\`.` : undefined,
429
+ 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
430
  next: report.status === 'pass' ? 'Any shopping agent can buy from this store.' : 'Fix the failed checks and run `c11r ucp check` again.',
318
431
  };
319
432
  }
@@ -384,7 +497,7 @@ const needId = (id, command) => {
384
497
  };
385
498
  const checkoutNext = (ck) => {
386
499
  if (ck.status === 'completed')
387
- return `Paid. Tell the human: order ${ck.order?.label ?? ck.order?.id}, and give them ${ck.order?.permalink_url}.`;
500
+ return `Placed. Tell the human: order ${ck.order?.label ?? ck.order?.id}, and give them ${ck.order?.permalink_url}.`;
388
501
  if (ck.status === 'canceled')
389
502
  return 'Canceled. Open a new one with c11r ucp checkout create --item <variant id> if the human wants to buy.';
390
503
  if (ck.status === 'ready_for_complete') {
@@ -396,9 +509,25 @@ const checkoutNext = (ck) => {
396
509
  const errors = (ck.messages ?? []).filter((m) => m.type === 'error').map((m) => m.content);
397
510
  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
511
  };
399
- export async function ucpSearch(query, values) {
400
- const r = await shop.search(await shopOrigin(values), query, values.country);
401
- return { ...r, next: r.count ? 'Show the human the products (title, price.display), let them pick, then c11r ucp checkout create --item <variant id>[:<qty>] (variants[].id).' : 'Nothing matched: try another query, or none for the whole catalog.' };
512
+ export async function ucpSearch(query, values, lists = {}) {
513
+ const price = (flag) => {
514
+ const raw = values[flag];
515
+ if (raw === undefined)
516
+ return undefined;
517
+ const n = Number(raw);
518
+ if (raw.trim() === '' || !Number.isFinite(n) || n < 0)
519
+ fail('invalid_argument', `--${flag} ${raw} is not a price.`, `Give it in major units, for example --${flag} 15 or --${flag} 9.90.`);
520
+ return n;
521
+ };
522
+ const min = price('min-price');
523
+ const max = price('max-price');
524
+ if (min !== undefined && max !== undefined && min > max)
525
+ fail('invalid_argument', `--min-price ${min} is above --max-price ${max}.`, 'Swap them.');
526
+ const r = await shop.search(await shopOrigin(values), query, { country: values.country, categories: lists.category, min, max });
527
+ const next = r.count
528
+ ? '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).'
529
+ : '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.';
530
+ return { ...r, next };
402
531
  }
403
532
  export async function ucpProduct(id, values) {
404
533
  if (!id)
@@ -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
+ }
@@ -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/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
- /** The fixed CSV columns; any other column named by an ISO 4217 code is a price. */
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
  }