@commercelayer/agent-cli 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,8 @@ It is a separate tool from [`@commercelayer/cli`](https://www.npmjs.com/package/
6
6
  npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11r init
7
7
  ```
8
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.
9
+ - **The repo is the store.** `.commercelayer/store.yml` holds the desired state (name, markets, theme, policies, and the products of a small store) and is committed; a large catalog goes in `.commercelayer/products/` as `.yml` or `.csv` files, images in `.commercelayer/img/`. `plan` shows what `apply` would change; `apply` makes the store match the files.
10
+ - **The website comes with the store.** Every store has a hosted theme at its preview URL, set by a few `theme:` settings and the product data: `apply` is all it takes. A custom storefront (your own web app) is opt-in: `storefront init`, `publish` and `check`.
10
11
  - **JSON only.** Every command prints one JSON object. A failure exits 1 with `{ "error": { "code", "message", "hint" } }`; the hint is the fix.
11
12
  - **Safe to re-run.** Every command is idempotent; a failed step can be fixed and run again.
12
13
  - **No secrets in git.** `init` writes the sandbox token to `.commercelayer/credentials` (`C11R_ORG`, `C11R_TOKEN`) and gitignores it. The keys of a catalog source go in the same file.
@@ -14,12 +15,12 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
14
15
  | Command | Does |
15
16
  | --- | --- |
16
17
  | `init [--name <business name>]` | Create a sandbox store, with no login. The name (the folder name when absent) names the organization; the org slug and store hostname derive from it, numbered when taken. 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: markets, products, and the store's name and policies. |
18
- | `apply [--allow-delete] [--full]` | Make the store match the files. Refuses deletes without `--allow-delete`. Product changes go through a Commerce Layer import: `apply` waits for it, with progress as JSON lines on stderr, and prints one result. `apply_failed` lists the products Commerce Layer refused; While Commerce Layer is busy or unavailable, `apply` keeps reading (backing off on busy) and writes a `retrying` line. `apply_pending` means the import keeps going after 10 minutes, and `c11r status` follows it. An apply still running is finished before a new one starts. `--full` re-imports every product and price, after edits in the Commerce Layer dashboard. |
18
+ | `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete: markets, products, images, and the store's name, policies and theme. `missing_images` lists `img/<file>` references with no file in `.commercelayer/img/` (`apply` refuses them); `unused_images` lists files there that nothing references. |
19
+ | `apply [--allow-delete] [--full]` | Make the store match the files, the website included: the theme settings, and the images in `.commercelayer/img/` that the theme and products reference, uploaded when new or changed (unused ones are dropped from the store host). Refuses deletes without `--allow-delete`. Product changes go through a Commerce Layer import: `apply` waits for it, with progress as JSON lines on stderr, and prints one result. `apply_failed` lists the products Commerce Layer refused; While Commerce Layer is busy or unavailable, `apply` keeps reading (backing off on busy) and writes a `retrying` line. `apply_pending` means the import keeps going after 10 minutes, and `c11r status` follows it. An apply still running is finished before a new one starts. `--full` re-imports every product and price, after edits in the Commerce Layer dashboard. |
19
20
  | `catalog pull` | Read the products from the catalog source in `store.yml` (a Supabase table, a Google Sheet or a Google Merchant Center feed) into `.commercelayer/products/catalog.csv`. Then `plan` and `apply` as usual. |
20
- | `storefront init [--force]` | Write a static storefront on [drop-in.js](https://commercelayer.github.io/drop-in.js/), no build step, wired to the sandbox's sales channel. `index.html`: the config block, the products grouped by top-level category with a navigation, a minicart that slides in on add to cart, the hosted cart and checkout, and a product page at `#/p/<sku>` built from `<template id="product-template">` and the `#products` data. `styles.css`: theme tokens first (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`), which also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change; then the drop-in.js overrides, then the page. `app.js`: the market switch, the category links and the product page. `img/`: product images (`image_url: img/<file>`); products with no image get a neutral tile. Refuses to overwrite without the flag; with it, rewrites the three files. |
21
- | `storefront publish` | Upload the folder at `storefront.path` (default `storefront`) to the store host, leaving out dotfiles. Lists products whose `image_url` points at a file that is not in the folder (`missing_images`). |
22
- | `storefront check` | Read the published storefront at the preview URL and check it: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product and none for unknown skus, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides (no dead `::part` rules), title, `lang`, viewport, description, and whether the folder changed since publish. Prints `{ status, summary, checks: [{ id, kind, status: pass\|warn\|fail, message, fix, items }], render }` and exits 1 when a check fails; warnings do not fail. `kind` is `static` today; a later rendering phase adds `render` checks and fills `render`. |
21
+ | `storefront init [--force]` | Custom storefronts only. Write a static storefront on [drop-in.js](https://commercelayer.github.io/drop-in.js/), no build step, wired to the sandbox's sales channel. `index.html`: the config block, the products grouped by top-level category with a navigation, a minicart that slides in on add to cart, the hosted cart and checkout, and a product page at `#/p/<sku>` built from `<template id="product-template">` and the `#products` data. `styles.css`: theme tokens first (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`), which also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change; then the drop-in.js overrides, then the page. `app.js`: the market switch, the category links and the product page. Product images are the store's, at `/img/<file>` (from `.commercelayer/img/`, uploaded by `apply`); products with no image get a neutral tile. The site's own files go in `storefront/assets/`. Refuses to overwrite without the flag; with it, rewrites the three files. |
22
+ | `storefront publish` | Custom storefronts only. Upload the folder at `storefront.path` (default `storefront`) to the store host, where it replaces the hosted theme, leaving out dotfiles. `/img/` on the store host is always the store's images, so files under the folder's `img/` are not served: publish lists them as `not_served`. |
23
+ | `storefront check` | Custom storefronts only. Read the published storefront at the preview URL and check it: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product and none for unknown skus, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides (no dead `::part` rules), title, `lang`, viewport, description, and whether the folder changed since publish. Prints `{ status, summary, checks: [{ id, kind, status: pass\|warn\|fail, message, fix, items }], render }` and exits 1 when a check fails; warnings do not fail. `kind` is `static` today; a later rendering phase adds `render` checks and fills `render`. |
23
24
  | `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. |
24
25
  | `orders list` | The orders placed in the store, newest first, as UCP order entities. |
25
26
  | `orders show <id or number>` | One order. |
@@ -49,11 +50,14 @@ products:
49
50
  - sku: COFFEE-ETH-250
50
51
  name: Ethiopia Yirgacheffe, 250 g
51
52
  description: Washed, floral, bright.
52
- image_url: https://example.com/eth.jpg
53
+ image_url: img/eth.jpg # .commercelayer/img/eth.jpg, or an https:// URL
53
54
  prices: { EUR: 14.50, USD: 16.00 }
54
- storefront:
55
- # The web app in this repo that sells the products, relative to the repo root.
56
- path: storefront
55
+ theme:
56
+ # The hosted website. Every setting is optional.
57
+ color: "#2f5d50" # the accent: buttons, links, the hero band; text on it is black or white by contrast
58
+ logo: img/logo.svg # or an https:// URL; default: the name as a wordmark
59
+ hero: img/hero.jpg # the home page's wide image; default: a band in the accent
60
+ tagline: Small-batch coffee, roasted in Milan # at most 120 characters; default: "Ships to <countries>"
57
61
  policies:
58
62
  # Pages shopping agents show buyers before they pay. Without them the store
59
63
  # serves default sandbox pages at <preview_url>/policies/<name>.
@@ -85,7 +89,14 @@ catalog:
85
89
  - `merchant`: a Google Merchant Center product feed URL, RSS 2.0 (XML, `g:` namespace) or TSV. `id` is the sku, `title` the name, `description`, `image_link` the image, `price` (`14.50 EUR`) the price in its currency, `product_type` the category. An id listed once per currency is one product with several prices. Other attributes, `sale_price` included, are ignored.
86
90
  Error codes: `no_catalog_source`, `invalid_catalog`, `missing_credentials`, `catalog_unreachable`, `catalog_unauthorized`, `catalog_not_found`, `catalog_not_csv`, `catalog_error`, `empty_catalog`, plus `invalid_product` and `missing_price`.
87
91
 
88
- Error codes: `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`.
92
+ ### The website
93
+ The theme renders the home page (logo, hero with name and tagline, products by top-level category), a page per category at `/categories/<slug>/...` and per product at `/products/<sku>`, with the market from `?market=<code>`, cart and checkout. Images written `img/<file>` (`theme.logo`, `theme.hero`, a product's `image_url`) are files in `.commercelayer/img/`, served at `<preview_url>/img/<file>`: paths of letters, digits, `.`, `_`, `-` and `/`, a lowercase `png`, `jpg`, `jpeg`, `webp`, `gif`, `avif` or `svg` extension, at most 5 MB each. `apply` uploads the new and changed ones (by SHA-256) before anything else, and the store host drops the ones nothing references any more.
94
+
95
+ 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.
96
+
97
+ 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/`.
98
+
99
+ Error codes: `invalid_theme`, `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`.
89
100
  ## The store speaks UCP
90
101
  Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
91
102
  | What | Where |
@@ -31,6 +31,7 @@ export function createApi(auth) {
31
31
  createSandbox: (name) => request('POST', '/sandboxes', { name }),
32
32
  getStore: () => request('GET', org()),
33
33
  applyStore: (body) => request('PUT', `${org()}/resources`, body),
34
+ uploadAssets: (files) => request('PUT', `${org()}/assets`, { files }),
34
35
  publishStorefront: (path, files) => request('PUT', `${org()}/storefront`, { path, files }),
35
36
  listOrders: () => request('GET', `${org()}/orders`),
36
37
  getOrder: (id) => request('GET', `${org()}/orders/${encodeURIComponent(id)}`),
@@ -1,13 +1,13 @@
1
1
  // The management API at api.commercelayer.ai: sandboxes, resources, storefront
2
2
  // uploads, orders, claims. The CLI's only private API; the store hosts speak UCP.
3
3
  import crypto from 'node:crypto';
4
- import { canonical } from '../../store.js';
4
+ import { canonical, IMAGE_PATH } from '../../store.js';
5
5
  import { generateKey } from '../../ucp/signing.js';
6
6
  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
- const { token: _token, ucp: _ucp, pending: _pending, ...state } = r;
10
- return { ...state, name: r.name ?? null, policies: r.policies ?? null, apply: r.apply ?? null, orders: publicOrders(r) };
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) };
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-/;
@@ -98,7 +98,7 @@ export function handleManagement(req) {
98
98
  writeDb(db);
99
99
  if (record.apply?.status === 'in_progress')
100
100
  return error(409, 'apply_in_progress', 'Another apply is still running.', 'Run `c11r status` to follow it, then apply again.');
101
- const { applied, hash, name, policies, allow_delete, full } = readJson(req);
101
+ const { applied, hash, name, policies, theme, assets, allow_delete, full } = readJson(req);
102
102
  const before = record.applied?.products ?? {};
103
103
  const removed = Object.keys(before).filter((sku) => !(sku in applied.products));
104
104
  if (removed.length && !allow_delete)
@@ -107,6 +107,17 @@ export function handleManagement(req) {
107
107
  // import job (SKUs, prices, then cleanups for removals) when any changed.
108
108
  record.name = name;
109
109
  record.policies = policies ?? {};
110
+ if (theme !== undefined)
111
+ record.theme = theme;
112
+ // Images not in the list are no longer used: dropped now. No list (older CLIs): nothing is dropped.
113
+ if (assets) {
114
+ for (const p of Object.keys(record.assets ?? {})) {
115
+ if (!assets.includes(p)) {
116
+ delete record.assets[p];
117
+ delete record.asset_files[p];
118
+ }
119
+ }
120
+ }
110
121
  const changed = Object.keys(applied.products).filter((sku) => full || !(sku in before) || canonical(before[sku]) !== canonical(applied.products[sku]));
111
122
  if (changed.length === 0 && removed.length === 0) {
112
123
  record.applied = applied;
@@ -129,6 +140,30 @@ export function handleManagement(req) {
129
140
  writeDb(db);
130
141
  return json(202, publicState(record));
131
142
  }
143
+ if (req.method === 'PUT' && rest === '/assets') {
144
+ const { files } = readJson(req);
145
+ let total = 0;
146
+ for (const f of files ?? []) {
147
+ if (!IMAGE_PATH.test(f.path) || f.path.includes('..') || f.path.includes('//'))
148
+ return error(422, 'invalid_asset', `${f.path} is not an image path the store host serves.`, 'Use img/<file> with letters, digits, . _ - and /, and an image extension.');
149
+ const bytes = Buffer.from(f.content_base64, 'base64');
150
+ if (crypto.createHash('sha256').update(bytes).digest('hex') !== f.sha256)
151
+ return error(422, 'asset_mismatch', `${f.path} does not hash to the sha256 sent.`, 'Run `c11r apply` again.');
152
+ if (bytes.length > 5 * 1024 * 1024)
153
+ return error(413, 'asset_too_large', `${f.path} is larger than 5 MB.`, 'Make the image smaller and apply again.');
154
+ total += bytes.length;
155
+ }
156
+ if (total > 25 * 1024 * 1024)
157
+ return error(413, 'upload_too_large', 'The upload is larger than 25 MB.', 'This is a bug in the CLI: it batches uploads. Tell the human, with this output.');
158
+ record.assets ??= {};
159
+ record.asset_files ??= {};
160
+ for (const f of files ?? []) {
161
+ record.assets[f.path] = f.sha256;
162
+ record.asset_files[f.path] = f.content_base64;
163
+ }
164
+ writeDb(db);
165
+ return json(200, { assets: record.assets });
166
+ }
132
167
  if (req.method === 'PUT' && rest === '/storefront') {
133
168
  const { path: dir, files } = readJson(req);
134
169
  if (!files?.some((f) => f.path === 'index.html')) {
@@ -0,0 +1,63 @@
1
+ // A stand-in for the hosted theme the store Worker renders on every store without a
2
+ // custom storefront: the same settings in the same places (accent, logo or wordmark,
3
+ // hero image or band, tagline), the products by top-level category, a page per product
4
+ // at /products/<sku>. Prices come from what is applied; "Buy" opens the cart permalink.
5
+ const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
6
+ /** Black or white, whichever reads better on the accent. */
7
+ function onAccent(hex) {
8
+ const h = hex.length === 4 ? hex.slice(1).replace(/./g, (c) => c + c) : hex.slice(1);
9
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
10
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.179 ? '#000000' : '#ffffff';
11
+ }
12
+ export function renderTheme(r, path, search) {
13
+ const products = Object.values(r.applied?.products ?? {});
14
+ const markets = Object.values(r.applied?.markets ?? {});
15
+ const theme = r.theme ?? {};
16
+ const color = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(theme.color ?? '') ? theme.color : '#2f5d50';
17
+ const name = r.name ?? r.org;
18
+ const market = markets.find((m) => m.code === search.get('market')) ?? markets[0];
19
+ const keep = market && market !== markets[0] ? `?market=${encodeURIComponent(market.code)}` : '';
20
+ const price = (p) => (market && p.prices[market.currency] !== undefined ? `${market.currency} ${p.prices[market.currency].toFixed(2)}` : '');
21
+ const tagline = theme.tagline ?? `Ships to ${markets.flatMap((m) => m.countries).join(', ')}`;
22
+ const logo = theme.logo ? `<img class="logo" src="/${esc(theme.logo.replace(/^\//, ''))}" alt="${esc(name)}">` : `<span class="wordmark">${esc(name)}</span>`;
23
+ const img = (url) => (/^https?:/.test(url) ? url : `/${url}`);
24
+ const card = (p) => `<article class="product"><a href="/products/${encodeURIComponent(p.sku)}${keep}">${p.image_url ? `<img src="${esc(img(p.image_url))}" alt="${esc(p.name)}">` : '<div class="placeholder"></div>'}<h3>${esc(p.name)}</h3></a><p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></article>`;
25
+ let title = name;
26
+ let body;
27
+ const m = path.match(/^\/products\/([^/]+)$/);
28
+ if (path === '/') {
29
+ const groups = new Map();
30
+ for (const p of products) {
31
+ const top = p.category?.split(' > ')[0] ?? 'Other';
32
+ groups.set(top, [...(groups.get(top) ?? []), p]);
33
+ }
34
+ const hero = theme.hero ? `<section class="hero" style="background-image:url('${esc(img(theme.hero))}')">` : '<section class="hero band">';
35
+ body = `${hero}<h1>${esc(name)}</h1><p class="tagline">${esc(tagline)}</p></section><main>${[...groups].map(([k, ps]) => `<section><h2>${esc(k)}</h2><div class="grid">${ps.map(card).join('')}</div></section>`).join('')}</main>`;
36
+ }
37
+ else if (m) {
38
+ const p = r.applied?.products[decodeURIComponent(m[1])];
39
+ if (!p)
40
+ return null;
41
+ title = `${p.name} · ${name}`;
42
+ body = `<main class="product-page">${p.image_url ? `<img src="${esc(img(p.image_url))}" alt="${esc(p.name)}">` : '<div class="placeholder"></div>'}<div>${p.category ? `<p class="category">${esc(p.category)}</p>` : ''}<h1>${esc(p.name)}</h1>${p.description ? `<p>${esc(p.description)}</p>` : ''}<p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></div></main>`;
43
+ }
44
+ else
45
+ return null;
46
+ const html = `<!doctype html>
47
+ <html lang="en">
48
+ <head>
49
+ <meta charset="utf-8">
50
+ <meta name="viewport" content="width=device-width, initial-scale=1">
51
+ <title>${esc(title)}</title>
52
+ <meta name="description" content="${esc(tagline)}">
53
+ <!-- c11r stub: a stand-in for the hosted theme. -->
54
+ <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>
55
+ </head>
56
+ <body>
57
+ <header><a href="/${keep}">${logo}</a><nav>${markets.map((x) => `<a href="?market=${encodeURIComponent(x.code)}">${esc(x.name)}</a>`).join(' ')}</nav></header>
58
+ ${body}
59
+ </body>
60
+ </html>
61
+ `;
62
+ return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: html };
63
+ }
@@ -8,6 +8,7 @@ import { contentDigest, signHeaders } from '../../ucp/signing.js';
8
8
  import { strip } from './db.js';
9
9
  import { json } from './http.js';
10
10
  import * as shop from './shopping.js';
11
+ import { renderTheme } from './theme.js';
11
12
  const SCOPES = ['dev.ucp.shopping.order:read', 'dev.ucp.shopping.order:manage'];
12
13
  const OUR_CAPABILITIES = [...SHOPPING_CAPABILITIES, 'dev.ucp.common.ask', ...Object.keys(EXTENSIONS), 'dev.ucp.common.identity_linking', 'dev.ucp.shopping.permalink'];
13
14
  // ---------- profile
@@ -301,9 +302,21 @@ function handlePermalink(req, r, origin, path) {
301
302
  target = `${origin}/carts/${cartId}`;
302
303
  return { status: 303, headers: { location: target, ...safe }, body: '' };
303
304
  }
304
- const MIME = { html: 'text/html; charset=utf-8', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript', json: 'application/json', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', ico: 'image/x-icon', txt: 'text/plain' };
305
- function serveStorefront(r, path) {
305
+ const MIME = { html: 'text/html; charset=utf-8', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript', json: 'application/json', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif', ico: 'image/x-icon', txt: 'text/plain' };
306
+ function serveStorefront(r, path, search) {
306
307
  const files = r.ucp.files;
308
+ // A custom storefront (an uploaded index.html) serves every path; otherwise the theme renders its routes.
309
+ if (files['index.html'] === undefined) {
310
+ const page = renderTheme(r, path, search);
311
+ if (page)
312
+ return page;
313
+ }
314
+ // /img/ is the store's images (uploaded by apply), whatever is in front of them.
315
+ const asset = r.asset_files?.[path.replace(/^\//, '')];
316
+ if (asset !== undefined) {
317
+ const ext = path.split('.').pop() ?? '';
318
+ return { status: 200, headers: { 'content-type': MIME[ext] ?? 'application/octet-stream' }, body: Buffer.from(asset, 'base64').toString('binary') };
319
+ }
307
320
  const candidates = [path.replace(/^\//, ''), `${path.replace(/^\/|\/$/g, '')}/index.html`.replace(/^\//, ''), ...(path === '/' ? ['index.html'] : [])];
308
321
  for (const c of candidates) {
309
322
  if (files[c] !== undefined) {
@@ -311,8 +324,6 @@ function serveStorefront(r, path) {
311
324
  return { status: 200, headers: { 'content-type': MIME[ext] ?? 'application/octet-stream' }, body: Buffer.from(files[c], 'base64').toString(ext in MIME && !MIME[ext].startsWith('image') ? 'utf8' : 'binary') };
312
325
  }
313
326
  }
314
- if (Object.keys(files).length === 0)
315
- return html(200, r.org, '<p>Nothing published yet. Run <code>c11r storefront publish</code>.</p>');
316
327
  return html(404, 'Not found', `<p>No ${path} in this storefront.</p>`);
317
328
  }
318
329
  // ---------- the host
@@ -342,7 +353,7 @@ export function handleUcp(req, r) {
342
353
  return html(404, 'Not found', `<p>No ${kind} ${m[2]}.</p>`);
343
354
  return html(200, `${kind === 'order' ? `Order ${found.label}` : `Your ${kind}`}`, `<p>${kind === 'order' ? 'Thank you. ' : ''}${found.line_items.length} line item(s), ${found.currency} ${(found.totals.at(-1).amount / 100).toFixed(2)}.</p>`);
344
355
  }
345
- return serveStorefront(r, path);
356
+ return serveStorefront(r, path, req.url.searchParams);
346
357
  }
347
358
  /** Lets the service read the bearer token for order authorization. */
348
359
  export function bearerOf(req) {
package/dist/assets.js ADDED
@@ -0,0 +1,100 @@
1
+ // The store's image files: logo, hero and product images written img/<file> live in
2
+ // .commercelayer/img/ and are served by the store host at <preview_url>/img/<file>,
3
+ // in front of the theme or a custom storefront alike.
4
+ // apply uploads the new and changed ones (by sha256) before the resources, and the
5
+ // resources PUT lists the ones still used, so the API drops the rest. plan and status
6
+ // report the same changes, the referenced files that are missing, and files nothing uses.
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { paths } from './files.js';
11
+ import { fail } from './output.js';
12
+ import { isLocalImage } from './store.js';
13
+ export const imgDir = path.join(paths.dir, 'img');
14
+ /** The API's limits: a file, and the decoded bytes of one upload. */
15
+ const MAX_FILE = 5 * 1024 * 1024;
16
+ const MAX_BATCH = 20 * 1024 * 1024;
17
+ /** Every img/<file> the store references: theme logo and hero, product images. */
18
+ export function referencedImages(store) {
19
+ const refs = new Map();
20
+ const add = (url, by) => {
21
+ if (url && isLocalImage(url))
22
+ refs.set(url, [...(refs.get(url) ?? []), by]);
23
+ };
24
+ add(store.theme?.logo, 'theme.logo');
25
+ add(store.theme?.hero, 'theme.hero');
26
+ for (const p of store.products)
27
+ add(p.image_url, p.sku);
28
+ return refs;
29
+ }
30
+ function filesUnder(dir) {
31
+ if (!fs.existsSync(dir))
32
+ return [];
33
+ return fs
34
+ .readdirSync(dir, { recursive: true, withFileTypes: true })
35
+ .filter((e) => e.isFile())
36
+ .map((e) => path.relative(dir, path.join(e.parentPath, e.name)).split(path.sep).join('/'))
37
+ .filter((rel) => !rel.split('/').some((part) => part.startsWith('.')));
38
+ }
39
+ const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
40
+ export function planAssets(state, store) {
41
+ const refs = referencedImages(store);
42
+ const missing = [];
43
+ const local = new Map();
44
+ for (const [ref, by] of refs) {
45
+ const file = path.join(paths.dir, ref);
46
+ if (fs.existsSync(file)) {
47
+ if (fs.statSync(file).size > MAX_FILE)
48
+ fail('asset_too_large', `${file} is larger than 5 MB.`, 'Make the image smaller (a product photo needs about 2000 px on the long side), keep the name, and run the command again.');
49
+ local.set(ref, { file, sha256: sha256(file) });
50
+ }
51
+ else
52
+ missing.push(`${by.join(', ')}: ${ref}`);
53
+ }
54
+ const unused = filesUnder(imgDir)
55
+ .map((rel) => `img/${rel}`)
56
+ .filter((p) => !refs.has(p));
57
+ const supported = state.assets !== undefined;
58
+ const served = state.assets ?? {};
59
+ const changes = [];
60
+ const upload = [];
61
+ if (supported) {
62
+ for (const [p, l] of [...local].sort(([a], [b]) => (a < b ? -1 : 1))) {
63
+ if (served[p] === l.sha256)
64
+ continue;
65
+ changes.push({ action: p in served ? 'update' : 'create', type: 'asset', id: p });
66
+ upload.push({ path: p, ...l });
67
+ }
68
+ for (const p of Object.keys(served).sort())
69
+ if (!local.has(p))
70
+ changes.push({ action: 'delete', type: 'asset', id: p });
71
+ }
72
+ return { supported, changes, missing, unused, keep: [...local.keys()].sort(), upload };
73
+ }
74
+ /** Upload the new and changed files, in requests under the API's size cap. */
75
+ export async function uploadAssets(api, plan) {
76
+ let batch = [];
77
+ let bytes = 0;
78
+ const flush = async () => {
79
+ if (batch.length)
80
+ await api.uploadAssets(batch);
81
+ batch = [];
82
+ bytes = 0;
83
+ };
84
+ for (const u of plan.upload) {
85
+ const content = fs.readFileSync(u.file);
86
+ if (bytes + content.length > MAX_BATCH)
87
+ await flush();
88
+ batch.push({ path: u.path, sha256: u.sha256, content_base64: content.toString('base64') });
89
+ bytes += content.length;
90
+ }
91
+ await flush();
92
+ return plan.upload.length;
93
+ }
94
+ /** The fields plan and status add for images: only when there is something to say. */
95
+ export function imageNotes(plan) {
96
+ return {
97
+ missing_images: plan.missing.length ? plan.missing : undefined,
98
+ unused_images: plan.unused.length ? plan.unused : undefined,
99
+ };
100
+ }
package/dist/commands.js CHANGED
@@ -6,8 +6,9 @@ import path from 'node:path';
6
6
  import { createApi } from './api/client.js';
7
7
  import { ignoreCredentials, paths, readCredentials, requireCredentials, writeCredentials } from './files.js';
8
8
  import { CliError, fail, progress } from './output.js';
9
- import { diff, hashStore, isLocalImage, loadStore, storefrontPath, template, toApplied } from './store.js';
9
+ import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from './store.js';
10
10
  import { storefrontFiles } from './storefront.js';
11
+ import { imageNotes, planAssets, uploadAssets } from './assets.js';
11
12
  import { pull } from './catalog.js';
12
13
  import { storefrontCheck } from './check.js';
13
14
  import { buy } from './ucp/buy.js';
@@ -18,12 +19,12 @@ import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
18
19
  export const VERSION = createRequire(import.meta.url)('../package.json').version;
19
20
  export const HELP = {
20
21
  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. Reuses the store if store.yml already exists.',
21
- plan: 'Show what apply would create, update or delete: markets, products and the store\'s name and policies, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku).',
22
- apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/. 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.',
22
+ 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).',
23
+ 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.',
23
24
  '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.',
24
- 'storefront init': '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, plus img/ for product images. Refuses to overwrite unless --force is given.',
25
- 'storefront check': '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.',
26
- 'storefront publish': 'Upload the storefront folder (store.yml storefront.path, default storefront) to the sandbox preview URL.',
25
+ '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.',
26
+ '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.',
27
+ 'storefront publish': 'Custom storefronts only: upload the storefront folder (store.yml storefront.path, default storefront) to the preview URL, where it replaces the hosted theme.',
27
28
  'orders test': 'Buy from the store over UCP as a shopping agent would, paying with the sandbox handler: discover, search, checkout, address, pay, read the order. Options: --sku <id> --quantity <n> --country <CC> --code <discount> --via-cart --decline.',
28
29
  'orders list': 'List the orders placed in the store, newest first, as UCP order entities.',
29
30
  'orders show': 'Show one order: c11r orders show <id or number>.',
@@ -75,15 +76,22 @@ export async function init(values) {
75
76
  expires_at: sandbox.expires_at,
76
77
  preview_url: sandbox.preview_url,
77
78
  files,
78
- next: 'Describe the store in .commercelayer/store.yml (products there or in .commercelayer/products/*.yml|csv), then run c11r plan.',
79
+ next: 'Describe the store in .commercelayer/store.yml (products there or in .commercelayer/products/*.yml|csv, the theme under theme:, images in .commercelayer/img/), then run c11r plan and c11r apply: the website is live at preview_url.',
79
80
  };
80
81
  }
81
82
  export async function plan() {
82
83
  const api = createApi(requireCredentials());
83
84
  const desired = loadStore();
84
85
  const state = await api.getStore();
85
- const changes = diff(state, desired);
86
- return { status: changes.length ? 'changes' : 'up_to_date', changes, deletes: changes.filter((c) => c.action === 'delete').length };
86
+ const assets = planAssets(state, desired);
87
+ const changes = [...diff(state, desired), ...assets.changes];
88
+ return {
89
+ status: changes.length ? 'changes' : 'up_to_date',
90
+ changes,
91
+ deletes: changes.filter((c) => c.action === 'delete' && c.type !== 'asset').length,
92
+ ...imageNotes(assets),
93
+ next: assets.missing.length ? 'Put the missing images in .commercelayer/img/ (img/<file> in store.yml is .commercelayer/img/<file>), or fix the references: apply refuses until they are there.' : undefined,
94
+ };
87
95
  }
88
96
  /** How often apply reads a running import, and when it stops waiting. Env overrides are for tests. */
89
97
  const POLL_MS = Number(process.env.C11R_APPLY_POLL_MS) || 2000;
@@ -130,12 +138,28 @@ export async function apply(flags) {
130
138
  let state = await api.getStore();
131
139
  if (state.apply?.status === 'in_progress')
132
140
  state = await follow(api, state);
133
- const changes = diff(state, desired);
134
- const deletes = changes.filter((c) => c.action === 'delete');
141
+ const assets = planAssets(state, desired);
142
+ const changes = [...diff(state, desired), ...assets.changes];
143
+ // Images nothing uses any more are dropped without asking: they are copies of files in the repo.
144
+ const deletes = changes.filter((c) => c.action === 'delete' && c.type !== 'asset');
135
145
  if (deletes.length && !flags.has('--allow-delete')) {
136
146
  fail('deletes_need_approval', `Apply would delete ${deletes.map((d) => `${d.type} ${d.id}`).join(', ')}.`, 'Ask the human whether to delete these. If they agree, run `c11r apply --allow-delete`.');
137
147
  }
138
- const body = { applied: toApplied(desired), hash: hashStore(), name: desired.name, policies: desired.policies, ...(flags.has('--allow-delete') && { allow_delete: true }), ...(flags.has('--full') && { full: true }) };
148
+ if (assets.missing.length) {
149
+ fail('missing_images', `The store references images that are not in .commercelayer/img/: ${assets.missing.join('; ')}.`, 'Put the files there (img/<file> in store.yml is .commercelayer/img/<file>), or fix or remove the references, then apply again.');
150
+ }
151
+ // Images first, so the theme and products never point at a file the store host does not have yet.
152
+ const uploaded = assets.supported ? await uploadAssets(api, assets) : 0;
153
+ const body = {
154
+ applied: toApplied(desired),
155
+ hash: hashStore(),
156
+ name: desired.name,
157
+ policies: desired.policies,
158
+ theme: desired.theme ?? {},
159
+ ...(assets.supported && { assets: assets.keep }),
160
+ ...(flags.has('--allow-delete') && { allow_delete: true }),
161
+ ...(flags.has('--full') && { full: true }),
162
+ };
139
163
  const started = await api.applyStore(body);
140
164
  const job = started.apply?.status === 'in_progress' ? started.apply : null;
141
165
  const done = job ? await follow(api, started) : started;
@@ -152,6 +176,8 @@ export async function apply(flags) {
152
176
  markets: desired.markets.length,
153
177
  products: desired.products.length,
154
178
  payment_gateway: 'sandbox (test payments only)',
179
+ images_uploaded: uploaded || undefined,
180
+ unused_images: assets.unused.length ? assets.unused : undefined,
155
181
  apply: job && done.apply ? jobSummary(done.apply) : undefined,
156
182
  };
157
183
  }
@@ -173,17 +199,12 @@ export async function storefrontInit(flags) {
173
199
  fs.writeFileSync(file, content);
174
200
  written.push(file);
175
201
  }
176
- // img/ is where product images go (image_url: img/<file>); .gitkeep keeps the empty folder in git, and publish skips dotfiles.
177
- const keep = path.join(dir, 'img', '.gitkeep');
178
- fs.mkdirSync(path.dirname(keep), { recursive: true });
179
- if (!fs.existsSync(keep))
180
- fs.writeFileSync(keep, '');
181
202
  return {
182
203
  status: 'created',
183
- files: [...written, keep],
204
+ files: written,
184
205
  markets: desired.markets.map((m) => m.code),
185
206
  products: desired.products.length,
186
- next: `Set storefront.path: ${dir} in ${paths.store}, make it the human's (restyle through the tokens at the top of styles.css; product images go in ${dir}/img/ with image_url: img/<file>; each product has a page at #/p/<sku>, built from #product-template in index.html), then run c11r storefront publish.`,
207
+ next: `Set storefront.path: ${dir} in ${paths.store}, make it the human's (restyle through the tokens at the top of styles.css; each product has a page at #/p/<sku>, built from #product-template in index.html; product images come from .commercelayer/img/ at /img/, and this site's own files go in ${dir}/assets/), then run c11r storefront publish.`,
187
208
  };
188
209
  }
189
210
  export async function storefrontCheckCmd() {
@@ -202,18 +223,8 @@ export async function storefrontPublish() {
202
223
  .map((e) => path.relative(dir, path.join(e.parentPath, e.name)).split(path.sep).join('/'))
203
224
  .filter((rel) => !rel.split('/').some((part) => part.startsWith('.')))
204
225
  .map((rel) => ({ path: rel, content_base64: fs.readFileSync(path.join(dir, rel)).toString('base64') }));
205
- // Products whose image_url is a path in the storefront that is not there.
206
- let missingImages = [];
207
- try {
208
- const uploaded = new Set(files.map((f) => f.path));
209
- missingImages = loadStore()
210
- .products.filter((p) => p.image_url && isLocalImage(p.image_url) && !uploaded.has(p.image_url.replace(/^\.?\//, '')))
211
- .map((p) => `${p.sku}: ${p.image_url}`);
212
- }
213
- catch (e) {
214
- if (!(e instanceof CliError))
215
- throw e;
216
- }
226
+ // /img/ on the store host is the store's images (.commercelayer/img/, uploaded by apply): files here under img/ are never served.
227
+ const shadowed = files.filter((f) => f.path.startsWith('img/')).map((f) => f.path);
217
228
  const published = await api.publishStorefront(dir, files);
218
229
  const state = await api.getStore();
219
230
  return {
@@ -222,8 +233,8 @@ export async function storefrontPublish() {
222
233
  path: dir,
223
234
  files: published.files,
224
235
  published_at: published.published_at,
225
- missing_images: missingImages.length ? missingImages : undefined,
226
- next: missingImages.length ? `Add the missing images under ${dir}/ (image_url is relative to it), or fix image_url, then publish again.` : undefined,
236
+ not_served: shadowed.length ? shadowed : undefined,
237
+ next: shadowed.length ? `/img/ is the store's images, from .commercelayer/img/ (uploaded by apply): move product images there and this site's own files to ${dir}/assets/.` : undefined,
227
238
  };
228
239
  }
229
240
  /** The store's origin on the web: where it speaks UCP. */
@@ -283,7 +294,8 @@ export async function status() {
283
294
  let pending = [];
284
295
  let storeError;
285
296
  try {
286
- pending = diff(s, loadStore());
297
+ const desired = loadStore();
298
+ pending = [...diff(s, desired), ...planAssets(s, desired).changes];
287
299
  }
288
300
  catch (e) {
289
301
  if (!(e instanceof CliError))
package/dist/store.js CHANGED
@@ -10,6 +10,7 @@ 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
14
  export const template = (name) => `# Commerce Layer store. This file is the store: edit it, then run
14
15
  # c11r plan to see what will change
15
16
  # c11r apply to make the change
@@ -31,6 +32,7 @@ products:
31
32
  # <name>.yml a list of products in this same shape
32
33
  # <name>.csv one product per row, columns: sku, name, description, image_url,
33
34
  # category, and one column per currency (EUR, USD, ...) with the price
35
+ # image_url is an https:// URL or a file in .commercelayer/img/, written img/<file>.
34
36
  # category is optional, a path from the top level: Coffee > Single origin.
35
37
  # Use the folder for anything beyond a handful of products.
36
38
  # sku is unique and stays the same when you rename a product.
@@ -51,8 +53,17 @@ catalog:
51
53
  # url: https://...
52
54
  # table: products
53
55
 
56
+ theme:
57
+ # The store's website: a theme the platform renders and keeps up to date.
58
+ # Every setting is optional. Images live in .commercelayer/img/ and apply uploads them.
59
+ # color: "#2f5d50" # the accent: buttons, links, the hero band
60
+ # logo: img/logo.svg # or an https:// URL; without it the name is the logo
61
+ # hero: img/hero.jpg # the home page's wide image; without it a band in the accent
62
+ # tagline: Small-batch coffee, roasted in Milan # at most 120 characters
63
+
54
64
  storefront:
55
- # The web app in this repo that sells the products, relative to the repo root.
65
+ # Only for a custom storefront (your own web app in this repo, published with
66
+ # c11r storefront publish): it replaces the theme. Its folder, from the repo root.
56
67
  # path: storefront
57
68
 
58
69
  policies:
@@ -130,9 +141,10 @@ export function loadStore() {
130
141
  if (url && !/^https?:\/\//.test(String(url)))
131
142
  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.');
132
143
  }
144
+ const theme = checkTheme(doc.theme);
133
145
  const currencies = [...new Set(markets.map((m) => m.currency))];
134
146
  const products = checkProducts(sourced, currencies);
135
- return { name: doc.name, markets, products, storefront: doc.storefront, policies };
147
+ return { name: doc.name, markets, products, storefront: doc.storefront, policies, theme };
136
148
  }
137
149
  /** The product rules, shared by loadStore and catalog pull: errors name each product's source. */
138
150
  export function checkProducts(sourced, currencies) {
@@ -153,11 +165,8 @@ export function checkProducts(sourced, currencies) {
153
165
  const why = unknown.includes('stock') ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : '';
154
166
  fail('invalid_product', `Product ${p.sku} in ${source} has ${unknown.length === 1 ? 'a field' : 'fields'} not in the catalog: ${unknown.join(', ')}.`, `${why}A product has ${PRODUCT_KEYS.join(', ')}. Remove the other fields.`);
155
167
  }
156
- if (p.image_url !== undefined) {
157
- const url = String(p.image_url);
158
- const ok = isLocalImage(url) ? !url.split('/').includes('..') && url.trim() === url && url !== '' : /^https?:\/\/\S+$/.test(url);
159
- if (!ok)
160
- fail('invalid_product', `Product ${p.sku} in ${source} has image_url "${url}".`, 'Use a full https:// URL, or a path in the storefront folder such as img/eth.jpg (no "..").');
168
+ if (p.image_url !== undefined && !isImageRef(String(p.image_url))) {
169
+ fail('invalid_product', `Product ${p.sku} in ${source} has image_url "${p.image_url}".`, IMAGE_HINT);
161
170
  }
162
171
  if (p.category !== undefined) {
163
172
  const parts = typeof p.category === 'string' ? p.category.split('>').map((c) => c.trim()) : [''];
@@ -175,8 +184,36 @@ export function checkProducts(sourced, currencies) {
175
184
  }
176
185
  return products;
177
186
  }
178
- /** An image_url that is a path in the storefront (img/eth.jpg), not a URL. */
187
+ /** An image reference that is a file in .commercelayer/img/ (img/eth.jpg), not a URL. */
179
188
  export const isLocalImage = (url) => !/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//');
189
+ /** The paths the store host serves images at, as the API accepts them. */
190
+ export const IMAGE_PATH = /^img\/[A-Za-z0-9._-][A-Za-z0-9._/-]*\.(png|jpg|jpeg|webp|gif|avif|svg)$/;
191
+ /** image_url, logo, hero: an https URL, or img/<file> for a file in .commercelayer/img/. */
192
+ export const isImageRef = (url) => isLocalImage(url) ? IMAGE_PATH.test(url) && !url.includes('..') && !url.includes('//') : /^https?:\/\/\S+$/.test(url);
193
+ 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).';
194
+ /** The theme block of store.yml, validated: the theme would ignore bad values, so say so here. */
195
+ function checkTheme(raw) {
196
+ if (raw === undefined || raw === null)
197
+ return undefined;
198
+ if (typeof raw !== 'object' || Array.isArray(raw))
199
+ fail('invalid_theme', 'theme in store.yml is not a mapping.', 'Write `theme:` with color, logo, hero and tagline under it, each optional. See the comments in store.yml.');
200
+ const t = raw;
201
+ const unknown = Object.keys(t).filter((k) => !THEME_KEYS.includes(k));
202
+ if (unknown.length)
203
+ fail('invalid_theme', `theme in store.yml has ${unknown.length === 1 ? 'a setting' : 'settings'} the theme does not have: ${unknown.join(', ')}.`, `The theme's settings are ${THEME_KEYS.join(', ')}. A custom storefront's folder goes under storefront.path.`);
204
+ const theme = {};
205
+ for (const k of THEME_KEYS)
206
+ if (t[k] !== undefined && t[k] !== null)
207
+ theme[k] = String(t[k]);
208
+ if (theme.color !== undefined && !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(theme.color))
209
+ fail('invalid_theme', `theme.color is "${theme.color}".`, 'Use a hex colour in quotes, #rgb or #rrggbb, such as "#2f5d50".');
210
+ for (const k of ['logo', 'hero'])
211
+ if (theme[k] !== undefined && !isImageRef(theme[k]))
212
+ fail('invalid_theme', `theme.${k} is "${theme[k]}".`, IMAGE_HINT);
213
+ if (theme.tagline !== undefined && (theme.tagline.length > 120 || /[\r\n<>]/.test(theme.tagline)))
214
+ 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.');
215
+ return theme;
216
+ }
180
217
  export function toApplied(store) {
181
218
  return {
182
219
  markets: Object.fromEntries(store.markets.map((m) => [m.code, m])),
@@ -194,6 +231,8 @@ export function diff(state, desired) {
194
231
  changes.push({ action: 'update', type: 'store', id: 'name' });
195
232
  if (state.policies !== undefined && canonical(state.policies) !== canonical(desired.policies))
196
233
  changes.push({ action: 'update', type: 'store', id: 'policies' });
234
+ if (state.theme !== undefined && canonical(state.theme) !== canonical(desired.theme))
235
+ changes.push({ action: 'update', type: 'store', id: 'theme' });
197
236
  }
198
237
  const after = toApplied(desired);
199
238
  for (const kind of ['markets', 'products']) {
@@ -2,7 +2,11 @@
2
2
  // index.html (the config block, the drop-in elements, the products grouped by
3
3
  // top-level category), styles.css (theme tokens first, then the drop-in.css
4
4
  // overrides, then the page) and app.js (market switch, category links, and a
5
- // product page at #/p/<sku>). The agent then makes it the human's.
5
+ // product page at #/p/<sku>). Product images are the store's, at /img/<file> (from
6
+ // .commercelayer/img/, uploaded by apply). The agent then makes it the human's.
7
+ import { isLocalImage } from './store.js';
8
+ /** img/<file> is the store's image at /img/<file> on the store host, whatever page links it. */
9
+ const imageSrc = (url) => (isLocalImage(url) ? `/${url}` : url);
6
10
  const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
7
11
  /** The scaffold's files, by path relative to the storefront folder. */
8
12
  export function storefrontFiles(store, clientId) {
@@ -13,7 +17,7 @@ function indexHtml(store, clientId) {
13
17
  const card = (p, h) => {
14
18
  const href = `#/p/${encodeURIComponent(p.sku)}`;
15
19
  return ` <article class="product" id="p-${esc(p.sku)}">
16
- <a class="media" href="${esc(href)}" tabindex="-1">${p.image_url ? `<img src="${esc(p.image_url)}" alt="${esc(p.name)}">` : '<div class="placeholder" aria-hidden="true"></div>'}</a>
20
+ <a class="media" href="${esc(href)}" tabindex="-1">${p.image_url ? `<img src="${esc(imageSrc(p.image_url))}" alt="${esc(p.name)}">` : '<div class="placeholder" aria-hidden="true"></div>'}</a>
17
21
  <${h}><a href="${esc(href)}">${esc(p.name)}</a></${h}>
18
22
  ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-price code="${esc(p.sku)}"><cl-price-amount type="compare-at"></cl-price-amount> <cl-price-amount type="price"></cl-price-amount></cl-price>
19
23
  <cl-availability code="${esc(p.sku)}"><cl-availability-status type="available">In stock</cl-availability-status><cl-availability-status type="unavailable">Sold out</cl-availability-status></cl-availability>
@@ -49,7 +53,7 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
49
53
  }
50
54
  const options = store.markets.map((m) => ` <option value="${esc(m.code)}">${esc(m.name)} (${esc(m.currency)})</option>`).join('\n');
51
55
  // What the product page shows, by sku. Prices and availability come live from drop-in.js.
52
- const data = Object.fromEntries(store.products.map((p) => [p.sku, { name: p.name, description: p.description, image_url: p.image_url, category: p.category }]));
56
+ const data = Object.fromEntries(store.products.map((p) => [p.sku, { name: p.name, description: p.description, image_url: p.image_url && imageSrc(p.image_url), category: p.category }]));
53
57
  const json = JSON.stringify(data, null, 2).replace(/</g, '\\u003c');
54
58
  return `<!doctype html>
55
59
  <html lang="en">
@@ -60,7 +64,8 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
60
64
  <!-- Commerce Layer drop-in.js: prices, availability, add to cart, the minicart (a slide-in panel, styled by minicart.css) and links to the hosted cart and checkout. -->
61
65
  <link href="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.css" rel="stylesheet">
62
66
  <link href="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/minicart.css" rel="stylesheet">
63
- <!-- After the drop-in.js stylesheets: styles.css overrides them with the same selectors. Restyle through its tokens. -->
67
+ <!-- After the drop-in.js stylesheets: styles.css overrides them with the same selectors. Restyle through its tokens.
68
+ Product images are the store's, at /img/ (.commercelayer/img/, uploaded by apply). Put this site's own files in assets/: /img/ is not served from here. -->
64
69
  <link href="styles.css" rel="stylesheet">
65
70
  <script>
66
71
  // The market comes from ?market=<code>; the first market in store.yml is the default. Keep clientId as it is.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.1.13",
3
+ "version": "0.2.0",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [
@@ -24,7 +24,7 @@
24
24
  "README.md"
25
25
  ],
26
26
  "scripts": {
27
- "build": "tsc -p tsconfig.json && chmod +x dist/bin.js dist/api/server.js",
27
+ "build": "rm -rf dist.next && tsc -p tsconfig.json --outDir dist.next && chmod +x dist.next/bin.js dist.next/api/server.js && rm -rf dist.old && { mv dist dist.old 2>/dev/null; mv dist.next dist; } && rm -rf dist.old",
28
28
  "dev": "tsx src/bin.ts",
29
29
  "stub-api": "tsx src/api/server.ts",
30
30
  "test": "npm run build && vitest run",