@commercelayer/agent-cli 0.1.7 → 0.1.9

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
@@ -9,21 +9,22 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
9
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
10
  - **JSON only.** Every command prints one JSON object. A failure exits 1 with `{ "error": { "code", "message", "hint" } }`; the hint is the fix.
11
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.
12
+ - **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.
13
13
  ## Commands
14
14
  | Command | Does |
15
15
  | --- | --- |
16
16
  | `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
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]` | 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 a minicart that slides in on add to cart, and the hosted cart and checkout, wired to the sandbox's sales channel. Refuses to overwrite without the flag. |
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; `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
+ | `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 `storefront/index.html`: a static page on [drop-in.js](https://commercelayer.github.io/drop-in.js/) listing the products in `store.yml`, grouped by top-level category with a navigation, with a minicart that slides in on add to cart, and the hosted cart and checkout, wired to the sandbox's sales channel. Refuses to overwrite without the flag. |
20
21
  | `storefront publish` | Upload the folder at `storefront.path` (default `storefront`) to the store host. |
21
22
  | `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
23
  | `orders list` | The orders placed in the store, newest first, as UCP order entities. |
23
24
  | `orders show <id or number>` | One order. |
24
25
  | `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
25
26
  | `ucp check` | Walk every UCP capability the store advertises and report what passes. Places and pays one real test order on the way (the output says which). Exits 1 on a failed check. |
26
- | `status` | The sandbox, the sales channel client id, the published storefront, what is applied, the pending changes (exactly what `plan` lists; a store that does not load is pending too, with the error), orders, the UCP profile URL. |
27
+ | `status` | The sandbox, the sales channel client id, the published storefront, what is applied, the last import job (`apply`), the pending changes (exactly what `plan` lists; a store that does not load is pending too, with the error), orders, the UCP profile URL. |
27
28
  | `claim` | Start the device flow that keeps the sandbox: a URL and a code for the human. |
28
29
  | `help [<command>]` | Describe the commands. `--version` prints the version. |
29
30
  ## store.yml
@@ -62,15 +63,28 @@ policies:
62
63
  ### The products folder
63
64
  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
65
  - `<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
+ - `<name>.csv`: one product per row. The first line names the columns: `sku`, `name`, `description`, `image_url`, `category`, 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
67
  ```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
68
+ sku,name,description,category,EUR,USD
69
+ COFFEE-ETH-250,"Ethiopia Yirgacheffe, 250 g","Washed, floral, bright.",Coffee > Single origin,14.50,16.00
70
+ COFFEE-COL-250,"Colombia Huila, 250 g",,Coffee > Single origin,12.00,13.50
70
71
  ```
71
72
  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`.
73
+ 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`, `category` and `prices`; a `category` is a path of names from the top level, separated by ` > `; 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.
74
+ ### A catalog the human edits
75
+ When the products live in a tool the human edits too, `store.yml` names it and `c11r catalog pull` copies it into `.commercelayer/products/catalog.csv`, sorted by sku: same source, same file. Pull applies the product rules above before it writes, with errors naming the row in the source, and reports the skus added, changed and removed. Removed products leave the store only through `apply --allow-delete`. Every source has the same columns: `sku`, `name`, `description`, `image_url`, `category`, and one price column per market currency, named by its ISO 4217 code in lowercase (`eur`, `usd`).
76
+ ```yaml
77
+ catalog:
78
+ source: sheets # sheets | supabase | merchant
79
+ url: https://docs.google.com/spreadsheets/d/e/2PACX-.../pub?output=csv
80
+ # table: products # supabase only
81
+ ```
82
+ - `sheets`: a Google Sheet, by its CSV link (File > Share > Publish to web > Comma-separated values) or by its own link when anyone with the link can view it. No key.
83
+ - `supabase`: a table (default `products`), read over the REST API with the project's anon key: `C11R_SUPABASE_URL=https://<project>.supabase.co` and `C11R_SUPABASE_KEY=<anon key>` in `.commercelayer/credentials`, or the environment. The key needs a row level security policy that allows select. Never use the `service_role` key. Columns `id`, `created_at` and `updated_at` are skipped.
84
+ - `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.
85
+ 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`.
86
+
87
+ 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`, `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
88
  ## The store speaks UCP
75
89
  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
90
  | What | Where |
@@ -1,12 +1,41 @@
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
5
  import { generateKey } from '../../ucp/signing.js';
5
6
  import { publicOrders, readDb, SANDBOX_DOMAIN, writeDb } from './db.js';
6
7
  import { bearer, error, json, readJson } from './http.js';
7
8
  function publicState(r) {
8
- const { token: _token, ucp: _ucp, ...state } = r;
9
- return { ...state, name: r.name ?? null, policies: r.policies ?? null, orders: publicOrders(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) };
11
+ }
12
+ /** Like Commerce Layer refusing a sku in an import. Only the stub does this, so tests can exercise apply_failed. */
13
+ const REFUSED = /^REFUSED-/;
14
+ /** Items an import gets through per read: small, so the CLI polls a few times. */
15
+ const ITEMS_PER_READ = 2;
16
+ /**
17
+ * Move a running apply job forward, as the API does on every read: nothing runs in
18
+ * the background. When it ends, the pending resources are installed, or refused.
19
+ */
20
+ function advance(r) {
21
+ const job = r.apply;
22
+ if (!job || job.status !== 'in_progress' || !r.pending)
23
+ return;
24
+ job.processed = Math.min(job.total, job.processed + ITEMS_PER_READ);
25
+ if (job.processed < job.total)
26
+ return;
27
+ job.step = 'done';
28
+ job.finished_at = new Date().toISOString();
29
+ if (r.pending.refused.length) {
30
+ job.status = 'failed';
31
+ job.errors = r.pending.refused.slice(0, 50).map((sku) => ({ sku, message: 'code is invalid' }));
32
+ }
33
+ else {
34
+ job.status = 'completed';
35
+ r.applied = r.pending.applied;
36
+ r.applied_hash = job.hash;
37
+ }
38
+ delete r.pending;
10
39
  }
11
40
  export function handleManagement(req) {
12
41
  const db = readDb();
@@ -53,16 +82,46 @@ export function handleManagement(req) {
53
82
  if (new Date(record.expires_at) < new Date() && !record.claimed) {
54
83
  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.');
55
84
  }
56
- if (req.method === 'GET' && rest === '')
85
+ if (req.method === 'GET' && rest === '') {
86
+ advance(record);
87
+ writeDb(db);
57
88
  return json(200, publicState(record));
89
+ }
58
90
  if (req.method === 'PUT' && rest === '/resources') {
59
- const { applied, hash, name, policies } = readJson(req);
60
- record.applied = applied;
61
- record.applied_hash = hash;
91
+ advance(record);
92
+ writeDb(db);
93
+ if (record.apply?.status === 'in_progress')
94
+ return error(409, 'apply_in_progress', 'Another apply is still running.', 'Run `c11r status` to follow it, then apply again.');
95
+ const { applied, hash, name, policies, allow_delete, full } = readJson(req);
96
+ const before = record.applied?.products ?? {};
97
+ const removed = Object.keys(before).filter((sku) => !(sku in applied.products));
98
+ if (removed.length && !allow_delete)
99
+ return error(409, 'deletes_need_approval', `Apply would delete product ${removed.join(', ')}.`, 'Ask the human whether to delete these. If they agree, run `c11r apply --allow-delete`.');
100
+ // The name, policies and markets apply in the request; products go through an
101
+ // import job (SKUs, prices, then cleanups for removals) when any changed.
62
102
  record.name = name;
63
103
  record.policies = policies ?? {};
104
+ const changed = Object.keys(applied.products).filter((sku) => full || !(sku in before) || canonical(before[sku]) !== canonical(applied.products[sku]));
105
+ if (changed.length === 0 && removed.length === 0) {
106
+ record.applied = applied;
107
+ record.applied_hash = hash;
108
+ writeDb(db);
109
+ return json(200, publicState(record));
110
+ }
111
+ record.apply = {
112
+ id: `apply_${crypto.randomBytes(6).toString('hex')}`,
113
+ status: 'in_progress',
114
+ step: 'skus',
115
+ hash,
116
+ processed: 0,
117
+ total: changed.length + removed.length,
118
+ started_at: new Date().toISOString(),
119
+ finished_at: null,
120
+ errors: [],
121
+ };
122
+ record.pending = { applied, refused: changed.filter((sku) => REFUSED.test(sku)) };
64
123
  writeDb(db);
65
- return json(200, publicState(record));
124
+ return json(202, publicState(record));
66
125
  }
67
126
  if (req.method === 'PUT' && rest === '/storefront') {
68
127
  const { path: dir, files } = readJson(req);
package/dist/bin.js CHANGED
@@ -41,6 +41,10 @@ async function run() {
41
41
  return cmd.status();
42
42
  case 'claim':
43
43
  return cmd.claim();
44
+ case 'catalog':
45
+ if (sub === 'pull')
46
+ return cmd.catalogPull();
47
+ throw new CliError('unknown_command', `There is no command "catalog ${sub ?? ''}".`, 'Use `c11r catalog pull`.');
44
48
  case 'storefront':
45
49
  if (sub === 'init')
46
50
  return cmd.storefrontInit(flags);
@@ -0,0 +1,288 @@
1
+ // c11r catalog pull: the products live in a tool the human edits too (a Supabase
2
+ // table, a Google Sheet, a Google Merchant Center feed). Pull reads them and
3
+ // writes .commercelayer/products/catalog.csv, so plan and apply work as always.
4
+ // Every source maps onto the same fixed schema: sku, name, description,
5
+ // image_url, category and one price column per currency.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import YAML from 'yaml';
9
+ import { credential, paths } from './files.js';
10
+ import { CliError, fail } from './output.js';
11
+ import { parseCsv, readProductFile, toCsv } from './products.js';
12
+ import { canonical, checkProducts, readStoreFile } from './store.js';
13
+ export const SOURCES = ['supabase', 'sheets', 'merchant'];
14
+ export const catalogFile = path.join(paths.products, 'catalog.csv');
15
+ const FIELDS = ['sku', 'name', 'description', 'image_url', 'category'];
16
+ /** Columns tools add on their own (Supabase's defaults): pull skips them. */
17
+ const IGNORED = ['id', 'created_at', 'updated_at'];
18
+ /** Supabase rows per request; pull pages until a page comes back empty. */
19
+ const PAGE = 1000;
20
+ const EXAMPLE = 'For example `catalog: { source: sheets, url: <the sheet\'s CSV link> }`. Sources are supabase, sheets and merchant; see the comments in store.yml.';
21
+ function readConfig(doc) {
22
+ const c = doc.catalog;
23
+ if (!c || typeof c !== 'object')
24
+ fail('no_catalog_source', 'store.yml has no catalog source.', `Add a \`catalog:\` entry to ${paths.store}. ${EXAMPLE}`);
25
+ if (!SOURCES.includes(c.source))
26
+ fail('invalid_catalog', `catalog.source in store.yml is "${c.source ?? ''}".`, `Use supabase, sheets or merchant. ${EXAMPLE}`);
27
+ const table = String(c.table ?? 'products');
28
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table))
29
+ fail('invalid_catalog', `catalog.table in store.yml is "${table}".`, 'Use the name of the Supabase table, such as products.');
30
+ const url = String(c.url ?? '');
31
+ if (c.source !== 'supabase' && !/^https?:\/\//.test(url)) {
32
+ fail('invalid_catalog', url ? `catalog.url in store.yml is "${url}", not a URL.` : 'catalog.url is missing in store.yml.', c.source === 'sheets'
33
+ ? 'Set url to the Google Sheet\'s CSV link (File > Share > Publish to web > Comma-separated values), or to the sheet\'s own link if it is shared with anyone who has the link.'
34
+ : 'Set url to the Merchant Center product feed (RSS/XML or TSV), as Merchant Center fetches it.');
35
+ }
36
+ return { source: c.source, url, table };
37
+ }
38
+ async function get(url, what, headers = {}) {
39
+ try {
40
+ return await fetch(url, { headers });
41
+ }
42
+ catch (e) {
43
+ const cause = e.cause?.message;
44
+ fail('catalog_unreachable', `Could not reach ${what}: ${cause ?? e.message}.`, 'Check the catalog entry in store.yml and the network, then try again.');
45
+ }
46
+ }
47
+ /** Rows with a header, from a sheet or a table: the fixed fields and one column per currency. */
48
+ function fromTable(header, rows, where, label, currencies) {
49
+ const prices = currencies.map((c) => c.toLowerCase()).join(', ') || 'eur, usd, ...';
50
+ const columns = header.map((h) => {
51
+ const key = h.trim().toLowerCase();
52
+ if (FIELDS.includes(key))
53
+ return { field: key };
54
+ if (key === '' || IGNORED.includes(key))
55
+ return null;
56
+ const code = /^[a-z]{3}$/.test(key);
57
+ if (code && (currencies.length === 0 || currencies.includes(key.toUpperCase())))
58
+ return { currency: key.toUpperCase() };
59
+ const why = key === 'stock' ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : code ? `${key.toUpperCase()} is not the currency of a market in store.yml. ` : '';
60
+ fail('invalid_product', `${where} has a column "${h}" that is not a product field.`, `${why}The catalog columns are ${FIELDS.join(', ')} and one price column per market currency, named by its ISO 4217 code in lowercase (${prices}). Ask the human to rename or remove the column.`);
61
+ });
62
+ if (!columns.some((c) => c && 'field' in c && c.field === 'sku'))
63
+ fail('invalid_product', `${where} has no sku column.`, `The catalog columns are ${FIELDS.join(', ')} and one price column per market currency (${prices}).`);
64
+ const out = [];
65
+ rows.forEach((cells, i) => {
66
+ if (cells.every((v) => v === null || v === undefined || String(v).trim() === ''))
67
+ return;
68
+ const product = { sku: '', name: '', prices: {} };
69
+ columns.forEach((col, j) => {
70
+ const v = cells[j];
71
+ if (!col || v === null || v === undefined || String(v).trim() === '')
72
+ return;
73
+ if ('currency' in col) {
74
+ const price = typeof v === 'number' ? v : Number(String(v).trim());
75
+ if (!Number.isFinite(price))
76
+ fail('invalid_product', `${label(i)}: ${col.currency} price "${v}" is not a number.`, 'Write prices as plain decimals, such as 14.50, with no currency sign or thousands separator.');
77
+ product.prices[col.currency] = price;
78
+ }
79
+ else
80
+ product[col.field] = String(v).trim();
81
+ });
82
+ out.push({ product, source: label(i) });
83
+ });
84
+ return out;
85
+ }
86
+ async function fromSupabase(c, currencies) {
87
+ const base = credential('C11R_SUPABASE_URL');
88
+ const key = credential('C11R_SUPABASE_KEY');
89
+ const missing = [!base && 'C11R_SUPABASE_URL', !key && 'C11R_SUPABASE_KEY'].filter(Boolean);
90
+ if (missing.length || !base || !key) {
91
+ fail('missing_credentials', `The Supabase catalog needs ${missing.join(' and ')}.`, `Ask the human for the Supabase project URL and its anon (public) key, and for a policy that lets it select from the ${c.table} table. Add them to ${paths.credentials} as C11R_SUPABASE_URL=https://<project>.supabase.co and C11R_SUPABASE_KEY=<anon key>. Never use the service_role key.`);
92
+ }
93
+ const where = `Supabase table ${c.table}`;
94
+ const records = [];
95
+ for (;;) {
96
+ const url = `${base.replace(/\/+$/, '')}/rest/v1/${c.table}?select=*&order=sku.asc&limit=${PAGE}&offset=${records.length}`;
97
+ const res = await get(url, where, { apikey: key, Authorization: `Bearer ${key}`, Accept: 'application/json' });
98
+ const body = (await res.json().catch(() => null));
99
+ if (!res.ok) {
100
+ const detail = body?.message ? `: ${body.message}` : '';
101
+ if (res.status === 401 || res.status === 403)
102
+ fail('catalog_unauthorized', `Supabase refused the key${detail}.`, `Check that C11R_SUPABASE_KEY in ${paths.credentials} is the anon key of the project at C11R_SUPABASE_URL.`);
103
+ if (body?.code === '42703')
104
+ fail('invalid_product', `${where} has no sku column${detail}.`, `The catalog columns are ${FIELDS.join(', ')} and one price column per market currency, in lowercase.`);
105
+ if (res.status === 404 || body?.code === 'PGRST205' || body?.code === '42P01')
106
+ fail('catalog_not_found', `There is no ${where} at C11R_SUPABASE_URL${detail}.`, 'Check catalog.table in store.yml and C11R_SUPABASE_URL, or ask the human to create the table.');
107
+ fail('catalog_error', `${where} answered ${res.status}${detail}.`, 'Try again. If it keeps happening, tell the human, with this output.');
108
+ }
109
+ if (!Array.isArray(body))
110
+ fail('catalog_error', `${where} did not answer with rows.`, 'Check that C11R_SUPABASE_URL is the project URL, such as https://<project>.supabase.co.');
111
+ if (body.length === 0)
112
+ break;
113
+ records.push(...body);
114
+ }
115
+ if (records.length === 0)
116
+ return [];
117
+ const header = [...new Set(records.flatMap((r) => Object.keys(r)))];
118
+ return fromTable(header, records.map((r) => header.map((h) => r[h])), where, (i) => `${where}, row ${i + 1}`, currencies);
119
+ }
120
+ /** The CSV link of a Google Sheet, from the link the human has. */
121
+ export function sheetCsvUrl(url) {
122
+ const u = new URL(url);
123
+ if (u.hostname !== 'docs.google.com')
124
+ return url;
125
+ const gid = u.searchParams.get('gid') ?? u.hash.match(/gid=(\d+)/)?.[1];
126
+ const published = u.pathname.match(/^\/spreadsheets\/d\/e\/([^/]+)\/pub(html)?$/);
127
+ if (published)
128
+ return `https://docs.google.com/spreadsheets/d/e/${published[1]}/pub?output=csv${gid ? `&gid=${gid}` : ''}`;
129
+ const shared = u.pathname.match(/^\/spreadsheets\/d\/([^/]+)/);
130
+ if (shared && shared[1] !== 'e')
131
+ return `https://docs.google.com/spreadsheets/d/${shared[1]}/export?format=csv${gid ? `&gid=${gid}` : ''}`;
132
+ return url;
133
+ }
134
+ async function fromSheet(c, currencies) {
135
+ const where = 'The Google Sheet';
136
+ const res = await get(sheetCsvUrl(c.url), 'the Google Sheet');
137
+ const help = 'In the sheet, use File > Share > Publish to web, pick the sheet and "Comma-separated values (.csv)", and put that link in catalog.url. Sharing the sheet with "Anyone with the link" works too.';
138
+ if (!res.ok)
139
+ fail('catalog_error', `${where} answered ${res.status}.`, help);
140
+ if ((res.headers.get('content-type') ?? '').includes('text/html'))
141
+ fail('catalog_not_csv', `${where} answered with a web page, not CSV: it is not published or shared.`, help);
142
+ const rows = parseCsv((await res.text()).replace(/^\uFEFF/, ''));
143
+ if (rows.length === 0)
144
+ return [];
145
+ return fromTable(rows[0], rows.slice(1), where, (i) => `the Google Sheet, row ${i + 2}`, currencies);
146
+ }
147
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
148
+ function xmlText(raw) {
149
+ const cdata = raw.match(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/);
150
+ if (cdata)
151
+ return cdata[1].trim();
152
+ return raw
153
+ .replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (m, e) => (e[0] === '#' ? String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1))) : (ENTITIES[e] ?? m)))
154
+ .trim();
155
+ }
156
+ /** The top-level tags of each <item> (RSS 2.0) or <entry> (Atom), by name; g:title wins over title. */
157
+ export function feedItems(xml) {
158
+ return [...xml.matchAll(/<(item|entry)\b[^>]*>([\s\S]*?)<\/\1>/g)].map(([, , body]) => {
159
+ const plain = {};
160
+ const google = {};
161
+ for (const [, tag, inner] of body.matchAll(/<([\w-]+(?::[\w-]+)?)\b[^>]*?(?:\/>|>([\s\S]*?)<\/\1>)/g)) {
162
+ if (inner === undefined)
163
+ continue;
164
+ const [prefix, name] = tag.includes(':') ? tag.toLowerCase().split(':') : ['', tag.toLowerCase()];
165
+ const into = prefix === 'g' ? google : prefix === '' ? plain : null;
166
+ if (into && !(name in into))
167
+ into[name] = xmlText(inner);
168
+ }
169
+ return { ...plain, ...google };
170
+ });
171
+ }
172
+ function feedRows(text) {
173
+ if (text.trimStart().startsWith('<'))
174
+ return { items: feedItems(text), label: (i) => `the Merchant feed, item ${i + 1}` };
175
+ const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/);
176
+ const header = lines[0].split('\t').map((h) => h.trim().replace(/^"|"$/g, '').replace(/^g:/i, '').toLowerCase());
177
+ const items = [];
178
+ const lineOf = [];
179
+ lines.slice(1).forEach((line, i) => {
180
+ if (!line.trim())
181
+ return;
182
+ const cells = line.split('\t');
183
+ const item = {};
184
+ header.forEach((h, j) => {
185
+ const v = (cells[j] ?? '').trim().replace(/^"(.*)"$/, '$1');
186
+ if (h && v && !(h in item))
187
+ item[h] = v;
188
+ });
189
+ items.push(item);
190
+ lineOf.push(i + 2);
191
+ });
192
+ return { items, label: (i) => `the Merchant feed, line ${lineOf[i]}` };
193
+ }
194
+ /** "14.50 EUR" (the Merchant format), or "EUR 14.50". */
195
+ function feedPrice(value, label) {
196
+ const v = value.trim();
197
+ const after = v.match(/^([0-9][0-9.,]*)\s*([A-Za-z]{3})$/);
198
+ const before = v.match(/^([A-Za-z]{3})\s*([0-9][0-9.,]*)$/);
199
+ const amount = Number((after?.[1] ?? before?.[2] ?? '').replace(/,/g, '') || NaN);
200
+ const currency = after?.[2] ?? before?.[1];
201
+ if (!currency || !Number.isFinite(amount))
202
+ fail('invalid_product', `${label}: price "${value}" is not an amount and a currency.`, 'Merchant feed prices are written like "14.50 EUR".');
203
+ return [currency.toUpperCase(), amount];
204
+ }
205
+ async function fromMerchant(c) {
206
+ const res = await get(c.url, 'the Merchant feed');
207
+ if (!res.ok)
208
+ fail('catalog_error', `The Merchant feed answered ${res.status}.`, 'Check catalog.url in store.yml: it is the feed URL Merchant Center fetches.');
209
+ const { items, label } = feedRows(await res.text());
210
+ const bySku = new Map();
211
+ const out = [];
212
+ items.forEach((item, i) => {
213
+ if (!item.id)
214
+ fail('invalid_product', `${label(i)} has no id.`, 'Every feed item needs id (the sku), title and price.');
215
+ const product = { sku: item.id, name: item.title ?? '', prices: {} };
216
+ if (item.description)
217
+ product.description = item.description;
218
+ if (item.image_link)
219
+ product.image_url = item.image_link;
220
+ if (item.product_type)
221
+ product.category = item.product_type;
222
+ if (item.price) {
223
+ const [currency, amount] = feedPrice(item.price, label(i));
224
+ product.prices[currency] = amount;
225
+ // The same id once per country feed: one product, a price per currency.
226
+ const seen = bySku.get(item.id);
227
+ if (seen && seen.product.prices[currency] === undefined) {
228
+ seen.product.prices[currency] = amount;
229
+ return;
230
+ }
231
+ }
232
+ const sourced = { product, source: label(i) };
233
+ bySku.set(item.id, sourced);
234
+ out.push(sourced);
235
+ });
236
+ return out;
237
+ }
238
+ export async function pull() {
239
+ if (!fs.existsSync(paths.store))
240
+ fail('no_store', 'There is no store in this repo.', 'Run `c11r init` in the repo root first.');
241
+ let doc;
242
+ try {
243
+ doc = YAML.parse(readStoreFile()) ?? {};
244
+ }
245
+ catch (e) {
246
+ const reason = e.message.split('\n')[0];
247
+ fail('invalid_yaml', `store.yml is not valid YAML: ${reason}`, `Fix the YAML syntax in ${paths.store} and run the command again.`);
248
+ }
249
+ const config = readConfig(doc);
250
+ const markets = Array.isArray(doc.markets) ? doc.markets : [];
251
+ const currencies = [...new Set(markets.map((m) => m?.currency).filter(Boolean).map(String))];
252
+ const sourced = config.source === 'supabase' ? await fromSupabase(config, currencies) : config.source === 'sheets' ? await fromSheet(config, currencies) : await fromMerchant(config);
253
+ if (sourced.length === 0) {
254
+ const rls = config.source === 'supabase' ? ' If the table has rows, row level security hides them from the anon key: ask the human to add a policy that allows select.' : '';
255
+ fail('empty_catalog', `The ${config.source} catalog has no products.`, `Ask the human to add products to it, then pull again.${rls}`);
256
+ }
257
+ const products = checkProducts(sourced, currencies);
258
+ let before = new Map();
259
+ if (fs.existsSync(catalogFile)) {
260
+ try {
261
+ before = new Map(readProductFile(catalogFile).map((s) => [String(s.product.sku), s.product]));
262
+ }
263
+ catch (e) {
264
+ if (!(e instanceof CliError))
265
+ throw e;
266
+ }
267
+ }
268
+ const after = new Map(products.map((p) => [p.sku, p]));
269
+ const csv = toCsv(products);
270
+ const changed = !fs.existsSync(catalogFile) || fs.readFileSync(catalogFile, 'utf8') !== csv;
271
+ if (changed) {
272
+ fs.mkdirSync(paths.products, { recursive: true });
273
+ fs.writeFileSync(catalogFile, csv);
274
+ }
275
+ const removed = [...before.keys()].filter((sku) => !after.has(sku));
276
+ return {
277
+ status: changed ? 'pulled' : 'up_to_date',
278
+ source: config.source,
279
+ file: catalogFile,
280
+ products: products.length,
281
+ added: [...after.keys()].filter((sku) => !before.has(sku)),
282
+ changed: [...after.keys()].filter((sku) => before.has(sku) && canonical(before.get(sku)) !== canonical(after.get(sku))),
283
+ removed,
284
+ next: changed
285
+ ? `Run c11r plan to see what apply will change in the store.${removed.length ? ' Removed products are deleted from the store only by c11r apply --allow-delete, once the human agrees.' : ''} ${catalogFile} is rewritten by every pull: change products in the ${config.source} source, not in the file.`
286
+ : `${catalogFile} already matches the ${config.source} source.`,
287
+ };
288
+ }
package/dist/commands.js CHANGED
@@ -5,9 +5,10 @@ import { createRequire } from 'node:module';
5
5
  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
- import { CliError, fail } from './output.js';
8
+ import { CliError, fail, progress } from './output.js';
9
9
  import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from './store.js';
10
10
  import { storefrontHtml } from './storefront.js';
11
+ import { pull } from './catalog.js';
11
12
  import { buy } from './ucp/buy.js';
12
13
  import { check } from './ucp/check.js';
13
14
  import { discover } from './ucp/client.js';
@@ -17,7 +18,8 @@ export const VERSION = createRequire(import.meta.url)('../package.json').version
17
18
  export const HELP = {
18
19
  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.',
19
20
  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).',
20
- apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/. Refuses deletes unless --allow-delete is given.',
21
+ 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. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
22
+ '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.',
21
23
  'storefront init': 'Write storefront/index.html: a static page on drop-in.js listing the products in store.yml, with a minicart that opens on add to cart, and the hosted cart and checkout. Refuses to overwrite unless --force is given.',
22
24
  'storefront publish': 'Upload the storefront folder (store.yml storefront.path, default storefront) to the sandbox preview URL.',
23
25
  '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.',
@@ -75,25 +77,64 @@ export async function plan() {
75
77
  const changes = diff(state, desired);
76
78
  return { status: changes.length ? 'changes' : 'up_to_date', changes, deletes: changes.filter((c) => c.action === 'delete').length };
77
79
  }
80
+ /** How often apply reads a running import, and when it stops waiting. Env overrides are for tests. */
81
+ const POLL_MS = Number(process.env.C11R_APPLY_POLL_MS) || 2000;
82
+ const WAIT_MS = Number(process.env.C11R_APPLY_WAIT_MS) || 10 * 60 * 1000;
83
+ const jobSummary = (j) => ({ id: j.id, status: j.status, step: j.step, processed: j.processed, total: j.total });
84
+ /** Read the store until its apply job is no longer in progress, with a progress line per change. */
85
+ async function follow(api, state) {
86
+ const until = Date.now() + WAIT_MS;
87
+ let s = state;
88
+ let last = '';
89
+ while (s.apply?.status === 'in_progress') {
90
+ const line = JSON.stringify(jobSummary(s.apply));
91
+ if (line !== last)
92
+ progress({ apply: jobSummary(s.apply) });
93
+ last = line;
94
+ if (Date.now() >= until) {
95
+ const j = s.apply;
96
+ fail('apply_pending', `The apply is still running: ${j.processed} of ${j.total} items done (step ${j.step}).`, 'The apply keeps going; run `c11r status` to follow it.');
97
+ }
98
+ await new Promise((r) => setTimeout(r, POLL_MS));
99
+ s = await api.getStore();
100
+ }
101
+ return s;
102
+ }
78
103
  export async function apply(flags) {
79
104
  const api = createApi(requireCredentials());
80
105
  const desired = loadStore();
81
- const state = await api.getStore();
106
+ // An apply still running is finished first, so the changes are counted from what it installs.
107
+ let state = await api.getStore();
108
+ if (state.apply?.status === 'in_progress')
109
+ state = await follow(api, state);
82
110
  const changes = diff(state, desired);
83
111
  const deletes = changes.filter((c) => c.action === 'delete');
84
112
  if (deletes.length && !flags.has('--allow-delete')) {
85
113
  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`.');
86
114
  }
87
- const applied = await api.applyStore({ applied: toApplied(desired), hash: hashStore(), name: desired.name, policies: desired.policies });
115
+ 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 }) };
116
+ const started = await api.applyStore(body);
117
+ const job = started.apply?.status === 'in_progress' ? started.apply : null;
118
+ const done = job ? await follow(api, started) : started;
119
+ if (job && done.apply?.id === job.id && done.apply.status === 'failed') {
120
+ const errors = done.apply.errors;
121
+ const list = errors.map((e) => (e.sku ? `${e.sku}: ${e.message}` : e.message)).join('; ');
122
+ const what = errors.length === 1 ? '1 product was refused' : `${errors.length} products were refused`;
123
+ fail('apply_failed', errors.length ? `${what}: ${list}` : 'The apply failed in Commerce Layer.', 'Fix those products in store.yml or the catalog source and run `c11r apply` again; the rest of the store is unchanged.');
124
+ }
88
125
  return {
89
126
  status: 'applied',
90
127
  changes,
91
- preview_url: applied.preview_url,
128
+ preview_url: done.preview_url,
92
129
  markets: desired.markets.length,
93
130
  products: desired.products.length,
94
131
  payment_gateway: 'sandbox (test payments only)',
132
+ apply: job && done.apply ? jobSummary(done.apply) : undefined,
95
133
  };
96
134
  }
135
+ export async function catalogPull() {
136
+ return pull();
137
+ }
97
138
  export async function storefrontInit(flags) {
98
139
  const api = createApi(requireCredentials());
99
140
  const desired = loadStore();
@@ -203,6 +244,7 @@ export async function status() {
203
244
  sales_channel_client_id: s.sales_channel_client_id,
204
245
  storefront: s.storefront,
205
246
  applied: Boolean(s.applied),
247
+ apply: s.apply ?? undefined,
206
248
  pending_changes: pending.length > 0 || storeError !== undefined,
207
249
  pending: pending.length ? pending : undefined,
208
250
  store_error: storeError,
@@ -210,7 +252,7 @@ export async function status() {
210
252
  products: s.applied ? Object.keys(s.applied.products).length : 0,
211
253
  orders: s.orders.map((o) => ({ id: o.id, label: o.label })),
212
254
  ucp_profile: `${s.preview_url}/.well-known/ucp`,
213
- next: storeError ? 'Fix the store files (see store_error), then run `c11r plan`.' : pending.length ? 'Run `c11r plan` to see the changes, then `c11r apply`.' : undefined,
255
+ next: s.apply?.status === 'in_progress' ? 'An apply is running: run `c11r status` again to follow it, or `c11r apply` to wait for it.' : storeError ? 'Fix the store files (see store_error), then run `c11r plan`.' : pending.length ? 'Run `c11r plan` to see the changes, then `c11r apply`.' : undefined,
214
256
  };
215
257
  }
216
258
  export async function claim() {
package/dist/files.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The files the CLI keeps in the repo: .commercelayer/store.yml and the product
2
2
  // files under .commercelayer/products/ (committed), and .commercelayer/credentials
3
- // (gitignored: the sandbox token).
3
+ // (gitignored: the sandbox token, and the keys of a catalog source).
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { fail } from './output.js';
@@ -11,22 +11,38 @@ export const paths = {
11
11
  products: path.join(dir, 'products'),
12
12
  credentials: path.join(dir, 'credentials'),
13
13
  };
14
- export function readCredentials() {
15
- if (!fs.existsSync(paths.credentials))
16
- return null;
14
+ /** Every KEY=value line of the credentials file; quotes around a value are dropped. */
15
+ function credentialVars() {
17
16
  const vars = {};
17
+ if (!fs.existsSync(paths.credentials))
18
+ return vars;
18
19
  for (const line of fs.readFileSync(paths.credentials, 'utf8').split('\n')) {
19
- const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
20
+ const m = line.trim().match(/^([A-Z0-9_]+)=(.*)$/);
20
21
  if (m)
21
- vars[m[1]] = m[2];
22
+ vars[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, '$2');
22
23
  }
24
+ return vars;
25
+ }
26
+ /** A secret the CLI needs besides the store's own token: the environment wins over the credentials file. */
27
+ export function credential(key) {
28
+ return process.env[key] || credentialVars()[key] || undefined;
29
+ }
30
+ export function readCredentials() {
31
+ if (!fs.existsSync(paths.credentials))
32
+ return null;
33
+ const vars = credentialVars();
23
34
  if (!vars.C11R_ORG || !vars.C11R_TOKEN)
24
35
  return null;
25
36
  return { org: vars.C11R_ORG, token: vars.C11R_TOKEN };
26
37
  }
27
38
  export function writeCredentials(c) {
28
39
  fs.mkdirSync(paths.dir, { recursive: true });
29
- fs.writeFileSync(paths.credentials, `C11R_ORG=${c.org}\nC11R_TOKEN=${c.token}\n`, { mode: 0o600 });
40
+ // Keep any other keys already in the file (C11R_SUPABASE_*, ...).
41
+ const others = Object.entries(credentialVars())
42
+ .filter(([k]) => k !== 'C11R_ORG' && k !== 'C11R_TOKEN')
43
+ .map(([k, v]) => `${k}=${v}\n`)
44
+ .join('');
45
+ fs.writeFileSync(paths.credentials, `C11R_ORG=${c.org}\nC11R_TOKEN=${c.token}\n${others}`, { mode: 0o600 });
30
46
  }
31
47
  /** The store and its credentials, or a failure telling the agent to run init. */
32
48
  export function requireCredentials() {
package/dist/output.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Everything the CLI says is JSON on stdout: one object per run, pretty-printed.
2
2
  // A failure is { error: { code, message, hint } } with exit code 1. The hint is
3
3
  // the fix: an agent reads it and acts on it before trying anything else.
4
+ // A command that waits (apply) writes progress to stderr, one JSON object per line.
4
5
  export class CliError extends Error {
5
6
  code;
6
7
  hint;
@@ -13,6 +14,9 @@ export class CliError extends Error {
13
14
  export function fail(code, message, hint) {
14
15
  throw new CliError(code, message, hint);
15
16
  }
17
+ export function progress(data) {
18
+ process.stderr.write(JSON.stringify(data) + '\n');
19
+ }
16
20
  export function print(data) {
17
21
  process.stdout.write(JSON.stringify(data, null, 2) + '\n');
18
22
  }
package/dist/products.js CHANGED
@@ -8,7 +8,7 @@ import YAML from 'yaml';
8
8
  import { paths } from './files.js';
9
9
  import { fail } from './output.js';
10
10
  /** The fixed CSV columns; any other column named by an ISO 4217 code is a price. */
11
- export const CSV_COLUMNS = ['sku', 'name', 'description', 'image_url'];
11
+ export const CSV_COLUMNS = ['sku', 'name', 'description', 'image_url', 'category'];
12
12
  /** The product files, in a stable order. */
13
13
  export function productFiles() {
14
14
  if (!fs.existsSync(paths.products))
@@ -126,3 +126,16 @@ function fromCsv(file, text) {
126
126
  });
127
127
  return products;
128
128
  }
129
+ const csvCell = (v) => {
130
+ const t = v === undefined ? '' : String(v);
131
+ return /[",\r\n]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t;
132
+ };
133
+ /** Products as CSV in the shape fromCsv reads: the fixed columns, then one per currency, rows by sku. Same products, same bytes. */
134
+ export function toCsv(products) {
135
+ const currencies = [...new Set(products.flatMap((p) => Object.keys(p.prices ?? {})))].sort();
136
+ const rows = [...products].sort((a, b) => (a.sku < b.sku ? -1 : a.sku > b.sku ? 1 : 0));
137
+ const lines = [[...CSV_COLUMNS, ...currencies].join(',')];
138
+ for (const p of rows)
139
+ lines.push([...CSV_COLUMNS.map((c) => csvCell(p[c])), ...currencies.map((c) => csvCell(p.prices?.[c]))].join(','));
140
+ return lines.join('\n') + '\n';
141
+ }
package/dist/store.js CHANGED
@@ -9,7 +9,7 @@ import { fail } from './output.js';
9
9
  import { paths } from './files.js';
10
10
  import { productFiles, readProductFile } from './products.js';
11
11
  /** The keys a product may have. Stock is not one: inventory lives in Commerce Layer. */
12
- export const PRODUCT_KEYS = ['sku', 'name', 'description', 'image_url', 'prices'];
12
+ export const PRODUCT_KEYS = ['sku', 'name', 'description', 'image_url', 'category', 'prices'];
13
13
  export const template = (name) => `# Commerce Layer store. This file is the store: edit it, then run
14
14
  # c11r plan to see what will change
15
15
  # c11r apply to make the change
@@ -30,15 +30,27 @@ products:
30
30
  # Products go here, or in files under .commercelayer/products/, merged by sku:
31
31
  # <name>.yml a list of products in this same shape
32
32
  # <name>.csv one product per row, columns: sku, name, description, image_url,
33
- # and one column per currency (EUR, USD, ...) with the price
33
+ # category, and one column per currency (EUR, USD, ...) with the price
34
+ # category is optional, a path from the top level: Coffee > Single origin.
34
35
  # Use the folder for anything beyond a handful of products.
35
36
  # sku is unique and stays the same when you rename a product.
36
37
  # - sku: COFFEE-ETH-250
37
38
  # name: Ethiopia Yirgacheffe, 250 g
38
39
  # description: Washed, floral, bright.
40
+ # category: Coffee > Single origin
39
41
  # prices: { EUR: 14.50 }
40
42
  # Stock is not part of the catalog: inventory is tracked live in Commerce Layer.
41
43
 
44
+ catalog:
45
+ # Where \`c11r catalog pull\` reads the products from, if they live in a tool the
46
+ # human edits. Pull writes them to .commercelayer/products/catalog.csv.
47
+ # source: sheets # a Google Sheet published as CSV: url is its CSV link
48
+ # source: supabase # a Supabase table: table is its name (default products);
49
+ # # C11R_SUPABASE_URL and C11R_SUPABASE_KEY in .commercelayer/credentials
50
+ # source: merchant # a Google Merchant Center feed, RSS (XML) or TSV: url is the feed
51
+ # url: https://...
52
+ # table: products
53
+
42
54
  storefront:
43
55
  # The web app in this repo that sells the products, relative to the repo root.
44
56
  # path: storefront
@@ -119,6 +131,11 @@ export function loadStore() {
119
131
  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.');
120
132
  }
121
133
  const currencies = [...new Set(markets.map((m) => m.currency))];
134
+ const products = checkProducts(sourced, currencies);
135
+ return { name: doc.name, markets, products, storefront: doc.storefront, policies };
136
+ }
137
+ /** The product rules, shared by loadStore and catalog pull: errors name each product's source. */
138
+ export function checkProducts(sourced, currencies) {
122
139
  const skus = new Map();
123
140
  const products = [];
124
141
  for (const { product: p, source } of sourced) {
@@ -136,15 +153,21 @@ export function loadStore() {
136
153
  const why = unknown.includes('stock') ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : '';
137
154
  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.`);
138
155
  }
156
+ if (p.category !== undefined) {
157
+ const parts = typeof p.category === 'string' ? p.category.split('>').map((c) => c.trim()) : [''];
158
+ if (parts.some((c) => !c))
159
+ fail('invalid_product', `Product ${p.sku} in ${source} has category "${p.category}".`, 'A category is a path of names from the top level, separated by " > ", such as "Coffee > Single origin". Leave it out for no category.');
160
+ p.category = parts.join(' > ');
161
+ }
139
162
  for (const c of currencies) {
140
163
  const price = p.prices?.[c];
141
164
  if (typeof price !== 'number' || price < 0) {
142
- fail('missing_price', `Product ${p.sku} in ${source} has no price in ${c}.`, `Add the ${c} price to the product (\`${c}: <amount>\` under prices in YAML, a ${c} column in CSV); every product needs a price in each market's currency (${currencies.join(', ')}).`);
165
+ fail('missing_price', `Product ${p.sku} in ${source} has no price in ${c}.`, `Add the ${c} price to the product (\`${c}: <amount>\` under prices in YAML, a ${c} column in CSV or in the catalog source); every product needs a price in each market's currency (${currencies.join(', ')}).`);
143
166
  }
144
167
  }
145
168
  products.push(p);
146
169
  }
147
- return { name: doc.name, markets, products, storefront: doc.storefront, policies };
170
+ return products;
148
171
  }
149
172
  export function toApplied(store) {
150
173
  return {
@@ -152,7 +175,8 @@ export function toApplied(store) {
152
175
  products: Object.fromEntries(store.products.map((p) => [p.sku, p])),
153
176
  };
154
177
  }
155
- const canonical = (v) => JSON.stringify(v ?? {}, (_k, x) => (x === undefined ? undefined : x));
178
+ /** JSON with sorted keys and no undefined values, so key order never reads as a change. */
179
+ export const canonical = (v) => JSON.stringify(v ?? {}, (_k, x) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x));
156
180
  /** The one definition of "pending changes": what apply would do. plan and status both use it. */
157
181
  export function diff(state, desired) {
158
182
  const changes = [];
@@ -170,7 +194,7 @@ export function diff(state, desired) {
170
194
  for (const id of Object.keys(after[kind])) {
171
195
  if (!(id in before))
172
196
  changes.push({ action: 'create', type, id });
173
- else if (JSON.stringify(before[id]) !== JSON.stringify(after[kind][id]))
197
+ else if (canonical(before[id]) !== canonical(after[kind][id]))
174
198
  changes.push({ action: 'update', type, id });
175
199
  }
176
200
  for (const id of Object.keys(before))
@@ -1,16 +1,43 @@
1
1
  // The storefront scaffold: one static page on drop-in.js listing the products in
2
- // store.yml, with the minicart, the hosted cart and checkout. The agent then makes it the human's.
2
+ // store.yml, grouped by top-level category, with the minicart, the hosted cart and
3
+ // checkout. The agent then makes it the human's.
3
4
  const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
4
5
  export function storefrontHtml(store, clientId) {
5
6
  const name = esc(store.name);
6
- const cards = store.products
7
- .map((p) => ` <article class="product">
8
- ${p.image_url ? ` <img src="${esc(p.image_url)}" alt="">\n` : ''} <h2>${esc(p.name)}</h2>
7
+ const card = (p, h) => ` <article class="product">
8
+ ${p.image_url ? ` <img src="${esc(p.image_url)}" alt="">\n` : ''} <${h}>${esc(p.name)}</${h}>
9
9
  ${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>
10
10
  <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>
11
11
  <cl-add-to-cart code="${esc(p.sku)}">Add to cart</cl-add-to-cart>
12
- </article>`)
13
- .join('\n');
12
+ </article>`;
13
+ // Products are grouped by the top level of their category, in order of first appearance;
14
+ // products with no category come last, under "Other". No categories: one grid.
15
+ const groups = new Map();
16
+ for (const p of store.products) {
17
+ const top = p.category?.split(' > ')[0] ?? '';
18
+ groups.set(top, [...(groups.get(top) ?? []), p]);
19
+ }
20
+ const categorized = [...groups.keys()].some((k) => k);
21
+ let body;
22
+ let categoryNav = '';
23
+ if (!categorized)
24
+ body = ` <div class="grid">\n${store.products.map((p) => card(p, 'h2')).join('\n')}\n </div>`;
25
+ else {
26
+ const ordered = [...groups.keys()].filter((k) => k).concat(groups.has('') ? [''] : []);
27
+ const used = new Set();
28
+ const ids = ordered.map((k) => {
29
+ const base = `category-${(k || 'other').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'other'}`;
30
+ let id = base;
31
+ for (let n = 2; used.has(id); n++)
32
+ id = `${base}-${n}`;
33
+ used.add(id);
34
+ return id;
35
+ });
36
+ categoryNav = ` <nav class="categories">\n${ordered.map((k, i) => ` <a href="#${ids[i]}">${esc(k || 'Other')}</a>`).join('\n')}\n </nav>\n`;
37
+ body = ordered
38
+ .map((k, i) => ` <section id="${ids[i]}">\n <h2>${esc(k || 'Other')}</h2>\n <div class="grid">\n${groups.get(k).map((p) => card(p, 'h3')).join('\n')}\n </div>\n </section>`)
39
+ .join('\n');
40
+ }
14
41
  const options = store.markets.map((m) => ` <option value="${esc(m.code)}">${esc(m.name)} (${esc(m.currency)})</option>`).join('\n');
15
42
  return `<!doctype html>
16
43
  <html lang="en">
@@ -37,10 +64,17 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
37
64
  header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--line); }
38
65
  header h1 { margin: 0; font-size: 20px; }
39
66
  header nav { display: flex; align-items: center; gap: 16px; }
40
- main { max-width: 1100px; margin: 0 auto; padding: 32px 24px; display: grid; gap: 24px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
67
+ nav.categories { display: flex; flex-wrap: wrap; gap: 8px 20px; padding: 14px 24px; border-bottom: 1px solid var(--line); }
68
+ nav.categories a { color: var(--ink); text-decoration: none; font-size: 15px; }
69
+ nav.categories a:hover { color: var(--accent); }
70
+ main { max-width: 1100px; margin: 0 auto; padding: 32px 24px; }
71
+ main section { scroll-margin-top: 16px; }
72
+ main section + section { margin-top: 40px; }
73
+ main section > h2 { margin: 0 0 16px; font-size: 22px; }
74
+ .grid { display: grid; gap: 24px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
41
75
  .product { display: flex; flex-direction: column; gap: 8px; padding: 20px; border: 1px solid var(--line); border-radius: 8px; }
42
76
  .product img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 6px; }
43
- .product h2 { margin: 0; font-size: 17px; }
77
+ .product h2, .product h3 { margin: 0; font-size: 17px; }
44
78
  .product p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
45
79
  cl-price { font-weight: 600; }
46
80
  cl-price-amount[type="compare-at"] { color: var(--muted); font-weight: 400; text-decoration: line-through; }
@@ -65,8 +99,8 @@ ${options}
65
99
  <cl-checkout-link>Checkout</cl-checkout-link>
66
100
  </nav>
67
101
  </header>
68
- <main>
69
- ${cards}
102
+ ${categoryNav} <main>
103
+ ${body}
70
104
  </main>
71
105
  <footer>Sandbox store: test payments only.</footer>
72
106
  <script>document.querySelector('header select').value = market;</script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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": [