@commercelayer/agent-cli 0.2.5 → 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 +15 -1
- package/dist/api/client.js +2 -1
- package/dist/api/stub/management.js +9 -3
- 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/bin.js +32 -4
- package/dist/commands.js +149 -7
- package/dist/store.js +31 -2
- package/dist/ucp/platform.js +1 -1
- package/dist/ucp/shop.js +308 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,11 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
|
|
|
31
31
|
| `orders list` | The orders placed in the store, newest first, as UCP order entities. |
|
|
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
|
+
| `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 <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
|
+
| `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 store that speaks UCP. |
|
|
34
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. |
|
|
35
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. |
|
|
36
41
|
| `claim` | Start the device flow that keeps the sandbox: a URL and a code for the human. |
|
|
@@ -64,6 +69,9 @@ theme:
|
|
|
64
69
|
logo: img/logo.svg # or an https:// URL; default: the name as a wordmark
|
|
65
70
|
hero: img/hero.jpg # the home page's wide image; default: a band in the accent
|
|
66
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
|
|
67
75
|
policies:
|
|
68
76
|
# Pages shopping agents show buyers before they pay. Without them the store
|
|
69
77
|
# serves default sandbox pages at <preview_url>/policies/<name>.
|
|
@@ -129,9 +137,11 @@ The theme renders the home page (logo, hero with name and tagline, products by t
|
|
|
129
137
|
|
|
130
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.
|
|
131
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
|
+
|
|
132
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/`.
|
|
133
143
|
|
|
134
|
-
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`.
|
|
135
145
|
## The store speaks UCP
|
|
136
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:
|
|
137
147
|
| What | Where |
|
|
@@ -146,6 +156,10 @@ Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (liv
|
|
|
146
156
|
| 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 `/`. |
|
|
147
157
|
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.
|
|
148
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.
|
|
159
|
+
### Buying for the human
|
|
160
|
+
|
|
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`.
|
|
162
|
+
|
|
149
163
|
## Requirements
|
|
150
164
|
|
|
151
165
|
Node 20 or later. One runtime dependency (`yaml`).
|
package/dist/api/client.js
CHANGED
|
@@ -66,7 +66,8 @@ export function createApi(auth) {
|
|
|
66
66
|
deleteLink: async (id) => {
|
|
67
67
|
await found('links', id, request('DELETE', one('links', id)));
|
|
68
68
|
},
|
|
69
|
-
|
|
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 }),
|
|
70
71
|
getStore: () => request('GET', org()),
|
|
71
72
|
applyStore: (body) => request('PUT', `${org()}/resources`, body),
|
|
72
73
|
uploadAssets: (files) => request('PUT', `${org()}/assets`, { files }),
|
|
@@ -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-/;
|
|
@@ -111,7 +111,8 @@ export function handleManagement(req) {
|
|
|
111
111
|
if (req.method === 'POST' && path === '/sandboxes') {
|
|
112
112
|
// Like Commerce Layer: the organization is named `name`, and its slug is derived
|
|
113
113
|
// from it, suffixed -2, -3, ... when taken. The slug is the org and the store host.
|
|
114
|
-
const
|
|
114
|
+
const body = readJson(req);
|
|
115
|
+
const name = String(body?.name ?? 'store');
|
|
115
116
|
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'store';
|
|
116
117
|
let slug = base;
|
|
117
118
|
for (let n = 2; db[slug]; n++)
|
|
@@ -124,6 +125,7 @@ export function handleManagement(req) {
|
|
|
124
125
|
org: slug,
|
|
125
126
|
token: `sbx_${crypto.randomBytes(16).toString('hex')}`,
|
|
126
127
|
sandbox: true,
|
|
128
|
+
...(body?.test === true ? { test: true } : {}),
|
|
127
129
|
claimed: false,
|
|
128
130
|
claim_url: `https://commercelayer.ai/claim/${crypto.randomBytes(6).toString('hex')}`,
|
|
129
131
|
expires_at: expires.toISOString(),
|
|
@@ -164,7 +166,9 @@ export function handleManagement(req) {
|
|
|
164
166
|
writeDb(db);
|
|
165
167
|
if (record.apply?.status === 'in_progress')
|
|
166
168
|
return error(409, 'apply_in_progress', 'Another apply is still running.', 'Run `c11r status` to follow it, then apply again.');
|
|
167
|
-
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.');
|
|
168
172
|
const before = record.applied?.products ?? {};
|
|
169
173
|
const removed = Object.keys(before).filter((sku) => !(sku in applied.products));
|
|
170
174
|
if (removed.length && !allow_delete)
|
|
@@ -175,6 +179,8 @@ export function handleManagement(req) {
|
|
|
175
179
|
record.policies = policies ?? {};
|
|
176
180
|
if (theme !== undefined)
|
|
177
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');
|
|
178
184
|
// Images not in the list are no longer used: dropped now. No list (older CLIs): nothing is dropped.
|
|
179
185
|
if (assets) {
|
|
180
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/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
|
-
|
|
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
|
-
|
|
32
|
+
set(a.slice(2), argv[++i]);
|
|
27
33
|
else
|
|
28
34
|
flags.add(a);
|
|
29
35
|
}
|
|
@@ -96,7 +102,29 @@ async function run() {
|
|
|
96
102
|
return cmd.ucpProfile(flags);
|
|
97
103
|
if (sub === 'check')
|
|
98
104
|
return cmd.ucpCheck();
|
|
99
|
-
|
|
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`.');
|
|
100
128
|
default:
|
|
101
129
|
throw new CliError('unknown_command', `There is no command "${name}".`, `Run \`c11r help\`. Commands: ${Object.keys(cmd.HELP).join(', ')}.`);
|
|
102
130
|
}
|
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';
|
|
@@ -17,13 +17,14 @@ import { storefrontCheck } from './check.js';
|
|
|
17
17
|
import { buy } from './ucp/buy.js';
|
|
18
18
|
import { check } from './ucp/check.js';
|
|
19
19
|
import { discover } from './ucp/client.js';
|
|
20
|
+
import * as shop from './ucp/shop.js';
|
|
20
21
|
import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
|
|
21
22
|
/** The package version, read from package.json so a release bumps it in one place. */
|
|
22
23
|
export const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
23
24
|
export const HELP = {
|
|
24
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.',
|
|
25
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).',
|
|
26
|
-
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.',
|
|
27
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.',
|
|
28
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.',
|
|
29
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.',
|
|
@@ -46,6 +47,15 @@ export const HELP = {
|
|
|
46
47
|
'orders show': 'Show one order: c11r orders show <id or number>.',
|
|
47
48
|
'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
|
|
48
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 <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
|
+
'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 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
|
+
'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.',
|
|
49
59
|
status: 'Show the sandbox, what is applied, and whether the store files have changes not yet applied (the same changes plan lists).',
|
|
50
60
|
claim: 'Start keeping this sandbox store: returns a URL and a code for the human to approve in the browser.',
|
|
51
61
|
help: 'Describe the commands. `c11r help <command>` for one command, `c11r help <group>` (catalog, orders, storefront, ucp) for a group.',
|
|
@@ -174,6 +184,7 @@ export async function apply(flags) {
|
|
|
174
184
|
name: desired.name,
|
|
175
185
|
policies: desired.policies,
|
|
176
186
|
theme: desired.theme ?? {},
|
|
187
|
+
storefront_mode: storefrontMode(desired),
|
|
177
188
|
...(assets.supported && { assets: assets.keep }),
|
|
178
189
|
...(flags.has('--allow-delete') && { allow_delete: true }),
|
|
179
190
|
...(flags.has('--full') && { full: true }),
|
|
@@ -354,6 +365,133 @@ export async function claim() {
|
|
|
354
365
|
next: 'Ask the human to open verification_url and enter user_code, then run `c11r status`.',
|
|
355
366
|
};
|
|
356
367
|
}
|
|
368
|
+
// ---------- ucp: buying for the human, step by step, from this store or any UCP store
|
|
369
|
+
/** The store to buy from: --store, else the one an id was made at, else this folder's. */
|
|
370
|
+
async function shopOrigin(values, id) {
|
|
371
|
+
if (values.store)
|
|
372
|
+
return shop.originOf(values.store);
|
|
373
|
+
const known = id ? shop.remembered(id) : undefined;
|
|
374
|
+
if (known)
|
|
375
|
+
return known;
|
|
376
|
+
if (!readCredentials())
|
|
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>.');
|
|
378
|
+
return storeOrigin();
|
|
379
|
+
}
|
|
380
|
+
const needId = (id, command) => {
|
|
381
|
+
if (!id)
|
|
382
|
+
fail('missing_argument', `${command} needs a checkout id.`, 'Use the id that c11r ucp checkout create printed (chk_...).');
|
|
383
|
+
return id;
|
|
384
|
+
};
|
|
385
|
+
const checkoutNext = (ck) => {
|
|
386
|
+
if (ck.status === 'completed')
|
|
387
|
+
return `Paid. Tell the human: order ${ck.order?.label ?? ck.order?.id}, and give them ${ck.order?.permalink_url}.`;
|
|
388
|
+
if (ck.status === 'canceled')
|
|
389
|
+
return 'Canceled. Open a new one with c11r ucp checkout create --item <variant id> if the human wants to buy.';
|
|
390
|
+
if (ck.status === 'ready_for_complete') {
|
|
391
|
+
const declined = (ck.messages ?? []).some((m) => m.code === 'payment_failed');
|
|
392
|
+
if (declined)
|
|
393
|
+
return `The payment failed: tell the human. Try again only if they ask: c11r ucp checkout complete ${ck.id}.`;
|
|
394
|
+
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}.`;
|
|
395
|
+
}
|
|
396
|
+
const errors = (ck.messages ?? []).filter((m) => m.type === 'error').map((m) => m.content);
|
|
397
|
+
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.`;
|
|
398
|
+
};
|
|
399
|
+
export async function ucpSearch(query, values) {
|
|
400
|
+
const r = await shop.search(await shopOrigin(values), query, values.country);
|
|
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.' };
|
|
402
|
+
}
|
|
403
|
+
export async function ucpProduct(id, values) {
|
|
404
|
+
if (!id)
|
|
405
|
+
fail('missing_argument', 'ucp product needs a product id.', 'Use a product id from c11r ucp search.');
|
|
406
|
+
return shop.product(await shopOrigin(values), id, values.country);
|
|
407
|
+
}
|
|
408
|
+
export async function ucpCheckoutCreate(values, lists) {
|
|
409
|
+
const items = (lists.item ?? []).map(shop.parseItem);
|
|
410
|
+
if (!items.length)
|
|
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).');
|
|
412
|
+
if (items.some((l) => l.quantity < 1))
|
|
413
|
+
fail('invalid_argument', 'An --item quantity must be 1 or more.', 'Example: --item ETH-250:2.');
|
|
414
|
+
const ck = await shop.createCheckout(await shopOrigin(values), items, lists.code ?? [], values.country);
|
|
415
|
+
return { ...ck, next: checkoutNext(ck) };
|
|
416
|
+
}
|
|
417
|
+
const BUYER_FLAGS = { email: 'email', 'first-name': 'first_name', 'last-name': 'last_name', phone: 'phone_number' };
|
|
418
|
+
const ADDRESS_FLAGS = { street: 'street_address', city: 'address_locality', 'postal-code': 'postal_code', region: 'address_region', country: 'address_country' };
|
|
419
|
+
export async function ucpCheckoutUpdate(id, values, lists) {
|
|
420
|
+
needId(id, 'ucp checkout update');
|
|
421
|
+
const pick = (map) => {
|
|
422
|
+
const out = {};
|
|
423
|
+
for (const [flag, key] of Object.entries(map))
|
|
424
|
+
if (values[flag] !== undefined)
|
|
425
|
+
out[key] = values[flag];
|
|
426
|
+
return Object.keys(out).length ? out : undefined;
|
|
427
|
+
};
|
|
428
|
+
const changes = { buyer: pick(BUYER_FLAGS), address: pick(ADDRESS_FLAGS), items: lists.item?.map(shop.parseItem), add_codes: lists.code, shipping: values.shipping };
|
|
429
|
+
const flagged = Object.values(changes).some((v) => v !== undefined);
|
|
430
|
+
if (!flagged || values.file) {
|
|
431
|
+
// No flags: the changes as YAML or JSON on stdin or -f.
|
|
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 }.`);
|
|
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 }.');
|
|
434
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
435
|
+
bad('the input is not a map.');
|
|
436
|
+
const r = raw;
|
|
437
|
+
for (const k of Object.keys(r))
|
|
438
|
+
if (!['buyer', 'address', 'items', 'codes', 'shipping'].includes(k))
|
|
439
|
+
bad(`unknown key ${k}.`);
|
|
440
|
+
const map = (o, keys, what) => {
|
|
441
|
+
if (o === undefined)
|
|
442
|
+
return undefined;
|
|
443
|
+
if (!o || typeof o !== 'object' || Array.isArray(o))
|
|
444
|
+
bad(`${what} is not a map.`);
|
|
445
|
+
for (const [k, v] of Object.entries(o))
|
|
446
|
+
if (!keys.includes(k) || typeof v !== 'string')
|
|
447
|
+
bad(`${what}.${k} is not one of ${keys.join(', ')} with a text value.`);
|
|
448
|
+
return o;
|
|
449
|
+
};
|
|
450
|
+
if (r.buyer !== undefined)
|
|
451
|
+
changes.buyer = { ...map(r.buyer, Object.values(BUYER_FLAGS), 'buyer'), ...changes.buyer };
|
|
452
|
+
if (r.address !== undefined)
|
|
453
|
+
changes.address = { ...map(r.address, ['first_name', 'last_name', 'extended_address', ...Object.values(ADDRESS_FLAGS)], 'address'), ...changes.address };
|
|
454
|
+
if (r.items !== undefined) {
|
|
455
|
+
if (!Array.isArray(r.items))
|
|
456
|
+
bad('items is not a list.');
|
|
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 ?? [])];
|
|
458
|
+
}
|
|
459
|
+
if (r.codes !== undefined) {
|
|
460
|
+
if (!Array.isArray(r.codes) || r.codes.some((c) => typeof c !== 'string'))
|
|
461
|
+
bad('codes is not a list of codes.');
|
|
462
|
+
changes.codes = [...r.codes, ...(changes.add_codes ?? [])];
|
|
463
|
+
}
|
|
464
|
+
if (r.shipping !== undefined)
|
|
465
|
+
changes.shipping = changes.shipping ?? String(r.shipping);
|
|
466
|
+
}
|
|
467
|
+
if (changes.items?.some((l) => !Number.isInteger(l.quantity) || l.quantity < 0))
|
|
468
|
+
fail('invalid_argument', 'An item quantity must be 0 (remove it) or more.', 'Example: --item ETH-250:2.');
|
|
469
|
+
const ck = await shop.updateCheckout(await shopOrigin(values, id), id, changes);
|
|
470
|
+
return { ...ck, next: checkoutNext(ck) };
|
|
471
|
+
}
|
|
472
|
+
export async function ucpCheckoutShow(id, values) {
|
|
473
|
+
const ck = await shop.getCheckout(await shopOrigin(values, needId(id, 'ucp checkout show')), id);
|
|
474
|
+
return { ...ck, next: checkoutNext(ck) };
|
|
475
|
+
}
|
|
476
|
+
export async function ucpCheckoutCancel(id, values) {
|
|
477
|
+
const ck = await shop.cancelCheckout(await shopOrigin(values, needId(id, 'ucp checkout cancel')), id);
|
|
478
|
+
return { ...ck, next: checkoutNext(ck) };
|
|
479
|
+
}
|
|
480
|
+
export async function ucpCheckoutComplete(id, values, flags) {
|
|
481
|
+
const ck = await shop.completeCheckout(await shopOrigin(values, needId(id, 'ucp checkout complete')), id, flags.has('--decline'));
|
|
482
|
+
return { ...ck, payment_handler: 'ai.commercelayer.sandbox (test payments only)', next: checkoutNext(ck) };
|
|
483
|
+
}
|
|
484
|
+
export async function ucpOrder(id, values) {
|
|
485
|
+
if (!id)
|
|
486
|
+
fail('missing_argument', 'ucp order needs an order id.', 'Use order.id from c11r ucp checkout complete.');
|
|
487
|
+
const o = await shop.getOrder(await shopOrigin(values, id), id);
|
|
488
|
+
return { ...o, next: `Tell the human: order ${o.label}, details at ${o.permalink_url}.` };
|
|
489
|
+
}
|
|
490
|
+
export async function ucpAsk(question, values) {
|
|
491
|
+
if (!question.trim())
|
|
492
|
+
fail('missing_argument', 'ucp ask needs a question.', 'Example: c11r ucp ask "Do you ship to Canada?".');
|
|
493
|
+
return shop.ask(await shopOrigin(values), question);
|
|
494
|
+
}
|
|
357
495
|
// ---------- promotions: live settings in Commerce Layer, not store files
|
|
358
496
|
/** What list and show print beyond the spec: dropped when it is sent back. */
|
|
359
497
|
const READ_ONLY = ['status', 'uses', 'managed', 'url', 'urls', 'created_at'];
|
|
@@ -362,7 +500,14 @@ const READ_ONLY = ['status', 'uses', 'managed', 'url', 'urls', 'created_at'];
|
|
|
362
500
|
* can be edited and sent back: the read-only fields are dropped.
|
|
363
501
|
*/
|
|
364
502
|
async function readInput(values, what) {
|
|
365
|
-
const
|
|
503
|
+
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}`);
|
|
504
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw))
|
|
505
|
+
for (const k of READ_ONLY)
|
|
506
|
+
delete raw[k];
|
|
507
|
+
return raw;
|
|
508
|
+
}
|
|
509
|
+
/** YAML or JSON from -f <file> or stdin, parsed. */
|
|
510
|
+
async function readText(values, what, usage, invalid = 'invalid_argument') {
|
|
366
511
|
let text = '';
|
|
367
512
|
if (values.file) {
|
|
368
513
|
if (!fs.existsSync(values.file))
|
|
@@ -385,11 +530,8 @@ async function readInput(values, what) {
|
|
|
385
530
|
raw = YAML.parse(text);
|
|
386
531
|
}
|
|
387
532
|
catch (e) {
|
|
388
|
-
fail(
|
|
533
|
+
fail(invalid, `${what}: not valid YAML or JSON: ${e.message.split('\n')[0]}`, 'Fix the syntax and run the command again.');
|
|
389
534
|
}
|
|
390
|
-
if (raw && typeof raw === 'object' && !Array.isArray(raw))
|
|
391
|
-
for (const k of READ_ONLY)
|
|
392
|
-
delete raw[k];
|
|
393
535
|
return raw;
|
|
394
536
|
}
|
|
395
537
|
/** A promotion spec, validated against store.yml's markets and products. */
|
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/platform.js
CHANGED
|
@@ -74,7 +74,7 @@ export function agentProfile() {
|
|
|
74
74
|
},
|
|
75
75
|
},
|
|
76
76
|
name: 'Commerce Layer agent CLI',
|
|
77
|
-
description: '
|
|
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). */
|
package/dist/ucp/shop.js
ADDED
|
@@ -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,
|
|
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
|
+
item_id: 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
|
+
item_id: 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 variant ids it sells are in c11r ucp search.`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** --item ID or ID:QTY, the variant id. */
|
|
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 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
|
+
}
|
|
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.id }, 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.id);
|
|
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.id }, 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, since
|
|
272
|
+
// fixed on the store host): 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 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
|
+
}
|
|
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.
|
|
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": [
|