@commercelayer/agent-cli 0.1.5 → 0.1.8
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 +21 -7
- package/dist/api/client.js +2 -2
- package/dist/bin.js +5 -1
- package/dist/catalog.js +288 -0
- package/dist/commands.js +5 -0
- package/dist/files.js +23 -7
- package/dist/products.js +14 -1
- package/dist/store.js +30 -6
- package/dist/storefront.js +46 -10
- package/dist/ucp/client.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,14 +9,15 @@ 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
18
|
| `apply [--allow-delete]` | Make the store match the files. Refuses deletes without the flag. |
|
|
19
|
-
| `
|
|
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. |
|
|
@@ -62,14 +63,27 @@ 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
|
+
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
|
+
|
|
73
87
|
Error codes: `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `storefront_exists`, `no_storefront`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
|
|
74
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:
|
package/dist/api/client.js
CHANGED
|
@@ -18,14 +18,14 @@ export function createApi(auth) {
|
|
|
18
18
|
data = res.body ? JSON.parse(res.body) : null;
|
|
19
19
|
}
|
|
20
20
|
catch {
|
|
21
|
-
fail('api_error', `${base} answered ${res.status} with something that is not JSON.`, 'Try again. If it keeps happening,
|
|
21
|
+
fail('api_error', `${base} answered ${res.status} with something that is not JSON.`, 'Try again. If it keeps happening, tell the human, with the output of `c11r status`.');
|
|
22
22
|
}
|
|
23
23
|
if (res.status < 400)
|
|
24
24
|
return data;
|
|
25
25
|
const err = data?.error;
|
|
26
26
|
if (err)
|
|
27
27
|
fail(err.code, err.message, err.hint);
|
|
28
|
-
fail('api_error', `The API answered ${res.status}.`, 'Try again. If it keeps happening,
|
|
28
|
+
fail('api_error', `The API answered ${res.status}.`, 'Try again. If it keeps happening, tell the human, with the output of `c11r status`.');
|
|
29
29
|
};
|
|
30
30
|
return {
|
|
31
31
|
createSandbox: (name) => request('POST', '/sandboxes', { name }),
|
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);
|
|
@@ -77,7 +81,7 @@ run()
|
|
|
77
81
|
}
|
|
78
82
|
else {
|
|
79
83
|
const message = e instanceof Error ? e.message : String(e);
|
|
80
|
-
print({ error: { code: 'internal', message, hint: 'This is a bug in the CLI.
|
|
84
|
+
print({ error: { code: 'internal', message, hint: 'This is a bug in the CLI. Tell the human, with this output, and try again with npx -y @commercelayer/agent-cli@latest in case it is already fixed.' } });
|
|
81
85
|
}
|
|
82
86
|
process.exit(1);
|
|
83
87
|
});
|
package/dist/catalog.js
ADDED
|
@@ -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
|
@@ -8,6 +8,7 @@ import { ignoreCredentials, paths, readCredentials, requireCredentials, writeCre
|
|
|
8
8
|
import { CliError, fail } 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';
|
|
@@ -18,6 +19,7 @@ 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
21
|
apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/. Refuses deletes unless --allow-delete is given.',
|
|
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.',
|
|
@@ -94,6 +96,9 @@ export async function apply(flags) {
|
|
|
94
96
|
payment_gateway: 'sandbox (test payments only)',
|
|
95
97
|
};
|
|
96
98
|
}
|
|
99
|
+
export async function catalogPull() {
|
|
100
|
+
return pull();
|
|
101
|
+
}
|
|
97
102
|
export async function storefrontInit(flags) {
|
|
98
103
|
const api = createApi(requireCredentials());
|
|
99
104
|
const desired = loadStore();
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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/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
|
|
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
|
-
|
|
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 (
|
|
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))
|
package/dist/storefront.js
CHANGED
|
@@ -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
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
4
5
|
export function storefrontHtml(store, clientId) {
|
|
5
6
|
const name = esc(store.name);
|
|
6
|
-
const
|
|
7
|
-
|
|
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
|
-
|
|
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,15 +64,24 @@ ${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
|
-
|
|
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; }
|
|
47
81
|
cl-availability { font-size: 13px; color: var(--muted); }
|
|
48
82
|
cl-add-to-cart { margin-top: auto; }
|
|
83
|
+
/* drop-in.css styles the count as a badge beside a cart icon (absolute, pushed right). The link is text here, so render it inline: "Cart (3)". */
|
|
84
|
+
cl-cart-link cl-cart-count { position: static; transform: none; display: inline; min-width: 0; height: auto; padding: 0; border-radius: 0; background: none; color: inherit; font-size: inherit; }
|
|
49
85
|
cl-add-to-cart::part(button), cl-checkout-link::part(a) { display: inline-block; padding: 10px 16px; border-radius: 6px; background: var(--accent); color: #fff; font: inherit; border: 0; cursor: pointer; text-decoration: none; }
|
|
50
86
|
footer { padding: 24px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; }
|
|
51
87
|
</style>
|
|
@@ -63,8 +99,8 @@ ${options}
|
|
|
63
99
|
<cl-checkout-link>Checkout</cl-checkout-link>
|
|
64
100
|
</nav>
|
|
65
101
|
</header>
|
|
66
|
-
<main>
|
|
67
|
-
${
|
|
102
|
+
${categoryNav} <main>
|
|
103
|
+
${body}
|
|
68
104
|
</main>
|
|
69
105
|
<footer>Sandbox store: test payments only.</footer>
|
|
70
106
|
<script>document.querySelector('header select').value = market;</script>
|
package/dist/ucp/client.js
CHANGED
|
@@ -65,7 +65,7 @@ export async function discover(origin) {
|
|
|
65
65
|
profile = JSON.parse(res.body);
|
|
66
66
|
}
|
|
67
67
|
catch {
|
|
68
|
-
fail('ucp_profile_malformed', `${origin}/.well-known/ucp is not JSON.`, '
|
|
68
|
+
fail('ucp_profile_malformed', `${origin}/.well-known/ucp is not JSON.`, 'The store host answered badly. Try again; if it keeps happening, tell the human, with the output of `c11r status`.');
|
|
69
69
|
}
|
|
70
70
|
const ucp = profile.ucp ?? {};
|
|
71
71
|
if (ucp.version !== UCP_VERSION) {
|
|
@@ -74,7 +74,7 @@ export async function discover(origin) {
|
|
|
74
74
|
const rest = (name) => (ucp.services?.[name] ?? []).find((s) => s.transport === 'rest')?.endpoint ?? null;
|
|
75
75
|
const endpoint = rest('dev.ucp.shopping');
|
|
76
76
|
if (!endpoint)
|
|
77
|
-
fail('ucp_no_rest_service', 'The store profile has no REST dev.ucp.shopping service.', '
|
|
77
|
+
fail('ucp_no_rest_service', 'The store profile has no REST dev.ucp.shopping service.', 'The store host serves a profile this CLI cannot use. Tell the human, with the output of `c11r ucp profile`.');
|
|
78
78
|
const capabilities = new Set();
|
|
79
79
|
for (const [name, entries] of Object.entries(ucp.capabilities ?? {})) {
|
|
80
80
|
if (entries.some((e) => e.version === UCP_VERSION))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commercelayer/agent-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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": [
|