@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 +37 -11
- package/dist/api/client.js +59 -0
- package/dist/api/stub/locale.js +52 -0
- package/dist/api/stub/management.js +43 -3
- package/dist/api/stub/orders.js +359 -0
- package/dist/api/stub/search.js +78 -0
- package/dist/api/stub/shopping.js +85 -18
- package/dist/api/stub/theme.js +121 -31
- package/dist/api/stub/ucp.js +20 -7
- package/dist/bin.js +15 -5
- package/dist/catalog.js +40 -4
- package/dist/commands.js +162 -32
- package/dist/languages.js +49 -0
- package/dist/locales.js +43 -0
- package/dist/products.js +15 -5
- package/dist/store.js +126 -11
- package/dist/ucp/shop.js +66 -19
- package/package.json +1 -1
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
|
|
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
|
|
32
|
-
| `orders show <
|
|
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 [<
|
|
35
|
-
| `ucp checkout create --item <
|
|
36
|
-
| `ucp checkout update <id> [--email …] [--street …] [--item <
|
|
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
|
|
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 <
|
|
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
|
|
package/dist/api/client.js
CHANGED
|
@@ -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
|
-
|
|
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}`);
|