@commercelayer/agent-cli 0.2.3 → 0.3.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
@@ -18,16 +18,24 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
18
18
  | `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete: markets, products, images, and the store's name, policies and theme. `missing_images` lists `img/<file>` references with no file in `.commercelayer/img/` (`apply` refuses them); `unused_images` lists files there that nothing references. |
19
19
  | `apply [--allow-delete] [--full]` | Make the store match the files, the website included: the theme settings, and the images in `.commercelayer/img/` that the theme and products reference, uploaded when new or changed (unused ones are dropped from the store host). Refuses deletes without `--allow-delete`. Product changes go through a Commerce Layer import: `apply` waits for it, with progress as JSON lines on stderr, and prints one result. `apply_failed` lists the products Commerce Layer refused. While Commerce Layer is busy or unavailable, `apply` keeps reading (backing off on busy) and writes a `retrying` line. `apply_pending` means the import keeps going after 10 minutes, and `c11r status` follows it. An apply still running is finished before a new one starts. `--full` re-imports every product and price, after edits in the Commerce Layer dashboard. |
20
20
  | `catalog pull` | Read the products from the catalog source in `store.yml` (a Supabase table, a Google Sheet or a Google Merchant Center feed) into `.commercelayer/products/catalog.csv`. Then `plan` and `apply` as usual. |
21
- | `storefront init [--force]` | Custom storefronts only. Write a static storefront on [drop-in.js](https://commercelayer.github.io/drop-in.js/), no build step, wired to the sandbox's sales channel. `index.html`: the config block, the products grouped by top-level category with a navigation, a minicart that slides in on add to cart, the hosted cart and checkout, and a product page at `#/p/<sku>` built from `<template id="product-template">` and the `#products` data. `styles.css`: theme tokens first (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`), which also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change; then the drop-in.js overrides, then the page. `app.js`: the market switch, the category links and the product page. Product images are the store's, at `/img/<file>` (from `.commercelayer/img/`, uploaded by `apply`); products with no image get a neutral tile. The site's own files go in `storefront/assets/`. Refuses to overwrite without the flag; with it, rewrites the three files. |
21
+ | `storefront init [--force]` | Custom storefronts only. Write a static storefront on [drop-in.js](https://commercelayer.github.io/drop-in.js/), no build step, wired to the sandbox's sales channel. `index.html`: the config block, the products grouped by top-level category with a navigation, a minicart that slides in on add to cart, the hosted cart and checkout, and a product page at `#/p/<sku>` built from `<template id="product-template">` and the `#products` data. `styles.css`: theme tokens first (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`), which also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change; then the drop-in.js overrides, then the page. `app.js`: the market switch, the category links and the product page. Product images are the store's, at `/img/<file>` (from `.commercelayer/img/`, uploaded by `apply`); products with no image show the tile the store host draws for them, at `/tiles/<sku>.svg`. The site's own files go in `storefront/assets/`. Refuses to overwrite without the flag; with it, rewrites the three files. |
22
22
  | `storefront publish` | Custom storefronts only. Upload the folder at `storefront.path` (default `storefront`) to the store host, where it replaces the hosted theme, leaving out dotfiles. `/img/` on the store host is always the store's images, so files under the folder's `img/` are not served: publish lists them as `not_served`. |
23
23
  | `storefront check` | Custom storefronts only. Read the published storefront at the preview URL and check it: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product and none for unknown skus, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides (no dead `::part` rules), title, `lang`, viewport, description, and whether the folder changed since publish. Prints `{ status, summary, checks: [{ id, kind, status: pass\|warn\|fail, message, fix, items }], render }` and exits 1 when a check fails; warnings do not fail. `kind` is `static` today; a later rendering phase adds `render` checks and fills `render`. |
24
24
  | `promotions list` · `show <id>` | The store's promotions, live in Commerce Layer: id, name, status, rules, actions, uses, dates; `managed: false` for ones made in the dashboard. |
25
25
  | `promotions create` · `update <id>` | Create a promotion, or replace one, from a spec in YAML or JSON on stdin or `-f <file>` (see [Promotions](#promotions)). Prints the promotion and the `orders test` call that checks it. |
26
26
  | `promotions enable <id>` · `disable <id>` · `delete <id>` | Turn a promotion on or off, or delete it (ask the human first). |
27
+ | `links list` · `show <id>` | The store's links, live in Commerce Layer: url, id, name, status, what it sells, markets, dates; `managed: false` for ones made in the dashboard. |
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
+ | `links enable <id>` · `disable <id>` · `delete <id>` | Turn a link on or off, or delete it (ask the human first). |
27
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. |
28
31
  | `orders list` | The orders placed in the store, newest first, as UCP order entities. |
29
32
  | `orders show <id or number>` | One order. |
30
33
  | `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 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. |
31
39
  | `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. |
32
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. |
33
41
  | `claim` | Start the device flow that keeps the sandbox: a URL and a code for the human. |
@@ -86,6 +94,17 @@ Optional: `market` (a market code; without it, every market), `starts` and `ends
86
94
 
87
95
  `list` and `show` print each promotion in the normalized form plus `status` (`active`, `scheduled`, `expired`, `disabled`, `used_up`) and `uses`; that output can be edited and sent back to `update`. Promotions made outside c11r (the dashboard, the sandbox's `SANDBOX10`) are listed with `managed: false` and Commerce Layer's id: `enable`, `disable` and `delete` work on them, `update` answers `promotion_not_managed`. `percentage` is a whole number from 1 to 100; a promotion takes at most 20 codes, a free gift at most 25 SKUs. More than 10 active promotion actions without a coupon gives a `warnings` entry (Commerce Layer applies 10). `orders test --code <CODE>` proves one: its `discounts` lists what was taken off, automatic promotions with `automatic: true`, and a code that applied nothing is a `discount_code_invalid` warning in `messages`.
88
96
 
97
+ ### Links
98
+ Links are live settings in Commerce Layer too, never store files: shareable URLs that open a hosted microstore for one sku, a set of skus or a category, or the hosted checkout for an order. A spec, in YAML or JSON on stdin or with `-f <file>`:
99
+
100
+ ```yaml
101
+ id: single-origins # the handle: lowercase letters, digits, dashes; unique
102
+ name: Single origins
103
+ for: { category: Coffee > Single origin } # or { sku: ETH }, { skus: [ETH, COL] }, { order: 1001 | cart_... }
104
+ ```
105
+
106
+ Optional: `market` (a code or a list; without it, every market; an order link takes the order's market and none), `starts` (default now) and `ends` (default a month after `starts`; a day, or a UTC time; a bare `ends` day runs to 23:59:59Z), `params` (names to plain values, added to the URL). A category becomes the skus of its products in store.yml when the command runs; a link sells at most 100 skus. An order link opens the checkout of a draft or pending order: its number, or the id of a UCP cart or checkout (`cart_...`, `chk_...`), read back as the order's number. A placed order opens its thank-you page, so `create` refuses it. Commerce Layer opens a link in one market, so a link has a URL per market: `urls`, by market code, and `url`, the first market's. `create`, `list` and `show` print `url` and `urls` first, then the spec, `status` (`pending`, `active`, `expired`, `disabled`: the first market's; enable and disable act on every market) and `created_at`. A disabled or pending link opens Commerce Layer's not-found page; checkout takes the sandbox's manual payment (wire transfer) unless a gateway is added. A link cannot change: delete it and create it again (what `show` prints can be sent back to `create`). Links made outside c11r are listed with `managed: false` and Commerce Layer's id, and may sell `for: { sku_list: <name> }`; `enable`, `disable` and `delete` work on them.
107
+
89
108
  ### The products folder
90
109
  Beyond a handful of products, put the catalog in `.commercelayer/products/` and leave `products` out of `store.yml`. Every file in the folder is merged into the catalog by sku, in file-name order:
91
110
  - `<name>.yml`: a list of products in the shape above (one file per collection, or one per product, as you like).
@@ -117,7 +136,7 @@ One rule for images: `img/<file>` anywhere in `store.yml` (product `image_url`,
117
136
 
118
137
  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/`.
119
138
 
120
- Error codes: `invalid_theme`, `invalid_promotion`, `promotion_exists`, `promotion_not_managed`, `flex_unavailable`, `promotions_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`.
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`.
121
140
  ## The store speaks UCP
122
141
  Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
123
142
  | What | Where |
@@ -132,6 +151,10 @@ Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (liv
132
151
  | Pages | `/policies/{privacy,terms,returns}` (defaults when `store.yml` has no `policies`), `/carts/{id}`, `/checkout-sessions/{id}` (`continue_url`), `/orders/{id}` (`permalink_url`), and the published storefront at `/`. |
133
152
  Transport rules the store enforces: `UCP-Agent` (an HTTPS platform profile URL) and `Request-Id` on every request; `Idempotency-Key` on every state change, replayed when the body matches and `409` when it does not; `Content-Digest` verified when present. Every service response carries `Content-Digest`, `Signature-Input` and `Signature` (ES256 over `@status`, `content-digest`, `content-type`, key `kid` from the profile). Orders are private: readable by the platform that placed them or with a buyer token carrying an order scope; anything else gets `401` with `WWW-Authenticate: Bearer`. Catalog ids are the skus (`product.id` is `prod_<sku>`, `variant.id` is the sku); amounts are integers in minor units.
134
153
  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
+ ### Buying for the human
155
+
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`.
157
+
135
158
  ## Requirements
136
159
 
137
160
  Node 20 or later. One runtime dependency (`yaml`).
@@ -27,39 +27,47 @@ export function createApi(auth) {
27
27
  fail(err.code, err.message, err.hint);
28
28
  fail('api_error', `The API answered ${res.status}.`, 'Try again. If it keeps happening, tell the human, with the output of `c11r status`.');
29
29
  };
30
- // Promotions: on an API older than them, the collection route is an unknown route (404 not_found).
31
- const promotions = () => `${org()}/promotions`;
32
- const one = (id) => `${promotions()}/${encodeURIComponent(id)}`;
33
- const unsupported = async (call) => {
30
+ // Promotions and links: on an API older than them, the collection route is an unknown route (404 not_found).
31
+ const collection = (kind) => `${org()}/${kind}`;
32
+ const one = (kind, id) => `${collection(kind)}/${encodeURIComponent(id)}`;
33
+ const unsupported = async (kind, call) => {
34
34
  try {
35
35
  return await call;
36
36
  }
37
37
  catch (e) {
38
38
  if (e instanceof CliError && (e.code === 'not_found' || (e.code === 'api_error' && /\b404\b/.test(e.message))))
39
- fail('promotions_unsupported', 'This API does not manage promotions yet.', 'Try again later; tell the human if it persists.');
39
+ fail(`${kind}_unsupported`, `This API does not manage ${kind} yet.`, 'Try again later; tell the human if it persists.');
40
40
  throw e;
41
41
  }
42
42
  };
43
- const found = async (id, call) => {
43
+ const found = async (kind, id, call) => {
44
44
  try {
45
45
  return await call;
46
46
  }
47
47
  catch (e) {
48
48
  if (e instanceof CliError && e.code === 'not_found')
49
- fail('not_found', `There is no promotion ${id}.`, 'Run `c11r promotions list` for the ids, or `c11r promotions create` to make it.');
49
+ fail('not_found', `There is no ${kind.slice(0, -1)} ${id}.`, `Run \`c11r ${kind} list\` for the ids, or \`c11r ${kind} create\` to make it.`);
50
50
  throw e;
51
51
  }
52
52
  };
53
53
  return {
54
- listPromotions: () => unsupported(request('GET', promotions())),
55
- createPromotion: (spec) => unsupported(request('POST', promotions(), spec)),
56
- getPromotion: (id) => found(id, request('GET', one(id))),
57
- updatePromotion: (id, spec) => found(id, request('PUT', one(id), spec)),
58
- setPromotionEnabled: (id, enabled) => found(id, request('POST', `${one(id)}/${enabled ? 'enable' : 'disable'}`)),
54
+ listPromotions: () => unsupported('promotions', request('GET', collection('promotions'))),
55
+ createPromotion: (spec) => unsupported('promotions', request('POST', collection('promotions'), spec)),
56
+ getPromotion: (id) => found('promotions', id, request('GET', one('promotions', id))),
57
+ updatePromotion: (id, spec) => found('promotions', id, request('PUT', one('promotions', id), spec)),
58
+ setPromotionEnabled: (id, enabled) => found('promotions', id, request('POST', `${one('promotions', id)}/${enabled ? 'enable' : 'disable'}`)),
59
59
  deletePromotion: async (id) => {
60
- await found(id, request('DELETE', one(id)));
60
+ await found('promotions', id, request('DELETE', one('promotions', id)));
61
61
  },
62
- createSandbox: (name) => request('POST', '/sandboxes', { name }),
62
+ listLinks: () => unsupported('links', request('GET', collection('links'))),
63
+ createLink: (spec) => unsupported('links', request('POST', collection('links'), spec)),
64
+ getLink: (id) => found('links', id, request('GET', one('links', id))),
65
+ setLinkEnabled: (id, enabled) => found('links', id, request('POST', `${one('links', id)}/${enabled ? 'enable' : 'disable'}`)),
66
+ deleteLink: async (id) => {
67
+ await found('links', id, request('DELETE', one('links', id)));
68
+ },
69
+ // C11R_TEST=1 (our own test runs) asks the API to tag the organization as a test.
70
+ createSandbox: (name) => request('POST', '/sandboxes', process.env.C11R_TEST === '1' ? { name, test: true } : { name }),
63
71
  getStore: () => request('GET', org()),
64
72
  applyStore: (body) => request('PUT', `${org()}/resources`, body),
65
73
  uploadAssets: (files) => request('PUT', `${org()}/assets`, { files }),
@@ -59,13 +59,60 @@ function unknownSkus(r, spec) {
59
59
  }
60
60
  return undefined;
61
61
  }
62
+ /** As the API lists it: the spec plus the first market's url, every url, and status. */
63
+ function listedLink(l) {
64
+ const { enabled, urls, ...link } = l;
65
+ const now = Date.now();
66
+ const status = !enabled ? 'disabled' : Date.parse(l.ends) <= now ? 'expired' : Date.parse(l.starts) > now ? 'pending' : 'active';
67
+ return { ...link, url: Object.values(urls)[0], urls, status, managed: true };
68
+ }
69
+ /** The API checks a link against the store: applied skus and markets, draft orders. Answers the markets it gets a link in. */
70
+ function linkMarkets(r, spec) {
71
+ const known = r.applied?.products ?? {};
72
+ const f = spec.for;
73
+ const list = 'sku' in f ? [['for.sku', f.sku]] : 'skus' in f ? f.skus.map((s, i) => [`for.skus[${i}]`, s]) : [];
74
+ for (const [path, sku] of list)
75
+ if (!(sku in known))
76
+ return error(422, 'invalid_link', `${path}: unknown sku ${sku}.`, 'Use skus of applied products: run `c11r apply` first if you just added them.');
77
+ if ('order' in f) {
78
+ if (spec.markets)
79
+ return error(422, 'invalid_link', "markets: an order link uses the order's market.", 'Leave markets out.');
80
+ // A UCP cart or checkout id names the draft order behind it, read back as its number (the stub's: the id's suffix).
81
+ const cart = /^cart_/.test(f.order) ? r.ucp.carts[f.order] : undefined;
82
+ // A cart is the same order as the checkout made from it.
83
+ const ucp = cart ? (Object.values(r.ucp.checkouts).find((c) => c._cart_id === f.order && c.status === 'completed') ?? cart) : /^chk_/.test(f.order) ? r.ucp.checkouts[f.order] : undefined;
84
+ if (ucp) {
85
+ const placed = ucp.order?.label;
86
+ if (ucp.status === 'completed' && placed)
87
+ return error(422, 'invalid_link', `for.order: order ${placed.replace(/^#/, '')} is placed: a link opens the checkout of a draft or pending order.`, 'Link a cart or checkout that is not completed.');
88
+ if (ucp.status === 'canceled')
89
+ return error(422, 'invalid_link', `for.order: no order ${f.order}.`, 'The checkout is canceled: link another cart or checkout.');
90
+ const market = Object.values(r.applied?.markets ?? {}).find((m) => m.currency === ucp.currency)?.code ?? Object.keys(r.applied?.markets ?? {})[0];
91
+ spec.for = { order: f.order.replace(/^(cart|chk)_/, '') };
92
+ return [market];
93
+ }
94
+ const draft = r.draft_orders?.[f.order];
95
+ if (draft)
96
+ return [draft.market];
97
+ if (publicOrders(r).some((o) => o.label.replace(/^#/, '') === f.order))
98
+ return error(422, 'invalid_link', `for.order: order ${f.order} is placed: a link opens the checkout of a draft or pending order.`, 'A placed order opens its thank-you page, not a checkout: link a draft or pending order.');
99
+ return error(422, 'invalid_link', `for.order: no order ${f.order}.`, 'Use the number of a draft or pending order in the store.');
100
+ }
101
+ const applied = Object.keys(r.applied?.markets ?? {});
102
+ const markets = spec.markets ?? applied;
103
+ const i = markets.findIndex((m) => !applied.includes(m));
104
+ if (i >= 0)
105
+ return error(422, 'invalid_link', `markets[${i}]: unknown market ${markets[i]}.`, 'Use markets that are applied: run `c11r apply` first if you just added it.');
106
+ return markets;
107
+ }
62
108
  export function handleManagement(req) {
63
109
  const db = readDb();
64
110
  const path = req.url.pathname;
65
111
  if (req.method === 'POST' && path === '/sandboxes') {
66
112
  // Like Commerce Layer: the organization is named `name`, and its slug is derived
67
113
  // from it, suffixed -2, -3, ... when taken. The slug is the org and the store host.
68
- const name = String(readJson(req)?.name ?? 'store');
114
+ const body = readJson(req);
115
+ const name = String(body?.name ?? 'store');
69
116
  const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'store';
70
117
  let slug = base;
71
118
  for (let n = 2; db[slug]; n++)
@@ -78,6 +125,7 @@ export function handleManagement(req) {
78
125
  org: slug,
79
126
  token: `sbx_${crypto.randomBytes(16).toString('hex')}`,
80
127
  sandbox: true,
128
+ ...(body?.test === true ? { test: true } : {}),
81
129
  claimed: false,
82
130
  claim_url: `https://commercelayer.ai/claim/${crypto.randomBytes(6).toString('hex')}`,
83
131
  expires_at: expires.toISOString(),
@@ -211,6 +259,48 @@ export function handleManagement(req) {
211
259
  }
212
260
  }
213
261
  }
262
+ // Links: live, synchronous, keyed by id; one Commerce Layer link per market.
263
+ if (rest === '/links' || rest.startsWith('/links/')) {
264
+ const store = (record.links ??= {});
265
+ const [, , rawId, verb] = rest.split('/');
266
+ const id = rawId === undefined ? undefined : decodeURIComponent(rawId);
267
+ const save = () => writeDb(db);
268
+ if (id === undefined) {
269
+ if (req.method === 'GET')
270
+ return json(200, { links: Object.keys(store).sort().map((k) => listedLink(store[k])) });
271
+ if (req.method === 'POST') {
272
+ const spec = readJson(req);
273
+ if (store[spec.id])
274
+ return error(409, 'link_exists', `There is already a link ${spec.id}.`, 'Pick another id, or delete the link and create it again.');
275
+ const markets = linkMarkets(record, spec);
276
+ if (!Array.isArray(markets))
277
+ return markets;
278
+ // One Commerce Layer link per market: a scope with several markets does not open.
279
+ const urls = Object.fromEntries(markets.map((m) => [m, `https://c11.page/${crypto.randomBytes(6).toString('base64url')}`]));
280
+ // An order link reads back with the order's market.
281
+ store[spec.id] = { ...spec, markets, urls, enabled: true, created_at: new Date().toISOString() };
282
+ save();
283
+ return json(201, listedLink(store[spec.id]));
284
+ }
285
+ }
286
+ else {
287
+ const l = store[id];
288
+ if (!l)
289
+ return error(404, 'not_found', `There is no link ${id}.`, 'Run `c11r links list`.');
290
+ if (verb === undefined && req.method === 'GET')
291
+ return json(200, listedLink(l));
292
+ if (verb === undefined && req.method === 'DELETE') {
293
+ delete store[id];
294
+ save();
295
+ return { status: 204, headers: {}, body: '' };
296
+ }
297
+ if ((verb === 'enable' || verb === 'disable') && req.method === 'POST') {
298
+ l.enabled = verb === 'enable';
299
+ save();
300
+ return json(200, listedLink(l));
301
+ }
302
+ }
303
+ }
214
304
  if (req.method === 'PUT' && rest === '/assets') {
215
305
  const { files } = readJson(req);
216
306
  let total = 0;
@@ -86,8 +86,9 @@ function returnPolicy(ctx) {
86
86
  ];
87
87
  }
88
88
  // ---------- catalog
89
- /** A path in the storefront (img/eth.jpg) is served from the store host: shopping agents always get a full URL. */
90
- const imageUrl = (ctx, url) => new URL(url, `${ctx.origin}/`).href;
89
+ /** A path in the storefront (img/eth.jpg) is served from the store host: shopping agents always get a full URL,
90
+ * and for a product without an image, its tile. */
91
+ const imageUrl = (ctx, p) => new URL(p.image_url ?? `tiles/${encodeURIComponent(p.sku)}.svg`, `${ctx.origin}/`).href;
91
92
  function variant(ctx, p, market) {
92
93
  const price = { amount: toMinor(p.prices[market.currency], market.currency), currency: market.currency };
93
94
  const v = {
@@ -100,8 +101,7 @@ function variant(ctx, p, market) {
100
101
  // Inventory lives in Commerce Layer; the sandbox catalog has no stock, so everything is in stock.
101
102
  availability: { available: true, status: 'in_stock' },
102
103
  };
103
- if (p.image_url)
104
- v.media = [{ type: 'image', url: imageUrl(ctx, p.image_url) }];
104
+ v.media = [{ type: 'image', url: imageUrl(ctx, p) }];
105
105
  if (ctx.shared.has('dev.ucp.shopping.fulfillment')) {
106
106
  const o = shippingOption(market);
107
107
  v.fulfillment = { methods: [{ type: 'shipping', description: { plain: `Ships to ${market.countries.join(', ')}` }, availability: v.availability, options: [{ id: o.id, title: o.title, description: o.description }] }] };
@@ -202,8 +202,7 @@ function buildLines(ctx, market, requested, existing = []) {
202
202
  const qty = quantity;
203
203
  const prev = existing.find((e) => e.id === l.id) ?? existing.find((e) => e.item.id === p.sku);
204
204
  const item = { id: p.sku, title: p.name, price };
205
- if (p.image_url)
206
- item.image_url = imageUrl(ctx, p.image_url);
205
+ item.image_url = imageUrl(ctx, p);
207
206
  lines.push({ id: prev?.id ?? `li_${++n}`, item, quantity: qty, totals: [{ type: 'subtotal', amount: price * qty }, { type: 'total', amount: price * qty }] });
208
207
  });
209
208
  return { lines, messages };
@@ -2,6 +2,8 @@
2
2
  // custom storefront: the same settings in the same places (accent, logo or wordmark,
3
3
  // hero image or band, tagline), the products by top-level category, a page per product
4
4
  // at /products/<sku>. Prices come from what is applied; "Buy" opens the cart permalink.
5
+ // A product without an image shows its tile, and an unset hero the generated one, both
6
+ // drawn at /tiles/ (renderTile) as the store host does.
5
7
  const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
6
8
  /** Black or white, whichever reads better on the accent. */
7
9
  function onAccent(hex) {
@@ -21,7 +23,8 @@ export function renderTheme(r, path, search) {
21
23
  const tagline = theme.tagline ?? `Ships to ${markets.flatMap((m) => m.countries).join(', ')}`;
22
24
  const logo = theme.logo ? `<img class="logo" src="/${esc(theme.logo.replace(/^\//, ''))}" alt="${esc(name)}">` : `<span class="wordmark">${esc(name)}</span>`;
23
25
  const img = (url) => (/^https?:/.test(url) ? url : `/${url}`);
24
- const card = (p) => `<article class="product"><a href="/products/${encodeURIComponent(p.sku)}${keep}">${p.image_url ? `<img src="${esc(img(p.image_url))}" alt="${esc(p.name)}">` : '<div class="placeholder"></div>'}<h3>${esc(p.name)}</h3></a><p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></article>`;
26
+ const image = (p) => p.image_url ? `<img src="${esc(img(p.image_url))}" alt="${esc(p.name)}">` : `<span class="placeholder"><img src="/tiles/${esc(encodeURIComponent(p.sku))}.svg" alt="${esc(p.name)}" loading="lazy"></span>`;
27
+ const card = (p) => `<article class="product"><a href="/products/${encodeURIComponent(p.sku)}${keep}">${image(p)}<h3>${esc(p.name)}</h3></a><p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></article>`;
25
28
  let title = name;
26
29
  let body;
27
30
  const m = path.match(/^\/products\/([^/]+)$/);
@@ -31,7 +34,7 @@ export function renderTheme(r, path, search) {
31
34
  const top = p.category?.split(' > ')[0] ?? 'Other';
32
35
  groups.set(top, [...(groups.get(top) ?? []), p]);
33
36
  }
34
- const hero = theme.hero ? `<section class="hero" style="background-image:url('${esc(img(theme.hero))}')">` : '<section class="hero band">';
37
+ const hero = `<section class="hero" style="background-image:url('${esc(theme.hero ? img(theme.hero) : '/tiles/hero.svg')}')">`;
35
38
  body = `${hero}<h1>${esc(name)}</h1><p class="tagline">${esc(tagline)}</p></section><main>${[...groups].map(([k, ps]) => `<section><h2>${esc(k)}</h2><div class="grid">${ps.map(card).join('')}</div></section>`).join('')}</main>`;
36
39
  }
37
40
  else if (m) {
@@ -39,7 +42,7 @@ export function renderTheme(r, path, search) {
39
42
  if (!p)
40
43
  return null;
41
44
  title = `${p.name} · ${name}`;
42
- body = `<main class="product-page">${p.image_url ? `<img src="${esc(img(p.image_url))}" alt="${esc(p.name)}">` : '<div class="placeholder"></div>'}<div>${p.category ? `<p class="category">${esc(p.category)}</p>` : ''}<h1>${esc(p.name)}</h1>${p.description ? `<p>${esc(p.description)}</p>` : ''}<p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></div></main>`;
45
+ body = `<main class="product-page">${image(p)}<div>${p.category ? `<p class="category">${esc(p.category)}</p>` : ''}<h1>${esc(p.name)}</h1>${p.description ? `<p>${esc(p.description)}</p>` : ''}<p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></div></main>`;
43
46
  }
44
47
  else
45
48
  return null;
@@ -61,3 +64,29 @@ ${body}
61
64
  `;
62
65
  return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: html };
63
66
  }
67
+ const xml = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]);
68
+ /** /tiles/<sku>.svg and /tiles/hero.svg: a plain stand-in for the store host's generated tiles. */
69
+ export function renderTile(r, path) {
70
+ const m = path.match(/^\/tiles\/([^/]+)\.svg$/);
71
+ if (!m)
72
+ return null;
73
+ const color = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(r.theme?.color ?? '') ? r.theme.color : '#2f5d50';
74
+ const headers = { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' };
75
+ let svg;
76
+ if (m[1] === 'hero')
77
+ svg = `<svg xmlns="http://www.w3.org/2000/svg" width="2400" height="900" viewBox="0 0 2400 900"><rect width="2400" height="900" fill="${color}"/><circle cx="1900" cy="450" r="360" fill="#ffffff" fill-opacity="0.12"/></svg>`;
78
+ else {
79
+ let sku;
80
+ try {
81
+ sku = decodeURIComponent(m[1]);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ const p = r.applied?.products[sku];
87
+ if (!p)
88
+ return null;
89
+ svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" viewBox="0 0 1200 1200"><rect width="1200" height="1200" fill="${color}" fill-opacity="0.08"/><text x="600" y="600" text-anchor="middle" font-family="system-ui, sans-serif" font-size="72" fill="${color}">${xml(p.name.slice(0, 60))}</text></svg>`;
90
+ }
91
+ return { status: 200, headers, body: svg };
92
+ }
@@ -8,7 +8,7 @@ import { contentDigest, signHeaders } from '../../ucp/signing.js';
8
8
  import { strip } from './db.js';
9
9
  import { json } from './http.js';
10
10
  import * as shop from './shopping.js';
11
- import { renderTheme } from './theme.js';
11
+ import { renderTheme, renderTile } from './theme.js';
12
12
  const SCOPES = ['dev.ucp.shopping.order:read', 'dev.ucp.shopping.order:manage'];
13
13
  const OUR_CAPABILITIES = [...SHOPPING_CAPABILITIES, 'dev.ucp.common.ask', ...Object.keys(EXTENSIONS), 'dev.ucp.common.identity_linking', 'dev.ucp.shopping.permalink'];
14
14
  // ---------- profile
@@ -305,6 +305,9 @@ function handlePermalink(req, r, origin, path) {
305
305
  const MIME = { html: 'text/html; charset=utf-8', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript', json: 'application/json', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif', ico: 'image/x-icon', txt: 'text/plain' };
306
306
  function serveStorefront(r, path, search) {
307
307
  const files = r.ucp.files;
308
+ // /tiles/ is the store host's own, in front of a custom storefront too: unknown tiles are a 404.
309
+ if (path.startsWith('/tiles/'))
310
+ return renderTile(r, path) ?? html(404, 'Not found', `<p>No ${path} on this store.</p>`);
308
311
  // A custom storefront (an uploaded index.html) serves every path; otherwise the theme renders its routes.
309
312
  if (files['index.html'] === undefined) {
310
313
  const page = renderTheme(r, path, search);
package/dist/bin.js CHANGED
@@ -4,10 +4,16 @@
4
4
  // Output is always JSON; exit code 1 on an error or a failed check.
5
5
  import * as cmd from './commands.js';
6
6
  import { CliError, print } from './output.js';
7
- const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file']);
7
+ const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file', '--store', '--item', '--email', '--first-name', '--last-name', '--phone', '--street', '--city', '--postal-code', '--region', '--shipping']);
8
8
  const argv = process.argv.slice(2);
9
9
  const flags = new Set();
10
10
  const values = {};
11
+ /** Every value of an option given more than once (--item, --code), in order; values keeps the last. */
12
+ const lists = {};
13
+ const set = (k, v) => {
14
+ values[k] = v;
15
+ (lists[k] ??= []).push(v);
16
+ };
11
17
  const args = [];
12
18
  for (let i = 0; i < argv.length; i++) {
13
19
  const a = argv[i];
@@ -21,9 +27,9 @@ for (let i = 0; i < argv.length; i++) {
21
27
  }
22
28
  const eq = a.indexOf('=');
23
29
  if (eq > 0)
24
- values[a.slice(2, eq)] = a.slice(eq + 1);
30
+ set(a.slice(2, eq), a.slice(eq + 1));
25
31
  else if (VALUE_OPTIONS.has(a) && i + 1 < argv.length && !argv[i + 1].startsWith('--'))
26
- values[a.slice(2)] = argv[++i];
32
+ set(a.slice(2), argv[++i]);
27
33
  else
28
34
  flags.add(a);
29
35
  }
@@ -71,6 +77,18 @@ async function run() {
71
77
  if (sub === 'delete')
72
78
  return cmd.promotionsDelete(args[2]);
73
79
  throw new CliError('unknown_command', `There is no command "promotions ${sub ?? ''}".`, 'Use `c11r promotions list`, `show <id>`, `create`, `update <id>`, `enable <id>`, `disable <id>` or `delete <id>`.');
80
+ case 'links':
81
+ if (sub === 'list')
82
+ return cmd.linksList();
83
+ if (sub === 'show')
84
+ return cmd.linksShow(args[2]);
85
+ if (sub === 'create')
86
+ return cmd.linksCreate(values);
87
+ if (sub === 'enable' || sub === 'disable')
88
+ return cmd.linksEnable(args[2], sub === 'enable');
89
+ if (sub === 'delete')
90
+ return cmd.linksDelete(args[2]);
91
+ throw new CliError('unknown_command', `There is no command "links ${sub ?? ''}".`, 'Use `c11r links list`, `show <id>`, `create`, `enable <id>`, `disable <id>` or `delete <id>`.');
74
92
  case 'orders':
75
93
  if (sub === 'test')
76
94
  return cmd.ordersTest(flags, values);
@@ -84,7 +102,29 @@ async function run() {
84
102
  return cmd.ucpProfile(flags);
85
103
  if (sub === 'check')
86
104
  return cmd.ucpCheck();
87
- throw new CliError('unknown_command', `There is no command "ucp ${sub ?? ''}".`, 'Use `c11r ucp profile` or `c11r ucp check`.');
105
+ if (sub === 'search')
106
+ return cmd.ucpSearch(args.slice(2).join(' '), values);
107
+ if (sub === 'product')
108
+ return cmd.ucpProduct(args[2], values);
109
+ if (sub === 'order')
110
+ return cmd.ucpOrder(args[2], values);
111
+ if (sub === 'ask')
112
+ return cmd.ucpAsk(args.slice(2).join(' '), values);
113
+ if (sub === 'checkout') {
114
+ const [, , action, id] = args;
115
+ if (action === 'create')
116
+ return cmd.ucpCheckoutCreate(values, lists);
117
+ if (action === 'update')
118
+ return cmd.ucpCheckoutUpdate(id, values, lists);
119
+ if (action === 'show')
120
+ return cmd.ucpCheckoutShow(id, values);
121
+ if (action === 'cancel')
122
+ return cmd.ucpCheckoutCancel(id, values);
123
+ if (action === 'complete')
124
+ return cmd.ucpCheckoutComplete(id, values, flags);
125
+ throw new CliError('unknown_command', `There is no command "ucp checkout ${action ?? ''}".`, 'Use `c11r ucp checkout create`, `update <id>`, `show <id>`, `cancel <id>` or `complete <id>`.');
126
+ }
127
+ throw new CliError('unknown_command', `There is no command "ucp ${sub ?? ''}".`, 'Use `c11r ucp search`, `product`, `checkout`, `order`, `ask`, `profile` or `check`.');
88
128
  default:
89
129
  throw new CliError('unknown_command', `There is no command "${name}".`, `Run \`c11r help\`. Commands: ${Object.keys(cmd.HELP).join(', ')}.`);
90
130
  }
package/dist/commands.js CHANGED
@@ -10,12 +10,14 @@ import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from
10
10
  import { storefrontFiles } from './storefront.js';
11
11
  import { imageNotes, planAssets, uploadAssets } from './assets.js';
12
12
  import { pull } from './catalog.js';
13
+ import { checkLink } from './links.js';
13
14
  import { ACTIVE_LIMIT, activeWithoutCoupon, checkPromotion } from './promotions.js';
14
15
  import YAML from 'yaml';
15
16
  import { storefrontCheck } from './check.js';
16
17
  import { buy } from './ucp/buy.js';
17
18
  import { check } from './ucp/check.js';
18
19
  import { discover } from './ucp/client.js';
20
+ import * as shop from './ucp/shop.js';
19
21
  import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
20
22
  /** The package version, read from package.json so a release bumps it in one place. */
21
23
  export const VERSION = createRequire(import.meta.url)('../package.json').version;
@@ -34,11 +36,26 @@ export const HELP = {
34
36
  'promotions enable': 'Turn a promotion on: c11r promotions enable <id>.',
35
37
  'promotions disable': 'Turn a promotion off without deleting it: c11r promotions disable <id>.',
36
38
  'promotions delete': 'Delete a promotion from Commerce Layer: c11r promotions delete <id>. Ask the human first; to pause one, disable it.',
39
+ 'links list': 'The store\'s links, live in Commerce Layer (not in store files): shareable URLs that open a hosted microstore for one sku, a set of skus or a category, or the hosted checkout for an order. For each: id, name, url, status (pending, active, expired, disabled), for (sku | skus | category with its skus | order), markets, starts, ends, params; managed: false marks one made in the dashboard (its id is Commerce Layer\'s).',
40
+ 'links show': 'One link by id, as list shows it: c11r links show <id>.',
41
+ 'links create': "Create a link from a spec in YAML or JSON, on stdin (c11r links create <<'EOF' ... EOF) or with -f <file>: { id, name, for: { sku } | { skus: [...] } | { category: \"A > B\" } | { order: <number> | cart_<id> | chk_<id> }, market (a code or a list; default every market; none for an order), starts (default now), ends (default a month after starts), params }. id is the handle, unique. A category becomes the skus of its products in store.yml now (at most 100 skus). An order link opens the checkout of a draft or pending order: its number, or the id of a UCP cart or checkout. One Commerce Layer link per market: urls has each, url the first market's. A link cannot be changed: delete it and create it again. Errors: invalid_link (the message starts with the path in the spec), link_exists.",
42
+ 'links enable': 'Turn a link on: c11r links enable <id>.',
43
+ 'links disable': 'Turn a link off without deleting it: its url stops opening. c11r links disable <id>.',
44
+ 'links delete': 'Delete a link from Commerce Layer: c11r links delete <id>. Ask the human first; to pause one, disable it.',
37
45
  'orders test': 'Buy from the store over UCP as a shopping agent would, paying with the sandbox handler: discover, search, checkout, address, pay, read the order. Options: --sku <id> --quantity <n> --country <CC> --code <discount> --via-cart --decline. discounts lists what promotions and codes took off; a code that applied nothing is a discount_code_invalid warning in messages.',
38
46
  'orders list': 'List the orders placed in the store, newest first, as UCP order entities.',
39
47
  'orders show': 'Show one order: c11r orders show <id or number>.',
40
48
  'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
41
49
  'ucp check': 'Walk every UCP capability the store advertises and report what passes. Places and pays one real test order on the way. Exits 1 when a check fails.',
50
+ 'ucp search': "Buy for the human, step 1: search a store's catalog over UCP. c11r ucp search [<query>] [--country CC] [--store <host>]; no query lists the whole catalog (up to 50). Returns { store, query, count, total_count, has_more, products: [{ id, title, description, url, image_url, variants: [{ id, sku, title, price: { amount, currency, major, display }, available, availability, ships }] }], signature }. Show the human the titles and price.display (amount is in minor units: never show it), let them pick, then c11r ucp checkout create --item <sku>. --store is any UCP store (hostname or https origin); the default is this folder's store.",
51
+ 'ucp product': 'Look at one product over UCP, with every variant and the full description: c11r ucp product <product id or sku> [--country CC] [--store <host>]. Returns { store, product: { id, title, description, url, image_url, variants: [...] }, signature }, variants as in ucp search.',
52
+ 'ucp checkout create': "Buy for the human, step 2: open a checkout with the items the human picked. c11r ucp checkout create --item <sku>[:<qty>] [--item ...] [--code <discount>] [--country CC] [--store <host>]. Returns the checkout: { id, status, store, currency, line_items: [{ id, sku, title, quantity, unit_price, total }], buyer, ship_to, shipping, discounts, codes, totals: [{ type, label, amount, major, display }], total, messages, continue_url, expires_at, links, signature }. status is incomplete until the buyer's email and shipping address are given: messages say what is missing. Ask the human for them, never invent them, then c11r ucp checkout update <id>. The id is remembered with its store: later steps need no --store.",
53
+ 'ucp checkout update': "Buy for the human, step 3: give the buyer's details, change items or codes. c11r ucp checkout update <id> [--email --first-name --last-name --phone] [--street --city --postal-code --region --country] [--item <sku>:<qty> (0 removes)] [--code <discount> (adds)] [--shipping <option id>]; or the same as YAML or JSON on stdin or -f <file>: { buyer: { email, first_name, last_name, phone_number }, address: { street_address, address_locality, postal_code, address_region, address_country }, items: [\"SKU:2\"], codes: [...] (replaces), shipping }. Only what is given changes. Returns the checkout as in create: status ready_for_complete once nothing is missing, with shipping and the total including it; or incomplete with messages (address_undeliverable says where the store ships). Show the human the line items, shipping and total.display, ask for an explicit yes, then c11r ucp checkout complete <id>.",
54
+ 'ucp checkout show': 'Show a checkout as the store has it now: c11r ucp checkout show <id> [--store <host>]. Returns the checkout as in ucp checkout create.',
55
+ 'ucp checkout cancel': 'Cancel a checkout the human no longer wants: c11r ucp checkout cancel <id> [--store <host>]. Returns the checkout, status canceled.',
56
+ 'ucp checkout complete': "Buy for the human, step 4, only after the human said yes to the total: pay the checkout. c11r ucp checkout complete <id> [--decline] [--store <host>]. Pays with the sandbox handler (ai.commercelayer.sandbox: test payments, no money moves); --decline uses the declined test card. Returns the checkout with status completed and order: { id, label, permalink_url }, plus payment_handler. Tell the human the order label and give them permalink_url. A store with no sandbox handler (a live store with a real payment gateway) answers payment_handler_unsupported: give the human continue_url to pay there. A declined payment leaves status ready_for_complete with a payment_failed message.",
57
+ 'ucp order': 'Read an order this CLI placed: c11r ucp order <id> [--store <host>]. Returns { id, label, permalink_url, store, checkout_id, currency, line_items: [{ sku, title, quantity, status, total }], fulfillment: [{ method, destination, description }], events, totals, signature }.',
58
+ 'ucp ask': 'Ask a store a question in plain language over UCP (shipping, returns, what it sells): c11r ucp ask "<question>" [--store <host>]. Returns { store, question, answer, links, messages, signature }. The answer is the store\'s: quote it, do not embellish it. Ask only answers; buy with ucp checkout.',
42
59
  status: 'Show the sandbox, what is applied, and whether the store files have changes not yet applied (the same changes plan lists).',
43
60
  claim: 'Start keeping this sandbox store: returns a URL and a code for the human to approve in the browser.',
44
61
  help: 'Describe the commands. `c11r help <command>` for one command, `c11r help <group>` (catalog, orders, storefront, ucp) for a group.',
@@ -347,15 +364,153 @@ export async function claim() {
347
364
  next: 'Ask the human to open verification_url and enter user_code, then run `c11r status`.',
348
365
  };
349
366
  }
367
+ // ---------- ucp: buying for the human, step by step, from this store or any UCP store
368
+ /** The store to buy from: --store, else the one an id was made at, else this folder's. */
369
+ async function shopOrigin(values, id) {
370
+ if (values.store)
371
+ return shop.originOf(values.store);
372
+ const known = id ? shop.remembered(id) : undefined;
373
+ if (known)
374
+ return known;
375
+ if (!readCredentials())
376
+ fail('no_store', 'There is no store in this folder, and no --store was given.', 'Pass the store to buy from: --store <hostname or https origin>.');
377
+ return storeOrigin();
378
+ }
379
+ const needId = (id, command) => {
380
+ if (!id)
381
+ fail('missing_argument', `${command} needs a checkout id.`, 'Use the id that c11r ucp checkout create printed (chk_...).');
382
+ return id;
383
+ };
384
+ const checkoutNext = (ck) => {
385
+ if (ck.status === 'completed')
386
+ return `Paid. Tell the human: order ${ck.order?.label ?? ck.order?.id}, and give them ${ck.order?.permalink_url}.`;
387
+ if (ck.status === 'canceled')
388
+ return 'Canceled. Open a new one with c11r ucp checkout create --item <sku> if the human wants to buy.';
389
+ if (ck.status === 'ready_for_complete') {
390
+ const declined = (ck.messages ?? []).some((m) => m.code === 'payment_failed');
391
+ if (declined)
392
+ return `The payment failed: tell the human. Try again only if they ask: c11r ucp checkout complete ${ck.id}.`;
393
+ return `Show the human the items, shipping and total (${ck.total?.display}), ask for an explicit yes, then run c11r ucp checkout complete ${ck.id}.`;
394
+ }
395
+ const errors = (ck.messages ?? []).filter((m) => m.type === 'error').map((m) => m.content);
396
+ return `Still needed: ${errors.join(' ') || 'see messages'} Ask the human (never invent buyer details), then run c11r ucp checkout update ${ck.id} with what they said.`;
397
+ };
398
+ export async function ucpSearch(query, values) {
399
+ const r = await shop.search(await shopOrigin(values), query, values.country);
400
+ return { ...r, next: r.count ? 'Show the human the products (title, price.display), let them pick, then c11r ucp checkout create --item <sku>[:<qty>].' : 'Nothing matched: try another query, or none for the whole catalog.' };
401
+ }
402
+ export async function ucpProduct(id, values) {
403
+ if (!id)
404
+ fail('missing_argument', 'ucp product needs a product id or sku.', 'Use an id or sku from c11r ucp search.');
405
+ return shop.product(await shopOrigin(values), id, values.country);
406
+ }
407
+ export async function ucpCheckoutCreate(values, lists) {
408
+ const items = (lists.item ?? []).map(shop.parseItem);
409
+ if (!items.length)
410
+ fail('missing_argument', 'ucp checkout create needs at least one --item.', 'Example: c11r ucp checkout create --item ETH-250:2. The skus are in c11r ucp search.');
411
+ if (items.some((l) => l.quantity < 1))
412
+ fail('invalid_argument', 'An --item quantity must be 1 or more.', 'Example: --item ETH-250:2.');
413
+ const ck = await shop.createCheckout(await shopOrigin(values), items, lists.code ?? [], values.country);
414
+ return { ...ck, next: checkoutNext(ck) };
415
+ }
416
+ const BUYER_FLAGS = { email: 'email', 'first-name': 'first_name', 'last-name': 'last_name', phone: 'phone_number' };
417
+ const ADDRESS_FLAGS = { street: 'street_address', city: 'address_locality', 'postal-code': 'postal_code', region: 'address_region', country: 'address_country' };
418
+ export async function ucpCheckoutUpdate(id, values, lists) {
419
+ needId(id, 'ucp checkout update');
420
+ const pick = (map) => {
421
+ const out = {};
422
+ for (const [flag, key] of Object.entries(map))
423
+ if (values[flag] !== undefined)
424
+ out[key] = values[flag];
425
+ return Object.keys(out).length ? out : undefined;
426
+ };
427
+ const changes = { buyer: pick(BUYER_FLAGS), address: pick(ADDRESS_FLAGS), items: lists.item?.map(shop.parseItem), add_codes: lists.code, shipping: values.shipping };
428
+ const flagged = Object.values(changes).some((v) => v !== undefined);
429
+ if (!flagged || values.file) {
430
+ // No flags: the changes as YAML or JSON on stdin or -f.
431
+ const raw = await readText(values, 'checkout update', `Give the changes as flags (--email ..., --street ...) or as YAML or JSON on stdin or -f <file>: { buyer: {...}, address: {...}, items, codes, shipping }.`);
432
+ const bad = (msg) => fail('invalid_argument', `checkout update: ${msg}`, 'The input is { buyer: { email, first_name, last_name, phone_number }, address: { street_address, address_locality, postal_code, address_region, address_country }, items: ["SKU:2"], codes: [...], shipping }.');
433
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
434
+ bad('the input is not a map.');
435
+ const r = raw;
436
+ for (const k of Object.keys(r))
437
+ if (!['buyer', 'address', 'items', 'codes', 'shipping'].includes(k))
438
+ bad(`unknown key ${k}.`);
439
+ const map = (o, keys, what) => {
440
+ if (o === undefined)
441
+ return undefined;
442
+ if (!o || typeof o !== 'object' || Array.isArray(o))
443
+ bad(`${what} is not a map.`);
444
+ for (const [k, v] of Object.entries(o))
445
+ if (!keys.includes(k) || typeof v !== 'string')
446
+ bad(`${what}.${k} is not one of ${keys.join(', ')} with a text value.`);
447
+ return o;
448
+ };
449
+ if (r.buyer !== undefined)
450
+ changes.buyer = { ...map(r.buyer, Object.values(BUYER_FLAGS), 'buyer'), ...changes.buyer };
451
+ if (r.address !== undefined)
452
+ changes.address = { ...map(r.address, ['first_name', 'last_name', 'extended_address', ...Object.values(ADDRESS_FLAGS)], 'address'), ...changes.address };
453
+ if (r.items !== undefined) {
454
+ if (!Array.isArray(r.items))
455
+ bad('items is not a list.');
456
+ changes.items = [...r.items.map((x) => (typeof x === 'string' ? shop.parseItem(x) : x && typeof x === 'object' && typeof x.sku === 'string' && Number.isInteger(x.quantity) ? { sku: x.sku, quantity: x.quantity } : bad('items holds "SKU:QTY" or { sku, quantity }.'))), ...(changes.items ?? [])];
457
+ }
458
+ if (r.codes !== undefined) {
459
+ if (!Array.isArray(r.codes) || r.codes.some((c) => typeof c !== 'string'))
460
+ bad('codes is not a list of codes.');
461
+ changes.codes = [...r.codes, ...(changes.add_codes ?? [])];
462
+ }
463
+ if (r.shipping !== undefined)
464
+ changes.shipping = changes.shipping ?? String(r.shipping);
465
+ }
466
+ if (changes.items?.some((l) => !Number.isInteger(l.quantity) || l.quantity < 0))
467
+ fail('invalid_argument', 'An item quantity must be 0 (remove it) or more.', 'Example: --item ETH-250:2.');
468
+ const ck = await shop.updateCheckout(await shopOrigin(values, id), id, changes);
469
+ return { ...ck, next: checkoutNext(ck) };
470
+ }
471
+ export async function ucpCheckoutShow(id, values) {
472
+ const ck = await shop.getCheckout(await shopOrigin(values, needId(id, 'ucp checkout show')), id);
473
+ return { ...ck, next: checkoutNext(ck) };
474
+ }
475
+ export async function ucpCheckoutCancel(id, values) {
476
+ const ck = await shop.cancelCheckout(await shopOrigin(values, needId(id, 'ucp checkout cancel')), id);
477
+ return { ...ck, next: checkoutNext(ck) };
478
+ }
479
+ export async function ucpCheckoutComplete(id, values, flags) {
480
+ const ck = await shop.completeCheckout(await shopOrigin(values, needId(id, 'ucp checkout complete')), id, flags.has('--decline'));
481
+ return { ...ck, payment_handler: 'ai.commercelayer.sandbox (test payments only)', next: checkoutNext(ck) };
482
+ }
483
+ export async function ucpOrder(id, values) {
484
+ if (!id)
485
+ fail('missing_argument', 'ucp order needs an order id.', 'Use order.id from c11r ucp checkout complete.');
486
+ const o = await shop.getOrder(await shopOrigin(values, id), id);
487
+ return { ...o, next: `Tell the human: order ${o.label}, details at ${o.permalink_url}.` };
488
+ }
489
+ export async function ucpAsk(question, values) {
490
+ if (!question.trim())
491
+ fail('missing_argument', 'ucp ask needs a question.', 'Example: c11r ucp ask "Do you ship to Canada?".');
492
+ return shop.ask(await shopOrigin(values), question);
493
+ }
350
494
  // ---------- promotions: live settings in Commerce Layer, not store files
351
- /** The fields list, show and the writes print, in a stable order. */
352
- const READ_ONLY = ['status', 'uses', 'managed'];
353
- /** The spec from -f <file> or stdin, YAML or JSON, validated against store.yml's markets and products. */
354
- async function readSpec(values) {
355
- let text = "";
495
+ /** What list and show print beyond the spec: dropped when it is sent back. */
496
+ const READ_ONLY = ['status', 'uses', 'managed', 'url', 'urls', 'created_at'];
497
+ /**
498
+ * A spec from -f <file> or stdin, YAML or JSON, for promotions and links. What show prints
499
+ * can be edited and sent back: the read-only fields are dropped.
500
+ */
501
+ async function readInput(values, what) {
502
+ const raw = await readText(values, what, `Give the ${what} as YAML or JSON on stdin (c11r ${what}s create <<'EOF' ... EOF) or with -f <file>.`, `invalid_${what}`);
503
+ if (raw && typeof raw === 'object' && !Array.isArray(raw))
504
+ for (const k of READ_ONLY)
505
+ delete raw[k];
506
+ return raw;
507
+ }
508
+ /** YAML or JSON from -f <file> or stdin, parsed. */
509
+ async function readText(values, what, usage, invalid = 'invalid_argument') {
510
+ let text = '';
356
511
  if (values.file) {
357
512
  if (!fs.existsSync(values.file))
358
- fail('missing_argument', `There is no file ${values.file}.`, 'Give the promotion as YAML or JSON with -f <file>, or on stdin.');
513
+ fail('missing_argument', `There is no file ${values.file}.`, `Give the ${what} as YAML or JSON with -f <file>, or on stdin.`);
359
514
  text = fs.readFileSync(values.file, 'utf8');
360
515
  }
361
516
  else if (!process.stdin.isTTY) {
@@ -366,20 +521,21 @@ async function readSpec(values) {
366
521
  text = Buffer.concat(chunks).toString('utf8');
367
522
  }
368
523
  else
369
- fail('missing_argument', 'No promotion given.', "Give the promotion as YAML or JSON on stdin (c11r promotions create <<'EOF' ... EOF) or with -f <file>.");
524
+ fail('missing_argument', `No ${what} given.`, usage);
370
525
  if (!text.trim())
371
- fail('missing_argument', 'No promotion given: the input is empty.', "Give the promotion as YAML or JSON on stdin (c11r promotions create <<'EOF' ... EOF) or with -f <file>.");
526
+ fail('missing_argument', `No ${what} given: the input is empty.`, usage);
372
527
  let raw;
373
528
  try {
374
529
  raw = YAML.parse(text);
375
530
  }
376
531
  catch (e) {
377
- fail('invalid_promotion', `promotion: not valid YAML or JSON: ${e.message.split('\n')[0]}`, 'Fix the syntax and run the command again.');
532
+ fail(invalid, `${what}: not valid YAML or JSON: ${e.message.split('\n')[0]}`, 'Fix the syntax and run the command again.');
378
533
  }
379
- // What show prints can be edited and sent back: the read-only fields are dropped.
380
- if (raw && typeof raw === 'object' && !Array.isArray(raw))
381
- for (const k of READ_ONLY)
382
- delete raw[k];
534
+ return raw;
535
+ }
536
+ /** A promotion spec, validated against store.yml's markets and products. */
537
+ async function readSpec(values) {
538
+ const raw = await readInput(values, 'promotion');
383
539
  const store = loadStore();
384
540
  return checkPromotion(raw, store.markets, store.products);
385
541
  }
@@ -433,3 +589,43 @@ export async function promotionsDelete(id) {
433
589
  await createApi(requireCredentials()).deletePromotion(id);
434
590
  return { status: 'deleted', id };
435
591
  }
592
+ // ---------- links: live settings in Commerce Layer, not store files
593
+ /** A link as list, show and the writes print it: url and urls first. */
594
+ const urlFirst = ({ url, urls, ...l }) => ({ url, urls, ...l });
595
+ const linkNext = (l) => {
596
+ const what = 'order' in l.for ? 'the hosted checkout for the order' : 'a microstore for what it sells';
597
+ const which = Object.keys(l.urls ?? {}).length > 1 ? `the url of each market in urls (url is ${Object.keys(l.urls)[0]}'s)` : 'the url';
598
+ if (l.status === 'pending')
599
+ return `Give the human ${which}: it opens ${what} from ${l.starts}; until then it shows a not-found page.`;
600
+ if (l.status === 'active')
601
+ return `Give the human ${which}: it opens ${what} until ${l.ends}. Payment is manual (wire transfer) unless a gateway is added.`;
602
+ return undefined;
603
+ };
604
+ export async function linksList() {
605
+ const { links } = await createApi(requireCredentials()).listLinks();
606
+ return { count: links.length, links: links.map(urlFirst) };
607
+ }
608
+ export async function linksShow(id) {
609
+ if (!id)
610
+ fail('missing_argument', 'Which link?', 'Use `c11r links show <id>`; `c11r links list` has the ids.');
611
+ return urlFirst(await createApi(requireCredentials()).getLink(id));
612
+ }
613
+ export async function linksCreate(values) {
614
+ const api = createApi(requireCredentials());
615
+ const raw = await readInput(values, 'link');
616
+ const store = loadStore();
617
+ const created = await api.createLink(checkLink(raw, store.markets, store.products));
618
+ return { status: 'created', link: urlFirst(created), next: linkNext(created) };
619
+ }
620
+ export async function linksEnable(id, enabled) {
621
+ if (!id)
622
+ fail('missing_argument', 'Which link?', `Use \`c11r links ${enabled ? 'enable' : 'disable'} <id>\`.`);
623
+ const link = await createApi(requireCredentials()).setLinkEnabled(id, enabled);
624
+ return { status: enabled ? 'enabled' : 'disabled', link: urlFirst(link), next: enabled ? linkNext(link) : undefined };
625
+ }
626
+ export async function linksDelete(id) {
627
+ if (!id)
628
+ fail('missing_argument', 'Which link?', 'Use `c11r links delete <id>`, after asking the human.');
629
+ await createApi(requireCredentials()).deleteLink(id);
630
+ return { status: 'deleted', id };
631
+ }
package/dist/links.js ADDED
@@ -0,0 +1,118 @@
1
+ // Links: shareable URLs that open a hosted microstore for one SKU, a set of SKUs or a
2
+ // category, or the hosted checkout for an order. Live configuration in Commerce Layer, made
3
+ // with c11r links create; never in store.yml. The CLI validates the spec against store.yml
4
+ // (skus, categories, markets) and sends one normalized link; the API makes the CL link.
5
+ import { isoDate } from './promotions.js';
6
+ import { fail } from './output.js';
7
+ const KEYS = ['id', 'name', 'for', 'market', 'markets', 'starts', 'ends', 'params'];
8
+ const FOR = ['sku', 'skus', 'category', 'order'];
9
+ /** The SKUs one link can sell: the API's limit. */
10
+ const MAX_SKUS = 100;
11
+ const SHAPE_HINT = 'See the links skill for the shape: https://commercelayer.ai/skills/links.md';
12
+ const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
13
+ const seconds = (d) => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
14
+ /** One calendar month after a date-time, in UTC. */
15
+ function monthAfter(iso) {
16
+ const d = new Date(iso);
17
+ d.setUTCMonth(d.getUTCMonth() + 1);
18
+ return seconds(d);
19
+ }
20
+ /**
21
+ * Validate one link spec (YAML or JSON, as given to links create) and normalize it to the
22
+ * wire shape, or fail invalid_link naming the path inside the spec. A category becomes its
23
+ * products' skus as they are now, from store.yml.
24
+ */
25
+ export function checkLink(raw, markets, products, now = new Date()) {
26
+ const skus = new Set(products.map((p) => p.sku));
27
+ const bad = (msg, hint, path = 'link') => fail('invalid_link', `${path}: ${msg}`, hint);
28
+ if (!isObj(raw))
29
+ bad('is not a map.', `A link is a map with id, name and for. ${SHAPE_HINT}`);
30
+ const l = raw;
31
+ if (typeof l.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(l.id))
32
+ bad(`id is ${l.id === undefined ? 'missing' : `"${String(l.id)}"`}.`, 'Give the link an id of lowercase letters, digits and dashes, such as summer-bundle: the handle for show, enable, disable and delete.');
33
+ const id = l.id;
34
+ for (const k of Object.keys(l))
35
+ if (!KEYS.includes(k))
36
+ bad(`unknown key ${k}.`, `A link's keys are ${KEYS.join(', ')}.`);
37
+ const link = { id, name: l.name === undefined ? id : String(l.name), for: { sku: '' }, starts: '', ends: '' };
38
+ if (!link.name.trim() || link.name.length > 255)
39
+ bad('name is empty or longer than 255 characters.', 'Write a short name the human will recognize in the dashboard.');
40
+ // for: exactly one of sku, skus, category, order. show prints a category with its skus: those are read again.
41
+ const f = l.for;
42
+ if (!isObj(f))
43
+ bad(f === undefined ? 'has no for.' : 'for is not a map.', `Say what the link sells: for: { sku: <sku> }, { skus: [...] }, { category: "A > B" } or { order: <number> }. ${SHAPE_HINT}`);
44
+ const given = Object.keys(f).filter((k) => !('category' in f && k === 'skus'));
45
+ if (given.includes('sku_list'))
46
+ bad('sku_list is a list made in the dashboard.', 'Name the skus (for: { skus: [...] }) or a category instead.', 'for');
47
+ for (const k of given)
48
+ if (!FOR.includes(k))
49
+ bad(`unknown key ${k}.`, `for takes one of ${FOR.join(', ')}.`, 'for');
50
+ if (given.length !== 1)
51
+ bad(`has ${given.length ? given.join(' and ') : 'nothing'}.`, 'Give exactly one of sku, skus, category or order: a link sells one thing.', 'for');
52
+ const kind = given[0];
53
+ const v = f[kind];
54
+ if (kind === 'sku') {
55
+ if (typeof v !== 'string' || !skus.has(v))
56
+ bad(`${String(v)} is not a product.`, "Use the SKU of one of the store's products.", 'for.sku');
57
+ link.for = { sku: v };
58
+ }
59
+ else if (kind === 'skus') {
60
+ if (!Array.isArray(v) || !v.length)
61
+ bad('is not a list of SKUs.', 'List one or more SKUs: skus: [ETH, COL].', 'for.skus');
62
+ if (v.length > MAX_SKUS)
63
+ bad(`has ${v.length} SKUs.`, `A link sells at most ${MAX_SKUS} SKUs: make several links.`, 'for.skus');
64
+ v.forEach((s, i) => {
65
+ if (typeof s !== 'string' || !skus.has(s))
66
+ bad(`${String(s)} is not a product.`, "Use SKUs of the store's products.", `for.skus[${i}]`);
67
+ });
68
+ link.for = { skus: [...new Set(v)] };
69
+ }
70
+ else if (kind === 'category') {
71
+ const name = String(v).split('>').map((s) => s.trim()).filter(Boolean).join(' > ');
72
+ const found = products.filter((x) => x.category === name || x.category?.startsWith(`${name} > `)).map((x) => x.sku);
73
+ if (!name || !found.length)
74
+ bad(`no product is in category "${String(v)}".`, `Use a category of the store's products, written as in the products: ${[...new Set(products.map((x) => x.category).filter(Boolean))].join(', ') || 'none has a category yet'}.`, 'for.category');
75
+ if (found.length > MAX_SKUS)
76
+ bad(`"${name}" has ${found.length} products.`, `A link sells at most ${MAX_SKUS} SKUs: use a narrower category, or several links.`, 'for.category');
77
+ link.for = { category: name, skus: found.sort() };
78
+ }
79
+ else {
80
+ if (!(typeof v === 'string' && v.trim()) && !(Number.isInteger(v) && v > 0))
81
+ bad(`${String(v)} is not an order number.`, 'Use the number of a draft or pending order, or the id of a UCP cart or checkout (cart_..., chk_...): the order behind it.', 'for.order');
82
+ link.for = { order: String(v).trim().replace(/^#/, '') };
83
+ }
84
+ // Markets: a code or a list, under market or markets; none means every market. An order has its own.
85
+ if (l.market !== undefined && l.markets !== undefined)
86
+ bad('has market and markets.', 'Use one of them.');
87
+ const m = l.market ?? l.markets;
88
+ const path = l.market !== undefined ? 'market' : 'markets';
89
+ if (kind === 'order') {
90
+ if (m !== undefined)
91
+ bad(`${path}: an order link has the order's market.`, 'Remove market: the checkout opens in the market the order was placed in.');
92
+ }
93
+ else if (m === undefined)
94
+ link.markets = markets.map((x) => x.code);
95
+ else {
96
+ const codes = Array.isArray(m) ? m : [m];
97
+ if (!codes.length)
98
+ bad('is empty.', 'List one or more market codes, or leave it out for every market.', path);
99
+ codes.forEach((c, i) => {
100
+ if (!markets.some((x) => x.code === c))
101
+ bad(`${String(c)} is not in markets.`, `Use the code of one of the store's markets: ${markets.map((x) => x.code).join(', ')}.`, Array.isArray(m) ? `${path}[${i}]` : path);
102
+ });
103
+ link.markets = [...new Set(codes)];
104
+ }
105
+ link.starts = l.starts === undefined ? seconds(now) : isoDate(l.starts, false, (msg, h) => bad(`starts ${msg}`, h));
106
+ link.ends = l.ends === undefined ? monthAfter(link.starts) : isoDate(l.ends, true, (msg, h) => bad(`ends ${msg}`, h));
107
+ if (Date.parse(link.ends) <= Date.parse(link.starts))
108
+ bad('ends is not after starts.', 'Make the end date later than the start date.');
109
+ if (l.params !== undefined) {
110
+ if (!isObj(l.params))
111
+ bad('params is not a map.', 'params is a map of names to text, numbers or true/false, added to the link URL.');
112
+ for (const [k, p] of Object.entries(l.params))
113
+ if (!['string', 'number', 'boolean'].includes(typeof p))
114
+ bad(`${k} is not text, a number or true/false.`, 'params values are plain values, added to the link URL.', `params.${k}`);
115
+ link.params = l.params;
116
+ }
117
+ return link;
118
+ }
@@ -17,7 +17,7 @@ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
17
17
  const isInt = (v, min) => Number.isInteger(v) && v >= min;
18
18
  const list = (v) => (Array.isArray(v) ? v : [v]);
19
19
  /** A date or a date-time, as ISO UTC. A bare day starts at 00:00:00Z and ends at 23:59:59Z. */
20
- function isoDate(v, end, bad) {
20
+ export function isoDate(v, end, bad) {
21
21
  const s = v instanceof Date ? v.toISOString().replace(/\.000Z$/, 'Z') : String(v);
22
22
  const hint = 'Write a day, 2026-12-01, or a time in UTC, 2026-12-01T09:00:00Z.';
23
23
  if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
@@ -3,10 +3,13 @@
3
3
  // top-level category), styles.css (theme tokens first, then the drop-in.css
4
4
  // overrides, then the page) and app.js (market switch, category links, and a
5
5
  // product page at #/p/<sku>). Product images are the store's, at /img/<file> (from
6
- // .commercelayer/img/, uploaded by apply). The agent then makes it the human's.
6
+ // .commercelayer/img/, uploaded by apply); a product without one shows the tile the
7
+ // store host draws at /tiles/<sku>.svg. The agent then makes it the human's.
7
8
  import { isLocalImage } from './store.js';
8
9
  /** img/<file> is the store's image at /img/<file> on the store host, whatever page links it. */
9
10
  const imageSrc = (url) => (isLocalImage(url) ? `/${url}` : url);
11
+ /** A product without an image gets the tile the store host draws for it, at /tiles/<sku>.svg. */
12
+ const productImage = (p) => (p.image_url ? imageSrc(p.image_url) : `/tiles/${encodeURIComponent(p.sku)}.svg`);
10
13
  const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
11
14
  /** The scaffold's files, by path relative to the storefront folder. */
12
15
  export function storefrontFiles(store, clientId) {
@@ -17,7 +20,7 @@ function indexHtml(store, clientId) {
17
20
  const card = (p, h) => {
18
21
  const href = `#/p/${encodeURIComponent(p.sku)}`;
19
22
  return ` <article class="product" id="p-${esc(p.sku)}">
20
- <a class="media" href="${esc(href)}" tabindex="-1">${p.image_url ? `<img src="${esc(imageSrc(p.image_url))}" alt="${esc(p.name)}">` : '<div class="placeholder" aria-hidden="true"></div>'}</a>
23
+ <a class="media" href="${esc(href)}" tabindex="-1"><img src="${esc(productImage(p))}" alt="${esc(p.name)}"${p.image_url ? '' : ' loading="lazy"'}></a>
21
24
  <${h}><a href="${esc(href)}">${esc(p.name)}</a></${h}>
22
25
  ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-price code="${esc(p.sku)}"><cl-price-amount type="compare-at"></cl-price-amount> <cl-price-amount type="price"></cl-price-amount></cl-price>
23
26
  <cl-availability code="${esc(p.sku)}"><cl-availability-status type="available">In stock</cl-availability-status><cl-availability-status type="unavailable">Sold out</cl-availability-status></cl-availability>
@@ -53,7 +56,7 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
53
56
  }
54
57
  const options = store.markets.map((m) => ` <option value="${esc(m.code)}">${esc(m.name)} (${esc(m.currency)})</option>`).join('\n');
55
58
  // What the product page shows, by sku. Prices and availability come live from drop-in.js.
56
- const data = Object.fromEntries(store.products.map((p) => [p.sku, { name: p.name, description: p.description, image_url: p.image_url && imageSrc(p.image_url), category: p.category }]));
59
+ const data = Object.fromEntries(store.products.map((p) => [p.sku, { name: p.name, description: p.description, image_url: productImage(p), category: p.category }]));
57
60
  const json = JSON.stringify(data, null, 2).replace(/</g, '\\u003c');
58
61
  return `<!doctype html>
59
62
  <html lang="en">
@@ -74,7 +74,7 @@ export function agentProfile() {
74
74
  },
75
75
  },
76
76
  name: 'Commerce Layer agent CLI',
77
- description: 'c11r, the Commerce Layer CLI for coding agents, acting as a shopping platform: it places test orders and checks a store speaks UCP.',
77
+ description: 'The Commerce Layer agent CLI: a shopping agent that buys from UCP stores on behalf of a human, places test orders in its own store and checks that a store speaks UCP.',
78
78
  };
79
79
  }
80
80
  /** Minor units per currency unit (ISO 4217 exponent). */
@@ -0,0 +1,308 @@
1
+ // Buying from a store step by step, for an agent shopping on behalf of its human: search,
2
+ // look at a product, open a checkout, give the buyer's details, pay, read the order. Each
3
+ // step is one command, so the agent talks to the human in between. The results are trimmed
4
+ // for a conversation: prices in major units as well as minor, what is still missing.
5
+ // buy.ts is the one-shot test buyer; this is the real one, with the human's details.
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fail } from '../output.js';
10
+ import { discover, replay, shopping, UcpError } from './client.js';
11
+ import { sandboxInstrument } from './buy.js';
12
+ import { minorUnits, SANDBOX_HANDLER } from './platform.js';
13
+ // ---------- which store an id belongs to
14
+ /** Checkout and order ids, by the store origin they were made at: later steps need no --store. */
15
+ const stateFile = () => process.env.C11R_UCP_STATE ?? path.join(process.env.XDG_STATE_HOME ?? path.join(os.homedir(), '.local', 'state'), 'c11r', 'ucp.json');
16
+ const MAX_REMEMBERED = 200;
17
+ function readState() {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
20
+ }
21
+ catch {
22
+ return {};
23
+ }
24
+ }
25
+ export function remember(id, origin) {
26
+ const s = readState();
27
+ delete s[id];
28
+ s[id] = origin;
29
+ const keys = Object.keys(s);
30
+ for (const k of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED)))
31
+ delete s[k];
32
+ fs.mkdirSync(path.dirname(stateFile()), { recursive: true });
33
+ fs.writeFileSync(stateFile(), JSON.stringify(s, null, 2) + '\n', { mode: 0o600 });
34
+ }
35
+ export const remembered = (id) => readState()[id];
36
+ /** A store origin from --store: an origin, a URL on the store, or a bare hostname. */
37
+ export function originOf(given) {
38
+ let url;
39
+ try {
40
+ url = new URL(/^[a-z]+:\/\//i.test(given) ? given : `https://${given}`);
41
+ }
42
+ catch {
43
+ fail('invalid_argument', `--store ${given} is not a URL or hostname.`, 'Give the store as a hostname (shop.example.com) or an origin (https://shop.example.com).');
44
+ }
45
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)))
46
+ fail('invalid_argument', `--store ${given} is not https.`, 'UCP stores are served over https: give https://<host>.');
47
+ return url.origin;
48
+ }
49
+ export function money(amount, currency) {
50
+ const minor = minorUnits(currency);
51
+ const decimals = Math.round(Math.log10(minor));
52
+ const major = amount / minor;
53
+ return { amount, currency, major, display: `${amount < 0 ? '-' : ''}${currency} ${Math.abs(major).toFixed(decimals)}` };
54
+ }
55
+ const plain = (d) => d?.plain ?? d?.markdown ?? d?.html;
56
+ function variantView(v) {
57
+ const shipping = v.fulfillment?.methods?.map((m) => plain(m.description)).filter(Boolean).join('; ');
58
+ return {
59
+ id: v.id,
60
+ sku: v.sku ?? v.id,
61
+ title: v.title,
62
+ price: v.price ? money(v.price.amount, v.price.currency) : undefined,
63
+ available: v.availability?.available ?? undefined,
64
+ availability: v.availability?.status ?? undefined,
65
+ image_url: v.media?.find((m) => m.type === 'image')?.url,
66
+ ships: shipping || undefined,
67
+ };
68
+ }
69
+ export function productView(p, full = false) {
70
+ const variants = (p.variants ?? []).map(variantView);
71
+ const description = plain(p.description);
72
+ const image = p.media?.find((m) => m.type === 'image')?.url ?? variants[0]?.image_url;
73
+ return {
74
+ id: p.id,
75
+ title: p.title,
76
+ description: full || !description || description.length <= 200 ? description : `${description.slice(0, 197)}...`,
77
+ url: p.url,
78
+ image_url: image,
79
+ // A search lists a variant's image only when it is not the product's.
80
+ variants: full ? variants : variants.map((v) => ({ ...v, image_url: v.image_url === image ? undefined : v.image_url })),
81
+ };
82
+ }
83
+ const sign = (c) => c.signature ?? 'unsigned';
84
+ function selectedDestination(ck) {
85
+ const m = ck.fulfillment?.methods?.[0];
86
+ return m?.destinations?.find((d) => d.id === m.selected_destination_id);
87
+ }
88
+ /** A checkout for the conversation: what is in it, what it costs, what is still missing. */
89
+ export function checkoutView(ck, origin, call) {
90
+ const cur = ck.currency;
91
+ const method = ck.fulfillment?.methods?.[0];
92
+ const group = method?.groups?.[0];
93
+ const options = group?.options ?? [];
94
+ const option = options.find((o) => o.id === group?.selected_option_id);
95
+ const optionView = (o) => ({ id: o.id, title: o.title, description: plain(o.description), price: o.totals?.find((t) => t.type === 'total') ? money(o.totals.find((t) => t.type === 'total').amount, cur) : undefined });
96
+ const { id: _id, type: _type, ...address } = selectedDestination(ck) ?? {};
97
+ const total = (ck.totals ?? []).find((t) => t.type === 'total');
98
+ return {
99
+ id: ck.id,
100
+ status: ck.status,
101
+ store: origin,
102
+ currency: cur,
103
+ line_items: (ck.line_items ?? []).map((l) => ({
104
+ id: l.id,
105
+ sku: l.item?.id,
106
+ title: l.item?.title,
107
+ quantity: l.quantity,
108
+ unit_price: l.item?.price === undefined ? undefined : money(l.item.price, cur),
109
+ total: money((l.totals ?? []).find((t) => t.type === 'total')?.amount ?? 0, cur),
110
+ })),
111
+ buyer: ck.buyer ? Object.fromEntries(Object.entries(ck.buyer).filter(([k]) => k !== 'consent')) : undefined,
112
+ ship_to: Object.keys(address).length ? address : undefined,
113
+ shipping: option ? optionView(option) : undefined,
114
+ shipping_options: options.length > 1 ? options.map(optionView) : undefined,
115
+ discounts: ck.discounts?.applied?.length ? ck.discounts.applied.map((d) => ({ code: d.code, title: d.title, automatic: d.automatic, amount: money(d.amount, cur) })) : undefined,
116
+ codes: ck.discounts?.codes?.length ? ck.discounts.codes : undefined,
117
+ totals: (ck.totals ?? []).map((t) => ({ type: t.type, label: t.display_text, ...money(t.amount, cur) })),
118
+ total: total ? money(total.amount, cur) : undefined,
119
+ messages: ck.messages,
120
+ order: ck.order,
121
+ continue_url: ck.continue_url,
122
+ expires_at: ck.expires_at,
123
+ links: ck.links,
124
+ signature: sign(call),
125
+ };
126
+ }
127
+ export function orderView(o, origin, call) {
128
+ const cur = o.currency;
129
+ return {
130
+ id: o.id,
131
+ label: o.label,
132
+ permalink_url: o.permalink_url,
133
+ store: origin,
134
+ checkout_id: o.checkout_id,
135
+ currency: cur,
136
+ line_items: (o.line_items ?? []).map((l) => ({
137
+ sku: l.item?.id,
138
+ title: l.item?.title,
139
+ quantity: l.quantity?.total ?? l.quantity,
140
+ status: l.status,
141
+ total: money((l.totals ?? []).find((t) => t.type === 'total')?.amount ?? 0, cur),
142
+ })),
143
+ fulfillment: (o.fulfillment?.expectations ?? []).map((e) => ({ method: e.method_type, destination: e.destination, description: e.description })),
144
+ events: o.fulfillment?.events?.length ? o.fulfillment.events : undefined,
145
+ totals: (o.totals ?? []).map((t) => ({ type: t.type, label: t.display_text, ...money(t.amount, cur) })),
146
+ signature: sign(call),
147
+ };
148
+ }
149
+ // ---------- the steps
150
+ /** Turn a store's refusal into a CLI error; a missing checkout or order is not_found. */
151
+ async function step(origin, run) {
152
+ try {
153
+ return await run();
154
+ }
155
+ catch (e) {
156
+ if (!(e instanceof UcpError))
157
+ throw e;
158
+ const code = e.call.body?.messages?.[0]?.code;
159
+ if (code === 'not_found' || e.call.status === 404)
160
+ fail('not_found', e.message, `The store at ${origin} does not know it. Check the id, or pass --store <the store it was made at>.`);
161
+ if (code === 'identity_required' || e.call.status === 401)
162
+ fail('identity_required', e.message, 'Orders are readable by the platform that placed them: this CLI can only read orders it placed.');
163
+ fail('ucp_error', e.message, `The store at ${origin} refused it: tell the human what it said. The skus it sells are in c11r ucp search.`);
164
+ }
165
+ }
166
+ /** --item SKU or SKU:QTY. */
167
+ export function parseItem(raw) {
168
+ const m = String(raw).match(/^(.+?)(?::(\d+))?$/);
169
+ if (!m || !m[1].trim())
170
+ fail('invalid_argument', `--item ${raw} is not SKU or SKU:QUANTITY.`, 'Example: --item ETH-250:2 (quantity 1 when left out, 0 to remove it in checkout update).');
171
+ return { sku: m[1].trim(), quantity: m[2] === undefined ? 1 : Number(m[2]) };
172
+ }
173
+ export async function open(origin) {
174
+ const store = await discover(origin);
175
+ if (!store.capabilities.has('dev.ucp.shopping.checkout'))
176
+ fail('checkout_unsupported', `The store at ${origin} does not offer UCP checkout to this CLI.`, 'It can only be browsed: give the human the store URL to buy there.');
177
+ return store;
178
+ }
179
+ export async function search(origin, query, country) {
180
+ const store = await discover(origin);
181
+ const body = { query, pagination: { limit: 50 } };
182
+ if (country)
183
+ body.context = { address_country: country.toUpperCase() };
184
+ const call = await step(origin, () => shopping.search(store, body));
185
+ const r = call.body;
186
+ const products = r.products ?? [];
187
+ return {
188
+ store: origin,
189
+ query,
190
+ count: products.length,
191
+ total_count: r.pagination?.total_count,
192
+ has_more: r.pagination?.has_next_page || undefined,
193
+ products: products.map((p) => productView(p)),
194
+ messages: r.messages,
195
+ signature: sign(call),
196
+ };
197
+ }
198
+ export async function product(origin, id, country) {
199
+ const store = await discover(origin);
200
+ const body = { id };
201
+ if (country)
202
+ body.context = { address_country: country.toUpperCase() };
203
+ const call = await step(origin, () => shopping.product(store, body));
204
+ return { store: origin, product: productView(call.body.product, true), signature: sign(call) };
205
+ }
206
+ export async function createCheckout(origin, items, codes, country) {
207
+ const store = await open(origin);
208
+ const body = { line_items: items.map((l) => ({ item: { id: l.sku }, quantity: l.quantity })) };
209
+ if (country)
210
+ body.context = { address_country: country.toUpperCase() };
211
+ if (codes.length)
212
+ body.discounts = { codes };
213
+ const call = await step(origin, () => shopping.createCheckout(store, body));
214
+ remember(call.body.id, origin);
215
+ return checkoutView(call.body, origin, call);
216
+ }
217
+ export async function getCheckout(origin, id) {
218
+ const store = await open(origin);
219
+ const call = await step(origin, () => shopping.getCheckout(store, id));
220
+ return checkoutView(call.body, origin, call);
221
+ }
222
+ const clean = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== ''));
223
+ export async function updateCheckout(origin, id, ch) {
224
+ const store = await open(origin);
225
+ const current = (await step(origin, () => shopping.getCheckout(store, id))).body;
226
+ if (current.status === 'completed' || current.status === 'canceled')
227
+ fail('checkout_closed', `The checkout ${id} is ${current.status}.`, current.status === 'completed' ? `It is paid: c11r ucp order ${current.order?.id ?? '<id>'}.` : 'Open a new one: c11r ucp checkout create --item <sku>.');
228
+ const body = replay(current);
229
+ if (ch.buyer)
230
+ body.buyer = clean({ ...(current.buyer ?? {}), ...ch.buyer });
231
+ for (const l of ch.items ?? []) {
232
+ const i = body.line_items.findIndex((x) => x.item.id === l.sku);
233
+ if (l.quantity === 0) {
234
+ if (i >= 0)
235
+ body.line_items.splice(i, 1);
236
+ }
237
+ else if (i >= 0)
238
+ body.line_items[i].quantity = l.quantity;
239
+ else
240
+ body.line_items.push({ item: { id: l.sku }, quantity: l.quantity });
241
+ }
242
+ if (!body.line_items.length)
243
+ fail('invalid_argument', 'The update leaves the checkout with no items.', `Keep at least one item, or cancel it: c11r ucp checkout cancel ${id}.`);
244
+ const codes = ch.codes ?? [...(current.discounts?.codes ?? []), ...(ch.add_codes ?? [])];
245
+ if (ch.codes || ch.add_codes?.length)
246
+ body.discounts = { codes: [...new Set(codes)] };
247
+ if (ch.address || ch.shipping) {
248
+ const method = body.fulfillment?.methods?.[0] ?? { type: 'shipping', line_item_ids: (current.line_items ?? []).map((l) => l.id) };
249
+ if (ch.address) {
250
+ const prev = selectedDestination(current) ?? {};
251
+ const buyer = (body.buyer ?? {});
252
+ const dest = clean({ ...prev, first_name: buyer.first_name, last_name: buyer.last_name, ...ch.address, id: prev.id ?? 'dest_1' });
253
+ if (dest.address_country)
254
+ dest.address_country = String(dest.address_country).toUpperCase();
255
+ delete dest.type;
256
+ method.destinations = [dest];
257
+ method.selected_destination_id = dest.id;
258
+ }
259
+ if (ch.shipping)
260
+ method.groups = (method.groups?.length ? method.groups : [{ id: 'grp_1' }]).map((g) => ({ ...g, selected_option_id: ch.shipping }));
261
+ body.fulfillment = { methods: [method, ...(body.fulfillment?.methods?.slice(1) ?? [])] };
262
+ }
263
+ let call = await step(origin, () => shopping.updateCheckout(store, id, body));
264
+ // A store that offers shipping options without picking one: take its first, and say which in the result.
265
+ const group = call.body.fulfillment?.methods?.[0]?.groups?.[0];
266
+ if (group && !group.selected_option_id && group.options?.length) {
267
+ const again = replay(call.body);
268
+ again.fulfillment.methods[0].groups[0].selected_option_id = group.options[0].id;
269
+ call = await step(origin, () => shopping.updateCheckout(store, id, again));
270
+ }
271
+ // A store can answer ready_for_complete before the shipping is in the totals (seen once on a
272
+ // Commerce Layer store): read it again rather than show the human a total without shipping.
273
+ const ck = call.body;
274
+ if (ck.status === 'ready_for_complete' && ck.fulfillment?.methods?.[0]?.groups?.[0]?.selected_option_id && !(ck.totals ?? []).some((t) => t.type === 'fulfillment')) {
275
+ call = await step(origin, () => shopping.getCheckout(store, id));
276
+ }
277
+ return checkoutView(call.body, origin, call);
278
+ }
279
+ export async function cancelCheckout(origin, id) {
280
+ const store = await open(origin);
281
+ const call = await step(origin, () => shopping.cancelCheckout(store, id));
282
+ return checkoutView(call.body, origin, call);
283
+ }
284
+ export async function completeCheckout(origin, id, decline) {
285
+ const store = await open(origin);
286
+ if (!store.profile.ucp?.payment_handlers?.[SANDBOX_HANDLER]) {
287
+ const handlers = Object.keys(store.profile.ucp?.payment_handlers ?? {});
288
+ fail('payment_handler_unsupported', `The store at ${origin} takes ${handlers.length ? handlers.join(', ') : 'no payment handler this CLI knows'}; this CLI can only pay sandbox stores (${SANDBOX_HANDLER}) today.`, `Give the human the checkout's continue_url to pay there: c11r ucp checkout show ${id}.`);
289
+ }
290
+ const call = await step(origin, () => shopping.completeCheckout(store, id, { payment: { instruments: [sandboxInstrument(decline)] }, signals: { 'dev.ucp.user_agent': 'c11r' } }));
291
+ const ck = call.body;
292
+ if (ck.order?.id)
293
+ remember(ck.order.id, origin);
294
+ return checkoutView(ck, origin, call);
295
+ }
296
+ export async function getOrder(origin, id) {
297
+ const store = await discover(origin);
298
+ const call = await step(origin, () => shopping.getOrder(store, id));
299
+ return orderView(call.body, origin, call);
300
+ }
301
+ export async function ask(origin, question) {
302
+ const store = await discover(origin);
303
+ if (!store.capabilities.has('dev.ucp.common.ask'))
304
+ fail('ask_unsupported', `The store at ${origin} does not answer questions over UCP.`, 'Search its catalog instead: c11r ucp search <query>.');
305
+ const call = await step(origin, () => shopping.ask(store, { query: question }));
306
+ const r = call.body;
307
+ return { store: origin, question, answer: plain(r.answer), links: r.links, messages: r.messages, signature: sign(call) };
308
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [