@commercelayer/agent-cli 0.3.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -9
- package/dist/api/client.js +74 -1
- package/dist/api/stub/index.js +26 -1
- package/dist/api/stub/locale.js +52 -0
- package/dist/api/stub/management.js +38 -2
- package/dist/api/stub/orders.js +359 -0
- package/dist/api/stub/search.js +78 -0
- package/dist/api/stub/shopping.js +84 -18
- package/dist/api/stub/theme.js +119 -30
- package/dist/api/stub/ucp.js +10 -6
- package/dist/api/transport.js +3 -0
- package/dist/bin.js +18 -7
- package/dist/catalog.js +40 -4
- package/dist/commands.js +155 -25
- package/dist/files.js +14 -2
- package/dist/languages.js +49 -0
- package/dist/locales.js +43 -0
- package/dist/output.js +14 -1
- package/dist/products.js +15 -5
- package/dist/store.js +95 -9
- package/dist/ucp/shop.js +53 -6
- package/dist/version.js +9 -0
- package/package.json +1 -1
package/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
|
```
|
|
@@ -10,6 +10,7 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
|
|
|
10
10
|
- **The website comes with the store.** Every store has a hosted theme at its preview URL, set by a few `theme:` settings and the product data: `apply` is all it takes. A custom storefront (your own web app) is opt-in: `storefront init`, `publish` and `check`.
|
|
11
11
|
- **JSON only.** Every command prints one JSON object. A failure exits 1 with `{ "error": { "code", "message", "hint" } }`; the hint is the fix.
|
|
12
12
|
- **Safe to re-run.** Every command is idempotent; a failed step can be fixed and run again.
|
|
13
|
+
- **Always current.** Run it as `npx -y @commercelayer/agent-cli@latest`, even where a `c11r` binary is installed; the note `init` writes in `AGENTS.md` and `CLAUDE.md` says so. The CLI sends `User-Agent: c11r/<version> (node/<major>)`, and an outdated one gets a `cli_outdated` entry in `warnings`, with `current`, `latest` and a `hint`.
|
|
13
14
|
- **No secrets in git.** `init` writes the sandbox token to `.commercelayer/credentials` (`C11R_ORG`, `C11R_TOKEN`) and gitignores it. The keys of a catalog source go in the same file.
|
|
14
15
|
## Commands
|
|
15
16
|
| Command | Does |
|
|
@@ -28,18 +29,21 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
|
|
|
28
29
|
| `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
30
|
| `links enable <id>` · `disable <id>` · `delete <id>` | Turn a link on or off, or delete it (ask the human first). |
|
|
30
31
|
| `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 <
|
|
32
|
+
| `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)). |
|
|
33
|
+
| `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. |
|
|
34
|
+
| `orders approve <n> [--capture]` · `capture` · `cancel` · `refund <n> [--amount <major>]` · `archive` · `unarchive` | Move an order; each prints the order after it. |
|
|
35
|
+
| `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. |
|
|
36
|
+
| `fulfillment hold` · `pick` · `pack` · `ready` · `ship <shipment> [--carrier] [--tracking] [--tracking-url]` · `deliver` | Move a shipment; each prints the shipment after it. |
|
|
33
37
|
| `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
|
|
34
|
-
| `ucp search [<
|
|
38
|
+
| `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. |
|
|
35
39
|
| `ucp checkout create --item <variant id>[:<qty>] … [--code <discount>] [--country <CC>]` | Open a checkout; `messages` say what is still missing (email, shipping address). |
|
|
36
40
|
| `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
41
|
| `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
42
|
| `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
43
|
| `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
|
-
| `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. |
|
|
44
|
+
| `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 store's semantic search index (`search`: model, status, vectors, embedded, updated_at, error; null before its first run), the UCP profile URL. |
|
|
41
45
|
| `claim` | Start the device flow that keeps the sandbox: a URL and a code for the human. |
|
|
42
|
-
| `help [<command>]` | Describe the commands. `--version` prints the version. |
|
|
46
|
+
| `help [<command>]` | Describe the commands. `--version` prints the version and `latest_hint`, how to always run the newest. |
|
|
43
47
|
## store.yml
|
|
44
48
|
`init` writes a commented template. A filled-in store looks like this:
|
|
45
49
|
```yaml
|
|
@@ -119,6 +123,18 @@ COFFEE-COL-250,"Colombia Huila, 250 g",,Coffee > Single origin,12.00,13.50
|
|
|
119
123
|
```
|
|
120
124
|
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.
|
|
121
125
|
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.
|
|
126
|
+
### Languages
|
|
127
|
+
`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:
|
|
128
|
+
```yaml
|
|
129
|
+
language: en
|
|
130
|
+
products:
|
|
131
|
+
- sku: ETH
|
|
132
|
+
name: { en: Ethiopia, it: Etiopia }
|
|
133
|
+
category: { en: Coffee > Single origin, it: Caffè > Monorigine } # the same levels, each translated
|
|
134
|
+
prices: { EUR: 14.50 }
|
|
135
|
+
```
|
|
136
|
+
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.
|
|
137
|
+
|
|
122
138
|
### A catalog the human edits
|
|
123
139
|
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`).
|
|
124
140
|
```yaml
|
|
@@ -127,9 +143,9 @@ catalog:
|
|
|
127
143
|
url: https://docs.google.com/spreadsheets/d/e/2PACX-.../pub?output=csv
|
|
128
144
|
# table: products # supabase only
|
|
129
145
|
```
|
|
130
|
-
- `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.
|
|
146
|
+
- `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.
|
|
131
147
|
- `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.
|
|
132
|
-
- `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.
|
|
148
|
+
- `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`).
|
|
133
149
|
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`.
|
|
134
150
|
|
|
135
151
|
### The website
|
|
@@ -142,6 +158,12 @@ One rule for images: `img/<file>` anywhere in `store.yml` (product `image_url`,
|
|
|
142
158
|
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/`.
|
|
143
159
|
|
|
144
160
|
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`.
|
|
161
|
+
## Orders and fulfillment
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
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`.
|
|
166
|
+
|
|
145
167
|
## The store speaks UCP
|
|
146
168
|
Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
|
|
147
169
|
| What | Where |
|
|
@@ -158,7 +180,7 @@ Transport rules the store enforces: `UCP-Agent` (an HTTPS platform profile URL)
|
|
|
158
180
|
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.
|
|
159
181
|
### Buying for the human
|
|
160
182
|
|
|
161
|
-
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 <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`.
|
|
183
|
+
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`.
|
|
162
184
|
|
|
163
185
|
## Requirements
|
|
164
186
|
|
package/dist/api/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The client for the management API at api.commercelayer.ai.
|
|
2
|
-
import { CliError, fail } from '../output.js';
|
|
2
|
+
import { CliError, fail, notice } from '../output.js';
|
|
3
|
+
import { NPX } from '../version.js';
|
|
3
4
|
import { apiUrl, DEFAULT_API_URL, send } from './transport.js';
|
|
4
5
|
export function createApi(auth) {
|
|
5
6
|
const base = apiUrl() === 'stub' ? DEFAULT_API_URL : apiUrl().replace(/\/$/, '');
|
|
@@ -20,6 +21,19 @@ export function createApi(auth) {
|
|
|
20
21
|
catch {
|
|
21
22
|
fail('api_error', `${base} answered ${res.status} with something that is not JSON.`, 'Try again. If it keeps happening, tell the human, with the output of `c11r status`.');
|
|
22
23
|
}
|
|
24
|
+
// The API flags an outdated CLI (from the User-Agent) in warnings, on any answer, errors included.
|
|
25
|
+
const warnings = data && typeof data === 'object' && !Array.isArray(data) ? data.warnings : undefined;
|
|
26
|
+
if (Array.isArray(warnings)) {
|
|
27
|
+
const isOutdated = (w) => !!w && typeof w === 'object' && w.code === 'cli_outdated';
|
|
28
|
+
for (const n of warnings.filter(isOutdated))
|
|
29
|
+
notice({ ...n, hint: n.hint ?? `Run the commands with ${NPX}. If c11r is installed globally, tell the human it is on ${n.current} and ${n.latest} exists.` });
|
|
30
|
+
// Printed once, at the top of the output, not inside the order or store it came with.
|
|
31
|
+
const rest = warnings.filter((w) => !isOutdated(w));
|
|
32
|
+
if (rest.length)
|
|
33
|
+
data.warnings = rest;
|
|
34
|
+
else
|
|
35
|
+
delete data.warnings;
|
|
36
|
+
}
|
|
23
37
|
if (res.status < 400)
|
|
24
38
|
return data;
|
|
25
39
|
const err = data?.error;
|
|
@@ -50,7 +64,66 @@ export function createApi(auth) {
|
|
|
50
64
|
throw e;
|
|
51
65
|
}
|
|
52
66
|
};
|
|
67
|
+
// Orders and shipments for the merchant: an API older than them answers the UCP array on GET
|
|
68
|
+
// /orders?view=merchant and not_found on the routes it lacks. A not_found on one order or
|
|
69
|
+
// shipment is checked against the list, so a missing order is still not_found.
|
|
70
|
+
const qs = (query) => {
|
|
71
|
+
const p = new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== ''));
|
|
72
|
+
return p.toString() ? `&${p}` : '';
|
|
73
|
+
};
|
|
74
|
+
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.');
|
|
75
|
+
const shipmentsUnsupported = () => fail('fulfillment_unsupported', 'This API does not manage shipments yet.', 'Try again later; tell the human if it persists.');
|
|
76
|
+
const listMerchantOrders = async (query) => {
|
|
77
|
+
const r = (await request('GET', `${org()}/orders?view=merchant${qs(query)}`));
|
|
78
|
+
if (Array.isArray(r) || !Array.isArray(r.orders))
|
|
79
|
+
ordersUnsupported();
|
|
80
|
+
return r;
|
|
81
|
+
};
|
|
82
|
+
const listShipments = async (query) => {
|
|
83
|
+
try {
|
|
84
|
+
const p = new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== ''));
|
|
85
|
+
return (await request('GET', `${org()}/shipments${p.toString() ? `?${p}` : ''}`));
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
if (e instanceof CliError && e.code === 'not_found')
|
|
89
|
+
shipmentsUnsupported();
|
|
90
|
+
throw e;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const orderCall = async (call) => {
|
|
94
|
+
try {
|
|
95
|
+
return await call();
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
if (e instanceof CliError && e.code === 'not_found')
|
|
99
|
+
await listMerchantOrders({ limit: '1' });
|
|
100
|
+
throw e;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const shipmentCall = async (call) => {
|
|
104
|
+
try {
|
|
105
|
+
return await call();
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
if (e instanceof CliError && e.code === 'not_found')
|
|
109
|
+
await listShipments({ limit: '1' });
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const orderPath = (id) => `${org()}/orders/${encodeURIComponent(id)}`;
|
|
114
|
+
const shipmentPath = (id) => `${org()}/shipments/${encodeURIComponent(id)}`;
|
|
53
115
|
return {
|
|
116
|
+
listMerchantOrders,
|
|
117
|
+
getMerchantOrder: (id) => orderCall(async () => {
|
|
118
|
+
const o = (await request('GET', `${orderPath(id)}?view=merchant`));
|
|
119
|
+
if (o && o.payment_status === undefined)
|
|
120
|
+
ordersUnsupported();
|
|
121
|
+
return o;
|
|
122
|
+
}),
|
|
123
|
+
orderAction: (id, action, body) => orderCall(() => request('POST', `${orderPath(id)}/${action}`, body ?? {})),
|
|
124
|
+
listShipments,
|
|
125
|
+
getShipment: (id) => shipmentCall(() => request('GET', shipmentPath(id))),
|
|
126
|
+
shipmentAction: (id, action, body) => shipmentCall(() => request('POST', `${shipmentPath(id)}/${action}`, body ?? {})),
|
|
54
127
|
listPromotions: () => unsupported('promotions', request('GET', collection('promotions'))),
|
|
55
128
|
createPromotion: (spec) => unsupported('promotions', request('POST', collection('promotions'), spec)),
|
|
56
129
|
getPromotion: (id) => found('promotions', id, request('GET', one('promotions', id))),
|
package/dist/api/stub/index.js
CHANGED
|
@@ -16,7 +16,7 @@ export function handle(req) {
|
|
|
16
16
|
url = new URL(`https://${prefixed[1]}${prefixed[2] ?? '/'}${url.search}`);
|
|
17
17
|
const org = orgOfHost(url.hostname);
|
|
18
18
|
if (org === null)
|
|
19
|
-
return handleManagement({ ...req, url });
|
|
19
|
+
return outdated(req, handleManagement({ ...req, url }));
|
|
20
20
|
const db = readDb();
|
|
21
21
|
const store = db[org];
|
|
22
22
|
if (!store)
|
|
@@ -25,3 +25,28 @@ export function handle(req) {
|
|
|
25
25
|
writeDb(db);
|
|
26
26
|
return res;
|
|
27
27
|
}
|
|
28
|
+
/** "0.5.1" → [0, 5, 1], for comparing versions. */
|
|
29
|
+
const parts = (v) => v.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
30
|
+
const older = (a, b) => {
|
|
31
|
+
const [x, y] = [parts(a), parts(b)];
|
|
32
|
+
for (let i = 0; i < 3; i++)
|
|
33
|
+
if ((x[i] ?? 0) !== (y[i] ?? 0))
|
|
34
|
+
return (x[i] ?? 0) < (y[i] ?? 0);
|
|
35
|
+
return false;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Like the API: a CLI whose User-Agent (c11r/<version>) is older than the latest gets a
|
|
39
|
+
* cli_outdated warning on every JSON object it is answered. The stub's latest is
|
|
40
|
+
* C11R_STUB_LATEST_CLI; unset, every CLI is current.
|
|
41
|
+
*/
|
|
42
|
+
function outdated(req, res) {
|
|
43
|
+
const latest = process.env.C11R_STUB_LATEST_CLI;
|
|
44
|
+
const current = req.headers['user-agent']?.match(/^c11r\/(\d+\.\d+\.\d+)/)?.[1];
|
|
45
|
+
if (!latest || !current || !older(current, latest) || !res.body)
|
|
46
|
+
return res;
|
|
47
|
+
const body = JSON.parse(res.body);
|
|
48
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
49
|
+
return res;
|
|
50
|
+
const warning = { code: 'cli_outdated', current, latest, hint: `Run the commands with npx -y @commercelayer/agent-cli@latest. If c11r is installed globally, tell the human it is on ${current} and ${latest} exists.` };
|
|
51
|
+
return { ...res, body: JSON.stringify({ ...body, warnings: [...(Array.isArray(body.warnings) ? body.warnings : []), warning] }) };
|
|
52
|
+
}
|
|
@@ -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, storefront_mode: r.storefront_mode ?? 'hosted', 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-/;
|
|
@@ -175,6 +177,8 @@ export function handleManagement(req) {
|
|
|
175
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`.');
|
|
176
178
|
// The name, policies and markets apply in the request; products go through an
|
|
177
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);
|
|
178
182
|
record.name = name;
|
|
179
183
|
record.policies = policies ?? {};
|
|
180
184
|
if (theme !== undefined)
|
|
@@ -190,7 +194,9 @@ export function handleManagement(req) {
|
|
|
190
194
|
}
|
|
191
195
|
}
|
|
192
196
|
}
|
|
193
|
-
|
|
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]));
|
|
194
200
|
if (changed.length === 0 && removed.length === 0) {
|
|
195
201
|
record.applied = applied;
|
|
196
202
|
record.applied_hash = hash;
|
|
@@ -339,9 +345,39 @@ export function handleManagement(req) {
|
|
|
339
345
|
writeDb(db);
|
|
340
346
|
return json(200, record.storefront);
|
|
341
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])));
|
|
342
376
|
if (req.method === 'GET' && rest === '/orders')
|
|
343
377
|
return json(200, publicOrders(record));
|
|
344
378
|
const one = rest.match(/^\/orders\/([^/]+)$/);
|
|
379
|
+
if (req.method === 'GET' && one && merchantView)
|
|
380
|
+
return merchant(() => showOrder(record, decodeURIComponent(one[1])));
|
|
345
381
|
if (req.method === 'GET' && one) {
|
|
346
382
|
const wanted = decodeURIComponent(one[1]);
|
|
347
383
|
const found = publicOrders(record).find((o) => o.id === wanted || o.label === wanted || o.label === `#${wanted}`);
|