@commercelayer/agent-cli 0.3.0 → 0.3.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 +10 -5
- package/dist/api/stub/management.js +6 -2
- package/dist/api/stub/shopping.js +2 -1
- package/dist/api/stub/theme.js +4 -3
- package/dist/api/stub/ucp.js +11 -2
- package/dist/commands.js +16 -15
- package/dist/store.js +31 -2
- package/dist/ucp/shop.js +13 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,10 +32,10 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
|
|
|
32
32
|
| `orders show <id or number>` | One order. |
|
|
33
33
|
| `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
|
|
34
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 <
|
|
36
|
-
| `ucp checkout update <id> [--email …] [--street …] [--item <
|
|
35
|
+
| `ucp checkout create --item <variant id>[:<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 <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
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
|
|
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 store that speaks UCP. |
|
|
39
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. |
|
|
40
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. |
|
|
41
41
|
| `claim` | Start the device flow that keeps the sandbox: a URL and a code for the human. |
|
|
@@ -69,6 +69,9 @@ theme:
|
|
|
69
69
|
logo: img/logo.svg # or an https:// URL; default: the name as a wordmark
|
|
70
70
|
hero: img/hero.jpg # the home page's wide image; default: a band in the accent
|
|
71
71
|
tagline: Small-batch coffee, roasted in Milan # at most 120 characters; default: "Ships to <countries>"
|
|
72
|
+
badge: Demo # a short label next to the logo, at most 20 characters
|
|
73
|
+
storefront:
|
|
74
|
+
mode: hosted # default; agent-only: only UCP and the pages it needs, no catalog pages
|
|
72
75
|
policies:
|
|
73
76
|
# Pages shopping agents show buyers before they pay. Without them the store
|
|
74
77
|
# serves default sandbox pages at <preview_url>/policies/<name>.
|
|
@@ -134,9 +137,11 @@ The theme renders the home page (logo, hero with name and tagline, products by t
|
|
|
134
137
|
|
|
135
138
|
One rule for images: `img/<file>` anywhere in `store.yml` (product `image_url`, `theme.logo`, `theme.hero`) is `.commercelayer/img/<file>`, uploaded by `apply`, served at `/img/<file>`, whatever is in front.
|
|
136
139
|
|
|
140
|
+
`storefront.mode: agent-only` makes a store for shopping agents only: the store host serves UCP and the pages UCP needs (checkout, order, policy pages, `/buy` permalinks, images), a one-line home page pointing to `/.well-known/ucp`, and no catalog or product pages (`/products/…` and `/categories/…` redirect to `/`, so links shared earlier land on the agent page), nor a custom storefront. Removing it, or `mode: hosted`, brings the website back on the next `apply`.
|
|
141
|
+
|
|
137
142
|
A custom storefront replaces the theme: `storefront.path` in `store.yml` names its folder, `storefront init` writes a drop-in.js scaffold, `storefront publish` uploads it, `storefront check` checks it. Its pages link product images at `/img/`; its own files (fonts, icons, styles) go in `assets/`, not `img/`.
|
|
138
143
|
|
|
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`.
|
|
144
|
+
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`.
|
|
140
145
|
## The store speaks UCP
|
|
141
146
|
Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
|
|
142
147
|
| What | Where |
|
|
@@ -153,7 +158,7 @@ Transport rules the store enforces: `UCP-Agent` (an HTTPS platform profile URL)
|
|
|
153
158
|
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
159
|
### Buying for the human
|
|
155
160
|
|
|
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 <
|
|
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`.
|
|
157
162
|
|
|
158
163
|
## Requirements
|
|
159
164
|
|
|
@@ -7,7 +7,7 @@ import { publicOrders, readDb, SANDBOX_DOMAIN, writeDb } from './db.js';
|
|
|
7
7
|
import { bearer, error, json, readJson } from './http.js';
|
|
8
8
|
function publicState(r) {
|
|
9
9
|
const { token: _token, ucp: _ucp, pending: _pending, asset_files: _files, ...state } = r;
|
|
10
|
-
return { ...state, name: r.name ?? null, policies: r.policies ?? null, theme: r.theme ?? null, assets: r.assets ?? {}, apply: r.apply ?? null, orders: publicOrders(r) };
|
|
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) };
|
|
11
11
|
}
|
|
12
12
|
/** Like Commerce Layer refusing a sku in an import. Only the stub does this, so tests can exercise apply_failed. */
|
|
13
13
|
const REFUSED = /^REFUSED-/;
|
|
@@ -166,7 +166,9 @@ export function handleManagement(req) {
|
|
|
166
166
|
writeDb(db);
|
|
167
167
|
if (record.apply?.status === 'in_progress')
|
|
168
168
|
return error(409, 'apply_in_progress', 'Another apply is still running.', 'Run `c11r status` to follow it, then apply again.');
|
|
169
|
-
const { applied, hash, name, policies, theme, assets, allow_delete, full } = readJson(req);
|
|
169
|
+
const { applied, hash, name, policies, theme, storefront_mode, assets, allow_delete, full } = readJson(req);
|
|
170
|
+
if (storefront_mode != null && storefront_mode !== 'hosted' && storefront_mode !== 'agent-only')
|
|
171
|
+
return error(422, 'invalid_storefront', `storefront_mode is "${storefront_mode}".`, 'Use hosted or agent-only.');
|
|
170
172
|
const before = record.applied?.products ?? {};
|
|
171
173
|
const removed = Object.keys(before).filter((sku) => !(sku in applied.products));
|
|
172
174
|
if (removed.length && !allow_delete)
|
|
@@ -177,6 +179,8 @@ export function handleManagement(req) {
|
|
|
177
179
|
record.policies = policies ?? {};
|
|
178
180
|
if (theme !== undefined)
|
|
179
181
|
record.theme = theme;
|
|
182
|
+
// Absent or null is hosted, like the API: removing storefront: from store.yml reverts it.
|
|
183
|
+
record.storefront_mode = (storefront_mode ?? 'hosted');
|
|
180
184
|
// Images not in the list are no longer used: dropped now. No list (older CLIs): nothing is dropped.
|
|
181
185
|
if (assets) {
|
|
182
186
|
for (const p of Object.keys(record.assets ?? {})) {
|
|
@@ -96,7 +96,8 @@ function variant(ctx, p, market) {
|
|
|
96
96
|
sku: p.sku,
|
|
97
97
|
title: p.name,
|
|
98
98
|
description: { plain: p.description ?? p.name },
|
|
99
|
-
|
|
99
|
+
// Agent-only stores have no product pages to link to.
|
|
100
|
+
url: ctx.record.storefront_mode === 'agent-only' ? undefined : `${ctx.origin}/products/${encodeURIComponent(p.sku)}`,
|
|
100
101
|
price,
|
|
101
102
|
// Inventory lives in Commerce Layer; the sandbox catalog has no stock, so everything is in stock.
|
|
102
103
|
availability: { available: true, status: 'in_stock' },
|
package/dist/api/stub/theme.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// at /products/<sku>. Prices come from what is applied; "Buy" opens the cart permalink.
|
|
5
5
|
// A product without an image shows its tile, and an unset hero the generated one, both
|
|
6
6
|
// drawn at /tiles/ (renderTile) as the store host does.
|
|
7
|
-
const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
7
|
+
export const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
8
8
|
/** Black or white, whichever reads better on the accent. */
|
|
9
9
|
function onAccent(hex) {
|
|
10
10
|
const h = hex.length === 4 ? hex.slice(1).replace(/./g, (c) => c + c) : hex.slice(1);
|
|
@@ -21,7 +21,8 @@ export function renderTheme(r, path, search) {
|
|
|
21
21
|
const keep = market && market !== markets[0] ? `?market=${encodeURIComponent(market.code)}` : '';
|
|
22
22
|
const price = (p) => (market && p.prices[market.currency] !== undefined ? `${market.currency} ${p.prices[market.currency].toFixed(2)}` : '');
|
|
23
23
|
const tagline = theme.tagline ?? `Ships to ${markets.flatMap((m) => m.countries).join(', ')}`;
|
|
24
|
-
const
|
|
24
|
+
const badge = theme.badge ? ` <span class="badge">${esc(theme.badge)}</span>` : '';
|
|
25
|
+
const logo = (theme.logo ? `<img class="logo" src="/${esc(theme.logo.replace(/^\//, ''))}" alt="${esc(name)}">` : `<span class="wordmark">${esc(name)}</span>`) + badge;
|
|
25
26
|
const img = (url) => (/^https?:/.test(url) ? url : `/${url}`);
|
|
26
27
|
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
28
|
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>`;
|
|
@@ -54,7 +55,7 @@ export function renderTheme(r, path, search) {
|
|
|
54
55
|
<title>${esc(title)}</title>
|
|
55
56
|
<meta name="description" content="${esc(tagline)}">
|
|
56
57
|
<!-- c11r stub: a stand-in for the hosted theme. -->
|
|
57
|
-
<style>:root{--accent:${color};--on-accent:${onAccent(color)}}body{margin:0;font-family:system-ui,sans-serif;color:#16181d}header{display:flex;justify-content:space-between;padding:16px 24px}.logo{max-height:40px}.hero{padding:64px 24px;background-size:cover}.band{background:var(--accent);color:var(--on-accent)}.grid{display:grid;gap:24px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}.buy{background:var(--accent);color:var(--on-accent);padding:8px 14px}main{padding:24px}</style>
|
|
58
|
+
<style>:root{--accent:${color};--on-accent:${onAccent(color)}}body{margin:0;font-family:system-ui,sans-serif;color:#16181d}header{display:flex;justify-content:space-between;padding:16px 24px}.logo{max-height:40px}.badge{margin-left:8px;padding:2px 8px;border-radius:999px;background:var(--accent);color:var(--on-accent);font-size:12px;vertical-align:middle}.hero{padding:64px 24px;background-size:cover}.band{background:var(--accent);color:var(--on-accent)}.grid{display:grid;gap:24px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}.buy{background:var(--accent);color:var(--on-accent);padding:8px 14px}main{padding:24px}</style>
|
|
58
59
|
</head>
|
|
59
60
|
<body>
|
|
60
61
|
<header><a href="/${keep}">${logo}</a><nav>${markets.map((x) => `<a href="?market=${encodeURIComponent(x.code)}">${esc(x.name)}</a>`).join(' ')}</nav></header>
|
package/dist/api/stub/ucp.js
CHANGED
|
@@ -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, renderTile } from './theme.js';
|
|
11
|
+
import { esc, 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
|
|
@@ -308,8 +308,15 @@ function serveStorefront(r, path, search) {
|
|
|
308
308
|
// /tiles/ is the store host's own, in front of a custom storefront too: unknown tiles are a 404.
|
|
309
309
|
if (path.startsWith('/tiles/'))
|
|
310
310
|
return renderTile(r, path) ?? html(404, 'Not found', `<p>No ${path} on this store.</p>`);
|
|
311
|
+
// Agent-only: no pages for humans beyond what UCP needs; the images stay, UCP's media point at them.
|
|
312
|
+
const agentOnly = r.storefront_mode === 'agent-only';
|
|
313
|
+
// Links shared while the store was hosted land on the agent page.
|
|
314
|
+
if (agentOnly && /^\/(products|categories)\/./.test(path))
|
|
315
|
+
return { status: 302, headers: { location: '/' }, body: '' };
|
|
316
|
+
if (agentOnly && path === '/')
|
|
317
|
+
return html(200, r.name ?? r.org, `<p>${esc(r.name ?? r.org)} sells to shopping agents over UCP: <a href="/.well-known/ucp">/.well-known/ucp</a>.</p>`);
|
|
311
318
|
// A custom storefront (an uploaded index.html) serves every path; otherwise the theme renders its routes.
|
|
312
|
-
if (files['index.html'] === undefined) {
|
|
319
|
+
if (!agentOnly && files['index.html'] === undefined) {
|
|
313
320
|
const page = renderTheme(r, path, search);
|
|
314
321
|
if (page)
|
|
315
322
|
return page;
|
|
@@ -320,6 +327,8 @@ function serveStorefront(r, path, search) {
|
|
|
320
327
|
const ext = path.split('.').pop() ?? '';
|
|
321
328
|
return { status: 200, headers: { 'content-type': MIME[ext] ?? 'application/octet-stream' }, body: Buffer.from(asset, 'base64').toString('binary') };
|
|
322
329
|
}
|
|
330
|
+
if (agentOnly)
|
|
331
|
+
return html(404, 'Not found', `<p>This store has no pages for ${esc(path)}: it sells over UCP.</p>`);
|
|
323
332
|
const candidates = [path.replace(/^\//, ''), `${path.replace(/^\/|\/$/g, '')}/index.html`.replace(/^\//, ''), ...(path === '/' ? ['index.html'] : [])];
|
|
324
333
|
for (const c of candidates) {
|
|
325
334
|
if (files[c] !== undefined) {
|
package/dist/commands.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { createApi } from './api/client.js';
|
|
7
7
|
import { ignoreCredentials, paths, readCredentials, requireCredentials, writeAgentNotes, writeCredentials } from './files.js';
|
|
8
8
|
import { CliError, fail, progress } from './output.js';
|
|
9
|
-
import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from './store.js';
|
|
9
|
+
import { diff, hashStore, loadStore, storefrontMode, storefrontPath, template, toApplied } from './store.js';
|
|
10
10
|
import { storefrontFiles } from './storefront.js';
|
|
11
11
|
import { imageNotes, planAssets, uploadAssets } from './assets.js';
|
|
12
12
|
import { pull } from './catalog.js';
|
|
@@ -24,7 +24,7 @@ export const VERSION = createRequire(import.meta.url)('../package.json').version
|
|
|
24
24
|
export const HELP = {
|
|
25
25
|
init: 'Create a sandbox store for this repo, with no login: c11r init --name "<business name>" (the folder name when --name is absent). The name becomes the organization\'s name, and the org slug and store hostname derive from it. Writes .commercelayer/store.yml and .commercelayer/credentials, and a section in AGENTS.md and CLAUDE.md (created, or appended once) that points later agents in this repo to the skills: commit them. Reuses the store if store.yml already exists.',
|
|
26
26
|
plan: 'Show what apply would create, update or delete: markets, products, images, and the store\'s name, policies and theme; missing_images lists references with no file in .commercelayer/img/ (apply refuses them), unused_images files nothing references, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku).',
|
|
27
|
-
apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/: markets, products, the theme (its settings, and the images in .commercelayer/img/ that the theme and products reference, uploaded when new or changed), name and policies. The store\'s website is live at preview_url after apply. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
|
|
27
|
+
apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/: markets, products, the storefront mode (storefront.mode: hosted, the default, or agent-only: UCP and the pages it needs, no catalog pages), the theme (its settings, badge included, and the images in .commercelayer/img/ that the theme and products reference, uploaded when new or changed), name and policies. The store\'s website is live at preview_url after apply. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
|
|
28
28
|
'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant, url, table }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category and one price column per market currency, in lowercase (eur, usd). Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV.',
|
|
29
29
|
'storefront init': 'Custom storefronts only: the hosted theme needs none of this (set theme: in store.yml and apply). Write a static storefront on drop-in.js, no build step: storefront/index.html (the products by category, a minicart that opens on add to cart, the hosted cart and checkout, and a product page at #/p/<sku>), styles.css (theme tokens first) and app.js. Product images are the store\'s, at /img/ (.commercelayer/img/, uploaded by apply); the site\'s own files go in storefront/assets/. Refuses to overwrite unless --force is given.',
|
|
30
30
|
'storefront check': 'Custom storefronts only. Check the published storefront at the preview URL: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides, title, lang, viewport, description, and whether the folder changed since publish. Prints { status, summary, checks: [{ id, kind, status: pass|warn|fail, message, fix, items }] }; exits 1 when a check fails.',
|
|
@@ -47,14 +47,14 @@ export const HELP = {
|
|
|
47
47
|
'orders show': 'Show one order: c11r orders show <id or number>.',
|
|
48
48
|
'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
|
|
49
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 <
|
|
51
|
-
'ucp product': 'Look at one product over UCP, with every variant and the full description: c11r ucp product <product id
|
|
52
|
-
'ucp checkout create': "Buy for the human, step 2: open a checkout with the items the human picked. c11r ucp checkout create --item <
|
|
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 <
|
|
50
|
+
'ucp search': "Buy for the human, step 1: search a store's catalog over UCP. c11r ucp search [<query>] [--country CC] [--store <host>]; no query lists the whole catalog (up to 50). Returns { store, query, count, total_count, has_more, products: [{ id, title, description, url, image_url, variants: [{ id, sku, title, price: { amount, currency, major, display }, available, availability, ships }] }], signature }. Show the human the titles and price.display (amount is in minor units: never show it), let them pick, then c11r ucp checkout create --item <variant id>. --store is any store that speaks UCP (hostname or https origin); the default is this folder's store.",
|
|
51
|
+
'ucp product': 'Look at one product over UCP, with every variant and the full description: c11r ucp product <product id> [--country CC] [--store <host>]. Returns { store, product: { id, title, description, url, image_url, variants: [...] }, signature }, variants as in ucp search.',
|
|
52
|
+
'ucp checkout create': "Buy for the human, step 2: open a checkout with the items the human picked. c11r ucp checkout create --item <variant id>[:<qty>] [--item ...] [--code <discount>] [--country CC] [--store <host>]. Returns the checkout: { id, status, store, currency, line_items: [{ id, item_id, title, quantity, unit_price, total }], buyer, ship_to, shipping, discounts, codes, totals: [{ type, label, amount, major, display }], total, messages, continue_url, expires_at, links, signature }. status is incomplete until the buyer's email and shipping address are given: messages say what is missing. Ask the human for them, never invent them, then c11r ucp checkout update <id>. The id is remembered with its store: later steps need no --store.",
|
|
53
|
+
'ucp checkout update': "Buy for the human, step 3: give the buyer's details, change items or codes. c11r ucp checkout update <id> [--email --first-name --last-name --phone] [--street --city --postal-code --region --country] [--item <variant id>:<qty> (0 removes)] [--code <discount> (adds)] [--shipping <option id>]; or the same as YAML or JSON on stdin or -f <file>: { buyer: { email, first_name, last_name, phone_number }, address: { street_address, address_locality, postal_code, address_region, address_country }, items: [\"ID:2\"], codes: [...] (replaces), shipping }. Only what is given changes. Returns the checkout as in create: status ready_for_complete once nothing is missing, with shipping and the total including it; or incomplete with messages (address_undeliverable says where the store ships). Show the human the line items, shipping and total.display, ask for an explicit yes, then c11r ucp checkout complete <id>.",
|
|
54
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
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
|
|
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: [{
|
|
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 ai.commercelayer.sandbox test handler (no money moves): only stores that advertise it can be paid; --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 without that handler (any store taking real payments) 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: [{ item_id, title, quantity, status, total }], fulfillment: [{ method, destination, description }], events, totals, signature }.',
|
|
58
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.',
|
|
59
59
|
status: 'Show the sandbox, what is applied, and whether the store files have changes not yet applied (the same changes plan lists).',
|
|
60
60
|
claim: 'Start keeping this sandbox store: returns a URL and a code for the human to approve in the browser.',
|
|
@@ -184,6 +184,7 @@ export async function apply(flags) {
|
|
|
184
184
|
name: desired.name,
|
|
185
185
|
policies: desired.policies,
|
|
186
186
|
theme: desired.theme ?? {},
|
|
187
|
+
storefront_mode: storefrontMode(desired),
|
|
187
188
|
...(assets.supported && { assets: assets.keep }),
|
|
188
189
|
...(flags.has('--allow-delete') && { allow_delete: true }),
|
|
189
190
|
...(flags.has('--full') && { full: true }),
|
|
@@ -373,7 +374,7 @@ async function shopOrigin(values, id) {
|
|
|
373
374
|
if (known)
|
|
374
375
|
return known;
|
|
375
376
|
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
|
+
fail('no_store', 'There is no store in this folder, and no --store was given.', 'Pass the store to buy from, any store that speaks UCP: --store <hostname or https origin>.');
|
|
377
378
|
return storeOrigin();
|
|
378
379
|
}
|
|
379
380
|
const needId = (id, command) => {
|
|
@@ -385,7 +386,7 @@ const checkoutNext = (ck) => {
|
|
|
385
386
|
if (ck.status === 'completed')
|
|
386
387
|
return `Paid. Tell the human: order ${ck.order?.label ?? ck.order?.id}, and give them ${ck.order?.permalink_url}.`;
|
|
387
388
|
if (ck.status === 'canceled')
|
|
388
|
-
return 'Canceled. Open a new one with c11r ucp checkout create --item <
|
|
389
|
+
return 'Canceled. Open a new one with c11r ucp checkout create --item <variant id> if the human wants to buy.';
|
|
389
390
|
if (ck.status === 'ready_for_complete') {
|
|
390
391
|
const declined = (ck.messages ?? []).some((m) => m.code === 'payment_failed');
|
|
391
392
|
if (declined)
|
|
@@ -397,17 +398,17 @@ const checkoutNext = (ck) => {
|
|
|
397
398
|
};
|
|
398
399
|
export async function ucpSearch(query, values) {
|
|
399
400
|
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 <
|
|
401
|
+
return { ...r, next: r.count ? 'Show the human the products (title, price.display), let them pick, then c11r ucp checkout create --item <variant id>[:<qty>] (variants[].id).' : 'Nothing matched: try another query, or none for the whole catalog.' };
|
|
401
402
|
}
|
|
402
403
|
export async function ucpProduct(id, values) {
|
|
403
404
|
if (!id)
|
|
404
|
-
fail('missing_argument', 'ucp product needs a product id
|
|
405
|
+
fail('missing_argument', 'ucp product needs a product id.', 'Use a product id from c11r ucp search.');
|
|
405
406
|
return shop.product(await shopOrigin(values), id, values.country);
|
|
406
407
|
}
|
|
407
408
|
export async function ucpCheckoutCreate(values, lists) {
|
|
408
409
|
const items = (lists.item ?? []).map(shop.parseItem);
|
|
409
410
|
if (!items.length)
|
|
410
|
-
fail('missing_argument', 'ucp checkout create needs at least one --item.', 'Example: c11r ucp checkout create --item ETH-250:2
|
|
411
|
+
fail('missing_argument', 'ucp checkout create needs at least one --item.', 'Example: c11r ucp checkout create --item ETH-250:2, with variant ids from c11r ucp search (variants[].id).');
|
|
411
412
|
if (items.some((l) => l.quantity < 1))
|
|
412
413
|
fail('invalid_argument', 'An --item quantity must be 1 or more.', 'Example: --item ETH-250:2.');
|
|
413
414
|
const ck = await shop.createCheckout(await shopOrigin(values), items, lists.code ?? [], values.country);
|
|
@@ -429,7 +430,7 @@ export async function ucpCheckoutUpdate(id, values, lists) {
|
|
|
429
430
|
if (!flagged || values.file) {
|
|
430
431
|
// No flags: the changes as YAML or JSON on stdin or -f.
|
|
431
432
|
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: ["
|
|
433
|
+
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: ["ID:2"], codes: [...], shipping }.');
|
|
433
434
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
434
435
|
bad('the input is not a map.');
|
|
435
436
|
const r = raw;
|
|
@@ -453,7 +454,7 @@ export async function ucpCheckoutUpdate(id, values, lists) {
|
|
|
453
454
|
if (r.items !== undefined) {
|
|
454
455
|
if (!Array.isArray(r.items))
|
|
455
456
|
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.
|
|
457
|
+
changes.items = [...r.items.map((x) => (typeof x === 'string' ? shop.parseItem(x) : x && typeof x === 'object' && typeof x.id === 'string' && Number.isInteger(x.quantity) ? { id: x.id, quantity: x.quantity } : bad('items holds "ID:QTY" or { id, quantity }, with variant ids.'))), ...(changes.items ?? [])];
|
|
457
458
|
}
|
|
458
459
|
if (r.codes !== undefined) {
|
|
459
460
|
if (!Array.isArray(r.codes) || r.codes.some((c) => typeof c !== 'string'))
|
package/dist/store.js
CHANGED
|
@@ -10,7 +10,9 @@ import { paths } from './files.js';
|
|
|
10
10
|
import { productFiles, readProductFile } from './products.js';
|
|
11
11
|
/** The keys a product may have. Stock is not one: inventory lives in Commerce Layer. */
|
|
12
12
|
export const PRODUCT_KEYS = ['sku', 'name', 'description', 'image_url', 'category', 'prices'];
|
|
13
|
-
export const THEME_KEYS = ['color', 'logo', 'hero', 'tagline'];
|
|
13
|
+
export const THEME_KEYS = ['color', 'logo', 'hero', 'tagline', 'badge'];
|
|
14
|
+
export const STOREFRONT_MODES = ['hosted', 'agent-only'];
|
|
15
|
+
export const storefrontMode = (s) => s.storefront?.mode ?? 'hosted';
|
|
14
16
|
export const template = (name) => `# Commerce Layer store. This file is the store: edit it, then run
|
|
15
17
|
# c11r plan to see what will change
|
|
16
18
|
# c11r apply to make the change
|
|
@@ -60,8 +62,12 @@ theme:
|
|
|
60
62
|
# logo: img/logo.svg # or an https:// URL; without it the name is the logo
|
|
61
63
|
# hero: img/hero.jpg # the home page's wide image; without it a band in the accent
|
|
62
64
|
# tagline: Small-batch coffee, roasted in Milan # at most 120 characters
|
|
65
|
+
# badge: Demo # a short label next to the logo, at most 20 characters
|
|
63
66
|
|
|
64
67
|
storefront:
|
|
68
|
+
# mode: hosted # hosted (default): the website for humans, and UCP for agents.
|
|
69
|
+
# # agent-only: the store host serves only UCP and the pages it
|
|
70
|
+
# # needs (checkout, order, policy pages), no catalog pages.
|
|
65
71
|
# Only for a custom storefront (your own web app in this repo, published with
|
|
66
72
|
# c11r storefront publish): it replaces the theme. Its folder, from the repo root.
|
|
67
73
|
# path: storefront
|
|
@@ -144,6 +150,7 @@ export function loadStore() {
|
|
|
144
150
|
fail('invalid_store', `policies.${key} is not a URL.`, 'Policy entries are full URLs starting with https://, or left out to use the default sandbox pages.');
|
|
145
151
|
}
|
|
146
152
|
const theme = checkTheme(doc.theme);
|
|
153
|
+
checkStorefront(doc.storefront);
|
|
147
154
|
const currencies = [...new Set(markets.map((m) => m.currency))];
|
|
148
155
|
const products = checkProducts(sourced, currencies);
|
|
149
156
|
return { name: doc.name, markets, products, storefront: doc.storefront, policies, theme };
|
|
@@ -193,12 +200,28 @@ export const IMAGE_PATH = /^img\/[A-Za-z0-9._-][A-Za-z0-9._/-]*\.(png|jpg|jpeg|w
|
|
|
193
200
|
/** image_url, logo, hero: an https URL, or img/<file> for a file in .commercelayer/img/. */
|
|
194
201
|
export const isImageRef = (url) => isLocalImage(url) ? IMAGE_PATH.test(url) && !url.includes('..') && !url.includes('//') : /^https?:\/\/\S+$/.test(url);
|
|
195
202
|
const IMAGE_HINT = 'Use a full https:// URL, or img/<file> for a file in .commercelayer/img/ (letters, digits, . _ - and /; extension png, jpg, jpeg, webp, gif, avif or svg, in lowercase).';
|
|
203
|
+
/** The storefront block of store.yml: path, and mode hosted or agent-only. */
|
|
204
|
+
function checkStorefront(raw) {
|
|
205
|
+
if (raw === undefined || raw === null)
|
|
206
|
+
return;
|
|
207
|
+
const hint = 'Write `storefront:` with `mode: hosted` (the default) or `mode: agent-only`, and `path:` for a custom storefront. See the comments in store.yml.';
|
|
208
|
+
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
209
|
+
fail('invalid_storefront', 'storefront in store.yml is not a mapping.', hint);
|
|
210
|
+
const s = raw;
|
|
211
|
+
const unknown = Object.keys(s).filter((k) => !['mode', 'path'].includes(k));
|
|
212
|
+
if (unknown.length)
|
|
213
|
+
fail('invalid_storefront', `storefront in store.yml has ${unknown.join(', ')}, which it does not take.`, hint);
|
|
214
|
+
if (s.mode !== undefined && s.mode !== null && !STOREFRONT_MODES.includes(s.mode))
|
|
215
|
+
fail('invalid_storefront', `storefront.mode is "${String(s.mode)}".`, 'Use hosted (a website for humans, and UCP) or agent-only (UCP and the pages it needs, no catalog pages).');
|
|
216
|
+
if (s.path !== undefined && s.path !== null && typeof s.path !== 'string')
|
|
217
|
+
fail('invalid_storefront', 'storefront.path is not a folder name.', hint);
|
|
218
|
+
}
|
|
196
219
|
/** The theme block of store.yml, validated: the theme would ignore bad values, so say so here. */
|
|
197
220
|
function checkTheme(raw) {
|
|
198
221
|
if (raw === undefined || raw === null)
|
|
199
222
|
return undefined;
|
|
200
223
|
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
201
|
-
fail('invalid_theme', 'theme in store.yml is not a mapping.', 'Write `theme:` with color, logo, hero and
|
|
224
|
+
fail('invalid_theme', 'theme in store.yml is not a mapping.', 'Write `theme:` with color, logo, hero, tagline and badge under it, each optional. See the comments in store.yml.');
|
|
202
225
|
const t = raw;
|
|
203
226
|
const unknown = Object.keys(t).filter((k) => !THEME_KEYS.includes(k));
|
|
204
227
|
if (unknown.length)
|
|
@@ -214,6 +237,8 @@ function checkTheme(raw) {
|
|
|
214
237
|
fail('invalid_theme', `theme.${k} is "${theme[k]}".`, IMAGE_HINT);
|
|
215
238
|
if (theme.tagline !== undefined && (theme.tagline.length > 120 || /[\r\n<>]/.test(theme.tagline)))
|
|
216
239
|
fail('invalid_theme', `theme.tagline is ${theme.tagline.length > 120 ? `${theme.tagline.length} characters long` : 'not plain text on one line'}.`, 'Write the tagline as one line of plain text, at most 120 characters.');
|
|
240
|
+
if (theme.badge !== undefined && (theme.badge.length > 20 || !theme.badge.trim() || /[\r\n<>]/.test(theme.badge)))
|
|
241
|
+
fail('invalid_theme', `theme.badge is ${theme.badge.length > 20 ? `${theme.badge.length} characters long` : `"${theme.badge}"`}.`, 'Use a short plain-text label of at most 20 characters, such as Demo, with no line breaks or < >.');
|
|
217
242
|
return theme;
|
|
218
243
|
}
|
|
219
244
|
export function toApplied(store) {
|
|
@@ -244,6 +269,10 @@ export function diff(state, desired) {
|
|
|
244
269
|
else if (hasTheme(before) && canonical(before) !== canonical(desired.theme))
|
|
245
270
|
changes.push({ action: 'update', type: 'store', id: 'theme' });
|
|
246
271
|
}
|
|
272
|
+
// The mode: a change from what the API holds (hosted before the first apply). An API that does not know it is skipped.
|
|
273
|
+
if (!applied ? storefrontMode(desired) !== 'hosted' : state.storefront_mode !== undefined && (state.storefront_mode ?? 'hosted') !== storefrontMode(desired)) {
|
|
274
|
+
changes.push({ action: 'update', type: 'store', id: 'storefront_mode' });
|
|
275
|
+
}
|
|
247
276
|
const after = toApplied(desired);
|
|
248
277
|
for (const kind of ['markets', 'products']) {
|
|
249
278
|
const type = kind.slice(0, -1);
|
package/dist/ucp/shop.js
CHANGED
|
@@ -57,7 +57,7 @@ function variantView(v) {
|
|
|
57
57
|
const shipping = v.fulfillment?.methods?.map((m) => plain(m.description)).filter(Boolean).join('; ');
|
|
58
58
|
return {
|
|
59
59
|
id: v.id,
|
|
60
|
-
sku: v.sku
|
|
60
|
+
sku: v.sku,
|
|
61
61
|
title: v.title,
|
|
62
62
|
price: v.price ? money(v.price.amount, v.price.currency) : undefined,
|
|
63
63
|
available: v.availability?.available ?? undefined,
|
|
@@ -102,7 +102,7 @@ export function checkoutView(ck, origin, call) {
|
|
|
102
102
|
currency: cur,
|
|
103
103
|
line_items: (ck.line_items ?? []).map((l) => ({
|
|
104
104
|
id: l.id,
|
|
105
|
-
|
|
105
|
+
item_id: l.item?.id,
|
|
106
106
|
title: l.item?.title,
|
|
107
107
|
quantity: l.quantity,
|
|
108
108
|
unit_price: l.item?.price === undefined ? undefined : money(l.item.price, cur),
|
|
@@ -134,7 +134,7 @@ export function orderView(o, origin, call) {
|
|
|
134
134
|
checkout_id: o.checkout_id,
|
|
135
135
|
currency: cur,
|
|
136
136
|
line_items: (o.line_items ?? []).map((l) => ({
|
|
137
|
-
|
|
137
|
+
item_id: l.item?.id,
|
|
138
138
|
title: l.item?.title,
|
|
139
139
|
quantity: l.quantity?.total ?? l.quantity,
|
|
140
140
|
status: l.status,
|
|
@@ -160,15 +160,15 @@ async function step(origin, run) {
|
|
|
160
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
161
|
if (code === 'identity_required' || e.call.status === 401)
|
|
162
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
|
|
163
|
+
fail('ucp_error', e.message, `The store at ${origin} refused it: tell the human what it said. The variant ids it sells are in c11r ucp search.`);
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
|
-
/** --item
|
|
166
|
+
/** --item ID or ID:QTY, the variant id. */
|
|
167
167
|
export function parseItem(raw) {
|
|
168
168
|
const m = String(raw).match(/^(.+?)(?::(\d+))?$/);
|
|
169
169
|
if (!m || !m[1].trim())
|
|
170
|
-
fail('invalid_argument', `--item ${raw} is not
|
|
171
|
-
return {
|
|
170
|
+
fail('invalid_argument', `--item ${raw} is not ID or ID:QUANTITY.`, 'Use a variant id from c11r ucp search (variants[].id), for example --item ETH-250:2; quantity 1 when left out, 0 removes it in checkout update.');
|
|
171
|
+
return { id: m[1].trim(), quantity: m[2] === undefined ? 1 : Number(m[2]) };
|
|
172
172
|
}
|
|
173
173
|
export async function open(origin) {
|
|
174
174
|
const store = await discover(origin);
|
|
@@ -205,7 +205,7 @@ export async function product(origin, id, country) {
|
|
|
205
205
|
}
|
|
206
206
|
export async function createCheckout(origin, items, codes, country) {
|
|
207
207
|
const store = await open(origin);
|
|
208
|
-
const body = { line_items: items.map((l) => ({ item: { id: l.
|
|
208
|
+
const body = { line_items: items.map((l) => ({ item: { id: l.id }, quantity: l.quantity })) };
|
|
209
209
|
if (country)
|
|
210
210
|
body.context = { address_country: country.toUpperCase() };
|
|
211
211
|
if (codes.length)
|
|
@@ -229,7 +229,7 @@ export async function updateCheckout(origin, id, ch) {
|
|
|
229
229
|
if (ch.buyer)
|
|
230
230
|
body.buyer = clean({ ...(current.buyer ?? {}), ...ch.buyer });
|
|
231
231
|
for (const l of ch.items ?? []) {
|
|
232
|
-
const i = body.line_items.findIndex((x) => x.item.id === l.
|
|
232
|
+
const i = body.line_items.findIndex((x) => x.item.id === l.id);
|
|
233
233
|
if (l.quantity === 0) {
|
|
234
234
|
if (i >= 0)
|
|
235
235
|
body.line_items.splice(i, 1);
|
|
@@ -237,7 +237,7 @@ export async function updateCheckout(origin, id, ch) {
|
|
|
237
237
|
else if (i >= 0)
|
|
238
238
|
body.line_items[i].quantity = l.quantity;
|
|
239
239
|
else
|
|
240
|
-
body.line_items.push({ item: { id: l.
|
|
240
|
+
body.line_items.push({ item: { id: l.id }, quantity: l.quantity });
|
|
241
241
|
}
|
|
242
242
|
if (!body.line_items.length)
|
|
243
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}.`);
|
|
@@ -268,8 +268,8 @@ export async function updateCheckout(origin, id, ch) {
|
|
|
268
268
|
again.fulfillment.methods[0].groups[0].selected_option_id = group.options[0].id;
|
|
269
269
|
call = await step(origin, () => shopping.updateCheckout(store, id, again));
|
|
270
270
|
}
|
|
271
|
-
// A store can answer ready_for_complete before the shipping is in the totals (seen once
|
|
272
|
-
//
|
|
271
|
+
// A store can answer ready_for_complete before the shipping is in the totals (seen once, since
|
|
272
|
+
// fixed on the store host): read it again rather than show the human a total without shipping.
|
|
273
273
|
const ck = call.body;
|
|
274
274
|
if (ck.status === 'ready_for_complete' && ck.fulfillment?.methods?.[0]?.groups?.[0]?.selected_option_id && !(ck.totals ?? []).some((t) => t.type === 'fulfillment')) {
|
|
275
275
|
call = await step(origin, () => shopping.getCheckout(store, id));
|
|
@@ -285,7 +285,7 @@ export async function completeCheckout(origin, id, decline) {
|
|
|
285
285
|
const store = await open(origin);
|
|
286
286
|
if (!store.profile.ucp?.payment_handlers?.[SANDBOX_HANDLER]) {
|
|
287
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
|
|
288
|
+
fail('payment_handler_unsupported', `The store at ${origin} takes ${handlers.length ? handlers.join(', ') : 'no payment handler this CLI knows'}; this CLI pays only with the ${SANDBOX_HANDLER} test handler today.`, `Give the human the checkout's continue_url to pay there: c11r ucp checkout show ${id}.`);
|
|
289
289
|
}
|
|
290
290
|
const call = await step(origin, () => shopping.completeCheckout(store, id, { payment: { instruments: [sandboxInstrument(decline)] }, signals: { 'dev.ucp.user_agent': 'c11r' } }));
|
|
291
291
|
const ck = call.body;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commercelayer/agent-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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": [
|