@commercelayer/agent-cli 0.1.12 → 0.1.14
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 +2 -1
- package/dist/bin.js +3 -1
- package/dist/check.js +187 -0
- package/dist/commands.js +5 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
|
|
|
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
20
|
| `storefront init [--force]` | Write a static storefront on [drop-in.js](https://commercelayer.github.io/drop-in.js/), no build step, wired to the sandbox's sales channel. `index.html`: the config block, the products grouped by top-level category with a navigation, a minicart that slides in on add to cart, the hosted cart and checkout, and a product page at `#/p/<sku>` built from `<template id="product-template">` and the `#products` data. `styles.css`: theme tokens first (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`), which also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change; then the drop-in.js overrides, then the page. `app.js`: the market switch, the category links and the product page. `img/`: product images (`image_url: img/<file>`); products with no image get a neutral tile. Refuses to overwrite without the flag; with it, rewrites the three files. |
|
|
21
21
|
| `storefront publish` | Upload the folder at `storefront.path` (default `storefront`) to the store host, leaving out dotfiles. Lists products whose `image_url` points at a file that is not in the folder (`missing_images`). |
|
|
22
|
+
| `storefront check` | Read the published storefront at the preview URL and check it: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product and none for unknown skus, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides (no dead `::part` rules), title, `lang`, viewport, description, and whether the folder changed since publish. Prints `{ status, summary, checks: [{ id, kind, status: pass\|warn\|fail, message, fix, items }], render }` and exits 1 when a check fails; warnings do not fail. `kind` is `static` today; a later rendering phase adds `render` checks and fills `render`. |
|
|
22
23
|
| `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. |
|
|
23
24
|
| `orders list` | The orders placed in the store, newest first, as UCP order entities. |
|
|
24
25
|
| `orders show <id or number>` | One order. |
|
|
@@ -84,7 +85,7 @@ catalog:
|
|
|
84
85
|
- `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
86
|
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
|
|
|
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`.
|
|
88
|
+
Error codes: `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `storefront_unreachable`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
|
|
88
89
|
## The store speaks UCP
|
|
89
90
|
Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
|
|
90
91
|
| What | Where |
|
package/dist/bin.js
CHANGED
|
@@ -50,7 +50,9 @@ async function run() {
|
|
|
50
50
|
return cmd.storefrontInit(flags);
|
|
51
51
|
if (sub === 'publish')
|
|
52
52
|
return cmd.storefrontPublish();
|
|
53
|
-
|
|
53
|
+
if (sub === 'check')
|
|
54
|
+
return cmd.storefrontCheckCmd();
|
|
55
|
+
throw new CliError('unknown_command', `There is no command "storefront ${sub ?? ''}".`, 'Use `c11r storefront init`, `storefront publish` or `storefront check`.');
|
|
54
56
|
case 'orders':
|
|
55
57
|
if (sub === 'test')
|
|
56
58
|
return cmd.ordersTest(flags, values);
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// c11r storefront check: read the published storefront at preview_url, as a browser
|
|
2
|
+
// would fetch it, and report what would break it or what is still the scaffold's.
|
|
3
|
+
// This is the static phase: HTML and CSS as served, images by request. Every check
|
|
4
|
+
// has a kind; a rendering phase (contrast, overflow, minicart fit, screenshots)
|
|
5
|
+
// adds checks of kind "render" and fills `render`, without changing the shape.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { send } from './api/transport.js';
|
|
9
|
+
import { fail } from './output.js';
|
|
10
|
+
import { storefrontPath } from './store.js';
|
|
11
|
+
const decode = (t) => t.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (m, e) => {
|
|
12
|
+
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
13
|
+
if (e[0] !== '#')
|
|
14
|
+
return named[e.toLowerCase()] ?? m;
|
|
15
|
+
return String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1)));
|
|
16
|
+
});
|
|
17
|
+
/** Every start tag with its attributes, comments and script bodies left out. */
|
|
18
|
+
function tags(html) {
|
|
19
|
+
const text = html.replace(/<!--[\s\S]*?-->/g, '').replace(/(<script\b[^>]*>)[\s\S]*?(<\/script>)/gi, '$1$2');
|
|
20
|
+
return [...text.matchAll(/<([a-zA-Z][\w-]*)((?:\s+[^\s=/>]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*\/?>/g)].map(([, name, raw]) => {
|
|
21
|
+
const attrs = {};
|
|
22
|
+
for (const [, k, d, s, u] of raw.matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g))
|
|
23
|
+
attrs[k.toLowerCase()] = decode(d ?? s ?? u ?? '');
|
|
24
|
+
return { name: name.toLowerCase(), attrs };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const scripts = (html) => [...html.replace(/<!--[\s\S]*?-->/g, '').matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)].map(([, attrs, body]) => ({ attrs, body }));
|
|
28
|
+
/** GET a URL: through the CLI's transport on the store host (so the stub works), plain fetch elsewhere. */
|
|
29
|
+
async function get(url, origin, method = 'GET') {
|
|
30
|
+
if (new URL(url).origin === origin) {
|
|
31
|
+
const r = await send('GET', url);
|
|
32
|
+
return { status: r.status, type: r.headers['content-type'] ?? '', body: r.body };
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
let r = await fetch(url, { method, redirect: 'follow' });
|
|
36
|
+
if (method === 'HEAD' && (r.status === 405 || r.status === 403))
|
|
37
|
+
r = await fetch(url, { redirect: 'follow' });
|
|
38
|
+
return { status: r.status, type: r.headers.get('content-type') ?? '', body: method === 'HEAD' ? '' : await r.text() };
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
return { status: 0, type: '', body: String(e.cause?.message ?? e.message) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** In batches, so a large catalog does not open hundreds of connections at once. */
|
|
45
|
+
async function mapLimit(items, limit, fn) {
|
|
46
|
+
const out = [];
|
|
47
|
+
for (let i = 0; i < items.length; i += limit)
|
|
48
|
+
out.push(...(await Promise.all(items.slice(i, i + limit).map(fn))));
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
const check = (id, status, message, extra = {}) => ({ id, kind: 'static', status, message, ...extra });
|
|
52
|
+
export async function storefrontCheck(api) {
|
|
53
|
+
const state = await api.getStore();
|
|
54
|
+
if (!state.storefront)
|
|
55
|
+
fail('no_storefront', 'Nothing is published at the store host yet.', 'Run `c11r storefront init` if there is no storefront folder, then `c11r storefront publish`.');
|
|
56
|
+
const origin = new URL(state.preview_url).origin;
|
|
57
|
+
const pageUrl = `${origin}/`;
|
|
58
|
+
const page = await get(pageUrl, origin);
|
|
59
|
+
if (page.status !== 200 || !page.type.includes('text/html'))
|
|
60
|
+
fail('storefront_unreachable', `${pageUrl} answered ${page.status} ${page.type}.`, 'Run `c11r storefront publish` again; if it keeps happening, tell the human, with this output.');
|
|
61
|
+
const html = page.body;
|
|
62
|
+
const all = tags(html);
|
|
63
|
+
const find = (name) => all.filter((t) => t.name === name);
|
|
64
|
+
const checks = [];
|
|
65
|
+
// drop-in.js: the script, its stylesheets, and the config block wired to this store's sales channel.
|
|
66
|
+
const dropInJs = find('script').some((t) => /@commercelayer\/drop-in\.js@[^/]+\/dist\/drop-in\/drop-in\.esm\.js/.test(t.attrs.src ?? '') && t.attrs.type === 'module');
|
|
67
|
+
checks.push(dropInJs ? check('drop_in_script', 'pass', 'drop-in.js is loaded as a module.') : check('drop_in_script', 'fail', 'drop-in.js is not loaded: prices, cart and checkout will not work.', { fix: 'Keep <script type="module" src="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.esm.js"> in the <head>, as storefront init writes it.' }));
|
|
68
|
+
const config = scripts(html).find((s) => s.body.includes('commercelayerConfig'))?.body ?? '';
|
|
69
|
+
const clientId = config.match(/clientId\s*:\s*['"]([^'"]+)['"]/)?.[1];
|
|
70
|
+
if (!config)
|
|
71
|
+
checks.push(check('config', 'fail', 'There is no window.commercelayerConfig block.', { fix: 'Restore the config <script> from storefront init (clientId, scope, defaultAttributes) before the drop-in.js script.' }));
|
|
72
|
+
else if (clientId !== state.sales_channel_client_id)
|
|
73
|
+
checks.push(check('config', 'fail', `The config block's clientId is ${clientId ? `"${clientId}"` : 'missing'}, not this store's sales channel.`, { fix: `Set clientId: '${state.sales_channel_client_id}' in the config block.` }));
|
|
74
|
+
else if (!/market:code:/.test(config))
|
|
75
|
+
checks.push(check('config', 'fail', 'The config block has no market scope.', { fix: "Keep scope: 'market:code:' + market in the config block." }));
|
|
76
|
+
else
|
|
77
|
+
checks.push(check('config', 'pass', "The config block uses this store's sales channel and a market scope."));
|
|
78
|
+
// The elements a shopper needs, and an add to cart for every product applied.
|
|
79
|
+
const missing = ['cl-price', 'cl-add-to-cart', 'cl-cart-link', 'cl-checkout-link'].filter((n) => find(n).length === 0);
|
|
80
|
+
checks.push(missing.length ? check('drop_in_elements', 'fail', `The page has no ${missing.join(', ')}.`, { fix: 'Keep the drop-in.js elements storefront init writes: cl-price and cl-add-to-cart per product, cl-cart-link with cl-cart-count and the mini cl-cart, cl-checkout-link.', items: missing }) : check('drop_in_elements', 'pass', 'Prices, add to cart, the cart link and the checkout link are on the page.'));
|
|
81
|
+
let data = {};
|
|
82
|
+
const json = scripts(html).find((s) => /id\s*=\s*["']?products["']?/.test(s.attrs) && /application\/json/.test(s.attrs));
|
|
83
|
+
if (json) {
|
|
84
|
+
try {
|
|
85
|
+
data = JSON.parse(json.body);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
checks.push(check('product_data', 'fail', 'The #products data is not valid JSON: product pages will not open.', { fix: 'Fix the JSON in <script type="application/json" id="products">, or regenerate with `c11r storefront init --force`.' }));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const templated = json && all.some((t) => t.name === 'cl-add-to-cart' && 'data-code' in t.attrs);
|
|
92
|
+
const codes = new Set(all.filter((t) => t.name.startsWith('cl-') && t.attrs.code).map((t) => t.attrs.code));
|
|
93
|
+
const buyable = new Set([...find('cl-add-to-cart').map((t) => t.attrs.code).filter(Boolean), ...(templated ? Object.keys(data) : [])]);
|
|
94
|
+
const skus = Object.keys(state.applied?.products ?? {});
|
|
95
|
+
const unsold = skus.filter((s) => !buyable.has(s));
|
|
96
|
+
const unknown = [...new Set([...codes, ...(templated ? Object.keys(data) : [])])].filter((c) => !skus.includes(c));
|
|
97
|
+
if (unknown.length)
|
|
98
|
+
checks.push(check('products', 'fail', `The page offers skus the store does not have: ${unknown.join(', ')}.`, { fix: 'Use the skus in the catalog (code="<sku>"), or run `c11r apply` if the catalog has them and the store does not yet.', items: unknown }));
|
|
99
|
+
else if (unsold.length)
|
|
100
|
+
checks.push(check('products', 'warn', `${unsold.length} of ${skus.length} products have no add to cart on the page.`, { fix: 'Add a card with <cl-add-to-cart code="<sku>"> for each, or regenerate with `c11r storefront init --force`.', items: unsold }));
|
|
101
|
+
else
|
|
102
|
+
checks.push(check('products', 'pass', `All ${skus.length} products can be added to the cart.`));
|
|
103
|
+
// Images: every <img> and product image, requested; and alt text on the page's images.
|
|
104
|
+
const imageUrls = new Set();
|
|
105
|
+
for (const t of find('img'))
|
|
106
|
+
if (t.attrs.src && !('data-field' in t.attrs))
|
|
107
|
+
imageUrls.add(t.attrs.src);
|
|
108
|
+
for (const p of Object.values(data))
|
|
109
|
+
if (p?.image_url)
|
|
110
|
+
imageUrls.add(p.image_url);
|
|
111
|
+
const resolved = [...imageUrls].slice(0, 200).map((src) => {
|
|
112
|
+
try {
|
|
113
|
+
return { src, url: new URL(src, pageUrl).href };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { src, url: '' };
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
const answers = await mapLimit(resolved, 8, async (i) => ({ ...i, res: i.url ? await get(i.url, origin, 'HEAD') : { status: 0, type: '', body: 'not a URL' } }));
|
|
120
|
+
const broken = answers.filter((a) => a.res.status < 200 || a.res.status >= 300 || (a.res.type && !a.res.type.startsWith('image/')));
|
|
121
|
+
checks.push(broken.length
|
|
122
|
+
? check('images', 'fail', `${broken.length} of ${answers.length} images do not load.`, { fix: 'Put the files in the storefront folder (img/<file>) and publish, or fix the URLs.', items: broken.map((b) => `${b.src}: ${b.res.status || b.res.body}${b.res.type && !b.res.type.startsWith('image/') ? ` ${b.res.type}` : ''}`) })
|
|
123
|
+
: check('images', 'pass', answers.length ? `All ${answers.length} images load.` : 'The page has no images.'));
|
|
124
|
+
const noAlt = find('img').filter((t) => !('alt' in t.attrs) && !('data-field' in t.attrs)).map((t) => t.attrs.src ?? '(no src)');
|
|
125
|
+
checks.push(noAlt.length ? check('image_alt', 'warn', `${noAlt.length} images have no alt text.`, { fix: 'Give every <img> an alt: the product name, or alt="" for decoration.', items: noAlt }) : check('image_alt', 'pass', 'Every image has alt text.'));
|
|
126
|
+
// Styles: the page's own CSS after drop-in.css, the theme tokens, overrides that can apply.
|
|
127
|
+
const sheets = find('link').filter((t) => /\bstylesheet\b/i.test(t.attrs.rel ?? '') && t.attrs.href);
|
|
128
|
+
const dropInCss = sheets.findIndex((t) => /drop-in\.js@[^/]+\/dist\/drop-in\/drop-in\.css/.test(t.attrs.href));
|
|
129
|
+
const own = sheets.filter((t) => new URL(t.attrs.href, pageUrl).origin === origin);
|
|
130
|
+
const inline = [...html.replace(/<!--[\s\S]*?-->/g, '').matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]);
|
|
131
|
+
const ownCss = await mapLimit(own, 4, async (t) => (await get(new URL(t.attrs.href, pageUrl).href, origin)).body);
|
|
132
|
+
const css = [...ownCss, ...inline].join('\n');
|
|
133
|
+
if (dropInCss < 0)
|
|
134
|
+
checks.push(check('stylesheets', 'warn', 'drop-in.css is not linked: drop-in.js elements fall back to unstyled.', { fix: 'Keep <link href="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.css" rel="stylesheet">, before the page\'s own stylesheet.' }));
|
|
135
|
+
else if (own.some((t) => sheets.indexOf(t) < dropInCss))
|
|
136
|
+
checks.push(check('stylesheets', 'warn', "A stylesheet of the page loads before drop-in.css, so drop-in.css's rules win over it.", { fix: 'Link the page\'s stylesheets after drop-in.css and minicart.css.', items: own.filter((t) => sheets.indexOf(t) < dropInCss).map((t) => t.attrs.href) }));
|
|
137
|
+
else
|
|
138
|
+
checks.push(check('stylesheets', 'pass', "The page's stylesheets load after drop-in.css."));
|
|
139
|
+
const tokens = ['--paper', '--ink', '--accent'].filter((t) => !new RegExp(`${t}\\s*:`).test(css));
|
|
140
|
+
checks.push(tokens.length ? check('theme_tokens', 'warn', `The theme tokens ${tokens.join(', ')} are not defined.`, { fix: 'Keep the tokens block at the top of styles.css and restyle through it.', items: tokens }) : check('theme_tokens', 'pass', 'The theme tokens are defined.'));
|
|
141
|
+
const dead = [...css.matchAll(/(cl-add-to-cart|cl-checkout-link|cl-cart-link)::part\([^)]*\)/g)].map((m) => m[0]);
|
|
142
|
+
const overrides = /cl-price-amount\s*\{[^}]*color\s*:/.test(css) && /cl-add-to-cart(?::not\(\[aria-disabled=["']?true["']?\]\))?\s*\{[^}]*background(-color)?\s*:/.test(css);
|
|
143
|
+
if (dead.length)
|
|
144
|
+
checks.push(check('drop_in_overrides', 'fail', 'Some rules style ::part()s that these drop-in.js elements do not have: they never apply.', { fix: 'Style the elements themselves (cl-add-to-cart, cl-checkout-link a), as styles.css does.', items: [...new Set(dead)] }));
|
|
145
|
+
else if (!overrides)
|
|
146
|
+
checks.push(check('drop_in_overrides', 'warn', "Nothing overrides drop-in.css's colours for prices and the add to cart button, so they ignore the theme.", { fix: 'Keep the "drop-in.js overrides" section of styles.css.' }));
|
|
147
|
+
else
|
|
148
|
+
checks.push(check('drop_in_overrides', 'pass', 'Prices and the add to cart button follow the theme.'));
|
|
149
|
+
// The page itself.
|
|
150
|
+
const title = decode(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? '').trim();
|
|
151
|
+
if (!title)
|
|
152
|
+
checks.push(check('title', 'fail', 'The page has no <title>.', { fix: 'Add a <title> with the store name and what it sells.' }));
|
|
153
|
+
else if (title === state.name)
|
|
154
|
+
checks.push(check('title', 'warn', `The <title> is still the scaffold's: "${title}".`, { fix: 'Say what the store sells too, e.g. "<name> · Specialty coffee".' }));
|
|
155
|
+
else
|
|
156
|
+
checks.push(check('title', 'pass', `The <title> is "${title}".`));
|
|
157
|
+
const lang = find('html')[0]?.attrs.lang;
|
|
158
|
+
checks.push(lang ? check('lang', 'pass', `The page language is ${lang}.`) : check('lang', 'warn', 'The <html> element has no lang.', { fix: 'Add lang="en" (or the store\'s language) to <html>.' }));
|
|
159
|
+
const viewport = find('meta').some((t) => t.attrs.name === 'viewport');
|
|
160
|
+
checks.push(viewport ? check('viewport', 'pass', 'The page has a viewport meta tag.') : check('viewport', 'fail', 'The page has no viewport meta tag: phones show it zoomed out.', { fix: 'Add <meta name="viewport" content="width=device-width, initial-scale=1">.' }));
|
|
161
|
+
const description = find('meta').find((t) => t.attrs.name === 'description')?.attrs.content;
|
|
162
|
+
checks.push(description ? check('description', 'pass', 'The page has a meta description.') : check('description', 'warn', 'The page has no meta description, what search results and link previews show.', { fix: 'Add <meta name="description" content="<one sentence on the store>">.' }));
|
|
163
|
+
// The local folder against what is published.
|
|
164
|
+
const dir = storefrontPath();
|
|
165
|
+
if (fs.existsSync(dir)) {
|
|
166
|
+
const since = Date.parse(state.storefront.published_at);
|
|
167
|
+
const newer = fs
|
|
168
|
+
.readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
169
|
+
.filter((e) => e.isFile() && !path.relative(dir, path.join(e.parentPath, e.name)).split(path.sep).some((p) => p.startsWith('.')))
|
|
170
|
+
.filter((e) => fs.statSync(path.join(e.parentPath, e.name)).mtimeMs > since)
|
|
171
|
+
.map((e) => path.relative(dir, path.join(e.parentPath, e.name)).split(path.sep).join('/'));
|
|
172
|
+
checks.push(newer.length ? check('published', 'warn', `${newer.length} files in ${dir}/ changed after the last publish: this check read the published version.`, { fix: 'Run `c11r storefront publish`, then check again.', items: newer }) : check('published', 'pass', `The published storefront matches ${dir}/.`));
|
|
173
|
+
}
|
|
174
|
+
const summary = { pass: 0, warn: 0, fail: 0 };
|
|
175
|
+
for (const c of checks)
|
|
176
|
+
summary[c.status]++;
|
|
177
|
+
return {
|
|
178
|
+
status: summary.fail ? 'fail' : 'pass',
|
|
179
|
+
url: pageUrl,
|
|
180
|
+
published_at: state.storefront.published_at,
|
|
181
|
+
summary,
|
|
182
|
+
checks,
|
|
183
|
+
// Filled by the rendering phase (1440 and 390 px: contrast, overflow, minicart fit, screenshots); null until then.
|
|
184
|
+
render: null,
|
|
185
|
+
next: summary.fail ? 'Fix the failed checks (see fix), publish, and check again.' : summary.warn ? 'The storefront works. The warnings are worth fixing before the human sees it.' : undefined,
|
|
186
|
+
};
|
|
187
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -9,6 +9,7 @@ import { CliError, fail, progress } from './output.js';
|
|
|
9
9
|
import { diff, hashStore, isLocalImage, loadStore, storefrontPath, template, toApplied } from './store.js';
|
|
10
10
|
import { storefrontFiles } from './storefront.js';
|
|
11
11
|
import { pull } from './catalog.js';
|
|
12
|
+
import { storefrontCheck } from './check.js';
|
|
12
13
|
import { buy } from './ucp/buy.js';
|
|
13
14
|
import { check } from './ucp/check.js';
|
|
14
15
|
import { discover } from './ucp/client.js';
|
|
@@ -21,6 +22,7 @@ export const HELP = {
|
|
|
21
22
|
apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
|
|
22
23
|
'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.',
|
|
23
24
|
'storefront init': 'Write a static storefront on drop-in.js, no build step: storefront/index.html (the products by category, a minicart that opens on add to cart, the hosted cart and checkout, and a product page at #/p/<sku>), styles.css (theme tokens first) and app.js, plus img/ for product images. Refuses to overwrite unless --force is given.',
|
|
25
|
+
'storefront check': 'Check the published storefront at the preview URL: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides, title, lang, viewport, description, and whether the folder changed since publish. Prints { status, summary, checks: [{ id, kind, status: pass|warn|fail, message, fix, items }] }; exits 1 when a check fails.',
|
|
24
26
|
'storefront publish': 'Upload the storefront folder (store.yml storefront.path, default storefront) to the sandbox preview URL.',
|
|
25
27
|
'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.',
|
|
26
28
|
'orders list': 'List the orders placed in the store, newest first, as UCP order entities.',
|
|
@@ -184,6 +186,9 @@ export async function storefrontInit(flags) {
|
|
|
184
186
|
next: `Set storefront.path: ${dir} in ${paths.store}, make it the human's (restyle through the tokens at the top of styles.css; product images go in ${dir}/img/ with image_url: img/<file>; each product has a page at #/p/<sku>, built from #product-template in index.html), then run c11r storefront publish.`,
|
|
185
187
|
};
|
|
186
188
|
}
|
|
189
|
+
export async function storefrontCheckCmd() {
|
|
190
|
+
return storefrontCheck(createApi(requireCredentials()));
|
|
191
|
+
}
|
|
187
192
|
export async function storefrontPublish() {
|
|
188
193
|
const api = createApi(requireCredentials());
|
|
189
194
|
const dir = storefrontPath();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commercelayer/agent-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
|
|
5
5
|
"homepage": "https://commercelayer.ai",
|
|
6
6
|
"keywords": [
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"build": "tsc -p tsconfig.json && chmod +x dist/bin.js dist/api/server.js",
|
|
27
|
+
"build": "rm -rf dist.next && tsc -p tsconfig.json --outDir dist.next && chmod +x dist.next/bin.js dist.next/api/server.js && rm -rf dist.old && { mv dist dist.old 2>/dev/null; mv dist.next dist; } && rm -rf dist.old",
|
|
28
28
|
"dev": "tsx src/bin.ts",
|
|
29
29
|
"stub-api": "tsx src/api/server.ts",
|
|
30
30
|
"test": "npm run build && vitest run",
|