@wix/agent-skills 1.30.0 → 1.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-app/SKILL.md +7 -8
  3. package/skills/wix-app/references/DASHBOARD_PAGE.md +1 -1
  4. package/skills/wix-app/references/WIX_PATTERNS_DOCS.md +11 -50
  5. package/skills/wix-app/references/dashboard-page/DATA_SOURCES.md +14 -10
  6. package/skills/wix-app/references/dashboard-page/DRAFT_TEMPLATE.md +5 -8
  7. package/skills/wix-app/references/dashboard-page/TABLE_STATE.md +5 -4
  8. package/skills/wix-headless-kit/SKILL.md +20 -12
  9. package/skills/wix-headless-kit/guides/api-run.md +178 -0
  10. package/skills/wix-headless-kit/guides/existing-site.md +3 -2
  11. package/skills/wix-headless-kit/guides/reference-mode.md +9 -1
  12. package/skills/wix-headless-kit/install/AGENTS.md +7 -5
  13. package/skills/wix-headless-kit/install/attach.mjs +3 -3
  14. package/skills/wix-headless-kit/install/context.mjs +13 -3
  15. package/skills/wix-headless-kit/install/deploy.mjs +3 -3
  16. package/skills/wix-headless-kit/install/pins.json +1 -1
  17. package/skills/wix-headless-kit/install/setup.mjs +2 -2
  18. package/skills/wix-headless-kit/install/templates.mjs +2 -1
  19. package/skills/wix-headless-templates/blog/seed/SEED.md +1 -1
  20. package/skills/wix-headless-templates/blog/seed/seed-blog.mjs +2 -2
  21. package/skills/wix-headless-templates/bookings/seed/SEED.md +1 -1
  22. package/skills/wix-headless-templates/bookings/seed/seed-bookings.mjs +2 -1
  23. package/skills/wix-headless-templates/donations/seed/SEED.md +1 -1
  24. package/skills/wix-headless-templates/donations/seed/seed-donations.mjs +3 -2
  25. package/skills/wix-headless-templates/events/seed/SEED.md +1 -1
  26. package/skills/wix-headless-templates/events/seed/seed-events.mjs +3 -2
  27. package/skills/wix-headless-templates/portfolio/seed/SEED.md +4 -2
  28. package/skills/wix-headless-templates/portfolio/seed/seed-portfolio.mjs +6 -6
  29. package/skills/wix-headless-templates/rentals/seed/SEED.md +1 -1
  30. package/skills/wix-headless-templates/rentals/seed/seed-rentals.mjs +2 -1
  31. package/skills/wix-headless-templates/restaurants/seed/SEED.md +1 -1
  32. package/skills/wix-headless-templates/restaurants/seed/seed-restaurants.mjs +4 -3
  33. package/skills/wix-headless-templates/shared/seed/images.mjs +63 -7
  34. package/skills/wix-headless-templates/storefront/seed/SEED.md +17 -5
  35. package/skills/wix-headless-templates/storefront/seed/seed-store.mjs +49 -16
  36. package/skills/wix-manage/SKILL.md +25 -15
  37. package/skills/wix-manage/references/automations/activation-status.md +52 -0
  38. package/skills/wix-manage/references/automations/api-catalog.md +81 -0
  39. package/skills/wix-manage/references/automations/build-and-manage-automations.md +34 -0
  40. package/skills/wix-manage/references/bookings/create-and-update-booking-services.md +277 -363
  41. package/skills/wix-manage/references/custom-apps/use-app-skills-and-app-tools.md +3 -1
  42. package/skills/wix-manage/references/sites/send-feedback-to-wix.md +92 -0
  43. package/skills/wix-manage/references/sites/upload-static-site.md +258 -279
  44. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +7 -1
  45. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +7 -1
  46. package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +7 -1
  47. package/skills/wix-headless-kit/guides/no-machine.md +0 -115
  48. package/skills/wix-manage/references/bookings/create-appointment-service.md +0 -243
  49. package/skills/wix-manage/references/bookings/create-booking-service-from-prompt.md +0 -31
  50. package/skills/wix-manage/references/bookings/create-class-service.md +0 -222
  51. package/skills/wix-manage/references/bookings/create-course-service.md +0 -251
@@ -20,12 +20,14 @@ call is memory, and an empty or error reply to it says nothing about the site.
20
20
  Installed at `.agents/skills/` (by the Wix plugin, or by
21
21
  `npx skills add wix/skills --skill {{SKILL}} --skill wix-docs --skill wix-manage`).
22
22
 
23
- - `{{SKILL}}` — the code in this app and how to extend it. The shipped code sits in
23
+ - `{{SKILL}}` — the code in this app and how to extend it. The shipped code is the
24
+ `wix-headless-templates` skill: `node .agents/skills/{{SKILL}}/install/templates.mjs` prints where it
25
+ is (`.agents/skills/wix-headless-templates/` when installed beside this skill, else
24
26
  `.agents/skills/{{SKILL}}/templates/`, fetched once from the skill's repository and kept with this
25
- project at the commit it was built from (`templates/.source`); if that folder is missing,
26
- `node .agents/skills/{{SKILL}}/install/templates.mjs` fetches it. Each business solution has a playbook at
27
- `templates/<solution>/INSTRUCTIONS.md`; `node .agents/skills/{{SKILL}}/install/deploy.mjs <solution> --stack {{STACK}}`
28
- adds one; `node .agents/skills/{{SKILL}}/templates/<solution>/seed/read-site.mjs` reads what the
27
+ project at the commit it was built from, `templates/.source`) and fetches it when neither exists;
28
+ `<templates>` below is that folder. Each business solution has a playbook at
29
+ `<templates>/<solution>/INSTRUCTIONS.md`; `node .agents/skills/{{SKILL}}/install/deploy.mjs <solution> --stack {{STACK}}`
30
+ adds one; `node <templates>/<solution>/seed/read-site.mjs` reads what the
29
31
  site holds for it, with the documentation URL of every call it made. A code change ends with a release; the live URL shows it, the dev server does not count.
30
32
  `node .agents/skills/{{SKILL}}/install/check.mjs` says whether the skill or its templates have a newer
31
33
  version and prints the update commands; an update touches the skill folder or `templates/`, never `src/`.
@@ -304,7 +304,7 @@ emit("agent_configs", writeAgentsMd(projectDir, { skill: basename(SKILL_ROOT), s
304
304
  if (mode !== "link" && (stack !== "astro" || mode === "config-only")) {
305
305
  emit("ready", { projectDir, siteId, appId, baseUrl, hosting, frontend, stack, dashboardUrl: `https://manage.wix.com/dashboard/${siteId}`,
306
306
  next: `scaffold the ${stack} project in this folder per SKILL.md, then deploy.mjs <vertical…> --stack ${stack}${planPath ? ` --plan ${planPath}` : ""} (the client id is read from wix.config.json); ` +
307
- (planPath ? `then seed with the plan: node <SKILL_ROOT>/templates/${verticals[0]}/seed/seed-<vertical>.mjs ${planPath}` : `no plan given, so nothing seeds — the site owns its content`) +
307
+ (planPath ? `then seed with the plan: node ${join(TEMPLATES, verticals[0], "seed", `seed-${verticals[0]}.mjs`)} ${planPath}` : `no plan given, so nothing seeds — the site owns its content`) +
308
308
  (hosting === "self" ? `; you host it: origins on the OAuth app allow-list now: ${origins.join(", ") || "none — add them before the first checkout test"}` : "") });
309
309
  process.exit(0);
310
310
  }
@@ -349,10 +349,10 @@ if (planPath) {
349
349
  emit("seeding_started", { vertical: verticals[0], ...seed, ...(verticals.length > 1 ? { note: `the plan seeds ${verticals[0]}; the other verticals' seeds run afterwards, each with its own plan` } : {}) });
350
350
  } else {
351
351
  if (siteOrigin === "init") {
352
- emit("note", { step: "seed", detail: `this site was made for this run (init) and is empty: draft a plan per templates/${verticals[0]}/seed/SEED.md — the brief's content, else demo content — and run node <SKILL_ROOT>/templates/${verticals[0]}/seed/seed-<vertical>.mjs plan.json from the project root` });
352
+ emit("note", { step: "seed", detail: `this site was made for this run (init) and is empty: draft a plan per ${join(TEMPLATES, verticals[0], "seed", "SEED.md")} — the brief's content, else demo content — and run node <SKILL_ROOT>/templates/${verticals[0]}/seed/seed-<vertical>.mjs plan.json from the project root` });
353
353
  }
354
354
  if (verticals.includes("members")) {
355
- emit("note", { step: "seed", detail: "members: the Members Area app (the profile layer, no content) is installed by templates/members/seed/seed-members.mjs — run it unless the site already has the app" });
355
+ emit("note", { step: "seed", detail: `members: the Members Area app (the profile layer, no content) is installed by ${join(TEMPLATES, "members", "seed", "seed-members.mjs")} — run it unless the site already has the app` });
356
356
  }
357
357
  }
358
358
 
@@ -78,7 +78,9 @@ export function pullEnv(cwd) {
78
78
  /**
79
79
  * Whether a frontend exists in this folder: a `package.json` (a bundled project), an `index.html` at
80
80
  * the root (plain pages), or an `index.html` inside the folder the config's `site.outputDirectory`
81
- * names (a static site laid out for release, pages under site/). Nothing else counts — not this
81
+ * names (a static site laid out for release, pages under site/). `publicIndex` and `astroPages` tell
82
+ * a dropped site on its Wix Dev Machine apart from an Astro project: the machine copies the drop's
83
+ * pages into public/ of an Astro starter that has no src/pages/. Nothing else counts — not this
82
84
  * skill's code, not the skills folder, not AGENTS.md: those say who ran here, not what is here.
83
85
  */
84
86
  export function frontendPresent(cwd = process.cwd()) {
@@ -89,13 +91,16 @@ export function frontendPresent(cwd = process.cwd()) {
89
91
  rootIndex: has("index.html"),
90
92
  outputIndex: !!outDir && outDir !== "." && has(join(outDir, "index.html")),
91
93
  outputDirectory: outDir || null,
94
+ publicIndex: has(join("public", "index.html")),
95
+ astroPages: has(join("src", "pages")),
92
96
  };
93
97
  }
94
98
 
95
99
  /**
96
- * What the folder IS, from five file facts — the one classification every script and SKILL.md
100
+ * What the folder IS, from a few file facts — the one classification every script and SKILL.md
97
101
  * step 3 share: wix.config.json, its site.outputDirectory, the migration variables in .env.local,
98
- * package.json, index.html. `migrationActive` comes from siteContext (it needs `.env.local`).
102
+ * package.json, index.html (at the root or in public/), src/pages/. `migrationActive` comes from
103
+ * siteContext (it needs `.env.local`).
99
104
  *
100
105
  * empty nothing that reads as a project → setup CREATE: the run makes the site, seeds the plan
101
106
  * project a frontend, no wix.config.json → setup ADOPT: `init` gives it a new, empty site, seeds the plan
@@ -103,6 +108,9 @@ export function frontendPresent(cwd = process.cwd()) {
103
108
  * migration a config whose .env.local declares an ACTIVE editor migration, with or without the blank
104
109
  * Astro starter the download carries → setup MIGRATE; nothing seeded. Decided before the
105
110
  * frontend test: the starter's package.json must not read as a project to iterate on
111
+ * static-in-astro a config, a package.json, public/index.html and no src/pages/ (a site published
112
+ * through the drop flow, on its Wix Dev Machine) → the pages stay plain files in
113
+ * public/; the static REST layer deploys beside them; build and release
106
114
  * wix-project a config AND a frontend (package.json, or index.html in the output folder) → iterate:
107
115
  * never scaffold, init or reseed; deploy.mjs adds a solution, edits, release
108
116
  * published-static a config, index.html at the ROOT, no package.json, no laid-out output folder (a site
@@ -117,12 +125,14 @@ export function folderShape(cwd = process.cwd(), { migrationActive = false } = {
117
125
  project: "setup.mjs --vertical <v> --stack <stack> [--plan]: init links the folder to a new, empty site, seeds the plan and deploys (ADOPT)",
118
126
  "config-only": "attach.mjs: the site exists and has no frontend yet; read what it holds (the vertical's read-site.mjs) — nothing is seeded; the vertical's seed module with a plan only when the brief supplies or describes content",
119
127
  migration: "setup.mjs (MIGRATE): the shipped code into the starter the download carries (or the composed template around a bare config); the site being migrated owns its content — guides/migration.md",
128
+ "static-in-astro": "a dropped site on its Dev Machine: the pages are plain files in public/, served as they are. deploy.mjs <vertical…> --stack static --out public puts the REST layer in public/js/wix/; wire the pages in place with <script type=\"module\">: do the least that makes them work: connect each control the pages show and add only what a control can't work without (more only when the brief asks), and no Astro pages. Read the site (read-site.mjs); run the vertical's seed module with a plan only for content the brief supplies or describes. Release with npx @wix/cli@latest build && npx @wix/cli@latest release; the URL stays",
120
129
  "wix-project": "iterate: never scaffold, init or reseed. deploy.mjs <vertical…> --stack <stack> adds a solution, then ONE npm install; file edits for a change; release. Read the site (read-site.mjs) before any seed module runs",
121
130
  "published-static": "setup.mjs --vertical <v>: the config's site, no init; site/ becomes the upload and the REST layer lands in site/js/wix/; move the pages, styles and assets into site/. Nothing is seeded: read the site, then run the vertical's seed module with a plan when the brief gives content; release keeps the URL",
122
131
  };
123
132
  let shape;
124
133
  if (!config) shape = f.packageJson || f.rootIndex ? "project" : "empty";
125
134
  else if (migrationActive) shape = "migration";
135
+ else if (f.packageJson && f.publicIndex && !f.astroPages) shape = "static-in-astro";
126
136
  else if (f.packageJson || f.outputIndex) shape = "wix-project";
127
137
  else if (f.rootIndex) shape = "published-static";
128
138
  else shape = "config-only";
@@ -18,8 +18,8 @@
18
18
  // <out>/js/wix/ — shared/rest/ (client, media, config) + each vertical's rest/ + the
19
19
  // vertical's transport-agnostic core files (types, *-core) from its app/ — writes
20
20
  // the client id, and strips it to browser-ready ESM with tsc (comments kept, the
21
- // .ts sources kept beside the .js). No package.json is touched. Storefront only
22
- // until other verticals ship a rest/. `--out <dir>` (default: the project root)
21
+ // .ts sources kept beside the .js). No package.json is touched. Every vertical
22
+ // with a rest/ folder is supported. `--out <dir>` (default: the project root)
23
23
  // is the folder the site is served from — the one wix.config.json's
24
24
  // site.outputDirectory points at — so the modules land where the pages import
25
25
  // them and nothing else in the project gets uploaded.
@@ -431,7 +431,7 @@ if (siteSearch) {
431
431
  result.capabilities = { ...result.capabilities, siteSearch: true };
432
432
  if (siteSearch.install) {
433
433
  // Indexing needs the Wix Site Search app on the site — a site mutation the deploy never makes.
434
- result.note = [result.note, "siteSearch.install: run `node <SKILL_ROOT>/templates/shared/capabilities/site-search/seed/install.mjs` from the project root after the seed (results appear about half a minute later)"].filter(Boolean).join("; ");
434
+ result.note = [result.note, `siteSearch.install: run node ${join(REF, "shared", "capabilities", "site-search", "seed", "install.mjs")} from the project root after the seed (results appear about half a minute later)`].filter(Boolean).join("; ");
435
435
  }
436
436
  }
437
437
 
@@ -3,6 +3,6 @@
3
3
  "@wix/create-new": "0.0.124",
4
4
  "typescript": "5.8.3",
5
5
  "templates": {
6
- "ref": "v1.30.0"
6
+ "ref": "v1.31.1"
7
7
  }
8
8
  }
@@ -276,7 +276,7 @@ if (stack !== "static" && existsSync(join(projectDir, "package.json"))) {
276
276
  const madeTheSite = mode === "create" || mode === "adopt";
277
277
  let seed = null;
278
278
  if (planPath && !madeTheSite) {
279
- emit("seed_skipped", { reason: `${mode}: the site existed before this run; setup seeds only a site it created. Read what the site holds (templates/${vertical}/seed/read-site.mjs), then run templates/${vertical}/seed/seed-*.mjs ${planPath} yourself when the brief supplies or describes content` });
279
+ emit("seed_skipped", { reason: `${mode}: the site existed before this run; setup seeds only a site it created. Read what the site holds (${join(TEMPLATES, vertical, "seed", "read-site.mjs")}), then run templates/${vertical}/seed/seed-*.mjs ${planPath} yourself when the brief supplies or describes content` });
280
280
  }
281
281
  if (planPath && madeTheSite) {
282
282
  const seedDir = join(TEMPLATES, vertical, "seed");
@@ -325,7 +325,7 @@ emit("ready_for_brand_layer", {
325
325
  ? "a migration preview: the site being migrated owns its content (read it with the vertical's read-site.mjs when the brief allows probing; never seed it); theme + write the home page; "
326
326
  : madeTheSite
327
327
  ? (planPath ? "theme + write the home page; " : "the site is new and empty and no plan was given, so nothing was seeded yet: seed it now (a plan per step 2 — the brief's content, or one drafted per the vertical's SEED.md — then the vertical's seed module), and name the placeholder content in the closing message; theme + write the home page; ")
328
- : `nothing was seeded (setup seeds only a site it created): read what the site holds with templates/${vertical}/seed/read-site.mjs, then run templates/${vertical}/seed/seed-*.mjs <plan> when the brief supplies or describes content; theme + write the home page; `) +
328
+ : `nothing was seeded (setup seeds only a site it created): read what the site holds with ${join(TEMPLATES, vertical, "seed", "read-site.mjs")}, then run ${join(TEMPLATES, vertical, "seed")}/seed-*.mjs <plan> when the brief supplies or describes content; theme + write the home page; `) +
329
329
  (others.length && mode !== "migrate"
330
330
  ? `${others.join(", ")} deployed too, no further install needed: run each one's seed module (templates/<vertical>/seed/) with its own plan when the brief gives it content (the members seed installs the Members Area app and needs no plan); `
331
331
  : "") +
@@ -7,7 +7,8 @@
7
7
  //
8
8
  // Resolution, in order:
9
9
  // 1. the sibling skill folder, `<SKILL_ROOT>/../wix-headless-templates/`: a checkout of the repository,
10
- // or a plugin install that carries both skills (every plugin manifest lists the templates skill).
10
+ // or an install that carries both skills (the cold start's `npx skills add`, the npm package,
11
+ // the Codex and Cursor plugins; the Claude plugin leaves it out, so there it is fetched).
11
12
  // 2. the cache `<SKILL_ROOT>/templates/`, filled by an earlier call (`--refresh` refetches).
12
13
  // 3. a fetch: a sparse, shallow clone of `skills/wix-headless-templates/` from the repository the skill was installed
13
14
  // from (skills-lock.json's `source`, default wix/skills), at the branch or tag the install
@@ -52,7 +52,7 @@ feed without covers looks broken.
52
52
  - Cover — the default is a `coverImagePrompt` (AI-generated, ~1 Wix AI credit per image,
53
53
  account-billed): brand-contextual — subject, aesthetic/mood, palette, lighting — always
54
54
  ending "no text, no watermarks". At least one image in the set shows the real subject of the business — the actual product/space/service, not abstract decoration. For an asset the user actually supplied use
55
- `coverImagePath` (a file on this machine — uploaded to Wix Media) or `coverImageUrl` (their
55
+ `coverImageMediaId` (a file already in the site's Media Manager, such as one the user uploaded there: used as it is, no copy; a `static.wixstatic.com/media/<file id>` URL of a site file counts as one), `coverImagePath` (a file on this machine — uploaded to Wix Media) or `coverImageUrl` (their
56
56
  own hosted URL; verify it with `curl -sI` → 200) — never a stock-photo or guessed URL. Covers resolve
57
57
  in parallel and never block the seed; a failed cover leaves that post text-only (the script
58
58
  re-publishes each post it covers).
@@ -12,7 +12,7 @@
12
12
  // Plan shape (see SEED.md):
13
13
  // { "categories"?: [name], "tags"?: [name],
14
14
  // "posts": [{ "title", "content": [blocks] | "richContent"?, "category"?|"categories"?,
15
- // "tags"?, "coverImageUrl"? | "coverImagePrompt"? }] }
15
+ // "tags"?, "coverImageMediaId"? | "coverImageUrl"? | "coverImagePrompt"? }] }
16
16
  // content blocks: { type:"heading", text, level? } | { type:"paragraph", text }
17
17
  // | { type:"quote", text } | { type:"bulleted"|"ordered", items:[text,…] }
18
18
  //
@@ -255,7 +255,7 @@ export async function setupBlog(ctx, { posts = [], categories = [], tags = [] }
255
255
  // never depends on images.
256
256
  const files = await resolveItemImages(ctx, created.map((c, i) => (
257
257
  c?.id && c?.success
258
- ? { path: posts[i]?.coverImagePath, url: posts[i]?.coverImageUrl, prompt: posts[i]?.coverImagePrompt, displayName: `post-${i}.png` }
258
+ ? { mediaId: posts[i]?.coverImageMediaId, path: posts[i]?.coverImagePath, url: posts[i]?.coverImageUrl, prompt: posts[i]?.coverImagePrompt, displayName: `post-${i}.png` }
259
259
  : null
260
260
  )));
261
261
  const covers = created
@@ -19,7 +19,7 @@ service an image — the default is an `imagePrompt` (AI-generated, ~1 Wix AI cr
19
19
  account-billed): brand-contextual — subject, aesthetic/mood, palette, lighting — always ending
20
20
  "no text, no watermarks". At least one image in the set shows the real subject of the business —
21
21
  the actual product/space/service, not abstract decoration. For an asset the user actually supplied
22
- use `imagePath` (a file on this machine — uploaded to Wix Media) or `imageUrl` (their own hosted
22
+ use `imageMediaId` (a file already in the site's Media Manager, such as one the user uploaded there: used as it is, no copy; a `static.wixstatic.com/media/<file id>` URL of a site file counts as one), `imagePath` (a file on this machine — uploaded to Wix Media) or `imageUrl` (their own hosted
23
23
  URL; verify it with `curl -sI` → 200) — never a stock-photo or guessed URL. Images resolve in
24
24
  parallel and never block the seed; a failed image leaves that service text-only.
25
25
 
@@ -17,7 +17,7 @@
17
17
  // "price"? (number | { "from": number }), "priceText"?, "free"?, "deposit"?, "payInFull"?,
18
18
  // "duration"? (APPOINTMENT, minutes), "capacity"?, "waitlist"?, "maxParticipants"?,
19
19
  // "requireManualApproval"?, "conferencing"?, "staff"? (name | [names]), "location"? (name),
20
- // "category"? (name), "imageUrl"? | "imagePath"? | "imagePrompt"?,
20
+ // "category"? (name), "imageMediaId"? | "imageUrl"? | "imagePath"? | "imagePrompt"?,
21
21
  // "sessions"?: [{ "start", "end", "capacity"? }], // CLASS: one-off sessions, local "YYYY-MM-DDThh:mm:ss"
22
22
  // "weekly"?: { "days": ["MONDAY"], "time": "18:00", "duration": 60, "start", "end" } }] } // CLASS or COURSE: recurring
23
23
  //
@@ -355,6 +355,7 @@ export async function setupBookings(ctx, { services = [], staff = [], locations
355
355
  // Pass 2 — images: resolve (import by url / generate by prompt) in one parallel wave, then
356
356
  // attach. Failures leave the service text-only; the seed's exit never depends on images.
357
357
  const files = await resolveItemImages(ctx, created.map((c, i) => ({
358
+ mediaId: services[i]?.imageMediaId,
358
359
  url: services[i]?.imageUrl,
359
360
  path: services[i]?.imagePath,
360
361
  prompt: services[i]?.imagePrompt,
@@ -16,7 +16,7 @@ amount with a minimum, the note and the cover-fee prompt on. A second campaign (
16
16
  only when the brief lists several causes. Every campaign gets a cover image — the default is an
17
17
  `imagePrompt` (AI-generated, ~1 Wix AI credit per image, account-billed): brand-contextual — subject,
18
18
  mood, palette, lighting — always ending "no text, no watermarks"; a real subject of the cause, not
19
- abstract decoration. For an asset the user supplied use `imagePath` (a file on this machine) or
19
+ abstract decoration. For an asset the user supplied use `imageMediaId` (a file already in the site's Media Manager, such as one the user uploaded there: used as it is, no copy; a `static.wixstatic.com/media/<file id>` URL of a site file counts as one), `imagePath` (a file on this machine) or
20
20
  `imageUrl` (their own hosted URL; verify with `curl -sI` → 200) — never a stock or guessed URL.
21
21
  Images resolve in parallel and never block the seed; a failed image leaves the campaign text-only.
22
22
 
@@ -16,7 +16,7 @@
16
16
  // "customAmount"?: { "enabled", "min"?, "max"? },
17
17
  // "goal"?: { "target", "endDate"?, "acceptAfterGoal"?, "acceptAfterEndDate"? },
18
18
  // "comments"?: false, "askCoverFee"?: false,
19
- // "imageUrl"? | "imagePath"? | "imagePrompt"? }] }
19
+ // "imageMediaId"? | "imageUrl"? | "imagePath"? | "imagePrompt"? }] }
20
20
  //
21
21
  // Seeding is ADDITIVE and idempotent — a non-archived campaign with the same name is reused, never
22
22
  // duplicated; nothing is deleted or overwritten. Unexpected shapes → read the live API reference;
@@ -196,8 +196,9 @@ export async function setupDonations(ctx, { campaigns = [], currency } = {}) {
196
196
 
197
197
  // Pass 2 — images: resolve (upload a local file / import by url / generate by prompt) in one
198
198
  // parallel wave, then attach. Failures leave the campaign text-only; the exit never depends on them.
199
- const wanted = created.filter((c) => c.created && (c.plan.imageUrl || c.plan.imagePath || c.plan.imagePrompt));
199
+ const wanted = created.filter((c) => c.created && (c.plan.imageMediaId || c.plan.imageUrl || c.plan.imagePath || c.plan.imagePrompt));
200
200
  const files = await resolveItemImages(ctx, wanted.map((c) => ({
201
+ mediaId: c.plan.imageMediaId,
201
202
  url: c.plan.imageUrl,
202
203
  path: c.plan.imagePath,
203
204
  prompt: c.plan.imagePrompt,
@@ -17,7 +17,7 @@ the shape; the owner adds the rest in the dashboard) and make them exercise the
17
17
  image (an events page without images looks broken) — the default is an `imagePrompt`
18
18
  (AI-generated, ~1 Wix AI credit per image, account-billed): brand-contextual — subject,
19
19
  aesthetic/mood, palette, lighting — always ending "no text, no watermarks". At least one image in the set shows the real subject of the business — the actual product/space/service, not abstract decoration. For an asset the user actually
20
- supplied use `imagePath` (a file on this machine — uploaded to Wix Media) or `imageUrl`
20
+ supplied use `imageMediaId` (a file already in the site's Media Manager, such as one the user uploaded there: used as it is, no copy; a `static.wixstatic.com/media/<file id>` URL of a site file counts as one), `imagePath` (a file on this machine — uploaded to Wix Media) or `imageUrl`
21
21
  (their own hosted URL; verify it with `curl -sI` → 200) — never a stock-photo or guessed URL. Images resolve in parallel and never block the
22
22
  seed; a failed image leaves that event text-only.
23
23
 
@@ -14,7 +14,7 @@
14
14
  // "startDate", "endDate" (future ISO-8601 UTC), "timeZoneId",
15
15
  // "location" ({name,type:"VENUE",address} | {name,type:"ONLINE"} | {locationTbd:true,name}),
16
16
  // "ticketTiers"?: [{ "name" (≤30 chars), "price" (decimal STRING), "description"?, "initialLimit"?, "feeType"? }],
17
- // "category"? (name), "imageUrl"? | "imagePrompt"?, "rsvpResponseType"? }] }
17
+ // "category"? (name), "imageMediaId"? | "imageUrl"? | "imagePrompt"?, "rsvpResponseType"? }] }
18
18
  //
19
19
  // Seeding is ADDITIVE — never deletes or overwrites existing content. Unexpected shapes →
20
20
  // read the live API reference; every call below
@@ -260,7 +260,7 @@ export async function setupEvents(ctx, { events = [], currency } = {}) {
260
260
  catch (err) { console.error(`guest control skipped for "${ev.title}": ${err.message}`); }
261
261
  }
262
262
  await publishEvent(ctx, e.id);
263
- created.push({ ...e, category: ev.category, imageUrl: ev.imageUrl, imagePrompt: ev.imagePrompt, ticketCount: tiers.length, feeTypes: tiers.map((t) => t.feeType), ...(ev.guests ? { guestControl } : {}) });
263
+ created.push({ ...e, category: ev.category, imageMediaId: ev.imageMediaId, imageUrl: ev.imageUrl, imagePrompt: ev.imagePrompt, ticketCount: tiers.length, feeTypes: tiers.map((t) => t.feeType), ...(ev.guests ? { guestControl } : {}) });
264
264
  }
265
265
 
266
266
  const names = [...new Set(created.map((e) => e.category).filter(Boolean))];
@@ -273,6 +273,7 @@ export async function setupEvents(ctx, { events = [], currency } = {}) {
273
273
  // Pass 2 — images: resolve (import by url / generate by prompt) in one parallel wave, then
274
274
  // attach. Failures leave the event text-only; the seed's exit never depends on images.
275
275
  const files = await resolveItemImages(ctx, created.map((e) => ({
276
+ mediaId: e.imageMediaId,
276
277
  url: e.imageUrl,
277
278
  path: e.imagePath,
278
279
  prompt: e.imagePrompt,
@@ -53,8 +53,10 @@ rows (Role, Year, Client…).
53
53
  - Every image field's default is a prompt (`coverImagePrompt` / `items[].imagePrompt` —
54
54
  AI-generated, ~1 Wix AI credit per image, account-billed): brand-contextual — subject,
55
55
  aesthetic/mood, palette, lighting — always ending "no text, no watermarks". At least one image in the set shows the real subject of the business — the actual product/space/service, not abstract decoration. For an asset the user actually supplied
56
- use a path (`coverImagePath` / `items[].imagePath` — a file on this machine, uploaded to
57
- Wix Media) or a url (`coverImageUrl` / `items[].imageUrl` — their own hosted URL; verify it
56
+ use a Media Manager file (`coverImageMediaId` / `items[].imageMediaId` — a file already in the
57
+ site's Media Manager, such as one the user uploaded there: used as it is, no copy; a
58
+ `static.wixstatic.com/media/<file id>` URL of a site file counts as one), a path
59
+ (`coverImagePath` / `items[].imagePath` — a file on this machine, uploaded to Wix Media) or a url (`coverImageUrl` / `items[].imageUrl` — their own hosted URL; verify it
58
60
  with `curl -sI` → 200) — never a stock-photo or guessed URL. All images —
59
61
  covers and gallery items alike — resolve in one parallel wave and never block the seed; a
60
62
  failed image skips just that item/cover. The **cover** is the
@@ -9,12 +9,12 @@
9
9
  // project's gallery items, and imports+attaches cover images. Prints a JSON result to stdout.
10
10
  //
11
11
  // Plan shape (see SEED.md):
12
- // { "collections": [{ "title", "description"?, "hidden"?, "coverImageUrl"? | "coverImagePrompt"? }],
12
+ // { "collections": [{ "title", "description"?, "hidden"?, "coverImageMediaId"? | "coverImageUrl"? | "coverImagePrompt"? }],
13
13
  // "projects": [{ "title", "description"?, "hidden"?,
14
14
  // "collection"? (title), // resolved to that collection's id
15
15
  // "details"?: [{ "label", "text" }],
16
- // "coverImageUrl"? | "coverImagePrompt"?,
17
- // "items"?: [{ "sortOrder", "title"?, "imageUrl" | "imagePrompt" }] }] }
16
+ // "coverImageMediaId"? | "coverImageUrl"? | "coverImagePrompt"?,
17
+ // "items"?: [{ "sortOrder", "title"?, "imageMediaId" | "imageUrl" | "imagePrompt" }] }] }
18
18
  //
19
19
  // Seeding is ADDITIVE — never deletes or overwrites existing content. A fresh Portfolio
20
20
  // install ships its own sample content ("My Portfolio" + sample projects); removing it is the
@@ -186,13 +186,13 @@ export async function setupPortfolio(ctx, { collections = [], projects = [] } =
186
186
  projects.forEach((p, pi) => {
187
187
  for (const it of p.items ?? []) {
188
188
  galleryRefs.push({ pi, it, spec: specs.length });
189
- specs.push({ path: it.imagePath, url: it.imageUrl, prompt: it.imagePrompt, displayName: `${it.title || "item"}.png` });
189
+ specs.push({ mediaId: it.imageMediaId, path: it.imagePath, url: it.imageUrl, prompt: it.imagePrompt, displayName: `${it.title || "item"}.png` });
190
190
  }
191
191
  });
192
192
  const projCoverAt = specs.length;
193
- projects.forEach((p) => specs.push({ path: p.coverImagePath, url: p.coverImageUrl, prompt: p.coverImagePrompt, displayName: `${p.title || "project"}-cover.png` }));
193
+ projects.forEach((p) => specs.push({ mediaId: p.coverImageMediaId, path: p.coverImagePath, url: p.coverImageUrl, prompt: p.coverImagePrompt, displayName: `${p.title || "project"}-cover.png` }));
194
194
  const colCoverAt = specs.length;
195
- collections.forEach((c) => specs.push({ path: c.coverImagePath, url: c.coverImageUrl, prompt: c.coverImagePrompt, displayName: `${c.title || "collection"}-cover.png` }));
195
+ collections.forEach((c) => specs.push({ mediaId: c.coverImageMediaId, path: c.coverImagePath, url: c.coverImageUrl, prompt: c.coverImagePrompt, displayName: `${c.title || "collection"}-cover.png` }));
196
196
  const files = await resolveItemImages(ctx, specs);
197
197
  const dims = { height: 1024, width: 1024 };
198
198
 
@@ -19,7 +19,7 @@ exercise the UI: one hourly and one daily rental when the business allows it, a
19
19
  approval-gated one where it fits, and an image per rental — the default is an `imagePrompt`
20
20
  (AI-generated, ~1 Wix AI credit per image, account-billed): brand-contextual — subject,
21
21
  aesthetic/mood, palette, lighting — always ending "no text, no watermarks". At least one image shows
22
- the real thing being rented. For an asset the user actually supplied use `imagePath` (a file on this
22
+ the real thing being rented. For an asset the user actually supplied use `imageMediaId` (a file already in the site's Media Manager, such as one the user uploaded there: used as it is, no copy; a `static.wixstatic.com/media/<file id>` URL of a site file counts as one), `imagePath` (a file on this
23
23
  machine — uploaded to Wix Media) or `imageUrl` (their own hosted URL; verify it with `curl -sI` →
24
24
  200) — never a stock-photo or guessed URL. Images resolve in parallel and never block the seed.
25
25
 
@@ -17,7 +17,7 @@
17
17
  // "resourceTypes": [{ "name": "Kayaks", "resources": ["Kayak 1", "Kayak 2"] }],
18
18
  // "rentals": [{ "name", "description", "tagLine"?, "unit": "HOUR"|"DAY", "rate": 25, "min"?, "max"?,
19
19
  // "resourceType": "Kayaks", "resources"?: ["Kayak 1"], "free"?, "requireManualApproval"?,
20
- // "imageUrl"? | "imagePath"? | "imagePrompt"? }] }
20
+ // "imageMediaId"? | "imageUrl"? | "imagePath"? | "imagePrompt"? }] }
21
21
  //
22
22
  // Seeding is ADDITIVE — never deletes or overwrites existing content. Unexpected shapes →
23
23
  // read the live API reference; every call below carries a docs: line with its reference page.
@@ -268,6 +268,7 @@ export async function setupRentals(ctx, { resourceTypes = [], rentals = [], curr
268
268
  // Pass 2 — images: resolve (import by url / generate by prompt) in one parallel wave, then attach.
269
269
  // Failures leave the rental text-only; the seed's exit never depends on images.
270
270
  const files = await resolveItemImages(ctx, created.map((c, i) => ({
271
+ mediaId: rentals[i]?.imageMediaId,
271
272
  url: rentals[i]?.imageUrl,
272
273
  path: rentals[i]?.imagePath,
273
274
  prompt: rentals[i]?.imagePrompt,
@@ -16,7 +16,7 @@ of 2–3 items each** (the seed shows the shape; the owner adds the rest in the
16
16
  every item with an image (a menu without photos looks broken) — the default is an
17
17
  `imagePrompt` (AI-generated, ~1 Wix AI credit per image, account-billed): brand-contextual —
18
18
  subject, aesthetic/mood, palette, lighting — always ending "no text, no watermarks". At least one image in the set shows the real subject of the business — the actual product/space/service, not abstract decoration. For an asset the user actually
19
- supplied use `imagePath` (a file on this machine — uploaded to Wix Media) or `imageUrl`
19
+ supplied use `imageMediaId` (a file already in the site's Media Manager, such as one the user uploaded there: used as it is, no copy; a `static.wixstatic.com/media/<file id>` URL of a site file counts as one), `imagePath` (a file on this machine — uploaded to Wix Media) or `imageUrl`
20
20
  (their own hosted URL; verify it with `curl -sI` → 200) — never a stock-photo or guessed URL. Images resolve in parallel and never
21
21
  block the seed, a failed image leaves that item text-only — and both add-ons on for a
22
22
  restaurant that takes orders and reservations. Give at least one dish a choice (a `variants`
@@ -14,7 +14,7 @@
14
14
  // "items": [{ "name", "description"?, "price"? | "variants"?: [{ "name", "price" }],
15
15
  // "modifierGroups"?: [{ "name", "required"?, "min"?, "max"?,
16
16
  // "modifiers": [{ "name", "price"?, "preSelected"?, "inStock"? }] }],
17
- // "acceptSpecialRequests"?, "imageUrl"? | "imagePath"? | "imagePrompt"? }] }] }],
17
+ // "acceptSpecialRequests"?, "imageMediaId"? | "imageUrl"? | "imagePath"? | "imagePrompt"? }] }] }],
18
18
  // "ordering"?: true | { "address"? }, // menu-first add-on; address is STEP 0
19
19
  // "reservations"?: true | { "partySize"? { "min","max" }, "address"? } }
20
20
  //
@@ -459,12 +459,13 @@ export async function setupRestaurants(ctx, plan) {
459
459
  const flat = m.sections.flatMap((s) => s.items || []);
460
460
  flat.forEach((it, i) => {
461
461
  const created = createdMenus[mi]?.items?.[i];
462
- if ((it.imageUrl || it.imagePath || it.imagePrompt) && created?.id) {
463
- imageItems.push({ ...created, imageUrl: it.imageUrl, imagePath: it.imagePath, imagePrompt: it.imagePrompt, name: it.name });
462
+ if ((it.imageMediaId || it.imageUrl || it.imagePath || it.imagePrompt) && created?.id) {
463
+ imageItems.push({ ...created, imageMediaId: it.imageMediaId, imageUrl: it.imageUrl, imagePath: it.imagePath, imagePrompt: it.imagePrompt, name: it.name });
464
464
  }
465
465
  });
466
466
  });
467
467
  const files = await resolveItemImages(ctx, imageItems.map((it) => ({
468
+ mediaId: it.imageMediaId,
468
469
  url: it.imageUrl,
469
470
  path: it.imagePath,
470
471
  prompt: it.imagePrompt,
@@ -124,6 +124,37 @@ async function findExisting(ctx, displayName) {
124
124
  }
125
125
  }
126
126
 
127
+ /**
128
+ * The Media Manager file a spec names, when it names one: `mediaId`, or the file id at the end of a
129
+ * `static.wixstatic.com/media/<fileId>` URL. Returns { id, url, existing: true } for a file on this
130
+ * site, waiting out a short PENDING (an upload still processing), or null when the URL's file is
131
+ * not on this site (it is then imported like any external URL). An explicit `mediaId` that is not
132
+ * on the site throws, so the product is reported text-only with the reason.
133
+ */
134
+ // docs: https://dev.wix.com/docs/api-reference/assets/media/media-manager/files/get-file-descriptor.md
135
+ const WIX_MEDIA_URL = /^https?:\/\/static\.wixstatic\.com\/media\/([^/?#]+)/;
136
+ async function existingMediaFile(ctx, s) {
137
+ const id = s.mediaId ?? s.url?.match(WIX_MEDIA_URL)?.[1];
138
+ if (!id) return null;
139
+ for (let attempt = 0; attempt < 10; attempt++) {
140
+ const res = await fetch(`${API}/site-media/v1/files/get-file-by-id?fileId=${encodeURIComponent(id)}`, {
141
+ headers: { Authorization: `Bearer ${ctx.token}`, "wix-site-id": ctx.siteId },
142
+ signal: AbortSignal.timeout(20_000),
143
+ });
144
+ if (res.status === 404) {
145
+ if (s.mediaId) throw new Error(`no Media Manager file ${id} on this site`);
146
+ return null;
147
+ }
148
+ const json = await res.json().catch(() => ({}));
149
+ if (!res.ok) throw new Error(`GET get-file-by-id -> ${res.status}: ${JSON.stringify(json).slice(0, 200)}`);
150
+ const f = json.file;
151
+ if (f?.operationStatus === "FAILED") throw new Error(`Media Manager file ${id} failed processing`);
152
+ if (f?.url && f.operationStatus !== "PENDING") return { id: f.id, url: f.url, existing: true };
153
+ await new Promise((r) => setTimeout(r, 3000));
154
+ }
155
+ throw new Error(`Media Manager file ${id} still processing`);
156
+ }
157
+
127
158
  /** Short stable fingerprint of an image's source (url, prompt + size, or file bytes). */
128
159
  function sourceHash(s) {
129
160
  const h = createHash("sha1");
@@ -149,12 +180,17 @@ export async function importImage(ctx, url, displayName = "image.png") {
149
180
 
150
181
  /**
151
182
  * THE seed entry point. Resolves a batch of image specs to Wix Media files in ONE parallel
152
- * wave. Each spec: { path } (LOCAL file — the user's own asset, uploaded) OR { url }
153
- * (verified external URL — imported) OR { prompt } (generated, ~1 credit) — plus optional
154
- * displayName, width, height. Returns an array aligned with the input: { id, url } per
155
- * success, null per failure or empty spec. Never throws.
183
+ * wave. Each spec: { mediaId } (a file already in this site's Media Manager — used as it is,
184
+ * as is a static.wixstatic.com/media URL of one) OR { path } (LOCAL file — the user's own asset,
185
+ * uploaded) OR { url } (verified external URL — imported) OR { prompt } (generated, ~1 credit) —
186
+ * plus optional
187
+ * displayName, width, height. Returns an array aligned with the input: { file, error } per
188
+ * spec — `file` is { id, url } on success and null otherwise; `error` is the reason it is null
189
+ * (the thrown message, "empty spec", or "timed out"). Never throws. Every failure is also
190
+ * printed to stderr, so a seed log names the image and the reason even when the seed's
191
+ * own report does not.
156
192
  */
157
- export async function resolveItemImages(ctx, specs, { perImageBudgetMs = 120_000 } = {}) {
193
+ export async function resolveItemImagesDetailed(ctx, specs, { perImageBudgetMs = 120_000 } = {}) {
158
194
  // unref: the budget timer must never keep the seed process alive after the work is done —
159
195
  // a lingering timer delays the seed's exit (and the run's .seed-exit marker) by the budget.
160
196
  const deadline = new Promise((r) => {
@@ -163,8 +199,11 @@ export async function resolveItemImages(ctx, specs, { perImageBudgetMs = 120_000
163
199
  });
164
200
  const results = await Promise.allSettled(
165
201
  (specs ?? []).map(async (s) => {
166
- if (!s || (!s.path && !s.url && !s.prompt)) return null;
202
+ if (!s || (!s.path && !s.url && !s.prompt && !s.mediaId)) return null;
167
203
  const resolve = (async () => {
204
+ // A file already in this site's Media Manager is used as it is: no import, no copy.
205
+ const inMedia = await existingMediaFile(ctx, s);
206
+ if (inMedia) return inMedia;
168
207
  const name = stableName(s);
169
208
  // uploadImage swaps in the file's own extension; look for what it will actually be named
170
209
  const uploadedName = s.path ? name.replace(/\.[a-z0-9]+$/i, "") + extname(s.path).toLowerCase() : name;
@@ -179,5 +218,22 @@ export async function resolveItemImages(ctx, specs, { perImageBudgetMs = 120_000
179
218
  return Promise.race([resolve, deadline]);
180
219
  }),
181
220
  );
182
- return results.map((r) => (r.status === "fulfilled" ? r.value : null));
221
+ return results.map((r, i) => {
222
+ const spec = specs?.[i] ?? {};
223
+ const label = spec.displayName ?? spec.mediaId ?? spec.path ?? spec.url ?? (spec.prompt ? "(prompt)" : "(empty)");
224
+ if (r.status === "fulfilled") {
225
+ if (r.value) return { file: r.value, error: null };
226
+ const error = !spec.path && !spec.url && !spec.prompt && !spec.mediaId ? "empty spec" : `timed out after ${perImageBudgetMs} ms`;
227
+ if (error !== "empty spec") console.error(`image ${label}: ${error}`);
228
+ return { file: null, error };
229
+ }
230
+ const error = r.reason?.message ?? String(r.reason);
231
+ console.error(`image ${label}: ${error.slice(0, 200)}`);
232
+ return { file: null, error };
233
+ });
234
+ }
235
+
236
+ /** The plain array of { id, url } | null the seeds consume today; failures are still printed. */
237
+ export async function resolveItemImages(ctx, specs, opts) {
238
+ return (await resolveItemImagesDetailed(ctx, specs, opts)).map((r) => r.file);
183
239
  }
@@ -54,8 +54,8 @@ to a file if you want it later). `.seed-exit` and `seed-result.json` are written
54
54
  `type: "color"` renders as real swatches (give every color choice a `colorCode`); anything
55
55
  else renders as text pills. Variants are expanded automatically (full cross-product, each
56
56
  carrying the product's price/compareAtPrice/quantity) — keep option counts small.
57
- A choice may carry its own photo — `imageUrl` | `imagePath` | `imagePrompt` (+ `altText`), the
58
- same three forms as a product image: it joins the product's gallery and the product page shows
57
+ A choice may carry its own photo — `imageMediaId` | `imageUrl` | `imagePath` | `imagePrompt`
58
+ (+ `altText`), the same forms as a product image: it joins the product's gallery and the product page shows
59
59
  it when that choice is picked (a colour's photo). Give it when the source has one per colour.
60
60
  - `variantPrices` — `{ "<choice name>": price }` when one option's choices are priced differently
61
61
  ("L": 74): every variant carrying that choice takes that price (the first priced choice wins);
@@ -76,9 +76,14 @@ to a file if you want it later). `.seed-exit` and `seed-result.json` are written
76
76
  - **Give every product an image** (a store without product images looks broken: gray boxes on
77
77
  tiles, PDP, and cart) — the default is an `imagePrompt` (AI-generated, ~1 Wix AI credit
78
78
  per image, account-billed): brand-contextual — subject, aesthetic/mood, palette, lighting —
79
- always ending "no text, no watermarks". At least one image in the set shows the real subject of the business — the actual product/space/service, not abstract decoration. For an asset the user actually supplied use `imagePath` (a file on
80
- this machine — uploaded to Wix Media) or `imageUrl` (their own hosted URL; verify it with
81
- `curl -sI` → 200) — never a stock-photo or guessed URL. Images resolve in parallel and never block the seed; a failed image leaves
79
+ always ending "no text, no watermarks". At least one image in the set shows the real subject of the business — the actual product/space/service, not abstract decoration. For an asset the user actually supplied use `imageMediaId` (a file
80
+ already in the site's Media Manager, such as one the user uploaded there: the product uses that
81
+ file as it is, no copy), `imagePath` (a file on this machine — uploaded to Wix Media) or
82
+ `imageUrl` (their own hosted URL; verify it with `curl -sI` → 200) — never a stock-photo or
83
+ guessed URL. A `static.wixstatic.com/media/<file id>` URL of a file on this site counts as its
84
+ `imageMediaId`. List the site's files to find an id: the
85
+ [Media Manager](https://dev.wix.com/docs/api-reference/assets/media/media-manager/files/list-files.md)
86
+ names each by the name it was uploaded under. Images resolve in parallel and never block the seed; a failed image leaves
82
87
  that product text-only. Seed text-only only when the user explicitly asks.
83
88
  - `digitalFilePath` (a file on this machine) or `digitalFileUrl` — makes the product a digital
84
89
  download, uploaded and created with both the file and stock (`quantity` is ignored). It's also the
@@ -95,6 +100,13 @@ to a file if you want it later). `.seed-exit` and `seed-result.json` are written
95
100
  - `quantity` — tracked stock, a non-negative integer. For stock that isn't counted (made to
96
101
  order, print on demand, unlimited) use `"inStock": true` **instead** of `quantity`; sending
97
102
  both is rejected.
103
+ Right after the bulk create, a product's own stock summary can still read `OUT_OF_STOCK`
104
+ while its inventory items are already stocked; the items are what a buyer's add is checked
105
+ against. A product **without options** can still end up with no item (a create that ran while
106
+ the catalog was provisioning): the script stocks those afterwards with one Bulk Create
107
+ Inventory Items call (`productId`, its single `variantId`, and `inStock: true` or the
108
+ `quantity`), where `ALREADY_EXISTS` means the item is already there. A seed made call by call
109
+ reads the items (Query Inventory Items) and creates only the missing ones.
98
110
  - `currency` — 3-letter ISO code. Set it **only when the brief names one**: a sentence about
99
111
  currency ("prices in euros") or a price written with its unit ("9 dollars", "$9", "€20"). Do **not** infer it from a language, a country, or an address
100
112
  — an unrequested switch silently reprices the whole catalog. The seed applies it before