Setting the file. One moment.
Seed Store · Wix Headless Fast · wix/skills · Skills Docs
ContentsBack to the top of the page export async function installStoresApp
— line 193
This file
Number 7.144
Position 144 of 148
Type JavaScript
Size 25 KB
Lines 525 references/storefront/seed/ seed-store.mjs
JavaScript · 525 lines · 25 KB
// "options"?: [{ "name", "type"?: "text"|"color",
15 // "choices": ["S","M"] | [{ "name", "colorCode" }] }],
16 // "imageUrl"? | "imagePath"? | "imagePrompt"?, "altText"?,
17 // "digitalFileUrl"? | "digitalFilePath"?, "digitalFileName"? }],
18 // "categories"?: { "<category name>": ["<product name>", ...] } }
19 //
20 // Seeding is ADDITIVE — this script never deletes or overwrites existing content.
21 // If a call fails with an unexpected shape, read the live API reference (the authoritative
22 // source recipe is wix-headless/references/inline-recipes/setup-online-store.md) — never guess.
23 import { execFileSync } from "node:child_process" ;
24 import { basename } from "node:path" ;
25 import { readFileSync } from "node:fs" ;
26 import { resolveItemImages } from "../../shared/seed/images.mjs" ;
27
28 const API = "https://www.wixapis.com" ;
29 const STORES_APP_ID = "215238eb-22a5-4c36-9e7b-e7c08025e04e" ;
30
31 // ---- auth: siteId from wix.config.json, token minted by the Wix CLI ----------------------------
32
33 export function makeCtx ({ cwd = process. cwd () } = {}) {
34 const config = JSON . parse ( readFileSync ( `${ cwd }/wix.config.json` , "utf8" ));
35 const siteId = config.siteId ?? config.projectId;
36 if ( ! siteId) throw new Error ( "wix.config.json has no siteId — is this a Wix CLI project?" );
37 // The CLI returns a byte-identical token within a run — mint once, reuse.
38 const token = execFileSync ( "npx" , [ "@wix/cli@latest" , "token" , "--site" , siteId], {
39 encoding: "utf8" ,
40 cwd,
41 }). trim ();
42 if ( ! token) throw new Error ( "The Wix CLI returned no token — run `npx @wix/cli@latest login` first." );
43 return { token, siteId };
44 }
45
46 // ---- transport ----------------------------------------------------------------------------------
47
48 async function req ( ctx , path , { method = "POST" , body } = {}) {
49 // Retry while the catalog is still provisioning: right after a fresh Stores install the V3
50 // WRITE path becomes usable later than the read path, so the first bulk-create can 428 even
51 // after the read probe clears. Wait it out (~80s budget); other errors throw immediately.
52 // ⚠️ An errored bulk create (seen live with a bare 429 {}) may still have APPLIED
53 // server-side — creation is idempotent by name in setupStore for exactly that reason.
54 for ( let attempt = 0 ; ; attempt ++ ) {
55 const res = await fetch ( API + path, {
56 method,
57 headers: {
58 Authorization: `Bearer ${ ctx . token }` ,
59 "wix-site-id" : ctx.siteId,
60 "Content-Type" : "application/json" ,
61 },
62 body: body ? JSON . stringify (body) : undefined ,
63 });
64 const json = await res. json (). catch (() => ({}));
65 if (res.ok) return json;
66 if ( isProvisioning (res.status, json) && attempt < 40 ) {
67 await sleep ( 2000 );
68 continue ;
69 }
70 throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
71 }
72 }
73
74 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
75
76 // A freshly installed catalog signals "not writable yet" with a 428 under (at least) two codes.
77 const PROVISIONING_CODES = new Set ([ "CATALOG_V1_SITE_CALLING_CATALOG_V3_API" , "CATALOG_V3_SITE_PROVISIONING" ]);
78
79 function isProvisioning ( status , json ) {
80 if ( PROVISIONING_CODES . has (json?.details?.applicationError?.code)) return true ;
81 return status === 428 && /provision/ i . test (json?.message || "" );
82 }
83
84 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products.md
85 async function waitForCatalogV3 ( ctx , { attempts = 40 , delayMs = 2000 } = {}) {
86 for ( let i = 0 ; i < attempts; i ++ ) {
87 const res = await fetch ( `${ API }/stores/v3/products/query` , {
88 method: "POST" ,
89 headers: { Authorization: `Bearer ${ ctx . token }` , "wix-site-id" : ctx.siteId, "Content-Type" : "application/json" },
90 body: JSON . stringify ({ query: { paging: { limit: 1 } } }),
91 });
92 if (res.ok) return ;
93 const json = await res. json (). catch (() => ({}));
94 if ( ! isProvisioning (res.status, json)) return ;
95 await sleep (delayMs);
96 }
97 }
98
99 // ---- description string -> Wix rich-text nodes --------------------------------------------------
100 // Descriptions arrive as HTML as often as not. The writable field is `description` (Ricos
101 // nodes) — the HTML `plainDescription` the storefront renders is derived from them, so markup
102 // dropped into a TEXT node comes back escaped and the PDP shows literal tags. Convert the tags
103 // a model actually emits; a tag-free string stays one paragraph.
104
105 const HTML_ENTITIES = { "&" : "&" , "<" : "<" , ">" : ">" , """ : '"' , "'" : "'" , " " : " " };
106
107 function decodeEntities ( s ) {
108 return s. replace ( /&(?:amp | lt | gt | quot | #39 | nbsp);/ g , ( m ) => HTML_ENTITIES [m] ?? m);
109 }
110
111 function mkTextNodes ( html ) {
112 const nodes = [];
113 let bold = 0 , italic = 0 , last = 0 , m;
114 const tag = /<( \/ ? )(strong | b | em | i) \s * \/ ? >/ gi ;
115 const push = ( raw ) => {
116 const text = decodeEntities (raw. replace ( /< [ ^ >] * >/ g , "" ));
117 if ( ! text) return ;
118 const decorations = [];
119 if (bold > 0 ) decorations. push ({ type: "BOLD" });
120 if (italic > 0 ) decorations. push ({ type: "ITALIC" });
121 nodes. push ({ type: "TEXT" , textData: { text, decorations } });
122 };
123 while ((m = tag. exec (html)) !== null ) {
124 push (html. slice (last, m.index));
125 const step = m[ 1 ] ? - 1 : 1 ;
126 if ( / ^ (strong | b) $ / i . test (m[ 2 ])) bold = Math. max ( 0 , bold + step);
127 else italic = Math. max ( 0 , italic + step);
128 last = tag.lastIndex;
129 }
130 push (html. slice (last));
131 return nodes. length ? nodes : [{ type: "TEXT" , textData: { text: "" , decorations: [] } }];
132 }
133
134 function mkDesc ( text , i ) {
135 const blocks = String (text ?? "" ). split ( /< \/ p \s * > | <br \s * \/ ? >/ i ). map (( b ) => b. trim ()). filter (Boolean);
136 return {
137 nodes: (blocks. length ? blocks : [ "" ]). map (( block , n ) => ({
138 type: "PARAGRAPH" , id: `desc-${ i }-${ n }` ,
139 nodes: mkTextNodes (block),
140 paragraphData: { textStyle: { textAlignment: "AUTO" } },
141 })),
142 metadata: { version: 1 , id: `desc-meta-${ i }` },
143 };
144 }
145
146 // ---- options / variants -------------------------------------------------------------------------
147
148 function buildOptions ( options = []) {
149 return options. map (( o ) => {
150 const color = o.type === "color" ;
151 return {
152 name: o.name,
153 optionRenderType: color ? "SWATCH_CHOICES" : "TEXT_CHOICES" ,
154 choicesSettings: {
155 choices: o.choices. map (( c ) =>
156 color
157 ? { choiceType: "ONE_COLOR" , name: c.name, colorCode: c.colorCode }
158 : { choiceType: "CHOICE_TEXT" , name: typeof c === "string" ? c : c.name }),
159 },
160 };
161 });
162 }
163
164 // Full Cartesian product, each variant priced/stocked from the product; visible:true baked in.
165 function expandVariants ( options = [], { price , compareAtPrice , quantity , inStock }, digitalFileId ) {
166 const base = {
167 price: {
168 actualPrice: { amount: String (price) },
169 ... (compareAtPrice ? { compareAtPrice: { amount: String (compareAtPrice) } } : {}),
170 },
171 visible: true ,
172 ... (digitalFileId
173 ? { digitalProperties: { digitalFile: { id: digitalFileId } }, inventoryItem: { inStock: true } }
174 // inStock:true == untracked stock — always buyable, no count. Otherwise track a quantity.
175 : { physicalProperties: {}, inventoryItem: inStock === true
176 ? { inStock: true }
177 : { quantity: quantity ?? 0 , preorderInfo: { enabled: false } } }),
178 };
179 if ( ! options. length ) return [base];
180 let combos = [[]];
181 for ( const o of options) {
182 const rt = o.type === "color" ? "SWATCH_CHOICES" : "TEXT_CHOICES" ;
183 const names = o.choices. map (( c ) => ( typeof c === "string" ? c : c.name));
184 combos = combos. flatMap (( combo ) =>
185 names. map (( choiceName ) => [ ... combo, { optionChoiceNames: { optionName: o.name, choiceName, renderType: rt } }]));
186 }
187 return combos. map (( choices ) => ({ ... base, choices }));
188 }
189
190 // ---- operations ---------------------------------------------------------------------------------
191
192 // docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
193 export async function installStoresApp ( ctx ) {
194 try {
195 await req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
196 tenant: { tenantType: "SITE" , id: ctx.siteId },
197 appInstance: { appDefId: STORES_APP_ID , enabled: true },
198 } });
199 } catch {
200 // already installed is fine — the readiness wait below still confirms the V3 catalog is live
201 }
202 await waitForCatalogV3 (ctx);
203 }
204
205 // Existing products by exact name (for idempotent reruns). `name` is NOT filterable on the
206 // V3 query — fetch a page and match client-side (seed catalogs are small). Empty map on any
207 // failure — falling back to create-everything is the additive behavior we had before.
208 export async function queryProductsByNames ( ctx , names ) {
209 const out = new Map ();
210 if ( ! names. length ) return out;
211 try {
212 const wanted = new Set (names);
213 const r = await req (ctx, "/stores/v3/products/query" , { body: { query: { cursorPaging: { limit: 100 } } } });
214 for ( const p of r.products ?? []) {
215 if (wanted. has (p.name) && ! out. has (p.name)) out. set (p.name, { id: p.id, slug: p.slug, revision: p.revision });
216 }
217 } catch (e) {
218 console. error ( `product name pre-check failed (creating everything): ${ String ( e . message ). slice ( 0 , 120 ) }` );
219 }
220 return out;
221 }
222
223 // A digital variant is SELLABLE only with BOTH a file and stock: without the file the cart rejects
224 // it as ITEM_NOT_FOUND_IN_CATALOG, without stock as "exceeds available inventory" — and either way
225 // the product reads back visible and in the catalog, so nothing surfaces until a buyer tries to buy.
226 // A digitalFile* field is the only way into DIGITAL here, which makes the file-less product
227 // unbuildable. The bytes are PUT, not imported by url: an uploaded file is READY at once, while an
228 // imported one stays PENDING and the cart rejects the product until it settles.
229 // docs: https://dev.wix.com/docs/api-reference/assets/media/media-manager/files/generate-file-upload-url.md
230 const FILE_MIME = { pdf: "application/pdf" , zip: "application/zip" , epub: "application/epub+zip" ,
231 mp3: "audio/mpeg" , wav: "audio/wav" , mp4: "video/mp4" , png: "image/png" , jpg: "image/jpeg" };
232
233 async function uploadDigitalFile ( ctx , { digitalFileUrl , digitalFilePath , digitalFileName }) {
234 const src = digitalFilePath ?? digitalFileUrl;
235 const fileName = digitalFileName || decodeURIComponent ( basename ( new URL (src, "file:" ).pathname));
236 const mimeType = FILE_MIME [fileName. split ( "." ). pop (). toLowerCase ()];
237 if ( ! mimeType) throw new Error ( `digitalFileName needs one of these extensions (${ Object . keys ( FILE_MIME ). join ( ", " ) }): ${ fileName }` );
238 const bytes = digitalFilePath
239 ? readFileSync (digitalFilePath)
240 : await fetch (digitalFileUrl). then (( r ) => {
241 // Don't invent a file and don't ship an unbuyable DIGITAL product: seed it PHYSICAL
242 // with stock (drop digitalFileUrl/digitalFilePath, set inStock or a quantity) and
243 // tell the user the download still needs a real file.
244 if ( ! r.ok) throw new Error ( `digitalFileUrl ${ digitalFileUrl } -> ${ r . status }. No fetchable file: re-seed this product as PHYSICAL with stock and tell the user it needs a real file before it can be sold as a download.` );
245 return r. arrayBuffer ();
246 });
247 const { uploadUrl } = await req (ctx, "/site-media/v1/files/generate-upload-url" , { body: { mimeType, fileName } });
248 const res = await fetch (uploadUrl, { method: "PUT" , headers: { "Content-Type" : mimeType }, body: bytes });
249 const json = await res. json (). catch (() => ({}));
250 const id = (json.file || json)?.id;
251 if ( ! res.ok || ! id) throw new Error ( `digital file upload failed (${ res . status }): ${ JSON . stringify ( json ). slice ( 0 , 200 ) }` );
252 return id;
253 }
254
255 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/bulk-create-products-with-inventory.md
256 export async function bulkCreateProducts ( ctx , products ) {
257 const fileIds = await Promise . all (products. map (( p ) =>
258 p.digitalFileUrl || p.digitalFilePath ? uploadDigitalFile (ctx, p) : null ));
259 const body = {
260 returnEntity: true ,
261 products: products. map (( p , i ) => ({
262 name: p.name,
263 // DIGITAL drops physicalProperties and can't be POS-visible (DIGITAL_PRODUCT_CANNOT_BE_VISIBLE_IN_POS).
264 ... (fileIds[i]
265 ? { productType: "DIGITAL" }
266 : { productType: "PHYSICAL" , physicalProperties: {}, visibleInPos: true }),
267 visible: true ,
268 description: mkDesc (p.description, i),
269 options: buildOptions (p.options),
270 variantsInfo: { variants: expandVariants (p.options, p, fileIds[i]) },
271 })),
272 };
273 const r = await req (ctx, "/stores/v3/bulk/products-with-inventory/create" , { body });
274 // NB: results nest under productResults.results[].item — NOT a top-level `results`.
275 // The bulk returns 200 even on PARTIAL failure, so never map results by array position:
276 // pair each result to its input via itemMetadata.originalIndex and drop the ones that
277 // didn't persist. Positional mapping shifts every id after a failure onto the wrong
278 // product — which then mislabels categories and attaches images to the wrong items.
279 const created = [];
280 const failures = [];
281 for ( const x of r.productResults?.results ?? []) {
282 const i = x.itemMetadata?.originalIndex;
283 const src = typeof i === "number" ? products[i] : undefined ;
284 if ( ! x.itemMetadata?.success || ! x.item?.id) {
285 failures. push ({
286 name: src?.name,
287 error: x.itemMetadata?.error?.description ?? x.itemMetadata?.error?.code ?? "unknown" ,
288 });
289 continue ;
290 }
291 created. push ({
292 id: x.item.id, slug: x.item.slug, revision: x.item.revision, name: src?.name,
293 variantId: x.item.variantsInfo?.variants?.[ 0 ]?.id,
294 hasOptions: (src?.options?. length ?? 0 ) > 0 ,
295 isDigital: !! fileIds[i],
296 quantity: src?.quantity ?? 0 ,
297 inStock: src?.inStock,
298 });
299 }
300 await stockOptionlessProducts (ctx, created);
301 return {
302 created: created. map (( p ) => ({ id: p.id, slug: p.slug, revision: p.revision, name: p.name })),
303 failures,
304 };
305 }
306
307 // The bulk create stocks a variant via its choices; an OPTION-LESS product's single default
308 // variant is NOT stocked by it and lands OUT_OF_STOCK — stock those explicitly.
309 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products.md
310 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/inventory-items-v3/bulk-create-inventory-items.md
311 async function stockOptionlessProducts ( ctx , created ) {
312 const need = created. filter (( p ) => ! p.hasOptions && ! p.isDigital && p.id); // digital variants ship inStock from the create
313 if ( ! need. length ) return ;
314 const missing = need. filter (( p ) => ! p.variantId). map (( p ) => p.id);
315 if (missing. length ) {
316 const q = await req (ctx, "/stores/v3/products/query" , { body: { query: { filter: { id: { $in: missing } }, paging: { limit: missing. length } } } });
317 const vById = new Map ((q.products ?? []). map (( p ) => [p.id, p.variantsInfo?.variants?.[ 0 ]?.id]));
318 need. forEach (( p ) => { if ( ! p.variantId) p.variantId = vById. get (p.id); });
319 }
320 // inStock:true == untracked stock (always buyable, no count). Only send a quantity when the
321 // product actually tracks one, or Wix rejects the pair.
322 const inventoryItems = need
323 . filter (( p ) => p.variantId)
324 . map (( p ) => ({
325 productId: p.id,
326 variantId: p.variantId,
327 ... (p.inStock === true ? { inStock: true } : { quantity: p.quantity }),
328 }));
329 if (inventoryItems. length ) {
330 await req (ctx, "/stores/v3/bulk/inventory-items/create" , { body: { inventoryItems } });
331 }
332 }
333
334 // Existing categories by name (for idempotent reruns) — a re-run of the seed must reuse
335 // "Donuts", not create a second one. Empty map on any failure (falls back to create).
336 export async function queryCategoriesByNames ( ctx , names ) {
337 const out = new Map ();
338 if ( ! names. length ) return out;
339 try {
340 const r = await req (ctx, "/categories/v1/categories/query" , {
341 body: { treeReference: { appNamespace: "@wix/stores" , treeKey: null }, query: { cursorPaging: { limit: 100 } } },
342 });
343 const wanted = new Set (names);
344 for ( const c of r.categories ?? []) if (wanted. has (c.name) && ! out. has (c.name)) out. set (c.name, c.id);
345 } catch (e) {
346 console. error ( `category name pre-check failed (creating everything): ${ String ( e . message ). slice ( 0 , 120 ) }` );
347 }
348 return out;
349 }
350
351 // Categories share the @wix/stores tree revision — concurrent creates 409, so: sequential.
352 // Idempotent by name: a name that already exists is reused, never duplicated.
353 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/categories/create-category.md
354 export async function createCategories ( ctx , names ) {
355 const existing = await queryCategoriesByNames (ctx, names);
356 const out = [];
357 for ( const name of names) {
358 if (existing. has (name)) {
359 out. push ({ id: existing. get (name), name });
360 continue ;
361 }
362 const r = await req (ctx, "/categories/v1/categories" , {
363 body: { category: { name, visible: true }, treeReference: { appNamespace: "@wix/stores" , treeKey: null } },
364 });
365 out. push ({ id: r.category?.id, name });
366 }
367 return out;
368 }
369
370
371 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/categories/bulk-add-items-to-category.md
372 export async function addProductsToCategories ( ctx , mapping ) {
373 for ( const [ categoryId , productIds ] of Object. entries (mapping)) {
374 await req (ctx, `/categories/v1/bulk/categories/${ categoryId }/add-items` , {
375 body: {
376 items: productIds. map (( catalogItemId ) => ({ catalogItemId, appId: STORES_APP_ID })),
377 treeReference: { appNamespace: "@wix/stores" , treeKey: null },
378 },
379 });
380 }
381 }
382
383 // Bulk image attach in ONE call. items: [{ id, url, altText }] — no revision to pass: the
384 // current revision is read right before the update, so attach any number of times, any pass.
385 // Wix re-hosts each url server-side; the media can take a little while to appear on read-back
386 // (propagation) — normal, not a failure.
387 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/bulk-update-products.md
388 export async function attachProductImages ( ctx , items ) {
389 if ( ! items?. length ) return ;
390 const ids = items. map (( it ) => it.id);
391 const q = await req (ctx, "/stores/v3/products/query" , { body: { query: { filter: { id: { $in: ids } }, paging: { limit: ids. length } } } });
392 const revById = new Map ((q.products ?? []). map (( p ) => [p.id, p.revision]));
393 return req (ctx, "/stores/v3/bulk/products/update" , {
394 body: {
395 products: items. map (( it ) => ({
396 product: { id: it.id, revision: revById. get (it.id), media: { itemsInfo: { items: [{ url: it.url, altText: it.altText }] } } },
397 })),
398 },
399 });
400 }
401
402 // Reject plans the API would reject halfway through, while nothing has been created yet —
403 // a mid-batch 400 leaves a half-seeded store that the agent then has to reason about.
404 export function validateProducts ( products ) {
405 const problems = [];
406 const colorByName = new Map ();
407 products. forEach (( p , i ) => {
408 const where = p.name ? `"${ p . name }"` : `product #${ i + 1 }` ;
409 if ( ! p.name) problems. push ( `${ where }: name is required` );
410 if (p.quantity != null && ( ! Number. isInteger (p.quantity) || p.quantity < 0 )) {
411 problems. push ( `${ where }: quantity must be a non-negative integer (got ${ p . quantity }) — omit it and set inStock:true for untracked stock` );
412 }
413 for ( const opt of p.options ?? []) {
414 const seen = new Set ();
415 for ( const c of opt.choices ?? []) {
416 const key = typeof c === "string" ? c : c?.name;
417 if (seen. has (key)) problems. push ( `${ where }: option "${ opt . name }" repeats the choice "${ key }"` );
418 seen. add (key);
419 // Wix keys a color choice by name, so the same name with two codes collides.
420 const code = typeof c === "object" ? c?.colorCode : undefined ;
421 if (code) {
422 const prev = colorByName. get (key);
423 if (prev && prev !== code) problems. push ( `color "${ key }" is ${ prev } on one product and ${ code } on another — pick one` );
424 colorByName. set (key, code);
425 }
426 }
427 }
428 });
429 if (problems. length ) throw new Error ( `invalid seed plan: \n - ${ problems . join ( " \n - " ) }` );
430 }
431
432 // Site currency, set BEFORE any product exists (see setupStore). Existing product reads can
433 // keep reporting the old currency for a short while after this returns — that lag is expected
434 // and self-resolves, so don't re-verify or retry on it.
435 // docs: https://dev.wix.com/docs/rest/business-management/site-properties/properties/update-site-properties
436 async function setSiteCurrency ( ctx , currency ) {
437 await req (ctx, "/site-properties/v4/properties" , {
438 method: "PATCH" ,
439 body: { properties: { paymentCurrency: currency }, fields: [ "paymentCurrency" ] },
440 });
441 }
442
443 /**
444 * ONE-CALL seed: install → currency → create products → categories → attach images, ids
445 * threaded in memory. This is the default path — call it once instead of the individual
446 * functions.
447 */
448 export async function setupStore ( ctx , { products = [], categories = {}, currency } = {}) {
449 validateProducts (products);
450 await installStoresApp (ctx);
451 // Before any product exists: a product's price is stored in the site currency at create time,
452 // so switching afterwards leaves the catalog priced in the old one.
453 if (currency) await setSiteCurrency (ctx, currency);
454
455 // Idempotent by name: an errored bulk create (429/5xx) may still have applied server-side,
456 // and SKILL.md tells the agent to re-run a failed seed — creating only the names that don't
457 // exist yet makes that rerun safe instead of a duplicator.
458 const existing = await queryProductsByNames (ctx, products. map (( p ) => p.name));
459 const toCreate = products. filter (( p ) => ! existing. has (p.name));
460 const { created , failures } = toCreate. length
461 ? await bulkCreateProducts (ctx, toCreate)
462 : { created: [], failures: [] };
463 const createdByName = new Map (created. map (( p ) => [p.name, p]));
464 const withNames = products. map (( p ) => {
465 const hit = createdByName. get (p.name) ?? existing. get (p.name);
466 return { ... (hit ?? {}), name: p.name };
467 });
468 const idByName = new Map (withNames. map (( p ) => [p.name, p.id]));
469
470 const names = Object. keys (categories);
471 const cats = names. length ? await createCategories (ctx, names) : [];
472 if (cats. length ) {
473 const mapping = {};
474 for ( const c of cats) {
475 const ids = (categories[c.name] || []). map (( n ) => idByName. get (n)). filter (Boolean);
476 if (ids. length ) mapping[c.id] = ids;
477 }
478 if (Object. keys (mapping). length ) await addProductsToCategories (ctx, mapping);
479 }
480
481 // Pass 2 — images: resolve (import by url / generate by prompt) in one parallel wave, then
482 // bulk-attach. Failures leave the product text-only; the seed's exit never depends on images.
483 const files = await resolveItemImages (ctx, withNames. map (( p , i ) => ({
484 url: products[i]?.imageUrl,
485 path: products[i]?.imagePath,
486 prompt: products[i]?.imagePrompt,
487 displayName: `${ p . slug || "product"}.png` ,
488 })));
489 // `p.id` guards this: a product that failed to create has no id, and bulk-updating an
490 // undefined id would 400 the whole batch and cost every other product its image.
491 const imageItems = withNames
492 . map (( p , i ) => (files[i] && p.id ? { id: p.id, url: files[i].url, altText: products[i]?.altText ?? p.slug } : null ))
493 . filter (Boolean);
494 let imagesAttached = 0 ;
495 try {
496 if (imageItems. length ) await attachProductImages (ctx, imageItems);
497 imagesAttached = imageItems. length ;
498 } catch {
499 /* never block on image failure — the products stay text-only */
500 }
501
502 // failures is part of the result, not an exception: a partial seed still leaves a usable
503 // store, and the agent needs the names to report rather than silently shipping a short
504 // catalog. Re-run the seed to retry them — existing names are skipped, not duplicated.
505 return { products: withNames, categories: cats, imagesAttached, failures };
506 }
507
508 // ---- CLI entry ----------------------------------------------------------------------------------
509
510 const invokedDirectly = process.argv[ 1 ] && import . meta .url. endsWith (process.argv[ 1 ]. split ( "/" ). pop ());
511 if (invokedDirectly) {
512 const planPath = process.argv[ 2 ];
513 if ( ! planPath) {
514 console. error ( "usage: node seed-store.mjs <plan.json> (run from the project root)" );
515 process. exit ( 1 );
516 }
517 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
518 const ctx = makeCtx ();
519 setupStore (ctx, plan)
520 . then (( result ) => console. log ( JSON . stringify (result, null , 2 )))
521 . catch (( e ) => {
522 console. error (e.message);
523 process. exit ( 1 );
524 });
525 }