@commercelayer/agent-cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ // One function per command. Each returns the object the CLI prints, or throws a
2
+ // CliError. Every command is safe to re-run.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { createApi } from './api/client.js';
6
+ import { ignoreCredentials, paths, readCredentials, requireCredentials, writeCredentials } from './files.js';
7
+ import { fail } from './output.js';
8
+ import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from './store.js';
9
+ import { storefrontHtml } from './storefront.js';
10
+ import { buy } from './ucp/buy.js';
11
+ import { check } from './ucp/check.js';
12
+ import { discover } from './ucp/client.js';
13
+ import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
14
+ export const VERSION = '0.1.0';
15
+ export const HELP = {
16
+ init: 'Create a sandbox store for this repo, with no login. Writes .commercelayer/store.yml and .commercelayer/credentials. Reuses the store if store.yml already exists.',
17
+ plan: 'Show what apply would create, update or delete, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku).',
18
+ apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/. Refuses deletes unless --allow-delete is given.',
19
+ 'storefront init': 'Write storefront/index.html: a static page on drop-in.js listing the products in store.yml, with the hosted cart and checkout. Refuses to overwrite unless --force is given.',
20
+ 'storefront publish': 'Upload the storefront folder (store.yml storefront.path, default storefront) to the sandbox preview URL.',
21
+ '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.',
22
+ 'orders list': 'List the orders placed in the store, newest first, as UCP order entities.',
23
+ 'orders show': 'Show one order: c11r orders show <id or number>.',
24
+ 'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
25
+ 'ucp check': 'Walk every UCP capability the store advertises and report what passes. Exits 1 when a check fails.',
26
+ status: 'Show the sandbox, what is applied, and whether store.yml has changes not yet applied.',
27
+ claim: 'Start keeping this sandbox store: returns a URL and a code for the human to approve in the browser.',
28
+ help: 'Describe the commands. `c11r help <command>` for one command.',
29
+ };
30
+ export function help(name) {
31
+ if (name) {
32
+ if (!HELP[name])
33
+ fail('unknown_command', `There is no command "${name}".`, `Commands: ${Object.keys(HELP).join(', ')}.`);
34
+ const showsFormat = name === 'apply' || name === 'plan';
35
+ return { command: name, description: HELP[name], store_format: showsFormat ? template('<name>') : undefined };
36
+ }
37
+ return { version: VERSION, commands: HELP, note: 'Every command prints JSON. The store format is documented in the comments of .commercelayer/store.yml; large catalogs go in .commercelayer/products/ as .yml or .csv files.' };
38
+ }
39
+ export async function init() {
40
+ const existing = readCredentials();
41
+ if (existing && fs.existsSync(paths.store)) {
42
+ const s = await createApi(existing).getStore();
43
+ return { status: 'reused', org: s.org, claim_url: s.claim_url, expires_at: s.expires_at, preview_url: s.preview_url };
44
+ }
45
+ const name = path.basename(process.cwd());
46
+ const sandbox = await createApi(null).createSandbox(name);
47
+ fs.mkdirSync(paths.dir, { recursive: true });
48
+ if (!fs.existsSync(paths.store))
49
+ fs.writeFileSync(paths.store, template(name));
50
+ writeCredentials({ org: sandbox.org, token: sandbox.token });
51
+ const files = [paths.store, paths.credentials];
52
+ if (ignoreCredentials())
53
+ files.push('.gitignore');
54
+ return {
55
+ status: 'created',
56
+ org: sandbox.org,
57
+ claim_url: sandbox.claim_url,
58
+ expires_at: sandbox.expires_at,
59
+ preview_url: sandbox.preview_url,
60
+ files,
61
+ next: 'Describe the store in .commercelayer/store.yml (products there or in .commercelayer/products/*.yml|csv), then run c11r plan.',
62
+ };
63
+ }
64
+ export async function plan() {
65
+ const api = createApi(requireCredentials());
66
+ const desired = loadStore();
67
+ const state = await api.getStore();
68
+ const changes = diff(state.applied, desired);
69
+ return { status: changes.length ? 'changes' : 'up_to_date', changes, deletes: changes.filter((c) => c.action === 'delete').length };
70
+ }
71
+ export async function apply(flags) {
72
+ const api = createApi(requireCredentials());
73
+ const desired = loadStore();
74
+ const state = await api.getStore();
75
+ const changes = diff(state.applied, desired);
76
+ const deletes = changes.filter((c) => c.action === 'delete');
77
+ if (deletes.length && !flags.has('--allow-delete')) {
78
+ 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`.');
79
+ }
80
+ const applied = await api.applyStore({ applied: toApplied(desired), hash: hashStore(), name: desired.name, policies: desired.policies });
81
+ return {
82
+ status: 'applied',
83
+ changes,
84
+ preview_url: applied.preview_url,
85
+ markets: desired.markets.length,
86
+ products: desired.products.length,
87
+ payment_gateway: 'sandbox (test payments only)',
88
+ };
89
+ }
90
+ export async function storefrontInit(flags) {
91
+ const api = createApi(requireCredentials());
92
+ const desired = loadStore();
93
+ const dir = 'storefront';
94
+ if (fs.existsSync(dir) && !flags.has('--force')) {
95
+ fail('storefront_exists', `${dir}/ already exists.`, 'Edit the files in it, or run `c11r storefront init --force` to regenerate them from store.yml (this overwrites your changes).');
96
+ }
97
+ const state = await api.getStore();
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ const file = path.join(dir, 'index.html');
100
+ fs.writeFileSync(file, storefrontHtml(desired, state.sales_channel_client_id));
101
+ return {
102
+ status: 'created',
103
+ files: [file],
104
+ markets: desired.markets.map((m) => m.code),
105
+ products: desired.products.length,
106
+ next: `Set storefront.path: ${dir} in ${paths.store}, make the page the human's, then run c11r storefront publish.`,
107
+ };
108
+ }
109
+ export async function storefrontPublish() {
110
+ const api = createApi(requireCredentials());
111
+ const dir = storefrontPath();
112
+ if (!fs.existsSync(path.join(dir, 'index.html'))) {
113
+ fail('no_storefront', `There is no ${path.join(dir, 'index.html')}.`, 'Run `c11r storefront init` to create one, or set storefront.path in store.yml to the folder that has index.html.');
114
+ }
115
+ const files = fs
116
+ .readdirSync(dir, { recursive: true, withFileTypes: true })
117
+ .filter((e) => e.isFile())
118
+ .map((e) => {
119
+ const full = path.join(e.parentPath, e.name);
120
+ return { path: path.relative(dir, full).split(path.sep).join('/'), content_base64: fs.readFileSync(full).toString('base64') };
121
+ });
122
+ const published = await api.publishStorefront(dir, files);
123
+ const state = await api.getStore();
124
+ return { status: 'published', preview_url: state.preview_url, path: dir, files: published.files, published_at: published.published_at };
125
+ }
126
+ /** The store's origin on the web: where it speaks UCP. */
127
+ async function storeOrigin() {
128
+ const api = createApi(requireCredentials());
129
+ const s = await api.getStore();
130
+ if (!s.applied)
131
+ fail('not_applied', 'The store has nothing applied yet.', 'Run `c11r apply` first.');
132
+ return s.preview_url;
133
+ }
134
+ export async function ordersTest(flags, values) {
135
+ const origin = await storeOrigin();
136
+ const quantity = values.quantity === undefined ? undefined : Number(values.quantity);
137
+ if (quantity !== undefined && (!Number.isInteger(quantity) || quantity < 1))
138
+ fail('invalid_argument', '--quantity must be a positive integer.', 'Example: --quantity 2.');
139
+ const p = await buy(origin, { sku: values.sku, quantity, country: values.country, code: values.code, viaCart: flags.has('--via-cart'), decline: flags.has('--decline') });
140
+ return {
141
+ status: p.checkout.status === 'completed' ? 'paid' : p.checkout.status,
142
+ order: p.order,
143
+ checkout_id: p.checkout.id,
144
+ messages: p.checkout.messages,
145
+ payment_handler: 'ai.commercelayer.sandbox (test payments only)',
146
+ steps: p.steps,
147
+ };
148
+ }
149
+ export async function ordersList() {
150
+ const api = createApi(requireCredentials());
151
+ return { orders: await api.listOrders() };
152
+ }
153
+ export async function ordersShow(id) {
154
+ if (!id)
155
+ fail('missing_argument', 'orders show needs an order id or number.', 'Run `c11r orders list` to see the orders, then `c11r orders show <id>`.');
156
+ const api = createApi(requireCredentials());
157
+ return { order: await api.getOrder(id) };
158
+ }
159
+ export async function ucpProfile(flags) {
160
+ if (flags.has('--agent'))
161
+ return { url: AGENT_PROFILE_URL, profile: agentProfile() };
162
+ const origin = await storeOrigin();
163
+ const store = await discover(origin);
164
+ return { url: `${origin}/.well-known/ucp`, profile: store.profile };
165
+ }
166
+ export async function ucpCheck() {
167
+ const origin = await storeOrigin();
168
+ const report = await check(origin);
169
+ return { ...report, next: report.status === 'pass' ? 'Any shopping agent can buy from this store.' : 'Fix the failed checks and run `c11r ucp check` again.' };
170
+ }
171
+ export async function status() {
172
+ const api = createApi(requireCredentials());
173
+ const s = await api.getStore();
174
+ return {
175
+ org: s.org,
176
+ sandbox: s.sandbox,
177
+ claimed: s.claimed,
178
+ expires_at: s.expires_at,
179
+ preview_url: s.preview_url,
180
+ sales_channel_client_id: s.sales_channel_client_id,
181
+ storefront: s.storefront,
182
+ applied: Boolean(s.applied),
183
+ pending_changes: s.applied_hash !== hashStore(),
184
+ markets: s.applied ? Object.keys(s.applied.markets) : [],
185
+ products: s.applied ? Object.keys(s.applied.products).length : 0,
186
+ orders: s.orders.map((o) => ({ id: o.id, label: o.label })),
187
+ ucp_profile: `${s.preview_url}/.well-known/ucp`,
188
+ };
189
+ }
190
+ export async function claim() {
191
+ const api = createApi(requireCredentials());
192
+ const s = await api.getStore();
193
+ const started = await api.startClaim();
194
+ return {
195
+ status: 'pending',
196
+ verification_url: started.verification_url,
197
+ user_code: started.user_code,
198
+ claim_url: s.claim_url,
199
+ next: 'Ask the human to open verification_url and enter user_code, then run `c11r status`.',
200
+ };
201
+ }
package/dist/files.js ADDED
@@ -0,0 +1,47 @@
1
+ // The files the CLI keeps in the repo: .commercelayer/store.yml and the product
2
+ // files under .commercelayer/products/ (committed), and .commercelayer/credentials
3
+ // (gitignored: the sandbox token).
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fail } from './output.js';
7
+ const dir = '.commercelayer';
8
+ export const paths = {
9
+ dir,
10
+ store: path.join(dir, 'store.yml'),
11
+ products: path.join(dir, 'products'),
12
+ credentials: path.join(dir, 'credentials'),
13
+ };
14
+ export function readCredentials() {
15
+ if (!fs.existsSync(paths.credentials))
16
+ return null;
17
+ const vars = {};
18
+ for (const line of fs.readFileSync(paths.credentials, 'utf8').split('\n')) {
19
+ const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
20
+ if (m)
21
+ vars[m[1]] = m[2];
22
+ }
23
+ if (!vars.C11R_ORG || !vars.C11R_TOKEN)
24
+ return null;
25
+ return { org: vars.C11R_ORG, token: vars.C11R_TOKEN };
26
+ }
27
+ export function writeCredentials(c) {
28
+ fs.mkdirSync(paths.dir, { recursive: true });
29
+ fs.writeFileSync(paths.credentials, `C11R_ORG=${c.org}\nC11R_TOKEN=${c.token}\n`, { mode: 0o600 });
30
+ }
31
+ /** The store and its credentials, or a failure telling the agent to run init. */
32
+ export function requireCredentials() {
33
+ const c = readCredentials();
34
+ if (!c || !fs.existsSync(paths.store)) {
35
+ fail('no_store', 'There is no store in this repo.', 'Run `c11r init` in the repo root first.');
36
+ }
37
+ return c;
38
+ }
39
+ /** Make sure .gitignore lists the credentials file. Returns true if it changed. */
40
+ export function ignoreCredentials() {
41
+ const existing = fs.existsSync('.gitignore') ? fs.readFileSync('.gitignore', 'utf8') : '';
42
+ if (existing.split('\n').includes(paths.credentials))
43
+ return false;
44
+ const sep = existing && !existing.endsWith('\n') ? '\n' : '';
45
+ fs.appendFileSync('.gitignore', `${sep}${paths.credentials}\n`);
46
+ return true;
47
+ }
package/dist/output.js ADDED
@@ -0,0 +1,18 @@
1
+ // Everything the CLI says is JSON on stdout: one object per run, pretty-printed.
2
+ // A failure is { error: { code, message, hint } } with exit code 1. The hint is
3
+ // the fix: an agent reads it and acts on it before trying anything else.
4
+ export class CliError extends Error {
5
+ code;
6
+ hint;
7
+ constructor(code, message, hint) {
8
+ super(message);
9
+ this.code = code;
10
+ this.hint = hint;
11
+ }
12
+ }
13
+ export function fail(code, message, hint) {
14
+ throw new CliError(code, message, hint);
15
+ }
16
+ export function print(data) {
17
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
18
+ }
@@ -0,0 +1,128 @@
1
+ // The catalog can be large, so it lives in files under .commercelayer/products/
2
+ // next to (or instead of) the products list in store.yml. Every *.yml file is a
3
+ // list of products in the store.yml shape; every *.csv file is one product per
4
+ // row. All files are merged by sku, and errors name the file they come from.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import YAML from 'yaml';
8
+ import { paths } from './files.js';
9
+ import { fail } from './output.js';
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'];
12
+ /** The product files, in a stable order. */
13
+ export function productFiles() {
14
+ if (!fs.existsSync(paths.products))
15
+ return [];
16
+ return fs
17
+ .readdirSync(paths.products)
18
+ .filter((f) => /\.(ya?ml|csv)$/i.test(f))
19
+ .sort()
20
+ .map((f) => path.join(paths.products, f));
21
+ }
22
+ export function readProductFile(file) {
23
+ const text = fs.readFileSync(file, 'utf8');
24
+ const products = /\.csv$/i.test(file) ? fromCsv(file, text) : fromYaml(file, text);
25
+ return products.map((product) => ({ product, source: file }));
26
+ }
27
+ function fromYaml(file, text) {
28
+ let doc;
29
+ try {
30
+ doc = YAML.parse(text);
31
+ }
32
+ catch (e) {
33
+ const reason = e.message.split('\n')[0];
34
+ fail('invalid_yaml', `${file} is not valid YAML: ${reason}`, `Fix the YAML syntax in ${file} and run the command again.`);
35
+ }
36
+ if (doc === null || doc === undefined)
37
+ return [];
38
+ if (Array.isArray(doc))
39
+ return doc;
40
+ if (typeof doc === 'object') {
41
+ const obj = doc;
42
+ if (Array.isArray(obj.products))
43
+ return obj.products;
44
+ if ('sku' in obj)
45
+ return [obj];
46
+ }
47
+ fail('invalid_product', `${file} is not a list of products.`, 'A product file is a YAML list of products, each with sku, name and prices, in the same shape as products in store.yml.');
48
+ }
49
+ /** RFC 4180: quoted cells, doubled quotes, CRLF or LF. Blank lines are skipped. */
50
+ export function parseCsv(text) {
51
+ const rows = [];
52
+ let row = [];
53
+ let cell = '';
54
+ let quoted = false;
55
+ for (let i = 0; i < text.length; i++) {
56
+ const ch = text[i];
57
+ if (quoted) {
58
+ if (ch === '"') {
59
+ if (text[i + 1] === '"') {
60
+ cell += '"';
61
+ i++;
62
+ }
63
+ else
64
+ quoted = false;
65
+ }
66
+ else
67
+ cell += ch;
68
+ }
69
+ else if (ch === '"')
70
+ quoted = true;
71
+ else if (ch === ',') {
72
+ row.push(cell);
73
+ cell = '';
74
+ }
75
+ else if (ch === '\n' || ch === '\r') {
76
+ if (ch === '\r' && text[i + 1] === '\n')
77
+ i++;
78
+ row.push(cell);
79
+ rows.push(row);
80
+ row = [];
81
+ cell = '';
82
+ }
83
+ else
84
+ cell += ch;
85
+ }
86
+ if (cell !== '' || row.length) {
87
+ row.push(cell);
88
+ rows.push(row);
89
+ }
90
+ return rows.filter((r) => !(r.length === 1 && r[0].trim() === ''));
91
+ }
92
+ function fromCsv(file, text) {
93
+ const rows = parseCsv(text.replace(/^/, ''));
94
+ if (rows.length === 0)
95
+ return [];
96
+ const header = rows[0].map((h) => h.trim());
97
+ const columns = header.map((h) => {
98
+ const lower = h.toLowerCase();
99
+ if (CSV_COLUMNS.includes(lower))
100
+ return { field: lower };
101
+ if (/^[A-Z]{3}$/.test(h))
102
+ return { currency: h };
103
+ const why = lower === 'stock' ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : '';
104
+ fail('invalid_product', `${file} has a column "${h}" that is not a product field.`, `${why}CSV columns are ${CSV_COLUMNS.join(', ')} and one column per currency with the price, named by its ISO 4217 code (EUR, USD, ...). Remove or rename the column.`);
105
+ });
106
+ if (!columns.some((c) => 'field' in c && c.field === 'sku'))
107
+ fail('invalid_product', `${file} has no sku column.`, `The first line of a product CSV names its columns: ${CSV_COLUMNS.join(', ')} and one per currency.`);
108
+ const products = [];
109
+ rows.slice(1).forEach((cells, i) => {
110
+ const line = `${file}:${i + 2}`;
111
+ const product = { sku: '', name: '', prices: {} };
112
+ columns.forEach((col, j) => {
113
+ const value = (cells[j] ?? '').trim();
114
+ if (value === '')
115
+ return;
116
+ if ('currency' in col) {
117
+ const price = Number(value);
118
+ if (!Number.isFinite(price))
119
+ fail('invalid_product', `${line}: ${col.currency} price "${value}" is not a number.`, 'Write prices as plain decimals, such as 14.50, with no currency sign or thousands separator.');
120
+ product.prices[col.currency] = price;
121
+ }
122
+ else
123
+ product[col.field] = value;
124
+ });
125
+ products.push(product);
126
+ });
127
+ return products;
128
+ }
package/dist/store.js ADDED
@@ -0,0 +1,172 @@
1
+ // .commercelayer/store.yml is the store: the desired state, committed in the repo.
2
+ // Products can also live in files under .commercelayer/products/ (see products.ts).
3
+ // This module reads and validates the whole thing, and diffs it against what the
4
+ // API has applied.
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import YAML from 'yaml';
8
+ import { fail } from './output.js';
9
+ import { paths } from './files.js';
10
+ import { productFiles, readProductFile } from './products.js';
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'];
13
+ export const template = (name) => `# Commerce Layer store. This file is the store: edit it, then run
14
+ # c11r plan to see what will change
15
+ # c11r apply to make the change
16
+ # Every product needs a price in the currency of every market.
17
+
18
+ name: ${name}
19
+
20
+ markets:
21
+ # One market per region you sell in. countries are ISO 3166 codes.
22
+ # shipping is optional: without it orders ship free as "Standard".
23
+ # - code: eu
24
+ # name: Europe
25
+ # countries: [IT, FR, DE, ES]
26
+ # currency: EUR
27
+ # shipping: { method: Standard, price: 5.00, days: 3-5 }
28
+
29
+ products:
30
+ # Products go here, or in files under .commercelayer/products/, merged by sku:
31
+ # <name>.yml a list of products in this same shape
32
+ # <name>.csv one product per row, columns: sku, name, description, image_url,
33
+ # and one column per currency (EUR, USD, ...) with the price
34
+ # Use the folder for anything beyond a handful of products.
35
+ # sku is unique and stays the same when you rename a product.
36
+ # - sku: COFFEE-ETH-250
37
+ # name: Ethiopia Yirgacheffe, 250 g
38
+ # description: Washed, floral, bright.
39
+ # prices: { EUR: 14.50 }
40
+ # Stock is not part of the catalog: inventory is tracked live in Commerce Layer.
41
+
42
+ storefront:
43
+ # The web app in this repo that sells the products, relative to the repo root.
44
+ # path: storefront
45
+
46
+ policies:
47
+ # Pages shopping agents show buyers before they pay. Without them the store
48
+ # serves default sandbox pages at <preview_url>/policies/<name>.
49
+ # privacy_policy: https://example.com/privacy
50
+ # terms_of_service: https://example.com/terms
51
+ # returns: https://example.com/returns
52
+ `;
53
+ export function readStoreFile() {
54
+ return fs.readFileSync(paths.store, 'utf8');
55
+ }
56
+ /** storefront.path from store.yml without validating the rest, default "storefront". */
57
+ export function storefrontPath() {
58
+ try {
59
+ const doc = YAML.parse(readStoreFile()) ?? {};
60
+ return doc.storefront?.path ?? 'storefront';
61
+ }
62
+ catch {
63
+ return 'storefront';
64
+ }
65
+ }
66
+ /** Every file the store is read from: store.yml and the product files. */
67
+ export function storeFiles() {
68
+ return [paths.store, ...productFiles()];
69
+ }
70
+ /** A short digest of all the store's files, as they are on disk. The API keeps the one it applied. */
71
+ export function hashStore() {
72
+ const h = crypto.createHash('sha256');
73
+ for (const file of storeFiles())
74
+ h.update(`${file}\n`).update(fs.readFileSync(file)).update('\n');
75
+ return h.digest('hex').slice(0, 12);
76
+ }
77
+ /** Parse and validate store.yml and the product files, or fail with the fix in the hint. */
78
+ export function loadStore() {
79
+ let doc;
80
+ try {
81
+ doc = YAML.parse(readStoreFile()) ?? {};
82
+ }
83
+ catch (e) {
84
+ const reason = e.message.split('\n')[0];
85
+ fail('invalid_yaml', `store.yml is not valid YAML: ${reason}`, `Fix the YAML syntax in ${paths.store} and run the command again.`);
86
+ }
87
+ const markets = (doc.markets ?? []);
88
+ if (!Array.isArray(doc.products ?? []))
89
+ fail('invalid_store', 'products in store.yml is not a list.', 'Write products as a YAML list under `products:`, or move them to files under .commercelayer/products/.');
90
+ const sourced = (doc.products ?? []).map((product) => ({ product, source: paths.store }));
91
+ for (const file of productFiles())
92
+ sourced.push(...readProductFile(file));
93
+ if (!doc.name)
94
+ fail('invalid_store', 'store.yml has no name.', `Add \`name: <store name>\` at the top of ${paths.store}.`);
95
+ if (markets.length === 0)
96
+ fail('invalid_store', 'store.yml has no markets.', 'Add at least one market under `markets:`, with code, name, countries and currency. See the comments in store.yml.');
97
+ if (sourced.length === 0)
98
+ fail('invalid_store', 'The store has no products.', `Add at least one product, with sku, name and prices: under \`products:\` in ${paths.store}, or in a .yml or .csv file under ${paths.products}/. See the comments in store.yml.`);
99
+ const codes = new Set();
100
+ for (const m of markets) {
101
+ for (const key of ['code', 'name', 'countries', 'currency']) {
102
+ if (!m[key])
103
+ fail('invalid_market', `Market ${m.code ?? '(no code)'} has no ${key}.`, `Add \`${key}\` to the market in ${paths.store}.`);
104
+ }
105
+ if (!/^[A-Z]{3}$/.test(m.currency))
106
+ fail('invalid_market', `Market ${m.code} has currency "${m.currency}".`, 'Use an ISO 4217 code in capitals, such as EUR or USD.');
107
+ if (codes.has(m.code))
108
+ fail('invalid_market', `Two markets have code ${m.code}.`, 'Give every market a unique code.');
109
+ codes.add(m.code);
110
+ if (m.shipping !== undefined) {
111
+ if (!m.shipping.method || typeof m.shipping.price !== 'number' || m.shipping.price < 0) {
112
+ fail('invalid_market', `Market ${m.code} has an invalid shipping entry.`, 'Use `shipping: { method: <name>, price: <amount in the market currency>, days: <estimate> }`, or remove it for free Standard shipping.');
113
+ }
114
+ }
115
+ }
116
+ const policies = (doc.policies ?? {});
117
+ for (const [key, url] of Object.entries(policies)) {
118
+ if (url && !/^https?:\/\//.test(String(url)))
119
+ 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
+ }
121
+ const currencies = [...new Set(markets.map((m) => m.currency))];
122
+ const skus = new Map();
123
+ const products = [];
124
+ for (const { product: p, source } of sourced) {
125
+ if (!p || typeof p !== 'object')
126
+ fail('invalid_product', `${source} has an entry that is not a product.`, 'Every product is a mapping with sku, name and prices.');
127
+ if (!p.sku || !p.name)
128
+ fail('invalid_product', `A product in ${source} has no ${p.sku ? 'name' : 'sku'}${p.sku ? ` (sku ${p.sku})` : ''}.`, 'Every product needs a sku and a name.');
129
+ p.sku = String(p.sku);
130
+ const seen = skus.get(p.sku);
131
+ if (seen)
132
+ fail('invalid_product', `Two products have sku ${p.sku}: in ${seen} and ${source}.`, 'Give every product a unique sku, or remove the duplicate.');
133
+ skus.set(p.sku, source);
134
+ const unknown = Object.keys(p).filter((k) => !PRODUCT_KEYS.includes(k));
135
+ if (unknown.length) {
136
+ const why = unknown.includes('stock') ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : '';
137
+ 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
+ }
139
+ for (const c of currencies) {
140
+ const price = p.prices?.[c];
141
+ 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(', ')}).`);
143
+ }
144
+ }
145
+ products.push(p);
146
+ }
147
+ return { name: doc.name, markets, products, storefront: doc.storefront, policies };
148
+ }
149
+ export function toApplied(store) {
150
+ return {
151
+ markets: Object.fromEntries(store.markets.map((m) => [m.code, m])),
152
+ products: Object.fromEntries(store.products.map((p) => [p.sku, p])),
153
+ };
154
+ }
155
+ export function diff(applied, desired) {
156
+ const changes = [];
157
+ const after = toApplied(desired);
158
+ for (const kind of ['markets', 'products']) {
159
+ const type = kind.slice(0, -1);
160
+ const before = applied?.[kind] ?? {};
161
+ for (const id of Object.keys(after[kind])) {
162
+ if (!(id in before))
163
+ changes.push({ action: 'create', type, id });
164
+ else if (JSON.stringify(before[id]) !== JSON.stringify(after[kind][id]))
165
+ changes.push({ action: 'update', type, id });
166
+ }
167
+ for (const id of Object.keys(before))
168
+ if (!(id in after[kind]))
169
+ changes.push({ action: 'delete', type, id });
170
+ }
171
+ return changes;
172
+ }
@@ -0,0 +1,73 @@
1
+ // The storefront scaffold: one static page on drop-in.js listing the products in
2
+ // store.yml, with the hosted cart and checkout. The agent then makes it the human's.
3
+ const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
4
+ export function storefrontHtml(store, clientId) {
5
+ 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>
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
+ <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
+ <cl-add-to-cart code="${esc(p.sku)}">Add to cart</cl-add-to-cart>
12
+ </article>`)
13
+ .join('\n');
14
+ const options = store.markets.map((m) => ` <option value="${esc(m.code)}">${esc(m.name)} (${esc(m.currency)})</option>`).join('\n');
15
+ return `<!doctype html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <title>${name}</title>
21
+ <!-- Commerce Layer drop-in.js: prices, availability, add to cart, and links to the hosted cart and checkout. -->
22
+ <link href="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.css" rel="stylesheet">
23
+ <script>
24
+ // The market comes from ?market=<code>; the first market in store.yml is the default. Keep clientId as it is.
25
+ var market = new URLSearchParams(location.search).get('market') || '${esc(store.markets[0].code)}';
26
+ window.commercelayerConfig = {
27
+ clientId: '${esc(clientId)}',
28
+ scope: 'market:code:' + market,
29
+ defaultAttributes: { orders: { language_code: 'en', return_url: location.origin + location.pathname } }
30
+ };
31
+ </script>
32
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.esm.js"></script>
33
+ <style>
34
+ :root { color-scheme: light; --ink: #16181d; --muted: #5d6572; --line: #e3e6ea; --accent: #1f5eff; }
35
+ body { margin: 0; font-family: system-ui, sans-serif; color: var(--ink); background: #fff; }
36
+ header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--line); }
37
+ header h1 { margin: 0; font-size: 20px; }
38
+ header nav { display: flex; align-items: center; gap: 16px; }
39
+ main { max-width: 1100px; margin: 0 auto; padding: 32px 24px; display: grid; gap: 24px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
40
+ .product { display: flex; flex-direction: column; gap: 8px; padding: 20px; border: 1px solid var(--line); border-radius: 8px; }
41
+ .product img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 6px; }
42
+ .product h2 { margin: 0; font-size: 17px; }
43
+ .product p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
44
+ cl-price { font-weight: 600; }
45
+ cl-price-amount[type="compare-at"] { color: var(--muted); font-weight: 400; text-decoration: line-through; }
46
+ cl-availability { font-size: 13px; color: var(--muted); }
47
+ cl-add-to-cart { margin-top: auto; }
48
+ 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; }
49
+ footer { padding: 24px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; }
50
+ </style>
51
+ </head>
52
+ <body>
53
+ <header>
54
+ <h1>${name}</h1>
55
+ <nav>
56
+ <label>Market
57
+ <select onchange="location.search = '?market=' + this.value">
58
+ ${options}
59
+ </select>
60
+ </label>
61
+ <cl-cart-link>Cart (<cl-cart-count></cl-cart-count>)</cl-cart-link>
62
+ <cl-checkout-link>Checkout</cl-checkout-link>
63
+ </nav>
64
+ </header>
65
+ <main>
66
+ ${cards}
67
+ </main>
68
+ <footer>Sandbox store: test payments only.</footer>
69
+ <script>document.querySelector('header select').value = market;</script>
70
+ </body>
71
+ </html>
72
+ `;
73
+ }