@commercelayer/agent-cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -2
- package/dist/api/client.js +39 -0
- package/dist/api/server.js +26 -0
- package/dist/api/stub/db.js +37 -0
- package/dist/api/stub/http.js +18 -0
- package/dist/api/stub/index.js +27 -0
- package/dist/api/stub/management.js +86 -0
- package/dist/api/stub/shopping.js +543 -0
- package/dist/api/stub/ucp.js +350 -0
- package/dist/api/stub.js +112 -0
- package/dist/api/transport.js +37 -0
- package/dist/api/types.js +4 -0
- package/dist/bin.js +83 -0
- package/dist/commands.js +201 -0
- package/dist/files.js +47 -0
- package/dist/output.js +18 -0
- package/dist/products.js +128 -0
- package/dist/store.js +172 -0
- package/dist/storefront.js +73 -0
- package/dist/ucp/buy.js +88 -0
- package/dist/ucp/check.js +355 -0
- package/dist/ucp/client.js +129 -0
- package/dist/ucp/platform.js +90 -0
- package/dist/ucp/signing.js +67 -0
- package/package.json +34 -4
package/README.md
CHANGED
|
@@ -1,3 +1,160 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @commercelayer/agent-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`c11r`, the Commerce Layer CLI for coding agents. An agent reads [commercelayer.ai/start.md](https://commercelayer.ai/start.md), runs this CLI, and the human ends up with a working store in a sandbox: products, prices, a market, a storefront at `<org>.sandbox.commercelayer.store`, and a paid test order. No account needed until they decide to keep it.
|
|
4
|
+
|
|
5
|
+
Every store speaks the [Universal Commerce Protocol](https://ucp.dev) on its own hostname, so shopping agents can discover it, browse it and buy from it. The CLI itself buys through UCP (`orders test`) and checks the store against the spec (`ucp check`), so what it verifies is what any agent would get.
|
|
6
|
+
|
|
7
|
+
It is a separate tool from [`@commercelayer/cli`](https://www.npmjs.com/package/@commercelayer/cli), the CLI for humans. The package is `@commercelayer/agent-cli`; the command is `c11r` (c, eleven letters, r), short and unmistakable in an agent's transcript.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npx -y @commercelayer/agent-cli@latest init # runs c11r init
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## The four repos of commercelayer.ai
|
|
14
|
+
|
|
15
|
+
commercelayer.ai lets a coding agent build and run a store on Commerce Layer. A human pastes one prompt, the agent reads `start.md`, runs the CLI, and the human ends up with a store in a sandbox that both people and shopping agents can buy from. It is four repos, siblings in the same workspace:
|
|
16
|
+
|
|
17
|
+
| Repo | What it is | Where it runs |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| `commercelayer-ai-site` | the website and the agent-facing docs: `start.md`, `skills.md`, `skills/*.md`, plus the CLI's UCP platform profile and the sandbox payment-handler schema | commercelayer.ai, Cloudflare Pages |
|
|
20
|
+
| `commercelayer-ai-cli` | `@commercelayer/agent-cli`, the CLI the agent runs as `c11r`: `init`, `plan`, `apply`, `storefront`, `orders test`, `ucp check`, `claim`. Prints JSON only. | npm, in the agent's repo |
|
|
21
|
+
| `commercelayer-ai-api` | the management API the CLI talks to: sandboxes and their life cycle, `apply`, storefront uploads, orders, claims | api.commercelayer.ai, Cloudflare Worker |
|
|
22
|
+
| `commercelayer-ai-store` | the store host: every store's website and its [UCP](https://ucp.dev) merchant endpoints (release 2026-08-25) | `<org>.sandbox.commercelayer.store` and, once live, `<org>.commercelayer.store`, Cloudflare Worker |
|
|
23
|
+
|
|
24
|
+
**This repo is the CLI.** It owns everything the agent runs: the commands and their JSON, the store files in the agent's repo (`.commercelayer/store.yml` and `.commercelayer/products/`), the drop-in.js storefront scaffold, the UCP buyer and conformance check, and the stub of the whole platform in `src/api/stub/`, which is the reference the API and the store host are ported from. It depends on the API for the management contract in `src/api/types.ts`, on the store host for the UCP surface, and on the site for the platform profile and handler schema it names in `src/ucp/platform.ts`; a change to the stub is mirrored in the API and the store host, and a change to the management contract is mirrored in the API. The site depends on it: every command, flag, file and JSON field the skills describe must exist here, so a change to the commands or the store files is mirrored on the site.
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
- **The repo is the store.** `.commercelayer/store.yml` holds the desired state (name, markets, storefront, policies, and the products of a small store) and is committed; a large catalog goes in `.commercelayer/products/` as `.yml` or `.csv` files. `plan` shows what `apply` would change; `apply` makes the store match the files.
|
|
29
|
+
- **JSON only.** Every command prints one JSON object. A failure exits 1 with `{ "error": { "code", "message", "hint" } }`; the hint is the fix.
|
|
30
|
+
- **Safe to re-run.** Every command is idempotent; a failed step can be fixed and run again.
|
|
31
|
+
- **No secrets in git.** `init` writes the sandbox token to `.commercelayer/credentials` (`C11R_ORG`, `C11R_TOKEN`) and gitignores it.
|
|
32
|
+
|
|
33
|
+
## Commands
|
|
34
|
+
|
|
35
|
+
| Command | Does |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `init` | Create a sandbox store, with no login. Writes `store.yml` (a commented template) and `credentials`. Reuses the store if they exist. |
|
|
38
|
+
| `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete. |
|
|
39
|
+
| `apply [--allow-delete]` | Make the store match the files. Refuses deletes without the flag. |
|
|
40
|
+
| `storefront init [--force]` | Write `storefront/index.html`: a static page on [drop-in.js](https://commercelayer.github.io/drop-in.js/) listing the products in `store.yml`, with the hosted cart and checkout, wired to the sandbox's sales channel. Refuses to overwrite without the flag. |
|
|
41
|
+
| `storefront publish` | Upload the folder at `storefront.path` (default `storefront`) to the store host. |
|
|
42
|
+
| `orders test [--sku <id>] [--quantity <n>] [--country <CC>] [--code <discount>] [--via-cart] [--decline]` | Buy from the store over UCP as a shopping agent would: discover, search, (cart,) checkout, address, pay with the sandbox handler, read the order back. Prints the order and every step. |
|
|
43
|
+
| `orders list` | The orders placed in the store, newest first, as UCP order entities. |
|
|
44
|
+
| `orders show <id or number>` | One order. |
|
|
45
|
+
| `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
|
|
46
|
+
| `ucp check` | Walk every UCP capability the store advertises and report what passes. Exits 1 on a failed check. |
|
|
47
|
+
| `status` | The sandbox, the sales channel client id, the published storefront, what is applied, pending changes, orders, the UCP profile URL. |
|
|
48
|
+
| `claim` | Start the device flow that keeps the sandbox: a URL and a code for the human. |
|
|
49
|
+
| `help [<command>]` | Describe the commands. `--version` prints the version. |
|
|
50
|
+
|
|
51
|
+
## store.yml
|
|
52
|
+
|
|
53
|
+
`init` writes a commented template. A filled-in store looks like this:
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
name: Beans
|
|
57
|
+
|
|
58
|
+
markets:
|
|
59
|
+
# One market per region you sell in. countries are ISO 3166 codes.
|
|
60
|
+
# shipping is optional: without it orders ship free as "Standard".
|
|
61
|
+
- code: eu
|
|
62
|
+
name: Europe
|
|
63
|
+
countries: [IT, FR, DE, ES]
|
|
64
|
+
currency: EUR
|
|
65
|
+
shipping: { method: Standard, price: 4.90, days: 3-5 }
|
|
66
|
+
- code: us
|
|
67
|
+
name: United States
|
|
68
|
+
countries: [US]
|
|
69
|
+
currency: USD
|
|
70
|
+
|
|
71
|
+
products:
|
|
72
|
+
# sku is unique and stays the same when you rename a product.
|
|
73
|
+
- sku: COFFEE-ETH-250
|
|
74
|
+
name: Ethiopia Yirgacheffe, 250 g
|
|
75
|
+
description: Washed, floral, bright.
|
|
76
|
+
image_url: https://example.com/eth.jpg
|
|
77
|
+
prices: { EUR: 14.50, USD: 16.00 }
|
|
78
|
+
|
|
79
|
+
storefront:
|
|
80
|
+
# The web app in this repo that sells the products, relative to the repo root.
|
|
81
|
+
path: storefront
|
|
82
|
+
|
|
83
|
+
policies:
|
|
84
|
+
# Pages shopping agents show buyers before they pay. Without them the store
|
|
85
|
+
# serves default sandbox pages at <preview_url>/policies/<name>.
|
|
86
|
+
privacy_policy: https://example.com/privacy
|
|
87
|
+
terms_of_service: https://example.com/terms
|
|
88
|
+
returns: https://example.com/returns
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### The products folder
|
|
92
|
+
|
|
93
|
+
Beyond a handful of products, put the catalog in `.commercelayer/products/` and leave `products` out of `store.yml`. Every file in the folder is merged into the catalog by sku, in file-name order:
|
|
94
|
+
|
|
95
|
+
- `<name>.yml`: a list of products in the shape above (one file per collection, or one per product, as you like).
|
|
96
|
+
- `<name>.csv`: one product per row. The first line names the columns: `sku`, `name`, `description`, `image_url`, and one column per currency, named by its ISO 4217 code, holding the price. Quoted cells and CRLF are fine; empty cells are left out.
|
|
97
|
+
|
|
98
|
+
```csv
|
|
99
|
+
sku,name,description,EUR,USD
|
|
100
|
+
COFFEE-ETH-250,"Ethiopia Yirgacheffe, 250 g","Washed, floral, bright.",14.50,16.00
|
|
101
|
+
COFFEE-COL-250,"Colombia Huila, 250 g",,12.00,13.50
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Errors name the file (and line, for CSV) and the sku, so one product file can be fixed without reading the rest. Moving a product between `store.yml` and a file is not a change to the store: `plan` keys everything by sku. The catalog has no stock: inventory is tracked live in Commerce Layer, and the sandbox treats every product as in stock. Prices in these files are what the store charges; a published storefront reads prices and availability live from Commerce Layer.
|
|
105
|
+
|
|
106
|
+
Rules `plan` and `apply` enforce: a name, at least one market and one product; every market has `code`, `name`, `countries` and an ISO 4217 `currency`; codes and skus are unique across all files; a product has only `sku`, `name`, `description`, `image_url` and `prices`; every product has a price in the currency of every market; `shipping`, if present, has `method` and a non-negative `price`; policy entries are URLs. Currency, languages and tax follow from the countries.
|
|
107
|
+
|
|
108
|
+
Error codes: `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `storefront_exists`, `no_storefront`, `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`.
|
|
109
|
+
|
|
110
|
+
## The store speaks UCP
|
|
111
|
+
|
|
112
|
+
Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
|
|
113
|
+
|
|
114
|
+
| What | Where |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| Business profile | `GET /.well-known/ucp` (`Cache-Control: public, max-age=300`, ES256 public key in `keys[]`) |
|
|
117
|
+
| Shopping service `dev.ucp.shopping` | `/ucp/v1`: `POST /catalog/search`, `POST /catalog/lookup`, `POST /catalog/product`, `POST /carts`, `GET/PUT /carts/{id}`, `POST /carts/{id}/cancel`, `POST /checkout-sessions`, `GET/PUT /checkout-sessions/{id}`, `POST /checkout-sessions/{id}/complete`, `POST /checkout-sessions/{id}/cancel`, `GET /orders/{id}` |
|
|
118
|
+
| Common service `dev.ucp.common` | `/ucp/v1`: `POST /ask` |
|
|
119
|
+
| Extensions | `dev.ucp.shopping.fulfillment` (one shipping method per market, from `shipping` in `store.yml`), `dev.ucp.shopping.discount` (the sandbox accepts code `SANDBOX10`, 10% off the items), `dev.ucp.shopping.buyer_consent`, `dev.ucp.shopping.policy.return` (30-day returns) |
|
|
120
|
+
| Identity linking `dev.ucp.common.identity_linking` | `GET /.well-known/oauth-authorization-server`, `GET /oauth/authorize` (PKCE S256; the sandbox approves at once), `POST /oauth/token` (`authorization_code`, `client_credentials`), `POST /oauth/revoke`, `GET /oauth/jwks`. Scopes `dev.ucp.shopping.order:read`, `dev.ucp.shopping.order:manage`. |
|
|
121
|
+
| Permalink `dev.ucp.shopping.permalink` | `GET /buy/{sku}:{qty}[,...]?context/postal_code=…&discounts/codes/0=…` → `303` to the cart |
|
|
122
|
+
| Payment handler `ai.commercelayer.sandbox` | id `cl_sandbox`, `card` instruments, credential `{ "type": "sandbox_token", "token": "sbx_tok_success" \| "sbx_tok_declined", "expiry": … }`. No money moves. |
|
|
123
|
+
| 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 `/`. |
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
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.
|
|
128
|
+
|
|
129
|
+
## The management API
|
|
130
|
+
|
|
131
|
+
The CLI keeps no state of its own: the store lives behind `api.commercelayer.ai` (the `commercelayer-ai-api` Worker) and the store hosts (the `commercelayer-ai-store` Worker). This repo ships a stub of the whole platform with the same routes, which both Workers are ported from and tested against. `src/api/types.ts` is the management contract; `src/api/stub/management.ts` is the reference for the API, and `src/api/stub/ucp.ts` and `shopping.ts` are the reference for the store host. A change to the stub is mirrored in the Worker it belongs to.
|
|
132
|
+
|
|
133
|
+
| Route | Auth | Does |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| `POST /sandboxes` `{ name }` | none | Create a sandbox with one sales channel and one signing key. Returns `org`, `token`, `claim_url`, `expires_at`, `preview_url`, `sales_channel_client_id`. |
|
|
136
|
+
| `GET /stores/:org` | bearer | The store: `applied`, `applied_hash`, `orders` (UCP entities, newest first), `storefront`, `sales_channel_client_id`, `claimed`, `expires_at`, `preview_url`, `claim_url`. |
|
|
137
|
+
| `PUT /stores/:org/resources` `{ applied, hash, name, policies }` | bearer | Make the store match the resources (markets by code, products by sku). `hash` digests every store file on disk; `status` compares it to report pending changes. Market scopes are `market:code:<code>`. |
|
|
138
|
+
| `PUT /stores/:org/storefront` `{ path, files: [{ path, content_base64 }] }` | bearer | Replace the static files served at the store host. `422 no_storefront` without an `index.html`. Returns `{ path, files, published_at }`. (The real API should take a `tar.gz` body with a size cap; base64 JSON is fine for the sandbox.) |
|
|
139
|
+
| `GET /stores/:org/orders` | bearer | The orders, newest first. |
|
|
140
|
+
| `GET /stores/:org/orders/:id` | bearer | One order, by id or label number, or `404 not_found`. |
|
|
141
|
+
| `POST /stores/:org/claim` | bearer | Start the device flow: `verification_url`, `user_code`. |
|
|
142
|
+
|
|
143
|
+
Errors come back as `{ "error": { "code", "message", "hint" } }` with 4xx/5xx and are printed as they are.
|
|
144
|
+
|
|
145
|
+
`C11R_API_URL` picks the platform:
|
|
146
|
+
|
|
147
|
+
- unset: `https://api.commercelayer.ai` and the real store hosts
|
|
148
|
+
- `stub`: the stub in-process, state in `~/.local/state/c11r/stub-api.json` (or `$C11R_STUB_DB`). No server to run; what agent tests use.
|
|
149
|
+
- `http://localhost:4747`: the stub over HTTP (`npm run stub-api`), to exercise the real client. Store hosts are reached as `http://localhost:4747/-/<org>.sandbox.commercelayer.store/...` or with a `Host` header.
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
npm install
|
|
155
|
+
npm run dev -- help # run from source
|
|
156
|
+
npm test # build, then end-to-end tests against the in-process stub
|
|
157
|
+
npm run stub-api # serve the stub platform on :4747
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Node 20 or later, one runtime dependency (`yaml`). Source in `src/`: `bin.ts` parses arguments, `commands.ts` has one function per command, `store.ts` validates and diffs the store, `products.ts` reads the product files (YAML and CSV), `files.ts` handles the files in the repo, `storefront.ts` is the drop-in.js scaffold; `api/` is the management client, its contract, the transport and the stub (`stub/management.ts`, `stub/ucp.ts`, `stub/shopping.ts`); `ucp/` is the UCP side: `platform.ts` (this CLI's profile and shared constants), `client.ts` (discovery and the REST calls), `signing.ts` (RFC 9421/9530), `buy.ts` (the buyer flow), `check.ts` (the conformance walk).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The client for the management API at api.commercelayer.ai.
|
|
2
|
+
import { fail } from '../output.js';
|
|
3
|
+
import { apiUrl, DEFAULT_API_URL, send } from './transport.js';
|
|
4
|
+
export function createApi(auth) {
|
|
5
|
+
const base = apiUrl() === 'stub' ? DEFAULT_API_URL : apiUrl().replace(/\/$/, '');
|
|
6
|
+
const org = () => {
|
|
7
|
+
if (!auth)
|
|
8
|
+
fail('no_store', 'There is no store in this repo.', 'Run `c11r init` in the repo root first.');
|
|
9
|
+
return `/stores/${auth.org}`;
|
|
10
|
+
};
|
|
11
|
+
const request = async (method, path, body) => {
|
|
12
|
+
const headers = { accept: 'application/json' };
|
|
13
|
+
if (auth)
|
|
14
|
+
headers.authorization = `Bearer ${auth.token}`;
|
|
15
|
+
const res = await send(method, base + path, headers, body === undefined ? undefined : JSON.stringify(body));
|
|
16
|
+
let data;
|
|
17
|
+
try {
|
|
18
|
+
data = res.body ? JSON.parse(res.body) : null;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
fail('api_error', `${base} answered ${res.status} with something that is not JSON.`, 'Try again. If it keeps happening, report it at https://commercelayer.ai/support with the output of `c11r status`.');
|
|
22
|
+
}
|
|
23
|
+
if (res.status < 400)
|
|
24
|
+
return data;
|
|
25
|
+
const err = data?.error;
|
|
26
|
+
if (err)
|
|
27
|
+
fail(err.code, err.message, err.hint);
|
|
28
|
+
fail('api_error', `The API answered ${res.status}.`, 'Try again. If it keeps happening, report it at https://commercelayer.ai/support with the output of `c11r status`.');
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
createSandbox: (name) => request('POST', '/sandboxes', { name }),
|
|
32
|
+
getStore: () => request('GET', org()),
|
|
33
|
+
applyStore: (body) => request('PUT', `${org()}/resources`, body),
|
|
34
|
+
publishStorefront: (path, files) => request('PUT', `${org()}/storefront`, { path, files }),
|
|
35
|
+
listOrders: () => request('GET', `${org()}/orders`),
|
|
36
|
+
getOrder: (id) => request('GET', `${org()}/orders/${encodeURIComponent(id)}`),
|
|
37
|
+
startClaim: () => request('POST', `${org()}/claim`),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Serve the stub platform over HTTP, so the CLI's real HTTP client can be exercised:
|
|
3
|
+
// npm run stub-api (port 4747)
|
|
4
|
+
// C11R_API_URL=http://localhost:4747 c11r init
|
|
5
|
+
// Store hosts are reached as http://localhost:4747/-/<org>.sandbox.commercelayer.store/...
|
|
6
|
+
// or with a real Host header.
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import { handle } from './stub/index.js';
|
|
9
|
+
const port = Number(process.env.PORT ?? 4747);
|
|
10
|
+
http
|
|
11
|
+
.createServer((req, res) => {
|
|
12
|
+
let raw = '';
|
|
13
|
+
req.on('data', (chunk) => (raw += chunk));
|
|
14
|
+
req.on('end', () => {
|
|
15
|
+
const headers = {};
|
|
16
|
+
for (const [k, v] of Object.entries(req.headers))
|
|
17
|
+
if (typeof v === 'string')
|
|
18
|
+
headers[k.toLowerCase()] = v;
|
|
19
|
+
const host = headers.host ?? `localhost:${port}`;
|
|
20
|
+
const url = new URL(req.url ?? '/', `http://${host}`);
|
|
21
|
+
const { status, headers: resHeaders, body } = handle({ method: req.method ?? 'GET', url, headers, body: raw || undefined });
|
|
22
|
+
res.writeHead(status, resHeaders);
|
|
23
|
+
res.end(body);
|
|
24
|
+
});
|
|
25
|
+
})
|
|
26
|
+
.listen(port, () => console.log(`stub platform on http://localhost:${port}`));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The stub's state: one JSON file, one record per sandbox. The real platform keeps
|
|
2
|
+
// the same data behind api.commercelayer.ai and the store hosts.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
const dbFile = process.env.C11R_STUB_DB
|
|
7
|
+
?? path.join(process.env.XDG_STATE_HOME ?? path.join(os.homedir(), '.local', 'state'), 'c11r', 'stub-api.json');
|
|
8
|
+
export function readDb() {
|
|
9
|
+
return fs.existsSync(dbFile) ? JSON.parse(fs.readFileSync(dbFile, 'utf8')) : {};
|
|
10
|
+
}
|
|
11
|
+
export function writeDb(db) {
|
|
12
|
+
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
|
|
13
|
+
fs.writeFileSync(dbFile, JSON.stringify(db, null, 2) + '\n');
|
|
14
|
+
}
|
|
15
|
+
export const SANDBOX_DOMAIN = 'sandbox.commercelayer.store';
|
|
16
|
+
/** The org a store hostname belongs to, or null if it is not a store host. */
|
|
17
|
+
export function orgOfHost(hostname) {
|
|
18
|
+
const m = hostname.match(/^([a-z0-9-]+)\.sandbox\.commercelayer\.store$/);
|
|
19
|
+
return m ? m[1] : null;
|
|
20
|
+
}
|
|
21
|
+
/** Orders of a store, newest first, without private members. */
|
|
22
|
+
export function publicOrders(r) {
|
|
23
|
+
return Object.values(r.ucp.orders).map((o) => strip(o)).reverse();
|
|
24
|
+
}
|
|
25
|
+
/** A deep copy without the members whose name starts with _. */
|
|
26
|
+
export function strip(value) {
|
|
27
|
+
if (Array.isArray(value))
|
|
28
|
+
return value.map(strip);
|
|
29
|
+
if (value && typeof value === 'object') {
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const [k, v] of Object.entries(value))
|
|
32
|
+
if (!k.startsWith('_'))
|
|
33
|
+
out[k] = strip(v);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The HTTP shapes the stub works with: what a Cloudflare Worker would see. Keeping
|
|
2
|
+
// the stub HTTP-shaped lets UCP headers (UCP-Agent, Idempotency-Key, signatures)
|
|
3
|
+
// be modelled the way the real API will have to handle them.
|
|
4
|
+
export function json(status, body, headers = {}) {
|
|
5
|
+
return { status, headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) };
|
|
6
|
+
}
|
|
7
|
+
/** The management API's error shape, the same the CLI prints. */
|
|
8
|
+
export function error(status, code, message, hint) {
|
|
9
|
+
return json(status, { error: { code, message, hint } });
|
|
10
|
+
}
|
|
11
|
+
export function readJson(req) {
|
|
12
|
+
if (!req.body)
|
|
13
|
+
return undefined;
|
|
14
|
+
return JSON.parse(req.body);
|
|
15
|
+
}
|
|
16
|
+
export function bearer(req) {
|
|
17
|
+
return req.headers.authorization?.replace(/^Bearer /i, '');
|
|
18
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// A stand-in for the whole platform: api.commercelayer.ai (management) and
|
|
2
|
+
// <org>.sandbox.commercelayer.store (the store: UCP, OAuth, the storefront).
|
|
3
|
+
// server.ts serves it over HTTP; transport.ts calls handle() in-process when
|
|
4
|
+
// C11R_API_URL=stub, so agent tests need no server running.
|
|
5
|
+
//
|
|
6
|
+
// Routing is by hostname. Over local HTTP, where subdomains do not resolve, the
|
|
7
|
+
// path prefix /-/<host>/... stands in for the hostname.
|
|
8
|
+
import { orgOfHost, readDb, writeDb } from './db.js';
|
|
9
|
+
import { json } from './http.js';
|
|
10
|
+
import { handleManagement } from './management.js';
|
|
11
|
+
import { handleUcp } from './ucp.js';
|
|
12
|
+
export function handle(req) {
|
|
13
|
+
let url = req.url;
|
|
14
|
+
const prefixed = url.pathname.match(/^\/-\/([^/]+)(\/.*)?$/);
|
|
15
|
+
if (prefixed)
|
|
16
|
+
url = new URL(`https://${prefixed[1]}${prefixed[2] ?? '/'}${url.search}`);
|
|
17
|
+
const org = orgOfHost(url.hostname);
|
|
18
|
+
if (org === null)
|
|
19
|
+
return handleManagement({ ...req, url });
|
|
20
|
+
const db = readDb();
|
|
21
|
+
const store = db[org];
|
|
22
|
+
if (!store)
|
|
23
|
+
return json(404, { error: { code: 'not_found', message: `There is no store at ${url.hostname}.`, hint: 'The sandbox may have expired.' } });
|
|
24
|
+
const res = handleUcp({ ...req, url }, store);
|
|
25
|
+
writeDb(db);
|
|
26
|
+
return res;
|
|
27
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The management API at api.commercelayer.ai: sandboxes, resources, storefront
|
|
2
|
+
// uploads, orders, claims. The CLI's only private API; the store hosts speak UCP.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { generateKey } from '../../ucp/signing.js';
|
|
5
|
+
import { publicOrders, readDb, SANDBOX_DOMAIN, writeDb } from './db.js';
|
|
6
|
+
import { bearer, error, json, readJson } from './http.js';
|
|
7
|
+
function publicState(r) {
|
|
8
|
+
const { token: _token, ucp: _ucp, name: _name, policies: _policies, ...state } = r;
|
|
9
|
+
return { ...state, orders: publicOrders(r) };
|
|
10
|
+
}
|
|
11
|
+
export function handleManagement(req) {
|
|
12
|
+
const db = readDb();
|
|
13
|
+
const path = req.url.pathname;
|
|
14
|
+
if (req.method === 'POST' && path === '/sandboxes') {
|
|
15
|
+
const name = String(readJson(req)?.name ?? 'store');
|
|
16
|
+
const slug = `${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'store'}-${crypto.randomBytes(2).toString('hex')}`;
|
|
17
|
+
const expires = new Date(Date.now() + 7 * 24 * 3600 * 1000);
|
|
18
|
+
expires.setUTCHours(0, 0, 0, 0);
|
|
19
|
+
const kid = `${slug}-${new Date().toISOString().slice(0, 7)}`;
|
|
20
|
+
const { privateJwk, publicJwk } = generateKey(kid);
|
|
21
|
+
const record = {
|
|
22
|
+
org: slug,
|
|
23
|
+
token: `sbx_${crypto.randomBytes(16).toString('hex')}`,
|
|
24
|
+
sandbox: true,
|
|
25
|
+
claimed: false,
|
|
26
|
+
claim_url: `https://commercelayer.ai/claim/${crypto.randomBytes(6).toString('hex')}`,
|
|
27
|
+
expires_at: expires.toISOString(),
|
|
28
|
+
preview_url: `https://${slug}.${SANDBOX_DOMAIN}`,
|
|
29
|
+
sales_channel_client_id: `sc_${crypto.randomBytes(12).toString('hex')}`,
|
|
30
|
+
storefront: null,
|
|
31
|
+
applied: null,
|
|
32
|
+
applied_hash: null,
|
|
33
|
+
ucp: { kid, private_jwk: privateJwk, public_jwk: publicJwk, carts: {}, checkouts: {}, orders: {}, idempotency: {}, webhooks: [], tokens: {}, codes: {}, files: {} },
|
|
34
|
+
};
|
|
35
|
+
db[slug] = record;
|
|
36
|
+
writeDb(db);
|
|
37
|
+
const { org, token, claim_url, expires_at, preview_url, sales_channel_client_id } = record;
|
|
38
|
+
return json(201, { org, token, claim_url, expires_at, preview_url, sales_channel_client_id });
|
|
39
|
+
}
|
|
40
|
+
const m = path.match(/^\/stores\/([^/]+)(\/.*)?$/);
|
|
41
|
+
if (!m)
|
|
42
|
+
return error(404, 'not_found', `No route ${req.method} ${path}.`, 'Check the command; run `c11r help`.');
|
|
43
|
+
const [, org, rest = ''] = m;
|
|
44
|
+
const record = db[org];
|
|
45
|
+
if (!record || record.token !== bearer(req)) {
|
|
46
|
+
return error(401, 'unauthorized', `No sandbox ${org} for this token.`, 'The sandbox may have expired or the credentials file may be stale. Remove .commercelayer/credentials and run `c11r init` again.');
|
|
47
|
+
}
|
|
48
|
+
if (new Date(record.expires_at) < new Date() && !record.claimed) {
|
|
49
|
+
return error(410, 'expired', `Sandbox ${org} expired on ${record.expires_at}.`, 'Remove .commercelayer/credentials and run `c11r init` to create a new sandbox; store.yml is kept.');
|
|
50
|
+
}
|
|
51
|
+
if (req.method === 'GET' && rest === '')
|
|
52
|
+
return json(200, publicState(record));
|
|
53
|
+
if (req.method === 'PUT' && rest === '/resources') {
|
|
54
|
+
const { applied, hash, name, policies } = readJson(req);
|
|
55
|
+
record.applied = applied;
|
|
56
|
+
record.applied_hash = hash;
|
|
57
|
+
record.name = name;
|
|
58
|
+
record.policies = policies ?? {};
|
|
59
|
+
writeDb(db);
|
|
60
|
+
return json(200, publicState(record));
|
|
61
|
+
}
|
|
62
|
+
if (req.method === 'PUT' && rest === '/storefront') {
|
|
63
|
+
const { path: dir, files } = readJson(req);
|
|
64
|
+
if (!files?.some((f) => f.path === 'index.html')) {
|
|
65
|
+
return error(422, 'no_storefront', 'The upload has no index.html.', 'Run `c11r storefront init` to create one, or set storefront.path in store.yml to the folder that has index.html.');
|
|
66
|
+
}
|
|
67
|
+
record.ucp.files = Object.fromEntries(files.map((f) => [f.path, f.content_base64]));
|
|
68
|
+
record.storefront = { path: dir, files: files.length, published_at: new Date().toISOString() };
|
|
69
|
+
writeDb(db);
|
|
70
|
+
return json(200, record.storefront);
|
|
71
|
+
}
|
|
72
|
+
if (req.method === 'GET' && rest === '/orders')
|
|
73
|
+
return json(200, publicOrders(record));
|
|
74
|
+
const one = rest.match(/^\/orders\/([^/]+)$/);
|
|
75
|
+
if (req.method === 'GET' && one) {
|
|
76
|
+
const wanted = decodeURIComponent(one[1]);
|
|
77
|
+
const found = publicOrders(record).find((o) => o.id === wanted || o.label === wanted || o.label === `#${wanted}`);
|
|
78
|
+
if (!found)
|
|
79
|
+
return error(404, 'not_found', `There is no order ${wanted}.`, 'Run `c11r orders list` to see the orders.');
|
|
80
|
+
return json(200, found);
|
|
81
|
+
}
|
|
82
|
+
if (req.method === 'POST' && rest === '/claim') {
|
|
83
|
+
return json(200, { verification_url: 'https://commercelayer.ai/device', user_code: 'BXQT-4KMF' });
|
|
84
|
+
}
|
|
85
|
+
return error(404, 'not_found', `No route ${req.method} ${path}.`, 'Check the command; run `c11r help`.');
|
|
86
|
+
}
|