@commercelayer/agent-cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +98 -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/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 +43 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Commerce Layer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @commercelayer/agent-cli
|
|
2
|
+
`c11r`, the Commerce Layer CLI for coding agents. An agent reads [commercelayer.ai/start.md](https://commercelayer.ai/start.md), runs this CLI, and the human ends up with a working store in a sandbox: products, prices, a market, a storefront at `<org>.sandbox.commercelayer.store`, and a paid test order. No account needed until they decide to keep it.
|
|
3
|
+
Every store speaks the [Universal Commerce Protocol](https://ucp.dev) on its own hostname, so shopping agents can discover it, browse it and buy from it. The CLI itself buys through UCP (`orders test`) and checks the store against the spec (`ucp check`), so what it verifies is what any agent would get.
|
|
4
|
+
It is a separate tool from [`@commercelayer/cli`](https://www.npmjs.com/package/@commercelayer/cli), the CLI for humans. The package is `@commercelayer/agent-cli`; the command is `c11r` (c, eleven letters, r), short and unmistakable in an agent's transcript.
|
|
5
|
+
```
|
|
6
|
+
npx -y @commercelayer/agent-cli@latest init # runs c11r init
|
|
7
|
+
```
|
|
8
|
+
## How it works
|
|
9
|
+
- **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.
|
|
10
|
+
- **JSON only.** Every command prints one JSON object. A failure exits 1 with `{ "error": { "code", "message", "hint" } }`; the hint is the fix.
|
|
11
|
+
- **Safe to re-run.** Every command is idempotent; a failed step can be fixed and run again.
|
|
12
|
+
- **No secrets in git.** `init` writes the sandbox token to `.commercelayer/credentials` (`C11R_ORG`, `C11R_TOKEN`) and gitignores it.
|
|
13
|
+
## Commands
|
|
14
|
+
| Command | Does |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `init` | Create a sandbox store, with no login. Writes `store.yml` (a commented template) and `credentials`. Reuses the store if they exist. |
|
|
17
|
+
| `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete. |
|
|
18
|
+
| `apply [--allow-delete]` | Make the store match the files. Refuses deletes without the flag. |
|
|
19
|
+
| `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. |
|
|
20
|
+
| `storefront publish` | Upload the folder at `storefront.path` (default `storefront`) to the store host. |
|
|
21
|
+
| `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. |
|
|
22
|
+
| `orders list` | The orders placed in the store, newest first, as UCP order entities. |
|
|
23
|
+
| `orders show <id or number>` | One order. |
|
|
24
|
+
| `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
|
|
25
|
+
| `ucp check` | Walk every UCP capability the store advertises and report what passes. Exits 1 on a failed check. |
|
|
26
|
+
| `status` | The sandbox, the sales channel client id, the published storefront, what is applied, pending changes, orders, the UCP profile URL. |
|
|
27
|
+
| `claim` | Start the device flow that keeps the sandbox: a URL and a code for the human. |
|
|
28
|
+
| `help [<command>]` | Describe the commands. `--version` prints the version. |
|
|
29
|
+
## store.yml
|
|
30
|
+
`init` writes a commented template. A filled-in store looks like this:
|
|
31
|
+
```yaml
|
|
32
|
+
name: Beans
|
|
33
|
+
markets:
|
|
34
|
+
# One market per region you sell in. countries are ISO 3166 codes.
|
|
35
|
+
# shipping is optional: without it orders ship free as "Standard".
|
|
36
|
+
- code: eu
|
|
37
|
+
name: Europe
|
|
38
|
+
countries: [IT, FR, DE, ES]
|
|
39
|
+
currency: EUR
|
|
40
|
+
shipping: { method: Standard, price: 4.90, days: 3-5 }
|
|
41
|
+
- code: us
|
|
42
|
+
name: United States
|
|
43
|
+
countries: [US]
|
|
44
|
+
currency: USD
|
|
45
|
+
products:
|
|
46
|
+
# sku is unique and stays the same when you rename a product.
|
|
47
|
+
- sku: COFFEE-ETH-250
|
|
48
|
+
name: Ethiopia Yirgacheffe, 250 g
|
|
49
|
+
description: Washed, floral, bright.
|
|
50
|
+
image_url: https://example.com/eth.jpg
|
|
51
|
+
prices: { EUR: 14.50, USD: 16.00 }
|
|
52
|
+
storefront:
|
|
53
|
+
# The web app in this repo that sells the products, relative to the repo root.
|
|
54
|
+
path: storefront
|
|
55
|
+
policies:
|
|
56
|
+
# Pages shopping agents show buyers before they pay. Without them the store
|
|
57
|
+
# serves default sandbox pages at <preview_url>/policies/<name>.
|
|
58
|
+
privacy_policy: https://example.com/privacy
|
|
59
|
+
terms_of_service: https://example.com/terms
|
|
60
|
+
returns: https://example.com/returns
|
|
61
|
+
```
|
|
62
|
+
### The products folder
|
|
63
|
+
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:
|
|
64
|
+
- `<name>.yml`: a list of products in the shape above (one file per collection, or one per product, as you like).
|
|
65
|
+
- `<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.
|
|
66
|
+
```csv
|
|
67
|
+
sku,name,description,EUR,USD
|
|
68
|
+
COFFEE-ETH-250,"Ethiopia Yirgacheffe, 250 g","Washed, floral, bright.",14.50,16.00
|
|
69
|
+
COFFEE-COL-250,"Colombia Huila, 250 g",,12.00,13.50
|
|
70
|
+
```
|
|
71
|
+
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.
|
|
72
|
+
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.
|
|
73
|
+
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`.
|
|
74
|
+
## The store speaks UCP
|
|
75
|
+
Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
|
|
76
|
+
| What | Where |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| Business profile | `GET /.well-known/ucp` (`Cache-Control: public, max-age=300`, ES256 public key in `keys[]`) |
|
|
79
|
+
| 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}` |
|
|
80
|
+
| Common service `dev.ucp.common` | `/ucp/v1`: `POST /ask` |
|
|
81
|
+
| 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) |
|
|
82
|
+
| 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`. |
|
|
83
|
+
| Permalink `dev.ucp.shopping.permalink` | `GET /buy/{sku}:{qty}[,...]?context/postal_code=…&discounts/codes/0=…` → `303` to the cart |
|
|
84
|
+
| 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. |
|
|
85
|
+
| 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 `/`. |
|
|
86
|
+
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.
|
|
87
|
+
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.
|
|
88
|
+
## Requirements
|
|
2
89
|
|
|
3
|
-
|
|
90
|
+
Node 20 or later. One runtime dependency (`yaml`).
|
|
91
|
+
|
|
92
|
+
## Links and license
|
|
93
|
+
|
|
94
|
+
- [commercelayer.ai/start.md](https://commercelayer.ai/start.md): the entry point an agent reads.
|
|
95
|
+
- [Universal Commerce Protocol](https://ucp.dev): the protocol every store speaks.
|
|
96
|
+
- [`@commercelayer/cli`](https://www.npmjs.com/package/@commercelayer/cli): the Commerce Layer CLI for humans.
|
|
97
|
+
- [Commerce Layer](https://commercelayer.io): the commerce API behind the stores.
|
|
98
|
+
|
|
99
|
+
MIT. Copyright Commerce Layer.
|
|
@@ -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
|
+
}
|