@commercelayer/agent-cli 0.5.2 → 0.8.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.
- package/README.md +54 -6
- package/dist/api/client.js +39 -1
- package/dist/api/stub/http.js +2 -2
- package/dist/api/stub/management.js +19 -2
- package/dist/api/stub/messages.js +126 -0
- package/dist/api/stub/orders.js +286 -24
- package/dist/bin.js +30 -6
- package/dist/catalog.js +61 -19
- package/dist/commands.js +470 -7
- package/dist/cursor.js +31 -0
- package/dist/files.js +5 -3
- package/dist/inbox/cursor.js +11 -0
- package/dist/inbox/fake.js +95 -0
- package/dist/inbox/imap.js +139 -0
- package/dist/inbox/mailbox.js +25 -0
- package/dist/inbox/message.js +88 -0
- package/dist/inspect.js +230 -0
- package/dist/ops.js +190 -0
- package/dist/orderRequest.js +141 -0
- package/dist/output.js +7 -3
- package/dist/store.js +2 -0
- package/dist/web.js +316 -0
- package/dist/website.js +390 -0
- package/package.json +3 -1
package/dist/catalog.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
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)
|
|
3
|
-
// writes .commercelayer/products/catalog.csv, so plan and apply
|
|
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.
|
|
4
5
|
// Every source maps onto the same fixed schema: sku, name, description,
|
|
5
6
|
// image_url, category and one price column per currency.
|
|
6
7
|
import fs from 'node:fs';
|
|
@@ -11,28 +12,32 @@ import { CliError, fail } from './output.js';
|
|
|
11
12
|
import { parseCsv, readProductFile, toCsv } from './products.js';
|
|
12
13
|
import { ISO_639_1, setPlain, setTranslated, translatedColumn, TRANSLATED } from './languages.js';
|
|
13
14
|
import { canonical, checkProducts, readStoreFile, storeLanguage } from './store.js';
|
|
14
|
-
|
|
15
|
+
import { siteOrigin } from './web.js';
|
|
16
|
+
import { fromWebsite, WEBSITE_LIMIT, WEBSITE_MAX } from './website.js';
|
|
17
|
+
export const SOURCES = ['supabase', 'sheets', 'merchant', 'website'];
|
|
15
18
|
export const catalogFile = path.join(paths.products, 'catalog.csv');
|
|
16
19
|
const FIELDS = ['sku', 'name', 'description', 'image_url', 'category'];
|
|
17
20
|
/** Columns tools add on their own (Supabase's defaults): pull skips them. */
|
|
18
21
|
const IGNORED = ['id', 'created_at', 'updated_at'];
|
|
19
22
|
/** Supabase rows per request; pull pages until a page comes back empty. */
|
|
20
23
|
const PAGE = 1000;
|
|
21
|
-
const EXAMPLE = 'For example `catalog: { source: sheets, url: <the sheet\'s CSV link> }`. Sources are supabase, sheets and
|
|
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.';
|
|
22
25
|
function readConfig(doc) {
|
|
23
26
|
const c = doc.catalog;
|
|
24
27
|
if (!c || typeof c !== 'object')
|
|
25
28
|
fail('no_catalog_source', 'store.yml has no catalog source.', `Add a \`catalog:\` entry to ${paths.store}. ${EXAMPLE}`);
|
|
26
29
|
if (!SOURCES.includes(c.source))
|
|
27
|
-
fail('invalid_catalog', `catalog.source in store.yml is "${c.source ?? ''}".`, `Use supabase, sheets or
|
|
30
|
+
fail('invalid_catalog', `catalog.source in store.yml is "${c.source ?? ''}".`, `Use supabase, sheets, merchant or website. ${EXAMPLE}`);
|
|
28
31
|
const table = String(c.table ?? 'products');
|
|
29
32
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table))
|
|
30
33
|
fail('invalid_catalog', `catalog.table in store.yml is "${table}".`, 'Use the name of the Supabase table, such as products.');
|
|
31
34
|
const url = String(c.url ?? '');
|
|
32
35
|
if (c.source !== 'supabase' && !/^https?:\/\//.test(url)) {
|
|
33
|
-
fail('invalid_catalog', url ? `catalog.url in store.yml is "${url}", not a URL.` : 'catalog.url is missing in store.yml.', c.source === '
|
|
34
|
-
?
|
|
35
|
-
:
|
|
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.');
|
|
36
41
|
}
|
|
37
42
|
const translations = {};
|
|
38
43
|
if (c.translations !== undefined && c.translations !== null) {
|
|
@@ -48,7 +53,15 @@ function readConfig(doc) {
|
|
|
48
53
|
translations[ll] = String(u);
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
|
-
|
|
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 };
|
|
52
65
|
}
|
|
53
66
|
async function get(url, what, headers = {}) {
|
|
54
67
|
try {
|
|
@@ -271,7 +284,7 @@ async function fromMerchant(c) {
|
|
|
271
284
|
}
|
|
272
285
|
return out;
|
|
273
286
|
}
|
|
274
|
-
export async function pull() {
|
|
287
|
+
export async function pull(values = {}) {
|
|
275
288
|
if (!fs.existsSync(paths.store))
|
|
276
289
|
fail('no_store', 'There is no store in this repo.', 'Run `c11r init` in the repo root first.');
|
|
277
290
|
let doc;
|
|
@@ -285,16 +298,27 @@ export async function pull() {
|
|
|
285
298
|
const config = readConfig(doc);
|
|
286
299
|
const markets = Array.isArray(doc.markets) ? doc.markets : [];
|
|
287
300
|
const currencies = [...new Set(markets.map((m) => m?.currency).filter(Boolean).map(String))];
|
|
288
|
-
|
|
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;
|
|
289
313
|
if (sourced.length === 0) {
|
|
290
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.' : '';
|
|
291
315
|
fail('empty_catalog', `The ${config.source} catalog has no products.`, `Ask the human to add products to it, then pull again.${rls}`);
|
|
292
316
|
}
|
|
293
317
|
const products = checkProducts(sourced, currencies, storeLanguage(doc.language));
|
|
294
318
|
let before = new Map();
|
|
295
|
-
if (fs.existsSync(
|
|
319
|
+
if (fs.existsSync(file)) {
|
|
296
320
|
try {
|
|
297
|
-
before = new Map(readProductFile(
|
|
321
|
+
before = new Map(readProductFile(file).map((s) => [String(s.product.sku), s.product]));
|
|
298
322
|
}
|
|
299
323
|
catch (e) {
|
|
300
324
|
if (!(e instanceof CliError))
|
|
@@ -303,22 +327,40 @@ export async function pull() {
|
|
|
303
327
|
}
|
|
304
328
|
const after = new Map(products.map((p) => [p.sku, p]));
|
|
305
329
|
const csv = toCsv(products);
|
|
306
|
-
const changed = !fs.existsSync(
|
|
330
|
+
const changed = !fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== csv;
|
|
307
331
|
if (changed) {
|
|
308
332
|
fs.mkdirSync(paths.products, { recursive: true });
|
|
309
|
-
fs.writeFileSync(
|
|
333
|
+
fs.writeFileSync(file, csv);
|
|
310
334
|
}
|
|
311
335
|
const removed = [...before.keys()].filter((sku) => !after.has(sku));
|
|
312
336
|
return {
|
|
313
337
|
status: changed ? 'pulled' : 'up_to_date',
|
|
314
338
|
source: config.source,
|
|
315
|
-
|
|
339
|
+
...(site ? { strategy: site.strategy } : {}),
|
|
340
|
+
file,
|
|
316
341
|
products: products.length,
|
|
317
342
|
added: [...after.keys()].filter((sku) => !before.has(sku)),
|
|
318
343
|
changed: [...after.keys()].filter((sku) => before.has(sku) && canonical(before.get(sku)) !== canonical(after.get(sku))),
|
|
319
344
|
removed,
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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.`,
|
|
323
351
|
};
|
|
324
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
|
+
}
|