@commercelayer/agent-cli 0.3.0 → 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/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # @commercelayer/agent-cli
2
- `c11r`, the Commerce Layer CLI for coding agents. An agent reads [commercelayer.ai/start.md](https://commercelayer.ai/start.md), runs this CLI, and the human ends up with a working store in a sandbox: products, prices, a market, a storefront at `<org>.sandbox.commercelayer.store`, and a paid test order. No account needed until they decide to keep it.
2
+ `c11r`, the Commerce Layer CLI for coding agents. An agent reads [commercelayer.ai/start.md](https://commercelayer.ai/start.md), runs this CLI, and the human ends up with a working store in a sandbox: products, prices, a market, a storefront at `<org>.sandbox.commercelayer.store`, and a test order to approve, capture and ship. No account needed until they decide to keep it.
3
3
  Every store speaks the [Universal Commerce Protocol](https://ucp.dev) on its own hostname, so shopping agents can discover it, browse it and buy from it. The CLI itself buys through UCP (`orders test`) and checks the store against the spec (`ucp check`), so what it verifies is what any agent would get.
4
4
  It is a separate tool from [`@commercelayer/cli`](https://www.npmjs.com/package/@commercelayer/cli), the CLI for humans. The package is `@commercelayer/agent-cli`; the command is `c11r` (c, eleven letters, r), short and unmistakable in an agent's transcript.
5
5
  ```
@@ -28,14 +28,17 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
28
28
  | `links create` | Create a shareable link to a microstore (sku, skus or category) or an order's checkout, from a spec in YAML or JSON on stdin or `-f <file>` (see [Links](#links)). Prints the link, `url` first. |
29
29
  | `links enable <id>` · `disable <id>` · `delete <id>` | Turn a link on or off, or delete it (ask the human first). |
30
30
  | `orders test [--sku <id>] [--quantity <n>] [--country <CC>] [--code <discount>] [--via-cart] [--decline]` | Buy from the store over UCP as a shopping agent would: discover, search, (cart,) checkout, address, pay with the sandbox handler, read the order back. Prints the order, the `discounts` applied (promotions and codes, in minor units like the totals) and every step. |
31
- | `orders list` | The orders placed in the store, newest first, as UCP order entities. |
32
- | `orders show <id or number>` | One order. |
31
+ | `orders list [--status placed\|approved\|cancelled\|editing\|all] [--payment <status>] [--fulfillment <status>] [--archived [all]] [--limit <n>]` | The store's orders, live in Commerce Layer, newest first; by default placed and approved, not archived, 25 at most (see [Orders and fulfillment](#orders-and-fulfillment)). |
32
+ | `orders show <number or id>` | One order as the merchant sees it: line items, totals, addresses, payment and transactions, shipments, and `next`, what it waits for. |
33
+ | `orders approve <n> [--capture]` · `capture` · `cancel` · `refund <n> [--amount <major>]` · `archive` · `unarchive` | Move an order; each prints the order after it. |
34
+ | `fulfillment list [--status <status>\|all] [--limit <n>]` · `show <shipment>` | The shipments, by default the ones not shipped yet. A shipment is its number (`1001/S/001`), its id, or its order's number. |
35
+ | `fulfillment hold` · `pick` · `pack` · `ready` · `ship <shipment> [--carrier] [--tracking] [--tracking-url]` · `deliver` | Move a shipment; each prints the shipment after it. |
33
36
  | `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
34
- | `ucp search [<query>] [--country <CC>]` · `ucp product <id>` | Buy for the human, step by step (see [Buying for the human](#buying-for-the-human)): the store's products, prices in major units in `price.display`. |
35
- | `ucp checkout create --item <sku>[:<qty>] … [--code <discount>] [--country <CC>]` | Open a checkout; `messages` say what is still missing (email, shipping address). |
36
- | `ucp checkout update <id> [--email …] [--street …] [--item <sku>:<qty>] [--code …] [--shipping <option>]` | Give the buyer's details (flags, or YAML or JSON on stdin or `-f`), change items or codes; `ready_for_complete` once nothing is missing, with shipping in the total. |
37
+ | `ucp search [<words>] [--category <name>] [--min-price <n>] [--max-price <n>] [--country <CC>]` · `ucp product <id>` | Buy for the human, step by step (see [Buying for the human](#buying-for-the-human)): the store's products with their categories, prices in major units in `price.display`, sale prices, shipping and the market the prices are for. |
38
+ | `ucp checkout create --item <variant id>[:<qty>] … [--code <discount>] [--country <CC>]` | Open a checkout; `messages` say what is still missing (email, shipping address). |
39
+ | `ucp checkout update <id> [--email …] [--street …] [--item <variant id>:<qty>] [--code …] [--shipping <option>]` | Give the buyer's details (flags, or YAML or JSON on stdin or `-f`), change items or codes; `ready_for_complete` once nothing is missing, with shipping in the total. |
37
40
  | `ucp checkout show <id>` · `cancel <id>` · `complete <id> [--decline]` | Read, cancel, or pay a checkout with the sandbox handler (after the human said yes): `order` has `label` and `permalink_url`. |
38
- | `ucp order <id>` · `ucp ask "<question>"` | Read an order the CLI placed; ask the store a question. Every `ucp` buying command takes `--store <host>` to buy from any UCP store. |
41
+ | `ucp order <id>` · `ucp ask "<question>"` | Read an order the CLI placed; ask the store a question. Every `ucp` buying command takes `--store <host>` to buy from any store that speaks UCP. |
39
42
  | `ucp check` | Walk every UCP capability the store advertises and report what passes. Places and pays one real test order on the way (the output says which). Exits 1 on a failed check. |
40
43
  | `status` | The sandbox, the sales channel client id, the published storefront, what is applied, the last import job (`apply`), the pending changes (exactly what `plan` lists; a store that does not load is pending too, with the error), orders, the UCP profile URL. |
41
44
  | `claim` | Start the device flow that keeps the sandbox: a URL and a code for the human. |
@@ -69,6 +72,9 @@ theme:
69
72
  logo: img/logo.svg # or an https:// URL; default: the name as a wordmark
70
73
  hero: img/hero.jpg # the home page's wide image; default: a band in the accent
71
74
  tagline: Small-batch coffee, roasted in Milan # at most 120 characters; default: "Ships to <countries>"
75
+ badge: Demo # a short label next to the logo, at most 20 characters
76
+ storefront:
77
+ mode: hosted # default; agent-only: only UCP and the pages it needs, no catalog pages
72
78
  policies:
73
79
  # Pages shopping agents show buyers before they pay. Without them the store
74
80
  # serves default sandbox pages at <preview_url>/policies/<name>.
@@ -116,6 +122,18 @@ COFFEE-COL-250,"Colombia Huila, 250 g",,Coffee > Single origin,12.00,13.50
116
122
  ```
117
123
  Errors name the file (and line, for CSV) and the sku, so one product file can be fixed without reading the rest. Moving a product between `store.yml` and a file is not a change to the store: `plan` keys everything by sku. The catalog has no stock: inventory is tracked live in Commerce Layer, and the sandbox treats every product as in stock. Prices in these files are what the store charges; a published storefront reads prices and availability live from Commerce Layer.
118
124
  Rules `plan` and `apply` enforce: a name, at least one market and one product; every market has `code`, `name`, `countries` and an ISO 4217 `currency`; codes and skus are unique across all files; a product has only `sku`, `name`, `description`, `image_url`, `category` and `prices`; a `category` is a path of names from the top level, separated by ` > `; an `image_url` is an `https://` URL or a path in the storefront folder such as `img/eth.jpg` (served from the store host, given to shopping agents as a full URL); every product has a price in the currency of every market; `shipping`, if present, has `method` and a non-negative `price`; policy entries are URLs. Currency, languages and tax follow from the countries.
125
+ ### Languages
126
+ `language:` at the top of `store.yml` is the language the products are written in (ISO 639-1, default `en`). A product's `name`, `description` and `category` can also be a map with the base language and others, and a CSV takes `name_<ll>`, `description_<ll>` and `category_<ll>` columns next to the plain ones:
127
+ ```yaml
128
+ language: en
129
+ products:
130
+ - sku: ETH
131
+ name: { en: Ethiopia, it: Etiopia }
132
+ category: { en: Coffee > Single origin, it: Caffè > Monorigine } # the same levels, each translated
133
+ prices: { EUR: 14.50 }
134
+ ```
135
+ A store has at most 6 languages, the base one included. Every country gets its own pages, `/<cc>-<ll>/` (`/it-it/`), in that country's language: the product texts in it when the product has them, the base language otherwise; `/` lets the buyer choose the country when there are several. Shopping agents get the same texts over UCP, in the language they ask for. `plan` warns about a language no country of the store speaks. A store without translations plans and applies as before.
136
+
119
137
  ### A catalog the human edits
120
138
  When the products live in a tool the human edits too, `store.yml` names it and `c11r catalog pull` copies it into `.commercelayer/products/catalog.csv`, sorted by sku: same source, same file. Pull applies the product rules above before it writes, with errors naming the row in the source, and reports the skus added, changed and removed. Removed products leave the store only through `apply --allow-delete`. Every source has the same columns: `sku`, `name`, `description`, `image_url`, `category`, and one price column per market currency, named by its ISO 4217 code in lowercase (`eur`, `usd`).
121
139
  ```yaml
@@ -124,9 +142,9 @@ catalog:
124
142
  url: https://docs.google.com/spreadsheets/d/e/2PACX-.../pub?output=csv
125
143
  # table: products # supabase only
126
144
  ```
127
- - `sheets`: a Google Sheet, by its CSV link (File > Share > Publish to web > Comma-separated values) or by its own link when anyone with the link can view it. No key.
145
+ - `sheets`: a Google Sheet, by its CSV link (File > Share > Publish to web > Comma-separated values) or by its own link when anyone with the link can view it. No key. Translations are `name_<ll>`, `description_<ll>` and `category_<ll>` columns, in sheets and Supabase tables alike.
128
146
  - `supabase`: a table (default `products`), read over the REST API with the project's anon key: `C11R_SUPABASE_URL=https://<project>.supabase.co` and `C11R_SUPABASE_KEY=<anon key>` in `.commercelayer/credentials`, or the environment. The key needs a row level security policy that allows select. Never use the `service_role` key. Columns `id`, `created_at` and `updated_at` are skipped.
129
- - `merchant`: a Google Merchant Center product feed URL, RSS 2.0 (XML, `g:` namespace) or TSV. `id` is the sku, `title` the name, `description`, `image_link` the image, `price` (`14.50 EUR`) the price in its currency, `product_type` the category. An id listed once per currency is one product with several prices. Other attributes, `sale_price` included, are ignored.
147
+ - `merchant`: a Google Merchant Center product feed URL, RSS 2.0 (XML, `g:` namespace) or TSV. `id` is the sku, `title` the name, `description`, `image_link` the image, `price` (`14.50 EUR`) the price in its currency, `product_type` the category. An id listed once per currency is one product with several prices. Other attributes, `sale_price` included, are ignored. Other languages: `catalog.translations: { it: <the Italian feed URL> }`, one feed per language, merged by `id` (`title`, `description`, `product_type`).
130
148
  Error codes: `no_catalog_source`, `invalid_catalog`, `missing_credentials`, `catalog_unreachable`, `catalog_unauthorized`, `catalog_not_found`, `catalog_not_csv`, `catalog_error`, `empty_catalog`, plus `invalid_product` and `missing_price`.
131
149
 
132
150
  ### The website
@@ -134,9 +152,17 @@ The theme renders the home page (logo, hero with name and tagline, products by t
134
152
 
135
153
  One rule for images: `img/<file>` anywhere in `store.yml` (product `image_url`, `theme.logo`, `theme.hero`) is `.commercelayer/img/<file>`, uploaded by `apply`, served at `/img/<file>`, whatever is in front.
136
154
 
155
+ `storefront.mode: agent-only` makes a store for shopping agents only: the store host serves UCP and the pages UCP needs (checkout, order, policy pages, `/buy` permalinks, images), a one-line home page pointing to `/.well-known/ucp`, and no catalog or product pages (`/products/…` and `/categories/…` redirect to `/`, so links shared earlier land on the agent page), nor a custom storefront. Removing it, or `mode: hosted`, brings the website back on the next `apply`.
156
+
137
157
  A custom storefront replaces the theme: `storefront.path` in `store.yml` names its folder, `storefront init` writes a drop-in.js scaffold, `storefront publish` uploads it, `storefront check` checks it. Its pages link product images at `/img/`; its own files (fonts, icons, styles) go in `assets/`, not `img/`.
138
158
 
139
- Error codes: `invalid_theme`, `invalid_promotion`, `promotion_exists`, `promotion_not_managed`, `flex_unavailable`, `promotions_unsupported`, `invalid_link`, `link_exists`, `link_not_managed` (reserved), `links_unsupported`, `missing_images`, `asset_too_large`, `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `storefront_unreachable`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
159
+ Error codes: `invalid_theme`, `invalid_storefront`, `invalid_promotion`, `promotion_exists`, `promotion_not_managed`, `flex_unavailable`, `promotions_unsupported`, `invalid_link`, `link_exists`, `link_not_managed` (reserved), `links_unsupported`, `missing_images`, `asset_too_large`, `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `storefront_unreachable`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
160
+ ## Orders and fulfillment
161
+
162
+ Orders land **placed** with the payment **authorized**, not taken: the store no longer approves or captures them. The merchant, through the agent, accepts an order with `orders approve <n> --capture` (or approves now and runs `orders capture <n>` later), cancels one it doesn't want (`orders cancel`, which voids the authorization; a shipped order can't be cancelled, a captured one is refunded instead), and gives money back with `orders refund <n> [--amount <major units>]` (the last of it cancels the order). `orders archive` hides an order from the list, whatever its status; `orders list --archived` shows them. Every command prints the order after it and a `next` that says what it waits for; a move the order doesn't allow answers `invalid_transition`, with the commands it allows now in the hint.
163
+
164
+ Each order has one shipment, `upcoming` when placed. `fulfillment hold` works on a placed order; `pick`, `pack`, `ready` and `ship` need the order approved, and `on_hold`, `picking`, `packing` and `ready_to_ship` move freely between each other. `ship` takes the carrier's `--carrier`, `--tracking` and `--tracking-url` (https); `deliver` follows `ship`. An API older than these routes answers `orders_unsupported` or `fulfillment_unsupported`.
165
+
140
166
  ## The store speaks UCP
141
167
  Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
142
168
  | What | Where |
@@ -153,7 +179,7 @@ Transport rules the store enforces: `UCP-Agent` (an HTTPS platform profile URL)
153
179
  The CLI is a UCP **platform**: its profile is served at `https://commercelayer.ai/ucp/agent-cli/profile.json` (`ucp profile --agent` prints it) and goes in `UCP-Agent` on every call. The store negotiates the capability intersection with it. `orders test` is the buyer; `ucp check` runs 30-odd checks across profile, transport rules, catalog, cart, discount, checkout, fulfillment, consent, payment, signatures, idempotency, order, identity linking, ask and permalink.
154
180
  ### Buying for the human
155
181
 
156
- An agent can buy from any UCP store on behalf of its human, one step per command, talking to the human in between: `ucp search` to show the products, `ucp checkout create --item <sku>:<qty>` for what they picked, `ucp checkout update <id>` with the email and shipping address they give (never invented), then, once they said yes to the total, `ucp checkout complete <id>`, and the order's `label` and `permalink_url` back to them. Money comes as `{ amount, currency, major, display }`: `amount` in minor units as UCP has it, `display` for the human. The store is `--store <hostname or origin>`, or this folder's store when there is one; checkout and order ids are remembered with their store (in `~/.local/state/c11r/ucp.json`), so later steps need no `--store`. Paying needs the store's sandbox handler (`ai.commercelayer.sandbox`, test payments only): a store with a real gateway answers `payment_handler_unsupported`, and the human pays at the checkout's `continue_url`. Responses are signature-checked like `orders test`: `signature` is `verified`, `invalid`, `unknown_key` or `unsigned`.
182
+ An agent can buy from any UCP store on behalf of its human, one step per command, talking to the human in between: `ucp search` to show the products (words in any order; `--category`, which includes the subcategories, and `--min-price`/`--max-price` in major units narrow it; the `messages` the store sends, such as its categories when nothing matched, are passed on), `ucp checkout create --item <variant id>:<qty>` for what they picked (the `variants[].id` search lists; the sku on Commerce Layer stores), `ucp checkout update <id>` with the email and shipping address they give (never invented), then, once they said yes to the total, `ucp checkout complete <id>`, and the order's `label` and `permalink_url` back to them. Money comes as `{ amount, currency, major, display }`: `amount` in minor units as UCP has it, `display` for the human. The store is `--store <hostname or origin>`, or this folder's store when there is one; checkout and order ids are remembered with their store (in `~/.local/state/c11r/ucp.json`), so later steps need no `--store`. Paying needs the store's sandbox handler (`ai.commercelayer.sandbox`, test payments only): any store without it (every store taking real payments) answers `payment_handler_unsupported`, and the human pays at the checkout's `continue_url`. Responses are signature-checked like `orders test`: `signature` is `verified`, `invalid`, `unknown_key` or `unsigned`.
157
183
 
158
184
  ## Requirements
159
185
 
@@ -50,7 +50,66 @@ export function createApi(auth) {
50
50
  throw e;
51
51
  }
52
52
  };
53
+ // Orders and shipments for the merchant: an API older than them answers the UCP array on GET
54
+ // /orders?view=merchant and not_found on the routes it lacks. A not_found on one order or
55
+ // shipment is checked against the list, so a missing order is still not_found.
56
+ const qs = (query) => {
57
+ const p = new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== ''));
58
+ return p.toString() ? `&${p}` : '';
59
+ };
60
+ const ordersUnsupported = () => fail('orders_unsupported', 'This API does not manage orders for the merchant yet.', 'Try again later; tell the human if it persists. c11r orders test still places test orders.');
61
+ const shipmentsUnsupported = () => fail('fulfillment_unsupported', 'This API does not manage shipments yet.', 'Try again later; tell the human if it persists.');
62
+ const listMerchantOrders = async (query) => {
63
+ const r = (await request('GET', `${org()}/orders?view=merchant${qs(query)}`));
64
+ if (Array.isArray(r) || !Array.isArray(r.orders))
65
+ ordersUnsupported();
66
+ return r;
67
+ };
68
+ const listShipments = async (query) => {
69
+ try {
70
+ const p = new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== ''));
71
+ return (await request('GET', `${org()}/shipments${p.toString() ? `?${p}` : ''}`));
72
+ }
73
+ catch (e) {
74
+ if (e instanceof CliError && e.code === 'not_found')
75
+ shipmentsUnsupported();
76
+ throw e;
77
+ }
78
+ };
79
+ const orderCall = async (call) => {
80
+ try {
81
+ return await call();
82
+ }
83
+ catch (e) {
84
+ if (e instanceof CliError && e.code === 'not_found')
85
+ await listMerchantOrders({ limit: '1' });
86
+ throw e;
87
+ }
88
+ };
89
+ const shipmentCall = async (call) => {
90
+ try {
91
+ return await call();
92
+ }
93
+ catch (e) {
94
+ if (e instanceof CliError && e.code === 'not_found')
95
+ await listShipments({ limit: '1' });
96
+ throw e;
97
+ }
98
+ };
99
+ const orderPath = (id) => `${org()}/orders/${encodeURIComponent(id)}`;
100
+ const shipmentPath = (id) => `${org()}/shipments/${encodeURIComponent(id)}`;
53
101
  return {
102
+ listMerchantOrders,
103
+ getMerchantOrder: (id) => orderCall(async () => {
104
+ const o = (await request('GET', `${orderPath(id)}?view=merchant`));
105
+ if (o && o.payment_status === undefined)
106
+ ordersUnsupported();
107
+ return o;
108
+ }),
109
+ orderAction: (id, action, body) => orderCall(() => request('POST', `${orderPath(id)}/${action}`, body ?? {})),
110
+ listShipments,
111
+ getShipment: (id) => shipmentCall(() => request('GET', shipmentPath(id))),
112
+ shipmentAction: (id, action, body) => shipmentCall(() => request('POST', `${shipmentPath(id)}/${action}`, body ?? {})),
54
113
  listPromotions: () => unsupported('promotions', request('GET', collection('promotions'))),
55
114
  createPromotion: (spec) => unsupported('promotions', request('POST', collection('promotions'), spec)),
56
115
  getPromotion: (id) => found('promotions', id, request('GET', one('promotions', id))),
@@ -0,0 +1,52 @@
1
+ // Locales on the store host, as the store Worker does them: one per country, from the
2
+ // `locales` the API derives on apply (the stub keeps them on the record). The texts come
3
+ // from applied.products[sku].translations[ll], falling back to the base language.
4
+ export const COOKIE = 'c11r_locale';
5
+ export const baseLanguage = (r) => r.applied?.language ?? 'en';
6
+ /** The languages the store has: the base one, then every language a product is translated into. */
7
+ export function languagesOf(r) {
8
+ const out = new Set([baseLanguage(r)]);
9
+ for (const p of Object.values(r.applied?.products ?? {}))
10
+ for (const ll of Object.keys(p.translations ?? {}))
11
+ out.add(ll);
12
+ return out;
13
+ }
14
+ /** A product's name, description and category in a language, falling back to the base one. */
15
+ export function texts(p, ll) {
16
+ const t = p.translations?.[ll] ?? {};
17
+ return { name: t.name ?? p.name, description: t.description ?? p.description, category: t.category ?? p.category };
18
+ }
19
+ /** The store's locales; none before an apply that derived them (the site stays as it was). */
20
+ export const localesOf = (r) => r.locales ?? [];
21
+ /** The locale at /<path>/, by its country: a known country with the wrong language still matches. */
22
+ export const localeOfCountry = (r, country) => localesOf(r).find((l) => l.country === country.toUpperCase());
23
+ /** The locale a cookie names, if it is one of the store's. */
24
+ export function cookieLocale(r, cookie) {
25
+ const m = cookie?.match(new RegExp(`(?:^|;\\s*)${COOKIE}=([a-z]{2}-[a-z]{2})`));
26
+ return m ? localesOf(r).find((l) => l.path === m[1]) : undefined;
27
+ }
28
+ /** The locale of a product URL for a market: the buyer's country when it is in the market, else the market's first. */
29
+ export function marketLocale(r, market, country) {
30
+ const ls = localesOf(r).filter((l) => l.market === market);
31
+ return ls.find((l) => country && l.country === country.toUpperCase()) ?? ls[0];
32
+ }
33
+ /**
34
+ * The language of a UCP catalog answer: context.language (primary subtag), else the first
35
+ * Accept-Language the store has, else the language of the buyer's country's locale, else the base.
36
+ */
37
+ export function ucpLanguage(r, context, acceptLanguage) {
38
+ const has = languagesOf(r);
39
+ const primary = (tag) => tag.trim().split(/[-_;]/)[0].toLowerCase();
40
+ const asked = typeof context?.language === 'string' ? primary(context.language) : '';
41
+ if (has.has(asked))
42
+ return asked;
43
+ for (const part of (acceptLanguage ?? '').split(',')) {
44
+ const ll = primary(part);
45
+ if (ll && has.has(ll))
46
+ return ll;
47
+ }
48
+ const country = typeof context?.address_country === 'string' ? localeOfCountry(r, context.address_country) : undefined;
49
+ if (country && has.has(country.language))
50
+ return country.language;
51
+ return baseLanguage(r);
52
+ }
@@ -3,11 +3,13 @@
3
3
  import crypto from 'node:crypto';
4
4
  import { canonical, IMAGE_PATH } from '../../store.js';
5
5
  import { generateKey } from '../../ucp/signing.js';
6
+ import { localesOf } from '../../locales.js';
6
7
  import { publicOrders, readDb, SANDBOX_DOMAIN, writeDb } from './db.js';
7
8
  import { bearer, error, json, readJson } from './http.js';
9
+ import { listOrders, listShipments, OrderError, orderAction, shipmentAction, showOrder, showShipment } from './orders.js';
8
10
  function publicState(r) {
9
11
  const { token: _token, ucp: _ucp, pending: _pending, asset_files: _files, ...state } = r;
10
- return { ...state, name: r.name ?? null, policies: r.policies ?? null, theme: r.theme ?? null, assets: r.assets ?? {}, apply: r.apply ?? null, orders: publicOrders(r) };
12
+ return { ...state, name: r.name ?? null, policies: r.policies ?? null, theme: r.theme ?? null, storefront_mode: r.storefront_mode ?? 'hosted', locales: r.locales ?? [], assets: r.assets ?? {}, apply: r.apply ?? null, orders: publicOrders(r) };
11
13
  }
12
14
  /** Like Commerce Layer refusing a sku in an import. Only the stub does this, so tests can exercise apply_failed. */
13
15
  const REFUSED = /^REFUSED-/;
@@ -166,17 +168,23 @@ export function handleManagement(req) {
166
168
  writeDb(db);
167
169
  if (record.apply?.status === 'in_progress')
168
170
  return error(409, 'apply_in_progress', 'Another apply is still running.', 'Run `c11r status` to follow it, then apply again.');
169
- const { applied, hash, name, policies, theme, assets, allow_delete, full } = readJson(req);
171
+ const { applied, hash, name, policies, theme, storefront_mode, assets, allow_delete, full } = readJson(req);
172
+ if (storefront_mode != null && storefront_mode !== 'hosted' && storefront_mode !== 'agent-only')
173
+ return error(422, 'invalid_storefront', `storefront_mode is "${storefront_mode}".`, 'Use hosted or agent-only.');
170
174
  const before = record.applied?.products ?? {};
171
175
  const removed = Object.keys(before).filter((sku) => !(sku in applied.products));
172
176
  if (removed.length && !allow_delete)
173
177
  return error(409, 'deletes_need_approval', `Apply would delete product ${removed.join(', ')}.`, 'Ask the human whether to delete these. If they agree, run `c11r apply --allow-delete`.');
174
178
  // The name, policies and markets apply in the request; products go through an
175
179
  // import job (SKUs, prices, then cleanups for removals) when any changed.
180
+ // Locales come from the markets, which apply in the request, as the API derives them.
181
+ record.locales = localesOf(applied);
176
182
  record.name = name;
177
183
  record.policies = policies ?? {};
178
184
  if (theme !== undefined)
179
185
  record.theme = theme;
186
+ // Absent or null is hosted, like the API: removing storefront: from store.yml reverts it.
187
+ record.storefront_mode = (storefront_mode ?? 'hosted');
180
188
  // Images not in the list are no longer used: dropped now. No list (older CLIs): nothing is dropped.
181
189
  if (assets) {
182
190
  for (const p of Object.keys(record.assets ?? {})) {
@@ -186,7 +194,9 @@ export function handleManagement(req) {
186
194
  }
187
195
  }
188
196
  }
189
- const changed = Object.keys(applied.products).filter((sku) => full || !(sku in before) || canonical(before[sku]) !== canonical(applied.products[sku]));
197
+ // Translations live in applied only: changing them makes no Commerce Layer call, like the API.
198
+ const commerce = (p) => canonical(p && { ...p, translations: undefined });
199
+ const changed = Object.keys(applied.products).filter((sku) => full || !(sku in before) || commerce(before[sku]) !== commerce(applied.products[sku]));
190
200
  if (changed.length === 0 && removed.length === 0) {
191
201
  record.applied = applied;
192
202
  record.applied_hash = hash;
@@ -335,9 +345,39 @@ export function handleManagement(req) {
335
345
  writeDb(db);
336
346
  return json(200, record.storefront);
337
347
  }
348
+ // The merchant's orders and shipments (view=merchant); a bare GET /orders stays the UCP list older CLIs read.
349
+ const merchant = (run, save = false) => {
350
+ try {
351
+ const out = run();
352
+ if (save)
353
+ writeDb(db);
354
+ return json(200, out);
355
+ }
356
+ catch (e) {
357
+ if (e instanceof OrderError)
358
+ return error(e.status, e.code, e.message, e.hint);
359
+ throw e;
360
+ }
361
+ };
362
+ const q = req.url.searchParams;
363
+ const merchantView = q.get('view') === 'merchant';
364
+ if (req.method === 'GET' && rest === '/orders' && merchantView)
365
+ return merchant(() => listOrders(record, q));
366
+ const act = rest.match(/^\/orders\/([^/]+)\/(approve|capture|cancel|refund|archive|unarchive)$/);
367
+ if (req.method === 'POST' && act)
368
+ return merchant(() => orderAction(record, decodeURIComponent(act[1]), act[2], readJson(req) ?? {}), true);
369
+ if (req.method === 'GET' && rest === '/shipments')
370
+ return merchant(() => listShipments(record, q));
371
+ const ship = rest.match(/^\/shipments\/(.+?)(?:\/(hold|pick|pack|ready|ship|deliver))?$/);
372
+ if (ship && req.method === 'POST' && ship[2])
373
+ return merchant(() => shipmentAction(record, decodeURIComponent(ship[1]), ship[2], readJson(req) ?? {}), true);
374
+ if (ship && req.method === 'GET' && !ship[2])
375
+ return merchant(() => showShipment(record, decodeURIComponent(ship[1])));
338
376
  if (req.method === 'GET' && rest === '/orders')
339
377
  return json(200, publicOrders(record));
340
378
  const one = rest.match(/^\/orders\/([^/]+)$/);
379
+ if (req.method === 'GET' && one && merchantView)
380
+ return merchant(() => showOrder(record, decodeURIComponent(one[1])));
341
381
  if (req.method === 'GET' && one) {
342
382
  const wanted = decodeURIComponent(one[1]);
343
383
  const found = publicOrders(record).find((o) => o.id === wanted || o.label === wanted || o.label === `#${wanted}`);