create-brainerce-store 1.81.0 → 1.82.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/dist/index.js CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.81.0",
34
+ version: "1.82.0",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  homepage: "https://brainerce.com",
37
37
  repository: {
@@ -330,6 +330,26 @@ var BRAINERCE_RUNTIME_DEPS = Object.freeze({
330
330
  // both to show the merchant's welcome offer beside the field. On an older
331
331
  // SDK a scaffolded store fails type-check at creation, exactly like the KIT
332
332
  // floor above — so this floor is load-bearing, not cosmetic.
333
+ //
334
+ // ⛔⛔ RAISING THIS FLOOR: the version you pin must already be at least SEVEN
335
+ // DAYS OLD on npm, and template code must not call an SDK API until the
336
+ // version carrying it has aged that long.
337
+ //
338
+ // `minimumReleaseAge` — a package-age cooldown that refuses any dependency
339
+ // published within the last week — is now a standard supply-chain defence.
340
+ // pnpm ships the setting (`minimumReleaseAge: 10080`), and ChatGPT Sites
341
+ // enforces seven days on publish. Our SDK cadence is close to daily, so a
342
+ // floor set the day a release lands is a floor that NO cooldown-enforcing
343
+ // environment can install. Measured 2026-09-10: of every 2.x release, only
344
+ // 2.0.2 was over seven days old, and it predates `getBenefit` — which is why
345
+ // a Sites publish against this pin is blocked outright with no same-day fix
346
+ // available on our side, and why lowering the pin would not have helped.
347
+ // The failure is invisible here: `pnpm install` in this repo is happy, and
348
+ // the refusal only appears in someone else's environment, at publish time.
349
+ //
350
+ // So the ordering is: ship the SDK, wait out the week, THEN adopt its API in
351
+ // `templates/` and raise this floor in the same change. A feature is not
352
+ // late for waiting seven days; a template nobody can publish is worse.
333
353
  brainerce: "^2.7.0",
334
354
  "isomorphic-dompurify": "^3.8.0"
335
355
  });
@@ -401,6 +421,106 @@ function detectInvokingPackageManager() {
401
421
  return "npm";
402
422
  }
403
423
 
424
+ // ../cli-shared/src/seed-products.ts
425
+ var SEED_MIN_PRODUCTS = 1;
426
+ var SEED_MAX_PRODUCTS = 20;
427
+ var SEED_MAX_NAME = 191;
428
+ var SEED_MAX_DESCRIPTION = 5e3;
429
+ var SEED_FIELDS = ["name", "basePrice", "description"];
430
+ function parseSeedProducts(raw) {
431
+ if (typeof raw !== "string" || raw.trim() === "") {
432
+ return {
433
+ error: "--seed-products was passed with no value. Give it a JSON array of products, quoted as one shell argument, or drop the flag entirely to connect without seeding."
434
+ };
435
+ }
436
+ let parsed;
437
+ try {
438
+ parsed = JSON.parse(raw);
439
+ } catch (error) {
440
+ return {
441
+ error: `--seed-products is not valid JSON (${error instanceof Error ? error.message : String(error)}). Pass the array as a single quoted argument, or put it in BRAINERCE_SEED_PRODUCTS if your shell keeps breaking the quoting.`
442
+ };
443
+ }
444
+ const list = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.products) ? parsed.products : null;
445
+ if (!list) {
446
+ return {
447
+ error: '--seed-products must be a JSON array of products, or an object with a "products" array. A single product object on its own is not accepted; wrap it in an array.'
448
+ };
449
+ }
450
+ if (list.length < SEED_MIN_PRODUCTS) {
451
+ return {
452
+ error: "--seed-products was an empty list. Seeding needs at least one product; if the user has not described what they sell, leave the flag off and build against the empty catalog."
453
+ };
454
+ }
455
+ if (list.length > SEED_MAX_PRODUCTS) {
456
+ return {
457
+ error: `--seed-products carries ${list.length} products and the limit is ${SEED_MAX_PRODUCTS}. This is a starter catalog, not an import: pick the ${SEED_MAX_PRODUCTS} that best show the store, and point the merchant at the dashboard or Apps > Browse > Migration Tool for the rest.`
458
+ };
459
+ }
460
+ const products = [];
461
+ for (let i = 0; i < list.length; i += 1) {
462
+ const entry = list[i];
463
+ const at = `--seed-products product ${i + 1}`;
464
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
465
+ return {
466
+ error: `${at} is not an object. Each entry looks like {"name":"...","basePrice":0}.`
467
+ };
468
+ }
469
+ const unknown = Object.keys(entry).filter(
470
+ (k) => !SEED_FIELDS.includes(k)
471
+ );
472
+ if (unknown.length > 0) {
473
+ return {
474
+ error: `${at} carries ${unknown.map((k) => `"${k}"`).join(", ")}, which the seed route does not accept and will not ignore \u2014 one unknown key rejects the whole request. Only ${SEED_FIELDS.join(
475
+ ", "
476
+ )} are allowed: no sku, no type, no options or variants, no categories, no images. Every seeded product lands as a simple, active product.`
477
+ };
478
+ }
479
+ if (typeof entry.name !== "string" || entry.name.trim() === "") {
480
+ return { error: `${at} has no usable "name". It must be a non-empty string.` };
481
+ }
482
+ if (entry.name.length > SEED_MAX_NAME) {
483
+ return {
484
+ error: `${at} ("${entry.name.slice(0, 40)}...") has a name of ${entry.name.length} characters and the limit is ${SEED_MAX_NAME}.`
485
+ };
486
+ }
487
+ if (!("basePrice" in entry)) {
488
+ return {
489
+ error: `${at} ("${entry.name}") has no "basePrice". Every seeded product needs one, as a number: {"name":"${entry.name}","basePrice":0} is the minimum shape.`
490
+ };
491
+ }
492
+ if (typeof entry.basePrice === "string") {
493
+ return {
494
+ error: `${at} ("${entry.name}") has a "basePrice" quoted as text. Send ${entry.basePrice.trim() === "" ? "149" : entry.basePrice} without the quotes \u2014 the API does not coerce a quoted price, it rejects it.`
495
+ };
496
+ }
497
+ if (typeof entry.basePrice !== "number" || !Number.isFinite(entry.basePrice)) {
498
+ return {
499
+ error: `${at} ("${entry.name}") has a "basePrice" that is not a number. It must be a plain finite number, not null, not a string and not an expression.`
500
+ };
501
+ }
502
+ if (entry.basePrice < 0) {
503
+ return { error: `${at} ("${entry.name}") has a negative "basePrice".` };
504
+ }
505
+ if ("description" in entry) {
506
+ if (typeof entry.description !== "string") {
507
+ return {
508
+ error: `${at} ("${entry.name}") has a "description" that is not a string. Omit the key entirely rather than sending null.`
509
+ };
510
+ }
511
+ if (entry.description.length > SEED_MAX_DESCRIPTION) {
512
+ return {
513
+ error: `${at} ("${entry.name}") has a description of ${entry.description.length} characters and the limit is ${SEED_MAX_DESCRIPTION}.`
514
+ };
515
+ }
516
+ }
517
+ const product = { name: entry.name, basePrice: entry.basePrice };
518
+ if (typeof entry.description === "string") product.description = entry.description;
519
+ products.push(product);
520
+ }
521
+ return { products };
522
+ }
523
+
404
524
  // src/cli.ts
405
525
  async function runInteractive(defaults) {
406
526
  const questions = [];
@@ -947,6 +1067,72 @@ function openBrowser(url) {
947
1067
  return false;
948
1068
  }
949
1069
  }
1070
+ async function readError(res) {
1071
+ try {
1072
+ const body = await res.json();
1073
+ const message = body?.message;
1074
+ return Array.isArray(message) ? message.join("; ") : message ?? `HTTP ${res.status}`;
1075
+ } catch {
1076
+ return `HTTP ${res.status}`;
1077
+ }
1078
+ }
1079
+ async function runSeed(apiBaseUrl, deviceCode, products) {
1080
+ let res;
1081
+ try {
1082
+ res = await fetch(`${apiBaseUrl}/api/device-auth/seed`, {
1083
+ method: "POST",
1084
+ headers: { "Content-Type": "application/json" },
1085
+ body: JSON.stringify({ deviceCode, products })
1086
+ });
1087
+ } catch (error) {
1088
+ logger.error(
1089
+ ` Could not reach the seeding endpoint (${error instanceof Error ? error.message : String(error)}).
1090
+ The request may or may not have landed, so do not claim either way: check the
1091
+ catalog in the dashboard and add what is missing there.`
1092
+ );
1093
+ return;
1094
+ }
1095
+ if (!res.ok) {
1096
+ const message = await readError(res);
1097
+ if (message.startsWith("That seeding window has closed")) {
1098
+ logger.error(
1099
+ " The seeding window is closed \u2014 the grant was already spent, or more than ten\n minutes passed since the approval. The connection itself is fine; add the\n products in the dashboard."
1100
+ );
1101
+ } else {
1102
+ logger.error(
1103
+ ` The seed request was refused before the grant was touched: ${message}
1104
+ The store is connected and still empty. Report this message as written and
1105
+ add the products in the dashboard rather than retrying.`
1106
+ );
1107
+ }
1108
+ return;
1109
+ }
1110
+ let body;
1111
+ try {
1112
+ body = await res.json();
1113
+ } catch {
1114
+ logger.error(
1115
+ " The seed endpoint answered with something that is not JSON. Products may have\n been created. Check the catalog in the dashboard rather than assuming either way."
1116
+ );
1117
+ return;
1118
+ }
1119
+ const created = Array.isArray(body.created) ? body.created : [];
1120
+ const failed = Array.isArray(body.failed) ? body.failed : [];
1121
+ if (created.length > 0) {
1122
+ logger.success(
1123
+ ` Seeded ${created.length} product(s): ${created.map((p) => p.name).join(", ")}`
1124
+ );
1125
+ }
1126
+ for (const f of failed) {
1127
+ logger.info(` Not created: ${f.name} \u2014 ${f.reason}`);
1128
+ }
1129
+ if (failed.length > 0) {
1130
+ logger.info(
1131
+ ` ${created.length} of ${products.length} product(s) went in; ${failed.length} refused.`
1132
+ );
1133
+ logger.info(" The window is now spent, so add the refused ones in the dashboard.");
1134
+ }
1135
+ }
950
1136
  async function runDeviceFlow(apiBaseUrl, options = {}) {
951
1137
  let start;
952
1138
  try {
@@ -997,6 +1183,9 @@ async function runDeviceFlow(apiBaseUrl, options = {}) {
997
1183
  }
998
1184
  if (result.status === "approved" && result.connectionId) {
999
1185
  logger.success(" Approved.");
1186
+ if (options.seedProducts?.length) {
1187
+ await runSeed(apiBaseUrl, start.deviceCode, options.seedProducts);
1188
+ }
1000
1189
  return { connectionId: result.connectionId, storeId: result.storeId ?? null };
1001
1190
  }
1002
1191
  if (result.status === "denied") {
@@ -1091,6 +1280,9 @@ var program = new import_commander.Command();
1091
1280
  program.name("create-brainerce-store").description("Scaffold a production-ready e-commerce storefront connected to Brainerce").version(pkg.version).argument("[project-name]", "Name for the project directory").option("--connection-id <id>", "Brainerce vibe-coded connection ID (vc_*)").option(
1092
1281
  "--defer-connection",
1093
1282
  "Scaffold now and connect later. Skips the browser approval and writes a placeholder id; finish with `npm run connect` inside the project."
1283
+ ).option(
1284
+ "--seed-products <json>",
1285
+ "Starter catalog to create during the browser approval, as a JSON array of {name, basePrice, description?}. Only valid on the run that performs the approval: with --connection-id there is no grant, and with --defer-connection the approval happens later, in `npm run connect --seed-products`."
1094
1286
  ).option(
1095
1287
  "--api-url <url>",
1096
1288
  "Brainerce API base URL (overrides BRAINERCE_API_URL env, defaults to https://api.brainerce.com)"
@@ -1113,6 +1305,28 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
1113
1305
  scaffoldInPlace = true;
1114
1306
  }
1115
1307
  let connectionId = options.connectionId;
1308
+ let seedProducts;
1309
+ const seedRaw = options.seedProducts ?? process.env.BRAINERCE_SEED_PRODUCTS;
1310
+ if (seedRaw !== void 0) {
1311
+ if (connectionId) {
1312
+ logger.error(
1313
+ "--seed-products needs a browser approval, and --connection-id skips it: a channel\nyou already have carries no seeding grant. Scaffold without --connection-id to\napprove and seed in one step, or add the products in the dashboard."
1314
+ );
1315
+ process.exit(1);
1316
+ }
1317
+ if (options.deferConnection === true) {
1318
+ logger.error(
1319
+ "--seed-products does not belong on a deferred scaffold: the approval happens later,\ninside the project, and that is where the seeding grant is opened. Scaffold as\nnormal, then finish with:\n npm run connect -- --seed-products '[...]'"
1320
+ );
1321
+ process.exit(1);
1322
+ }
1323
+ const parsed = parseSeedProducts(seedRaw);
1324
+ if (parsed.error) {
1325
+ logger.error(parsed.error);
1326
+ process.exit(1);
1327
+ }
1328
+ seedProducts = parsed.products;
1329
+ }
1116
1330
  const explicitApiUrl = (options.apiUrl || process.env.BRAINERCE_API_URL || "").replace(/\/$/, "");
1117
1331
  const apiUrlError = validateApiUrl(explicitApiUrl);
1118
1332
  if (apiUrlError) {
@@ -1178,7 +1392,7 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
1178
1392
  language: language || "en"
1179
1393
  };
1180
1394
  } else {
1181
- const approved = await runDeviceFlow(candidateApiUrls[0]);
1395
+ const approved = await runDeviceFlow(candidateApiUrls[0], { seedProducts });
1182
1396
  if (!approved) {
1183
1397
  process.exit(1);
1184
1398
  }
@@ -1337,7 +1551,16 @@ Your store will be running at http://localhost:3000
1337
1551
  `
1338
1552
  This store is not connected yet. \`connect\` opens one browser approval,
1339
1553
  writes the sales channel id into .env.local, and creates a store and a
1340
- channel for you if you have neither. Until then the catalog is empty.`
1554
+ channel for you if you have neither.
1555
+
1556
+ Know what this store sells? Seed the catalog in that same command \u2014 the
1557
+ approval opens a short window that needs no admin key, and it is the only
1558
+ moment it is open:
1559
+
1560
+ ${pkgManager}${pkgManager === "npm" ? " run" : ""} connect -- --seed-products '[{"name":"Burr Grinder","basePrice":389}]'
1561
+
1562
+ (PowerShell mangles that quoting: put the same JSON in BRAINERCE_SEED_PRODUCTS
1563
+ instead.) Without it the store starts empty and products come from the dashboard.`
1341
1564
  );
1342
1565
  }
1343
1566
  logger.info(`
package/messages/en.json CHANGED
@@ -129,7 +129,7 @@
129
129
  },
130
130
  "reviews": {
131
131
  "title": "Reviews",
132
- "noReviews": "No reviews yet be the first to share your experience.",
132
+ "noReviews": "No reviews yet. Be the first to share your experience.",
133
133
  "verifiedPurchase": "Verified purchase",
134
134
  "loading": "Loading…",
135
135
  "signIn": "Sign in",
@@ -289,7 +289,7 @@
289
289
  "customFieldsApplying": "Updating…",
290
290
  "customFieldsFailed": "Failed to save selections",
291
291
  "customFieldsRequired": "Required",
292
- "customFieldsSelectPlaceholder": "Select ",
292
+ "customFieldsSelectPlaceholder": "Select an option",
293
293
  "customFieldsImageUpload": "Click to upload an image",
294
294
  "customFieldsImageRemove": "Remove",
295
295
  "customFieldsImageUploading": "Uploading...",
@@ -322,7 +322,7 @@
322
322
  "address": "Address",
323
323
  "streetAddress": "Street address",
324
324
  "searchingAddress": "Searching…",
325
- "outsideDeliveryZone": "This address is outside our regular delivery zones. You can still continue we'll confirm delivery by phone.",
325
+ "outsideDeliveryZone": "This address is outside our regular delivery zones. You can still continue, and we'll confirm delivery by phone.",
326
326
  "apartmentSuite": "Apartment, suite, etc.",
327
327
  "aptPlaceholder": "Apt, suite, unit, etc. (optional)",
328
328
  "city": "City",
@@ -574,7 +574,7 @@
574
574
  "placeholder": "your@email.com",
575
575
  "submit": "Subscribe",
576
576
  "submitting": "Sending...",
577
- "checkEmailTitle": "Almost there check your email",
577
+ "checkEmailTitle": "Almost there. Check your email",
578
578
  "checkEmailBody": "We've sent you a link to confirm. Look in your spam folder too; you won't get another copy for 24 hours.",
579
579
  "offerPercent": "{value}% off your first order",
580
580
  "offerAmount": "{value} off your first order",
@@ -618,6 +618,6 @@
618
618
  "locked": "Payment has already started, so this order can no longer take a gift card.",
619
619
  "lockedHint": "A gift card is added before the payment step.",
620
620
  "removeFailed": "We could not remove that gift card.",
621
- "applied": "Gift card applied {amount}"
621
+ "applied": "Gift card applied for {amount}"
622
622
  }
623
623
  }
package/messages/he.json CHANGED
@@ -129,7 +129,7 @@
129
129
  },
130
130
  "reviews": {
131
131
  "title": "ביקורות",
132
- "noReviews": "אין עדיין ביקורות היו הראשונים לשתף.",
132
+ "noReviews": "אין עדיין ביקורות. היו הראשונים לשתף.",
133
133
  "verifiedPurchase": "רכישה מאומתת",
134
134
  "loading": "טוען…",
135
135
  "signIn": "התחברו",
@@ -289,7 +289,7 @@
289
289
  "customFieldsApplying": "מעדכן…",
290
290
  "customFieldsFailed": "שגיאה בשמירת הבחירות",
291
291
  "customFieldsRequired": "חובה",
292
- "customFieldsSelectPlaceholder": "בחר ",
292
+ "customFieldsSelectPlaceholder": "בחר אפשרות",
293
293
  "customFieldsImageUpload": "לחץ להעלאת תמונה",
294
294
  "customFieldsImageRemove": "הסר",
295
295
  "customFieldsImageUploading": "...מעלה",
@@ -322,7 +322,7 @@
322
322
  "address": "כתובת",
323
323
  "streetAddress": "רחוב ומספר",
324
324
  "searchingAddress": "מחפש…",
325
- "outsideDeliveryZone": "הכתובת הזו מחוץ לאזורי המשלוח הרגילים שלנו. ניתן להמשיך ניצור איתך קשר טלפוני לתיאום המשלוח.",
325
+ "outsideDeliveryZone": "הכתובת הזו מחוץ לאזורי המשלוח הרגילים שלנו. ניתן להמשיך, וניצור איתך קשר טלפוני לתיאום המשלוח.",
326
326
  "apartmentSuite": "דירה, קומה וכו׳",
327
327
  "aptPlaceholder": "דירה, קומה, כניסה (אופציונלי)",
328
328
  "city": "עיר",
@@ -574,7 +574,7 @@
574
574
  "placeholder": "your@email.com",
575
575
  "submit": "הרשמה",
576
576
  "submitting": "שולח...",
577
- "checkEmailTitle": "כמעט סיימנו בדקו את המייל",
577
+ "checkEmailTitle": "כמעט סיימנו. בדקו את המייל",
578
578
  "checkEmailBody": "שלחנו לכם קישור לאישור. בדקו גם בתיקיית הספאם; עותק נוסף לא יישלח ב-24 השעות הקרובות.",
579
579
  "offerPercent": "{value}% הנחה על ההזמנה הראשונה",
580
580
  "offerAmount": "{value} הנחה על ההזמנה הראשונה",
@@ -618,6 +618,6 @@
618
618
  "locked": "התשלום כבר התחיל, אז אי אפשר להוסיף כרטיס מתנה להזמנה הזאת.",
619
619
  "lockedHint": "כרטיס מתנה מוסיפים לפני שלב התשלום.",
620
620
  "removeFailed": "לא הצלחנו להסיר את כרטיס המתנה.",
621
- "applied": "כרטיס מתנה {amount}"
621
+ "applied": "כרטיס מתנה בסך {amount}"
622
622
  }
623
623
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.81.0",
3
+ "version": "1.82.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "homepage": "https://brainerce.com",
6
6
  "repository": {
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Connect this storefront to a Brainerce sales channel.
3
+ * Connect this storefront to a Brainerce sales channel, and optionally fill it
4
+ * with a starter catalog.
4
5
  *
5
6
  * Run it when the build is finished, not before:
6
7
  *
7
8
  * npm run connect
9
+ * npm run connect -- --seed-products '[{"name":"Burr Grinder","basePrice":389}]'
8
10
  *
9
11
  * WHY THIS IS A SEPARATE STEP. Nothing in a storefront's code depends on WHICH
10
12
  * store it points at — the pages, the routing, the cart, the checkout and every
@@ -13,6 +15,14 @@
13
15
  * uninterrupted and comes here at the end, instead of stopping for an approval
14
16
  * before a single file exists.
15
17
  *
18
+ * ⛔ WHY SEEDING LIVES HERE TOO. The approval leaves a short single-use grant on
19
+ * /api/device-auth/seed keyed to the device code, and this script is the only
20
+ * thing that holds that code. Seeding from here is also strictly better than
21
+ * seeding at scaffold time: by now the storefront exists, so the products can
22
+ * match what was actually built rather than what was guessed before any file
23
+ * was written. Without this the deferred path could only ever produce an empty
24
+ * store, and the merchant's first look at their own site is a blank grid.
25
+ *
16
26
  * One click in a browser, and this writes the id into `.env.local`. A store and
17
27
  * a sales channel are created for you if you have neither.
18
28
  *
@@ -28,6 +38,169 @@ const API_BASE = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').
28
38
  const ENV_PATH = resolve(process.cwd(), '.env.local');
29
39
  const KEYS = ['NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID', 'NEXT_PUBLIC_BRAINERCE_CONNECTION_ID'];
30
40
 
41
+ // The seed route's own limits, mirrored here so a bad payload is refused before
42
+ // the grant is touched.
43
+ //
44
+ // ⛔ THIS IS A HAND-KEPT COPY. The canonical one is
45
+ // `packages/cli-shared/src/seed-products.ts`, which the scaffolder itself
46
+ // imports; this file cannot, because it ships standalone into the user's
47
+ // project where that package does not exist. When a limit moves, it moves
48
+ // there first and this follows. All of it mirrors SeedDeviceAuthDto /
49
+ // SeedProductDto in apps/backend/src/modules/device-auth/dto/device-auth.dto.ts,
50
+ // which is what actually enforces them.
51
+ const SEED_MIN_PRODUCTS = 1;
52
+ const SEED_MAX_PRODUCTS = 20;
53
+ const SEED_MAX_NAME = 191;
54
+ const SEED_MAX_DESCRIPTION = 5000;
55
+ const SEED_FIELDS = ['name', 'basePrice', 'description'];
56
+
57
+ /**
58
+ * Read `--seed-products <json>` from argv, or `BRAINERCE_SEED_PRODUCTS`.
59
+ *
60
+ * The environment variable is not a nicety: `npm run connect -- --seed-products '[...]'`
61
+ * loses its quoting in PowerShell, which is the default shell for a large share
62
+ * of the agents that run this, and a JSON array mangled into eight arguments
63
+ * fails in a way nobody can read. The variable is the escape hatch for those.
64
+ */
65
+ function readSeedArg(argv) {
66
+ const flag = argv.indexOf('--seed-products');
67
+ if (flag !== -1) return argv[flag + 1] ?? '';
68
+ const inline = argv.find((a) => a.startsWith('--seed-products='));
69
+ if (inline) return inline.slice('--seed-products='.length);
70
+ return process.env.BRAINERCE_SEED_PRODUCTS ?? null;
71
+ }
72
+
73
+ /**
74
+ * Parse and check the seed payload locally, before anything is sent.
75
+ *
76
+ * The seeding grant is single use and dies with the approval window, so a
77
+ * payload the API would reject must never reach it: there is no second attempt
78
+ * inside the run, and a second attempt would cost the user a second approval.
79
+ * Every rule here is one the backend enforces anyway — this only moves the
80
+ * refusal to a point where it is still free.
81
+ *
82
+ * The backend runs `forbidNonWhitelisted`, so ONE stray key (`sku`, `type`,
83
+ * `images` are the ones agents reach for) rejects the entire request rather
84
+ * than the row that carried it. That is why unknown keys are an error here and
85
+ * not something quietly dropped.
86
+ *
87
+ * Returns `{ products }` or `{ error }`. Pure: no I/O, no process state.
88
+ */
89
+ function parseSeedProducts(raw) {
90
+ if (typeof raw !== 'string' || raw.trim() === '') {
91
+ return {
92
+ error:
93
+ '--seed-products was passed with no value. Give it a JSON array of products, quoted as one shell argument, or drop the flag entirely to connect without seeding.',
94
+ };
95
+ }
96
+
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(raw);
100
+ } catch (error) {
101
+ return {
102
+ error: `--seed-products is not valid JSON (${error.message}). Pass the array as a single quoted argument, or put it in BRAINERCE_SEED_PRODUCTS if your shell keeps breaking the quoting.`,
103
+ };
104
+ }
105
+
106
+ // A bare array is the documented shape. An object with a `products` key is
107
+ // the body the seed route itself takes, and an agent copying from the route
108
+ // reference will send that, so accept it rather than refusing a payload that
109
+ // is right in every way that matters.
110
+ const list = Array.isArray(parsed)
111
+ ? parsed
112
+ : parsed && typeof parsed === 'object' && Array.isArray(parsed.products)
113
+ ? parsed.products
114
+ : null;
115
+
116
+ if (!list) {
117
+ return {
118
+ error:
119
+ '--seed-products must be a JSON array of products, or an object with a "products" array. A single product object on its own is not accepted; wrap it in an array.',
120
+ };
121
+ }
122
+ if (list.length < SEED_MIN_PRODUCTS) {
123
+ return {
124
+ error:
125
+ '--seed-products was an empty list. Seeding needs at least one product; if the user has not described what they sell, leave the flag off and build against the empty catalog.',
126
+ };
127
+ }
128
+ if (list.length > SEED_MAX_PRODUCTS) {
129
+ return {
130
+ error: `--seed-products carries ${list.length} products and the limit is ${SEED_MAX_PRODUCTS}. This is a starter catalog, not an import: pick the ${SEED_MAX_PRODUCTS} that best show the store, and point the merchant at the dashboard or Apps > Browse > Migration Tool for the rest.`,
131
+ };
132
+ }
133
+
134
+ const products = [];
135
+
136
+ for (let i = 0; i < list.length; i += 1) {
137
+ const entry = list[i];
138
+ const at = `--seed-products product ${i + 1}`;
139
+
140
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
141
+ return {
142
+ error: `${at} is not an object. Each entry looks like {"name":"...","basePrice":0}.`,
143
+ };
144
+ }
145
+
146
+ const unknown = Object.keys(entry).filter((k) => !SEED_FIELDS.includes(k));
147
+ if (unknown.length > 0) {
148
+ return {
149
+ error: `${at} carries ${unknown.map((k) => `"${k}"`).join(', ')}, which the seed route does not accept and will not ignore — one unknown key rejects the whole request. Only ${SEED_FIELDS.join(', ')} are allowed: no sku, no type, no options or variants, no categories, no images. Every seeded product lands as a simple, active product.`,
150
+ };
151
+ }
152
+
153
+ if (typeof entry.name !== 'string' || entry.name.trim() === '') {
154
+ return { error: `${at} has no usable "name". It must be a non-empty string.` };
155
+ }
156
+ if (entry.name.length > SEED_MAX_NAME) {
157
+ return {
158
+ error: `${at} ("${entry.name.slice(0, 40)}...") has a name of ${entry.name.length} characters and the limit is ${SEED_MAX_NAME}.`,
159
+ };
160
+ }
161
+
162
+ if (!('basePrice' in entry)) {
163
+ return {
164
+ error: `${at} ("${entry.name}") has no "basePrice". Every seeded product needs one, as a number: {"name":"${entry.name}","basePrice":0} is the minimum shape.`,
165
+ };
166
+ }
167
+ if (typeof entry.basePrice === 'string') {
168
+ // The failure that actually happens: a price quoted as a string. The API
169
+ // validates with @IsNumber(), which does not coerce, so "149" is refused.
170
+ return {
171
+ error: `${at} ("${entry.name}") has a "basePrice" quoted as text. Send ${entry.basePrice.trim() === '' ? '149' : entry.basePrice} without the quotes — the API does not coerce a quoted price, it rejects it.`,
172
+ };
173
+ }
174
+ if (typeof entry.basePrice !== 'number' || !Number.isFinite(entry.basePrice)) {
175
+ return {
176
+ error: `${at} ("${entry.name}") has a "basePrice" that is not a number. It must be a plain finite number, not null, not a string and not an expression.`,
177
+ };
178
+ }
179
+ if (entry.basePrice < 0) {
180
+ return { error: `${at} ("${entry.name}") has a negative "basePrice".` };
181
+ }
182
+
183
+ if ('description' in entry) {
184
+ if (typeof entry.description !== 'string') {
185
+ return {
186
+ error: `${at} ("${entry.name}") has a "description" that is not a string. Omit the key entirely rather than sending null.`,
187
+ };
188
+ }
189
+ if (entry.description.length > SEED_MAX_DESCRIPTION) {
190
+ return {
191
+ error: `${at} ("${entry.name}") has a description of ${entry.description.length} characters and the limit is ${SEED_MAX_DESCRIPTION}.`,
192
+ };
193
+ }
194
+ }
195
+
196
+ const product = { name: entry.name, basePrice: entry.basePrice };
197
+ if ('description' in entry) product.description = entry.description;
198
+ products.push(product);
199
+ }
200
+
201
+ return { products };
202
+ }
203
+
31
204
  /** Only ever hand the OS a URL on the host we asked. It arrives in a network
32
205
  * response, so it is untrusted input on the way to a process launch. */
33
206
  function openable(value) {
@@ -71,11 +244,134 @@ function writeEnv(connectionId) {
71
244
  writeFileSync(ENV_PATH, body.startsWith('\n') ? body.slice(1) : body);
72
245
  }
73
246
 
247
+ async function readError(res) {
248
+ try {
249
+ const body = await res.json();
250
+ const message = body?.message;
251
+ return Array.isArray(message) ? message.join('; ') : (message ?? `HTTP ${res.status}`);
252
+ } catch {
253
+ return `HTTP ${res.status}`;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Spend the seeding grant.
259
+ *
260
+ * ⛔ `deviceCode` is a 256-bit bearer credential and it stops here. It is the
261
+ * poll credential as well as the seed credential, so anything that put it on
262
+ * stdout or in a log would hand a reader inside the ten-minute window the
263
+ * ability to seed this store. It is never printed and never interpolated into
264
+ * an error string.
265
+ *
266
+ * Called once, with no retry. The route allows 5 requests a minute and the
267
+ * grant is single use, so a retry after an ambiguous failure would report "that
268
+ * window has closed" over a write that may well have landed.
269
+ */
270
+ async function runSeed(deviceCode, products) {
271
+ let res;
272
+ try {
273
+ res = await fetch(`${API_BASE}/api/device-auth/seed`, {
274
+ method: 'POST',
275
+ headers: { 'Content-Type': 'application/json' },
276
+ body: JSON.stringify({ deviceCode, products }),
277
+ });
278
+ } catch (error) {
279
+ console.error('');
280
+ console.error(` Could not reach the seeding endpoint (${error.message}).`);
281
+ console.error(
282
+ ' The request may or may not have landed, so do not claim either way: check the catalog'
283
+ );
284
+ console.error(
285
+ ' in the dashboard and add what is missing there. Do not run connect again to retry.'
286
+ );
287
+ return;
288
+ }
289
+
290
+ if (!res.ok) {
291
+ const message = await readError(res);
292
+ // A 400 from the route's own guard means the grant is gone. A 400 from the
293
+ // validation pipe means the controller never ran and the grant is intact —
294
+ // different facts, and telling the user the window closed when it has not
295
+ // is its own kind of wrong.
296
+ const windowClosed = message.startsWith('That seeding window has closed');
297
+ console.error('');
298
+ if (windowClosed) {
299
+ console.error(' The seeding window is closed — the grant was already spent, or more than');
300
+ console.error(' ten minutes passed since the approval. The connection itself is fine.');
301
+ console.error(' Add products in the dashboard; do not run connect again just to seed.');
302
+ } else {
303
+ console.error(` The seed request was refused before the grant was touched: ${message}`);
304
+ console.error(' The store is connected and still empty. Report this message as written and');
305
+ console.error(' add the products in the dashboard rather than retrying.');
306
+ }
307
+ return;
308
+ }
309
+
310
+ let body;
311
+ try {
312
+ body = await res.json();
313
+ } catch {
314
+ console.error('');
315
+ console.error(' The seed endpoint answered with something that is not JSON. Products may');
316
+ console.error(' have been created. Check the catalog in the dashboard rather than assuming');
317
+ console.error(' either outcome.');
318
+ return;
319
+ }
320
+
321
+ const created = Array.isArray(body.created) ? body.created : [];
322
+ const failed = Array.isArray(body.failed) ? body.failed : [];
323
+
324
+ // Both halves, always. A per-product failure does not abort the rest, so a
325
+ // partial write is a normal outcome — and reporting one as a clean win is how
326
+ // a merchant finds three of five products missing a week later.
327
+ console.log('');
328
+ if (created.length > 0) {
329
+ console.log(` Seeded ${created.length} product(s): ${created.map((p) => p.name).join(', ')}`);
330
+ }
331
+ for (const f of failed) {
332
+ console.log(` Not created: ${f.name} — ${f.reason}`);
333
+ }
334
+ if (failed.length > 0) {
335
+ console.log('');
336
+ console.log(
337
+ ` ${created.length} of ${products.length} product(s) went in; ${failed.length} refused.`
338
+ );
339
+ console.log(
340
+ ' Report BOTH halves by name. The window is now spent, so the refused ones have to be'
341
+ );
342
+ console.log(' added in the dashboard.');
343
+ } else if (created.length > 0) {
344
+ console.log(' Anything more comes from the dashboard, or Apps > Browse > Migration Tool.');
345
+ }
346
+ }
347
+
74
348
  async function main() {
349
+ const seedRaw = readSeedArg(process.argv.slice(2));
350
+ let seedProducts = null;
351
+ if (seedRaw !== null) {
352
+ const parsed = parseSeedProducts(seedRaw);
353
+ if (parsed.error) {
354
+ // Refuse before the approval, not after: a payload rejected on the other
355
+ // side of a click has already cost the user the click.
356
+ console.error(parsed.error);
357
+ process.exit(1);
358
+ }
359
+ seedProducts = parsed.products;
360
+ }
361
+
75
362
  if (process.env.BRAINERCE_CONNECTION_ID) {
76
363
  // Already known (CI, or a channel someone created by hand). Nothing to approve.
77
364
  writeEnv(process.env.BRAINERCE_CONNECTION_ID.trim());
78
365
  console.log('Wrote BRAINERCE_CONNECTION_ID from the environment into .env.local.');
366
+ if (seedProducts) {
367
+ // ⛔ Seeding is authenticated by the device code from an approval. There is
368
+ // no approval on this path, so there is nothing to seed with — say so
369
+ // rather than connecting and silently dropping the catalog.
370
+ console.error('');
371
+ console.error(' Seeding needs a browser approval and this run already had a connection id,');
372
+ console.error(' so no products were created. Add them in the dashboard, or in an agent');
373
+ console.error(' session use the Brainerce MCP server to create products directly.');
374
+ }
79
375
  return;
80
376
  }
81
377
 
@@ -97,9 +393,12 @@ async function main() {
97
393
  process.exit(1);
98
394
  }
99
395
 
100
- const opened = openable(start.verificationUriComplete) && openBrowser(start.verificationUriComplete);
396
+ const opened =
397
+ openable(start.verificationUriComplete) && openBrowser(start.verificationUriComplete);
101
398
  console.log('');
102
- console.log(opened ? 'Opened your browser to approve this connection.' : 'Open this link to approve:');
399
+ console.log(
400
+ opened ? 'Opened your browser to approve this connection.' : 'Open this link to approve:'
401
+ );
103
402
  console.log(` ${start.verificationUriComplete}`);
104
403
  // The code is the only thing a person can actually check: it proves the
105
404
  // request came from this run and not from someone else's.
@@ -124,9 +423,13 @@ async function main() {
124
423
  continue; // A blip mid-poll is not fatal; the deadline is the real bound.
125
424
  }
126
425
  if (result.status === 'approved' && result.connectionId) {
426
+ // Write the id BEFORE seeding. Seeding is the part that can fail, and a
427
+ // failed catalog must never cost the connection that already succeeded.
127
428
  writeEnv(result.connectionId);
128
429
  console.log('');
129
430
  console.log('Connected. The sales channel id is in .env.local.');
431
+ if (seedProducts) await runSeed(start.deviceCode, seedProducts);
432
+ console.log('');
130
433
  console.log('Start the storefront with: npm run dev');
131
434
  return;
132
435
  }
@@ -74,7 +74,8 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
74
74
  // than the inventory left) and PRODUCT_UNAVAILABLE (the line cannot be
75
75
  // bought at all any more). Both get their own sentence; everything else
76
76
  // is a blip worth one more tap. The 50-line cart cap is NOT a cause here
77
- // at all, it only ever refuses a brand new line. Keep the log: the
77
+ // at all, it only ever refuses a brand new line. Both codes are part of
78
+ // the documented API error catalogue, not guesses. Keep the log: the
78
79
  // shopper gets the sentence, the developer still needs the detail.
79
80
  const code = getErrorCode(err);
80
81
  setLineError(
@@ -9,7 +9,7 @@
9
9
  "home": {
10
10
  "heroEyebrow": "New collection · Handcrafted",
11
11
  "heroTitle": "Pieces that tell your story",
12
- "heroLead": "Thoughtfully designed, handcrafted with love made to be with you through the small moments and the big ones.",
12
+ "heroLead": "Thoughtfully designed and handcrafted with love, made to be with you through the small moments and the big ones.",
13
13
  "heroCtaSecondary": "Browse categories",
14
14
  "heroTrust": "Secure checkout on every order",
15
15
  "benefit1Title": "Delivery",
@@ -21,10 +21,10 @@
21
21
  "benefit4Title": "Returns",
22
22
  "benefit4Desc": "See our returns policy for details",
23
23
  "featuredEyebrow": "Our picks",
24
- "featuredSubtitle": "The pieces our customers love most hand-picked from the new collection.",
24
+ "featuredSubtitle": "The pieces our customers love most, hand-picked from the new collection.",
25
25
  "categoriesEyebrow": "Categories",
26
26
  "categoriesTitle": "Find the perfect piece",
27
- "categoriesSubtitle": "From everyday favorites to statement pieces there's something for everyone.",
27
+ "categoriesSubtitle": "From everyday favorites to statement pieces, there's something here for everyone.",
28
28
  "catRings": "Rings",
29
29
  "catNecklaces": "Necklaces",
30
30
  "catEarrings": "Earrings",
@@ -52,7 +52,7 @@
52
52
  "heroEmptyHint": "Your first published products will appear here"
53
53
  },
54
54
  "products": {
55
- "listingSubtitle": "Our full collection handmade, genuine materials and love for detail.",
55
+ "listingSubtitle": "Our full collection, handmade from genuine materials with love for detail.",
56
56
  "filtersLabel": "Filter",
57
57
  "emptyTitle": "No results found",
58
58
  "emptyBody": "Try clearing the filters or searching for something else.",
@@ -84,7 +84,7 @@
84
84
  "shippingAtCheckout": "Shipping calculated at checkout"
85
85
  },
86
86
  "footer": {
87
- "tagline": "{{storeName}} handcrafted with love in Israel.",
87
+ "tagline": "{{storeName}}, handcrafted with love in Israel.",
88
88
  "followUs": "Follow us",
89
89
  "quickLinksTitle": "Quick links",
90
90
  "linkProducts": "All products",
@@ -9,7 +9,7 @@
9
9
  "home": {
10
10
  "heroEyebrow": "קולקציה חדשה · עבודת יד",
11
11
  "heroTitle": "פריטים שמספרים את הסיפור שלך",
12
- "heroLead": "עיצובים עדינים, בעבודת יד ובאהבה נוצרו ללוות אתכן ברגעים הקטנים והגדולים.",
12
+ "heroLead": "עיצובים עדינים, בעבודת יד ובאהבה, שנוצרו ללוות אתכן ברגעים הקטנים והגדולים.",
13
13
  "heroCtaSecondary": "לצפייה בקטגוריות",
14
14
  "heroTrust": "קופה מאובטחת בכל הזמנה",
15
15
  "benefit1Title": "משלוח",
@@ -21,10 +21,10 @@
21
21
  "benefit4Title": "החזרות",
22
22
  "benefit4Desc": "הפרטים במדיניות ההחזרות שלנו",
23
23
  "featuredEyebrow": "הנבחרים שלנו",
24
- "featuredSubtitle": "הפריטים שהלקוחות שלנו הכי אוהבות נבחרו בקפידה מהקולקציה החדשה.",
24
+ "featuredSubtitle": "הפריטים שהלקוחות שלנו הכי אוהבות, שנבחרו בקפידה מהקולקציה החדשה.",
25
25
  "categoriesEyebrow": "קטגוריות",
26
26
  "categoriesTitle": "מצאו את הפריט המושלם",
27
- "categoriesSubtitle": "מהפריטים העדינים ועד הבולטים לכל אחת יש את השפה שלה.",
27
+ "categoriesSubtitle": "מהפריטים העדינים ועד הבולטים, לכל אחת יש את השפה שלה.",
28
28
  "catRings": "טבעות",
29
29
  "catNecklaces": "שרשראות",
30
30
  "catEarrings": "עגילים",
@@ -52,7 +52,7 @@
52
52
  "heroEmptyHint": "המוצרים הראשונים שתפרסמו לחנות יופיעו כאן"
53
53
  },
54
54
  "products": {
55
- "listingSubtitle": "הקולקציה המלאה שלנו עבודת יד, חומרים אמיתיים ואהבה לפרטים.",
55
+ "listingSubtitle": "הקולקציה המלאה שלנו, בעבודת יד, מחומרים אמיתיים ומתוך אהבה לפרטים.",
56
56
  "filtersLabel": "סינון",
57
57
  "emptyTitle": "לא מצאנו תוצאות",
58
58
  "emptyBody": "נסו לנקות את הסינון או לחפש משהו אחר.",
@@ -84,7 +84,7 @@
84
84
  "shippingAtCheckout": "משלוח יחושב בקופה"
85
85
  },
86
86
  "footer": {
87
- "tagline": "{{storeName}} עבודת יד, מעוצב באהבה בישראל.",
87
+ "tagline": "{{storeName}}, עבודת יד ועיצוב באהבה בישראל.",
88
88
  "followUs": "עקבו אחרינו",
89
89
  "quickLinksTitle": "ניווט מהיר",
90
90
  "linkProducts": "כל המוצרים",
@@ -78,7 +78,8 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
78
78
  // than the inventory left) and PRODUCT_UNAVAILABLE (the line cannot be
79
79
  // bought at all any more). Both get their own sentence; everything else
80
80
  // is a blip worth one more tap. The 50-line cart cap is NOT a cause here
81
- // at all, it only ever refuses a brand new line. Keep the log: the
81
+ // at all, it only ever refuses a brand new line. Both codes are part of
82
+ // the documented API error catalogue, not guesses. Keep the log: the
82
83
  // shopper gets the sentence, the developer still needs the detail.
83
84
  const code = getErrorCode(err);
84
85
  setLineError(
@@ -78,7 +78,8 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
78
78
  // than the inventory left) and PRODUCT_UNAVAILABLE (the line cannot be
79
79
  // bought at all any more). Both get their own sentence; everything else
80
80
  // is a blip worth one more tap. The 50-line cart cap is NOT a cause here
81
- // at all, it only ever refuses a brand new line. Keep the log: the
81
+ // at all, it only ever refuses a brand new line. Both codes are part of
82
+ // the documented API error catalogue, not guesses. Keep the log: the
82
83
  // shopper gets the sentence, the developer still needs the detail.
83
84
  const code = getErrorCode(err);
84
85
  setLineError(