@commercelayer/agent-cli 0.8.0 → 1.0.0-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +95 -215
  2. package/dist/argv.js +214 -0
  3. package/dist/bin.js +15 -178
  4. package/dist/builtins/auth.js +169 -0
  5. package/dist/builtins/extensions.js +123 -0
  6. package/dist/builtins/index.js +8 -0
  7. package/dist/builtins/loops.js +215 -0
  8. package/dist/builtins/sandbox.js +186 -0
  9. package/dist/config.js +211 -0
  10. package/dist/confirm.js +56 -0
  11. package/dist/engine.js +173 -0
  12. package/dist/errors.js +146 -0
  13. package/dist/help.js +78 -0
  14. package/dist/http.js +117 -0
  15. package/dist/login.js +133 -0
  16. package/dist/resources.js +41 -0
  17. package/dist/schema.js +152 -0
  18. package/dist/schemas.js +72 -0
  19. package/dist/secrets.js +189 -0
  20. package/dist/version.js +4 -8
  21. package/package.json +4 -9
  22. package/dist/api/client.js +0 -190
  23. package/dist/api/server.js +0 -26
  24. package/dist/api/stub/db.js +0 -37
  25. package/dist/api/stub/http.js +0 -18
  26. package/dist/api/stub/index.js +0 -52
  27. package/dist/api/stub/locale.js +0 -52
  28. package/dist/api/stub/management.js +0 -409
  29. package/dist/api/stub/messages.js +0 -126
  30. package/dist/api/stub/orders.js +0 -625
  31. package/dist/api/stub/search.js +0 -78
  32. package/dist/api/stub/shopping.js +0 -673
  33. package/dist/api/stub/theme.js +0 -197
  34. package/dist/api/stub/ucp.js +0 -377
  35. package/dist/api/transport.js +0 -57
  36. package/dist/api/types.js +0 -4
  37. package/dist/assets.js +0 -100
  38. package/dist/catalog.js +0 -366
  39. package/dist/check.js +0 -187
  40. package/dist/commands.js +0 -1241
  41. package/dist/cursor.js +0 -31
  42. package/dist/files.js +0 -98
  43. package/dist/inbox/cursor.js +0 -11
  44. package/dist/inbox/fake.js +0 -95
  45. package/dist/inbox/imap.js +0 -139
  46. package/dist/inbox/mailbox.js +0 -25
  47. package/dist/inbox/message.js +0 -88
  48. package/dist/inspect.js +0 -230
  49. package/dist/languages.js +0 -49
  50. package/dist/links.js +0 -118
  51. package/dist/locales.js +0 -43
  52. package/dist/ops.js +0 -190
  53. package/dist/orderRequest.js +0 -141
  54. package/dist/output.js +0 -39
  55. package/dist/products.js +0 -151
  56. package/dist/promotions.js +0 -291
  57. package/dist/store.js +0 -379
  58. package/dist/storefront.js +0 -285
  59. package/dist/ucp/buy.js +0 -90
  60. package/dist/ucp/check.js +0 -360
  61. package/dist/ucp/client.js +0 -129
  62. package/dist/ucp/images.js +0 -62
  63. package/dist/ucp/platform.js +0 -90
  64. package/dist/ucp/shop.js +0 -357
  65. package/dist/ucp/signing.js +0 -67
  66. package/dist/web.js +0 -316
  67. package/dist/website.js +0 -390
package/dist/assets.js DELETED
@@ -1,100 +0,0 @@
1
- // The store's image files: logo, hero and product images written img/<file> live in
2
- // .commercelayer/img/ and are served by the store host at <preview_url>/img/<file>,
3
- // in front of the theme or a custom storefront alike.
4
- // apply uploads the new and changed ones (by sha256) before the resources, and the
5
- // resources PUT lists the ones still used, so the API drops the rest. plan and status
6
- // report the same changes, the referenced files that are missing, and files nothing uses.
7
- import crypto from 'node:crypto';
8
- import fs from 'node:fs';
9
- import path from 'node:path';
10
- import { paths } from './files.js';
11
- import { fail } from './output.js';
12
- import { isLocalImage } from './store.js';
13
- export const imgDir = path.join(paths.dir, 'img');
14
- /** The API's limits: a file, and the decoded bytes of one upload. */
15
- const MAX_FILE = 5 * 1024 * 1024;
16
- const MAX_BATCH = 20 * 1024 * 1024;
17
- /** Every img/<file> the store references: theme logo and hero, product images. */
18
- export function referencedImages(store) {
19
- const refs = new Map();
20
- const add = (url, by) => {
21
- if (url && isLocalImage(url))
22
- refs.set(url, [...(refs.get(url) ?? []), by]);
23
- };
24
- add(store.theme?.logo, 'theme.logo');
25
- add(store.theme?.hero, 'theme.hero');
26
- for (const p of store.products)
27
- add(p.image_url, p.sku);
28
- return refs;
29
- }
30
- function filesUnder(dir) {
31
- if (!fs.existsSync(dir))
32
- return [];
33
- return fs
34
- .readdirSync(dir, { recursive: true, withFileTypes: true })
35
- .filter((e) => e.isFile())
36
- .map((e) => path.relative(dir, path.join(e.parentPath, e.name)).split(path.sep).join('/'))
37
- .filter((rel) => !rel.split('/').some((part) => part.startsWith('.')));
38
- }
39
- const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
40
- export function planAssets(state, store) {
41
- const refs = referencedImages(store);
42
- const missing = [];
43
- const local = new Map();
44
- for (const [ref, by] of refs) {
45
- const file = path.join(paths.dir, ref);
46
- if (fs.existsSync(file)) {
47
- if (fs.statSync(file).size > MAX_FILE)
48
- fail('asset_too_large', `${file} is larger than 5 MB.`, 'Make the image smaller (a product photo needs about 2000 px on the long side), keep the name, and run the command again.');
49
- local.set(ref, { file, sha256: sha256(file) });
50
- }
51
- else
52
- missing.push(`${by.join(', ')}: ${ref}`);
53
- }
54
- const unused = filesUnder(imgDir)
55
- .map((rel) => `img/${rel}`)
56
- .filter((p) => !refs.has(p));
57
- const supported = state.assets !== undefined;
58
- const served = state.assets ?? {};
59
- const changes = [];
60
- const upload = [];
61
- if (supported) {
62
- for (const [p, l] of [...local].sort(([a], [b]) => (a < b ? -1 : 1))) {
63
- if (served[p] === l.sha256)
64
- continue;
65
- changes.push({ action: p in served ? 'update' : 'create', type: 'asset', id: p });
66
- upload.push({ path: p, ...l });
67
- }
68
- for (const p of Object.keys(served).sort())
69
- if (!local.has(p))
70
- changes.push({ action: 'delete', type: 'asset', id: p });
71
- }
72
- return { supported, changes, missing, unused, keep: [...local.keys()].sort(), upload };
73
- }
74
- /** Upload the new and changed files, in requests under the API's size cap. */
75
- export async function uploadAssets(api, plan) {
76
- let batch = [];
77
- let bytes = 0;
78
- const flush = async () => {
79
- if (batch.length)
80
- await api.uploadAssets(batch);
81
- batch = [];
82
- bytes = 0;
83
- };
84
- for (const u of plan.upload) {
85
- const content = fs.readFileSync(u.file);
86
- if (bytes + content.length > MAX_BATCH)
87
- await flush();
88
- batch.push({ path: u.path, sha256: u.sha256, content_base64: content.toString('base64') });
89
- bytes += content.length;
90
- }
91
- await flush();
92
- return plan.upload.length;
93
- }
94
- /** The fields plan and status add for images: only when there is something to say. */
95
- export function imageNotes(plan) {
96
- return {
97
- missing_images: plan.missing.length ? plan.missing : undefined,
98
- unused_images: plan.unused.length ? plan.unused : undefined,
99
- };
100
- }
package/dist/catalog.js DELETED
@@ -1,366 +0,0 @@
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), or on a brand's website (a demo
3
- // store). Pull reads them and writes .commercelayer/products/catalog.csv, so plan and apply
4
- // work as always.
5
- // Every source maps onto the same fixed schema: sku, name, description,
6
- // image_url, category and one price column per currency.
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import YAML from 'yaml';
10
- import { credential, paths } from './files.js';
11
- import { CliError, fail } from './output.js';
12
- import { parseCsv, readProductFile, toCsv } from './products.js';
13
- import { ISO_639_1, setPlain, setTranslated, translatedColumn, TRANSLATED } from './languages.js';
14
- import { canonical, checkProducts, readStoreFile, storeLanguage } from './store.js';
15
- import { siteOrigin } from './web.js';
16
- import { fromWebsite, WEBSITE_LIMIT, WEBSITE_MAX } from './website.js';
17
- export const SOURCES = ['supabase', 'sheets', 'merchant', 'website'];
18
- export const catalogFile = path.join(paths.products, 'catalog.csv');
19
- const FIELDS = ['sku', 'name', 'description', 'image_url', 'category'];
20
- /** Columns tools add on their own (Supabase's defaults): pull skips them. */
21
- const IGNORED = ['id', 'created_at', 'updated_at'];
22
- /** Supabase rows per request; pull pages until a page comes back empty. */
23
- const PAGE = 1000;
24
- const EXAMPLE = 'For example `catalog: { source: sheets, url: <the sheet\'s CSV link> }`. Sources are supabase, sheets, merchant and website; see the comments in store.yml.';
25
- function readConfig(doc) {
26
- const c = doc.catalog;
27
- if (!c || typeof c !== 'object')
28
- fail('no_catalog_source', 'store.yml has no catalog source.', `Add a \`catalog:\` entry to ${paths.store}. ${EXAMPLE}`);
29
- if (!SOURCES.includes(c.source))
30
- fail('invalid_catalog', `catalog.source in store.yml is "${c.source ?? ''}".`, `Use supabase, sheets, merchant or website. ${EXAMPLE}`);
31
- const table = String(c.table ?? 'products');
32
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table))
33
- fail('invalid_catalog', `catalog.table in store.yml is "${table}".`, 'Use the name of the Supabase table, such as products.');
34
- const url = String(c.url ?? '');
35
- if (c.source !== 'supabase' && !/^https?:\/\//.test(url)) {
36
- fail('invalid_catalog', url ? `catalog.url in store.yml is "${url}", not a URL.` : 'catalog.url is missing in store.yml.', c.source === 'website'
37
- ? "Set url to the brand's website, such as https://brand.example."
38
- : c.source === 'sheets'
39
- ? '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.'
40
- : 'Set url to the Merchant Center product feed (RSS/XML or TSV), as Merchant Center fetches it.');
41
- }
42
- const translations = {};
43
- if (c.translations !== undefined && c.translations !== null) {
44
- if (c.source !== 'merchant')
45
- fail('invalid_catalog', `catalog.translations is for merchant feeds; this source is ${c.source}.`, 'In a sheet or a table, translations are columns: name_it, description_it, category_it.');
46
- if (typeof c.translations !== 'object' || Array.isArray(c.translations))
47
- fail('invalid_catalog', 'catalog.translations in store.yml is not a map.', 'Map each language to its feed: translations: { it: https://..., fr: https://... }.');
48
- for (const [ll, u] of Object.entries(c.translations)) {
49
- if (!ISO_639_1.has(ll))
50
- fail('invalid_catalog', `catalog.translations.${ll} is not a language.`, 'Use ISO 639-1 codes in lower case: it, fr, de, ...');
51
- if (!/^https?:\/\//.test(String(u)))
52
- fail('invalid_catalog', `catalog.translations.${ll} is not a URL.`, 'Set it to the Merchant Center feed in that language (RSS/XML or TSV).');
53
- translations[ll] = String(u);
54
- }
55
- }
56
- let limit = WEBSITE_LIMIT;
57
- if (c.limit !== undefined && c.limit !== null) {
58
- if (c.source !== 'website')
59
- fail('invalid_catalog', `catalog.limit is for a website; this source is ${c.source}.`, 'Pull reads every product of a sheet, a table or a feed: remove limit.');
60
- limit = Number(c.limit);
61
- if (!Number.isInteger(limit) || limit < 1 || limit > WEBSITE_MAX)
62
- fail('invalid_catalog', `catalog.limit in store.yml is "${c.limit}".`, `Use a whole number from 1 to ${WEBSITE_MAX} (default ${WEBSITE_LIMIT}).`);
63
- }
64
- return { source: c.source, url: c.source === 'website' ? siteOrigin(url, 'catalog.url in store.yml') : url, table, translations, limit };
65
- }
66
- async function get(url, what, headers = {}) {
67
- try {
68
- return await fetch(url, { headers });
69
- }
70
- catch (e) {
71
- const cause = e.cause?.message;
72
- fail('catalog_unreachable', `Could not reach ${what}: ${cause ?? e.message}.`, 'Check the catalog entry in store.yml and the network, then try again.');
73
- }
74
- }
75
- /** Rows with a header, from a sheet or a table: the fixed fields and one column per currency. */
76
- function fromTable(header, rows, where, label, currencies) {
77
- const prices = currencies.map((c) => c.toLowerCase()).join(', ') || 'eur, usd, ...';
78
- const columns = header.map((h) => {
79
- const key = h.trim().toLowerCase();
80
- if (FIELDS.includes(key))
81
- return { field: key };
82
- const t = translatedColumn(key);
83
- if (t)
84
- return { translated: t.field, language: t.language };
85
- if (key === '' || IGNORED.includes(key))
86
- return null;
87
- const code = /^[a-z]{3}$/.test(key);
88
- if (code && (currencies.length === 0 || currencies.includes(key.toUpperCase())))
89
- return { currency: key.toUpperCase() };
90
- 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. ` : '';
91
- fail('invalid_product', `${where} has a column "${h}" that is not a product field.`, `${why}The catalog columns are ${FIELDS.join(', ')}, name_<ll>, description_<ll> and category_<ll> for other languages (name_it), 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.`);
92
- });
93
- if (!columns.some((c) => c && 'field' in c && c.field === 'sku'))
94
- fail('invalid_product', `${where} has no sku column.`, `The catalog columns are ${FIELDS.join(', ')} and one price column per market currency (${prices}).`);
95
- const out = [];
96
- rows.forEach((cells, i) => {
97
- if (cells.every((v) => v === null || v === undefined || String(v).trim() === ''))
98
- return;
99
- const product = { sku: '', name: '', prices: {} };
100
- columns.forEach((col, j) => {
101
- const v = cells[j];
102
- if (!col || v === null || v === undefined || String(v).trim() === '')
103
- return;
104
- if ('currency' in col) {
105
- const price = typeof v === 'number' ? v : Number(String(v).trim());
106
- if (!Number.isFinite(price))
107
- 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.');
108
- product.prices[col.currency] = price;
109
- }
110
- else if ('translated' in col)
111
- setTranslated(product, col.translated, col.language, String(v).trim());
112
- else if (TRANSLATED.includes(col.field))
113
- setPlain(product, col.field, String(v).trim());
114
- else
115
- product[col.field] = String(v).trim();
116
- });
117
- out.push({ product, source: label(i) });
118
- });
119
- return out;
120
- }
121
- async function fromSupabase(c, currencies) {
122
- const base = credential('C11R_SUPABASE_URL');
123
- const key = credential('C11R_SUPABASE_KEY');
124
- const missing = [!base && 'C11R_SUPABASE_URL', !key && 'C11R_SUPABASE_KEY'].filter(Boolean);
125
- if (missing.length || !base || !key) {
126
- 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.`);
127
- }
128
- const where = `Supabase table ${c.table}`;
129
- const records = [];
130
- for (;;) {
131
- const url = `${base.replace(/\/+$/, '')}/rest/v1/${c.table}?select=*&order=sku.asc&limit=${PAGE}&offset=${records.length}`;
132
- const res = await get(url, where, { apikey: key, Authorization: `Bearer ${key}`, Accept: 'application/json' });
133
- const body = (await res.json().catch(() => null));
134
- if (!res.ok) {
135
- const detail = body?.message ? `: ${body.message}` : '';
136
- if (res.status === 401 || res.status === 403)
137
- 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.`);
138
- if (body?.code === '42703')
139
- fail('invalid_product', `${where} has no sku column${detail}.`, `The catalog columns are ${FIELDS.join(', ')} and one price column per market currency, in lowercase.`);
140
- if (res.status === 404 || body?.code === 'PGRST205' || body?.code === '42P01')
141
- 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.');
142
- fail('catalog_error', `${where} answered ${res.status}${detail}.`, 'Try again. If it keeps happening, tell the human, with this output.');
143
- }
144
- if (!Array.isArray(body))
145
- fail('catalog_error', `${where} did not answer with rows.`, 'Check that C11R_SUPABASE_URL is the project URL, such as https://<project>.supabase.co.');
146
- if (body.length === 0)
147
- break;
148
- records.push(...body);
149
- }
150
- if (records.length === 0)
151
- return [];
152
- const header = [...new Set(records.flatMap((r) => Object.keys(r)))];
153
- return fromTable(header, records.map((r) => header.map((h) => r[h])), where, (i) => `${where}, row ${i + 1}`, currencies);
154
- }
155
- /** The CSV link of a Google Sheet, from the link the human has. */
156
- export function sheetCsvUrl(url) {
157
- const u = new URL(url);
158
- if (u.hostname !== 'docs.google.com')
159
- return url;
160
- const gid = u.searchParams.get('gid') ?? u.hash.match(/gid=(\d+)/)?.[1];
161
- const published = u.pathname.match(/^\/spreadsheets\/d\/e\/([^/]+)\/pub(html)?$/);
162
- if (published)
163
- return `https://docs.google.com/spreadsheets/d/e/${published[1]}/pub?output=csv${gid ? `&gid=${gid}` : ''}`;
164
- const shared = u.pathname.match(/^\/spreadsheets\/d\/([^/]+)/);
165
- if (shared && shared[1] !== 'e')
166
- return `https://docs.google.com/spreadsheets/d/${shared[1]}/export?format=csv${gid ? `&gid=${gid}` : ''}`;
167
- return url;
168
- }
169
- async function fromSheet(c, currencies) {
170
- const where = 'The Google Sheet';
171
- const res = await get(sheetCsvUrl(c.url), 'the Google Sheet');
172
- 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.';
173
- if (!res.ok)
174
- fail('catalog_error', `${where} answered ${res.status}.`, help);
175
- if ((res.headers.get('content-type') ?? '').includes('text/html'))
176
- fail('catalog_not_csv', `${where} answered with a web page, not CSV: it is not published or shared.`, help);
177
- const rows = parseCsv((await res.text()).replace(/^\uFEFF/, ''));
178
- if (rows.length === 0)
179
- return [];
180
- return fromTable(rows[0], rows.slice(1), where, (i) => `the Google Sheet, row ${i + 2}`, currencies);
181
- }
182
- const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
183
- function xmlText(raw) {
184
- const cdata = raw.match(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/);
185
- if (cdata)
186
- return cdata[1].trim();
187
- return raw
188
- .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)))
189
- .trim();
190
- }
191
- /** The top-level tags of each <item> (RSS 2.0) or <entry> (Atom), by name; g:title wins over title. */
192
- export function feedItems(xml) {
193
- return [...xml.matchAll(/<(item|entry)\b[^>]*>([\s\S]*?)<\/\1>/g)].map(([, , body]) => {
194
- const plain = {};
195
- const google = {};
196
- for (const [, tag, inner] of body.matchAll(/<([\w-]+(?::[\w-]+)?)\b[^>]*?(?:\/>|>([\s\S]*?)<\/\1>)/g)) {
197
- if (inner === undefined)
198
- continue;
199
- const [prefix, name] = tag.includes(':') ? tag.toLowerCase().split(':') : ['', tag.toLowerCase()];
200
- const into = prefix === 'g' ? google : prefix === '' ? plain : null;
201
- if (into && !(name in into))
202
- into[name] = xmlText(inner);
203
- }
204
- return { ...plain, ...google };
205
- });
206
- }
207
- function feedRows(text) {
208
- if (text.trimStart().startsWith('<'))
209
- return { items: feedItems(text), label: (i) => `the Merchant feed, item ${i + 1}` };
210
- const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/);
211
- const header = lines[0].split('\t').map((h) => h.trim().replace(/^"|"$/g, '').replace(/^g:/i, '').toLowerCase());
212
- const items = [];
213
- const lineOf = [];
214
- lines.slice(1).forEach((line, i) => {
215
- if (!line.trim())
216
- return;
217
- const cells = line.split('\t');
218
- const item = {};
219
- header.forEach((h, j) => {
220
- const v = (cells[j] ?? '').trim().replace(/^"(.*)"$/, '$1');
221
- if (h && v && !(h in item))
222
- item[h] = v;
223
- });
224
- items.push(item);
225
- lineOf.push(i + 2);
226
- });
227
- return { items, label: (i) => `the Merchant feed, line ${lineOf[i]}` };
228
- }
229
- /** "14.50 EUR" (the Merchant format), or "EUR 14.50". */
230
- function feedPrice(value, label) {
231
- const v = value.trim();
232
- const after = v.match(/^([0-9][0-9.,]*)\s*([A-Za-z]{3})$/);
233
- const before = v.match(/^([A-Za-z]{3})\s*([0-9][0-9.,]*)$/);
234
- const amount = Number((after?.[1] ?? before?.[2] ?? '').replace(/,/g, '') || NaN);
235
- const currency = after?.[2] ?? before?.[1];
236
- if (!currency || !Number.isFinite(amount))
237
- fail('invalid_product', `${label}: price "${value}" is not an amount and a currency.`, 'Merchant feed prices are written like "14.50 EUR".');
238
- return [currency.toUpperCase(), amount];
239
- }
240
- async function fromMerchant(c) {
241
- const res = await get(c.url, 'the Merchant feed');
242
- if (!res.ok)
243
- fail('catalog_error', `The Merchant feed answered ${res.status}.`, 'Check catalog.url in store.yml: it is the feed URL Merchant Center fetches.');
244
- const { items, label } = feedRows(await res.text());
245
- const bySku = new Map();
246
- const out = [];
247
- items.forEach((item, i) => {
248
- if (!item.id)
249
- fail('invalid_product', `${label(i)} has no id.`, 'Every feed item needs id (the sku), title and price.');
250
- const product = { sku: item.id, name: item.title ?? '', prices: {} };
251
- if (item.description)
252
- product.description = item.description;
253
- if (item.image_link)
254
- product.image_url = item.image_link;
255
- if (item.product_type)
256
- product.category = item.product_type;
257
- if (item.price) {
258
- const [currency, amount] = feedPrice(item.price, label(i));
259
- product.prices[currency] = amount;
260
- // The same id once per country feed: one product, a price per currency.
261
- const seen = bySku.get(item.id);
262
- if (seen && seen.product.prices[currency] === undefined) {
263
- seen.product.prices[currency] = amount;
264
- return;
265
- }
266
- }
267
- const sourced = { product, source: label(i) };
268
- bySku.set(item.id, sourced);
269
- out.push(sourced);
270
- });
271
- // A feed per language: title, description and product_type of the same ids, in that language.
272
- for (const [ll, url] of Object.entries(c.translations)) {
273
- const res = await get(url, `the ${ll} Merchant feed`);
274
- if (!res.ok)
275
- fail('catalog_error', `The ${ll} Merchant feed answered ${res.status}.`, `Check catalog.translations.${ll} in store.yml: it is the feed URL Merchant Center fetches for that language.`);
276
- for (const item of feedRows(await res.text()).items) {
277
- const target = item.id ? bySku.get(item.id)?.product : undefined;
278
- if (!target)
279
- continue;
280
- for (const [tag, field] of [['title', 'name'], ['description', 'description'], ['product_type', 'category']])
281
- if (item[tag])
282
- setTranslated(target, field, ll, item[tag]);
283
- }
284
- }
285
- return out;
286
- }
287
- export async function pull(values = {}) {
288
- if (!fs.existsSync(paths.store))
289
- fail('no_store', 'There is no store in this repo.', 'Run `c11r init` in the repo root first.');
290
- let doc;
291
- try {
292
- doc = YAML.parse(readStoreFile()) ?? {};
293
- }
294
- catch (e) {
295
- const reason = e.message.split('\n')[0];
296
- fail('invalid_yaml', `store.yml is not valid YAML: ${reason}`, `Fix the YAML syntax in ${paths.store} and run the command again.`);
297
- }
298
- const config = readConfig(doc);
299
- const markets = Array.isArray(doc.markets) ? doc.markets : [];
300
- const currencies = [...new Set(markets.map((m) => m?.currency).filter(Boolean).map(String))];
301
- if (values.currency !== undefined && config.source !== 'website')
302
- fail('invalid_argument', `--currency is for a website catalog; this source is ${config.source}.`, 'A sheet, a table or a feed says its currencies in its columns: leave out --currency.');
303
- let site = null;
304
- let sourced;
305
- if (config.source === 'website') {
306
- const r = await fromWebsite(config, currencies, values.currency);
307
- site = r.report;
308
- sourced = r.products.map((product) => ({ product, source: `${site.source_site.url}, product ${product.sku}` }));
309
- }
310
- else
311
- sourced = config.source === 'supabase' ? await fromSupabase(config, currencies) : config.source === 'sheets' ? await fromSheet(config, currencies) : await fromMerchant(config);
312
- const file = catalogFile;
313
- if (sourced.length === 0) {
314
- 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.' : '';
315
- fail('empty_catalog', `The ${config.source} catalog has no products.`, `Ask the human to add products to it, then pull again.${rls}`);
316
- }
317
- const products = checkProducts(sourced, currencies, storeLanguage(doc.language));
318
- let before = new Map();
319
- if (fs.existsSync(file)) {
320
- try {
321
- before = new Map(readProductFile(file).map((s) => [String(s.product.sku), s.product]));
322
- }
323
- catch (e) {
324
- if (!(e instanceof CliError))
325
- throw e;
326
- }
327
- }
328
- const after = new Map(products.map((p) => [p.sku, p]));
329
- const csv = toCsv(products);
330
- const changed = !fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== csv;
331
- if (changed) {
332
- fs.mkdirSync(paths.products, { recursive: true });
333
- fs.writeFileSync(file, csv);
334
- }
335
- const removed = [...before.keys()].filter((sku) => !after.has(sku));
336
- return {
337
- status: changed ? 'pulled' : 'up_to_date',
338
- source: config.source,
339
- ...(site ? { strategy: site.strategy } : {}),
340
- file,
341
- products: products.length,
342
- added: [...after.keys()].filter((sku) => !before.has(sku)),
343
- changed: [...after.keys()].filter((sku) => before.has(sku) && canonical(before.get(sku)) !== canonical(after.get(sku))),
344
- removed,
345
- ...(site ? websiteOutput(site, products.length) : {}),
346
- next: site
347
- ? websiteNext(site, products.length, changed, removed.length, file)
348
- : changed
349
- ? `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.' : ''} ${file} is rewritten by every pull: change products in the ${config.source} source, not in the file.`
350
- : `${file} already matches the ${config.source} source.`,
351
- };
352
- }
353
- function websiteOutput(r, pulled) {
354
- const { strategy: _, ...rest } = r;
355
- return { ...rest, pulled };
356
- }
357
- function websiteNext(r, pulled, changed, removed, file) {
358
- const seen = r.source_site.product_count_seen;
359
- const found = seen !== null && seen > pulled ? `I found ${seen} products on the site and pulled ${pulled} (catalog.limit is ${r.limit})` : `I pulled ${pulled} products from the site`;
360
- const left = [r.no_price_skipped && `${r.no_price_skipped} without a price`, r.duplicates_skipped && `${r.duplicates_skipped} with a sku already taken`, r.robots_skipped && `${r.robots_skipped} pages robots.txt disallows`, r.other_currency_skipped && `${r.other_currency_skipped} in another currency`].filter(Boolean);
361
- return [
362
- `Tell the human: "${found}, in ${r.source_site.currency}${r.variants_skipped ? `, one variant each (${r.variants_skipped} other variants left out)` : ''}." ${left.length ? `Left out: ${left.join(', ')}. ` : ''}${r.budget_exhausted ? 'The site was slow: pull stopped at 60 s, so this is what it read by then. ' : ''}`,
363
- 'Images and the logo stay on the brand\'s servers: image_url links them, nothing is copied. ',
364
- changed ? `Run c11r plan to see what apply will change in the store.${removed ? ' Removed products are deleted from the store only by c11r apply --allow-delete, once the human agrees.' : ''} ${file} is rewritten by every pull: never edit it; products to add go in another file in ${paths.products}.` : `${file} already matches the site.`,
365
- ].join('');
366
- }