@wix/agent-skills 1.22.0 → 1.23.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.
@@ -45,6 +45,7 @@ import { dirname, join, resolve } from "node:path";
45
45
  import { fileURLToPath } from "node:url";
46
46
  import { templatesDir } from "./templates.mjs";
47
47
  import { syncLockRoot } from "./lock.mjs";
48
+ import { siteContext } from "./context.mjs";
48
49
 
49
50
  const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
50
51
  // The shipped code: the repository's templates/ (a checkout, the cache, or fetched now).
@@ -80,7 +81,11 @@ const CAPABILITY_DEPS = {
80
81
  "@wix/media": "^1.0.271",
81
82
  "@wix/essentials": "^1.0.10",
82
83
  },
84
+ "site-search": {
85
+ "@wix/search": "^1.0.90",
86
+ },
83
87
  };
88
+ const SITE_SEARCH = join("shared", "capabilities", "site-search");
84
89
  const VERTICAL_DEPS = {
85
90
  storefront: {
86
91
  core: {
@@ -160,6 +165,19 @@ const VERTICAL_DEPS = {
160
165
  "@wix/redirects": "^1.0.125",
161
166
  },
162
167
  },
168
+ faq: {
169
+ core: {
170
+ "@wix/faq": "^1.0.76",
171
+ },
172
+ // no astro block: the FAQ page is a plain page (no wixMetadata item routing); its JSON-LD is inline
173
+ },
174
+ donations: {
175
+ core: {
176
+ "@wix/donations": "^1.0.64",
177
+ "@wix/ecom": "^1.0.2454",
178
+ "@wix/redirects": "^1.0.125",
179
+ },
180
+ },
163
181
  };
164
182
 
165
183
  const COPY = { recursive: true, force: false, errorOnExist: false };
@@ -211,6 +229,27 @@ if (!Array.isArray(uploadPolicies)) {
211
229
  console.log(JSON.stringify({ error: "plan.capabilities.mediaUpload.policies must be an array" }));
212
230
  process.exit(1);
213
231
  }
232
+ // Site search is on when the plan carries the entry (`{}` is enough; `true` too). Its fields are
233
+ // data for the generated config; the shipped core drops unknown type names, so only shapes are checked.
234
+ const siteSearchEntry = plan.capabilities?.siteSearch;
235
+ const siteSearch = siteSearchEntry === true ? {} : siteSearchEntry && typeof siteSearchEntry === "object" ? siteSearchEntry : null;
236
+ if (siteSearchEntry != null && siteSearchEntry !== false && !siteSearch) {
237
+ console.log(JSON.stringify({ error: "plan.capabilities.siteSearch must be an object (or true)" }));
238
+ process.exit(1);
239
+ }
240
+ if (siteSearch) {
241
+ const strings = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
242
+ const map = (v) => v === undefined || (v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string"));
243
+ if ((siteSearch.types !== undefined && !strings(siteSearch.types)) || !map(siteSearch.routes) || !map(siteSearch.labels)) {
244
+ console.log(JSON.stringify({ error: "plan.capabilities.siteSearch: types must be string[]; routes and labels must be objects of strings" }));
245
+ process.exit(1);
246
+ }
247
+ }
248
+ // The generated file is data only; the shipped core resolves defaults (types, routes, labels) from it.
249
+ const siteSearchConfigSource = () =>
250
+ `// Generated by wix-headless-fast install/deploy.mjs from plan.capabilities.siteSearch.\n` +
251
+ `// Edit the plan and redeploy. Defaults for anything omitted are in search-core.ts (resolveConfig).\n` +
252
+ `export const siteSearchPlan = ${JSON.stringify(siteSearch, null, 2)};\n`;
214
253
  // ---- static stack: the REST layer, composed flat and stripped ------------------------------------
215
254
  if (stack === "static") {
216
255
  const { spawnSync } = await import("node:child_process");
@@ -218,10 +257,9 @@ if (stack === "static") {
218
257
  const OUT = outDir ? resolve(PROJECT, outDir) : PROJECT;
219
258
  const JS = join(OUT, "js", "wix");
220
259
  result.out = outDir ?? ".";
221
- let clientId = clientIdFlag;
222
- if (!clientId && existsSync(join(PROJECT, "wix.config.json"))) {
223
- clientId = JSON.parse(readFileSync(join(PROJECT, "wix.config.json"), "utf8")).appId ?? null;
224
- }
260
+ // The client id the browser mints visitor tokens with: --client-id, else the CONTENT app from
261
+ // .env.local (`wix env pull`; on a migration preview the parent site's app), else the config's appId.
262
+ let clientId = clientIdFlag ?? (existsSync(join(PROJECT, "wix.config.json")) ? siteContext({ cwd: PROJECT }).content.clientId : null);
225
263
  if (!clientId) {
226
264
  console.log(JSON.stringify({ error: "static stack needs the public OAuth client id — run `npm create @wix/new@latest init` here first, or pass --client-id" }));
227
265
  process.exit(1);
@@ -233,19 +271,23 @@ if (stack === "static") {
233
271
  }
234
272
  // Only files that aren't there yet — a re-run restores, never clobbers (same as the copy below).
235
273
  cpSync(join(REF, "shared", "rest"), JS, COPY);
236
- for (const vertical of requested) {
237
- cpSync(join(REF, vertical, "rest"), JS, COPY);
274
+ // Every layer with a rest/ twin: the requested verticals, plus the site-search capability when the
275
+ // plan turns it on (same shape — rest/ beside app/wix/<name>/ — at templates/shared/capabilities).
276
+ const layers = requested.map((vertical) => [vertical, join(REF, vertical)]);
277
+ if (siteSearch) layers.push(["site-search", join(REF, SITE_SEARCH)]);
278
+ for (const [vertical, root] of layers) {
279
+ cpSync(join(root, "rest"), JS, COPY);
238
280
  // The vertical's transport-agnostic core: types, every *-core.ts, and every *-store.ts under its
239
281
  // app/wix/<vertical>/ — the stores are the hooks' logic without React, over the same function
240
282
  // names the REST twin exports, so they run against it unchanged.
241
- const appWix = join(REF, vertical, "app", "wix", vertical);
283
+ const appWix = join(root, "app", "wix", vertical);
242
284
  for (const f of readdirSync(appWix)) {
243
285
  // Every vertical has a types.ts; flat in one folder they would collide, so each lands as
244
286
  // <vertical>-types.ts and the vertical's own files are pointed at it below.
245
287
  if (f === "types.ts") cpSync(join(appWix, f), join(JS, `${vertical}-types.ts`), COPY);
246
288
  else if (f.endsWith("-core.ts") || f.endsWith("-store.ts")) cpSync(join(appWix, f), join(JS, f), COPY);
247
289
  }
248
- const own = new Set([...readdirSync(join(REF, vertical, "rest")), ...readdirSync(appWix)].filter((f) => f.endsWith(".ts") && f !== "types.ts"));
290
+ const own = new Set([...readdirSync(join(root, "rest")), ...readdirSync(appWix)].filter((f) => f.endsWith(".ts") && f !== "types.ts"));
249
291
  for (const f of own) {
250
292
  const fp = join(JS, f);
251
293
  if (!existsSync(fp)) continue;
@@ -253,7 +295,12 @@ if (stack === "static") {
253
295
  const fixed = src.replace(/from\s+"\.\/types(?:\.js)?"/g, `from "./${vertical}-types.js"`);
254
296
  if (fixed !== src) writeFileSync(fp, fixed);
255
297
  }
256
- result.verticals.push(vertical);
298
+ if (VERTICALS.includes(vertical)) result.verticals.push(vertical);
299
+ }
300
+ if (siteSearch) {
301
+ // The REST twin imports the plan as data from beside it (shared/rest already owns config.ts).
302
+ writeFileSync(join(JS, "site-search-config.generated.ts"), siteSearchConfigSource());
303
+ result.capabilities = { ...result.capabilities, siteSearch: true };
257
304
  }
258
305
  // Browser ESM resolves nothing: every relative import needs its .js. The rest/ files are written
259
306
  // that way; the files borrowed from app/wix/ are bundler-style and get the suffix here.
@@ -286,7 +333,7 @@ if (stack === "static") {
286
333
  result.js = `${outDir ? outDir.replace(/\/$/, "") + "/" : ""}js/wix/*.js`;
287
334
  // The note lists what actually landed, so it holds for every vertical (and several at once).
288
335
  const stores = readdirSync(JS).filter((f) => f.endsWith("-store.js")).map((f) => `./js/wix/${f}`);
289
- const dataFiles = readdirSync(JS).filter((f) => f.endsWith(".js") && !f.endsWith("-store.js") && !f.endsWith("-core.js") && !f.endsWith("-types.js") && !["client.js", "config.js", "media.js"].includes(f)).map((f) => `./js/wix/${f}`);
336
+ const dataFiles = readdirSync(JS).filter((f) => f.endsWith(".js") && !f.endsWith("-store.js") && !f.endsWith("-core.js") && !f.endsWith("-types.js") && !f.endsWith(".generated.js") && !["client.js", "config.js", "media.js"].includes(f)).map((f) => `./js/wix/${f}`);
290
337
  result.note = `import the data layer (${dataFiles.join(", ")})${stores.length ? ` and the stores (${stores.join(", ")})` : ""} relative to ${outDir ?? "the project root"} in a <script type="module">; the .ts beside them are the same files with types, for reading; point wix.config.json site.outputDirectory at "./${outDir ?? "."}"`;
291
338
  console.log(JSON.stringify(result));
292
339
  process.exit(0);
@@ -319,11 +366,20 @@ if (uploadPolicies.length) {
319
366
  cpSync(join(REF, "shared", "capabilities", "media-upload", "app"), SRC, COPY);
320
367
  cpSync(join(REF, "shared", "capabilities", "media-upload", "app-astro"), SRC, COPY);
321
368
  }
369
+ // Site search ships like a vertical (data layer, stores, hooks, components, a search page) at a
370
+ // disjoint path (src/wix/site-search/, src/components/site-search/, …), so the same per-stack rule
371
+ // applies: lib takes only app/wix/, astro adds app-astro/.
372
+ if (siteSearch) {
373
+ if (stack === "lib") cpSync(join(REF, SITE_SEARCH, "app", "wix"), join(SRC, "wix"), COPY);
374
+ else cpSync(join(REF, SITE_SEARCH, "app"), SRC, COPY);
375
+ if (stack === "astro") cpSync(join(REF, SITE_SEARCH, "app-astro"), SRC, COPY);
376
+ }
322
377
 
323
378
  const unknown = requested.filter((v) => !VERTICALS.includes(v));
324
379
  // lib: only @wix/sdk from the shared deps — the Tailwind pair serves the components, which don't ship there.
325
380
  const wantedDeps = stack === "lib" ? { "@wix/sdk": SHARED_DEPS["@wix/sdk"] } : { ...SHARED_DEPS };
326
381
  if (uploadPolicies.length) Object.assign(wantedDeps, CAPABILITY_DEPS["media-upload"]);
382
+ if (siteSearch) Object.assign(wantedDeps, CAPABILITY_DEPS["site-search"]);
327
383
  for (const vertical of requested.filter((v) => VERTICALS.includes(v))) {
328
384
  if (stack === "lib") cpSync(join(REF, vertical, "app", "wix"), join(SRC, "wix"), COPY);
329
385
  else cpSync(join(REF, vertical, "app"), SRC, COPY);
@@ -351,6 +407,15 @@ if (uploadPolicies.length) {
351
407
  );
352
408
  result.capabilities = { mediaUpload: uploadPolicies.map((policy) => policy.id) };
353
409
  }
410
+ if (siteSearch) {
411
+ // The SDK transport imports the plan as data from beside it; defaults resolve in the shipped core.
412
+ writeFileSync(join(SRC, "wix", "site-search", "config.generated.ts"), siteSearchConfigSource());
413
+ result.capabilities = { ...result.capabilities, siteSearch: true };
414
+ if (siteSearch.install) {
415
+ // Indexing needs the Wix Site Search app on the site — a site mutation the deploy never makes.
416
+ 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("; ");
417
+ }
418
+ }
354
419
 
355
420
  // ---- dependency patch (fill-only) ----------------------------------------------------------------
356
421
  if (result.verticals.length && existsSync(PKG_JSON)) {
@@ -416,12 +481,15 @@ if (stack === "astro" && result.verticals.length && existsSync(ASTRO_CONFIG)) {
416
481
 
417
482
  // ---- ready-made links -----------------------------------------------------------------------------
418
483
  // Emit the siteId and the dashboard deep links so nothing downstream re-derives or retypes them.
484
+ // The site the links point at is the CONTENT site — the parent on a migration preview, where the
485
+ // config's site only hosts the deployment (install/context.mjs).
419
486
  const WIX_CONFIG = join(PROJECT, "wix.config.json");
420
- if (existsSync(WIX_CONFIG)) {
421
- const config = JSON.parse(readFileSync(WIX_CONFIG, "utf8"));
422
- const siteId = config.siteId ?? config.projectId;
487
+ const ctx = existsSync(WIX_CONFIG) ? siteContext({ cwd: PROJECT }) : null;
488
+ if (ctx) {
489
+ const siteId = ctx.content.siteId;
423
490
  if (siteId) {
424
491
  result.siteId = siteId;
492
+ if (ctx.migration.active) result.migration = { parentSiteId: ctx.migration.parentSiteId, deploySiteId: ctx.deploy.siteId };
425
493
  result.dashboardUrl = `https://manage.wix.com/dashboard/${siteId}`;
426
494
  if (result.verticals.includes("storefront")) {
427
495
  result.productsUrl = `https://manage.wix.com/dashboard/${siteId}/wix-stores/products`;
@@ -440,13 +508,10 @@ if (!requested.length) {
440
508
  // ---- client ids ---------------------------------------------------------------------------------
441
509
  // The shared data client is ambient on managed Astro. Members are different: their shipped
442
510
  // custom credential flow always needs an explicit public OAuth client, including on Astro.
443
- let clientId = clientIdFlag;
444
- if (!clientId && existsSync(join(PROJECT, "wix.config.json"))) {
445
- // On a Wix-managed project the public OAuth client id IS the appId.
446
- clientId =
447
- JSON.parse(readFileSync(join(PROJECT, "wix.config.json"), "utf8")).appId ??
448
- null;
449
- }
511
+ // --client-id, else the CONTENT app: .env.local's WIX_CLIENT_ID (what `wix env pull` writes — on a
512
+ // migration preview the parent site's app, the one the Astro integration itself runs as), else the
513
+ // config's appId. Never the config first: on a preview that is the deploy-only child app.
514
+ const clientId = clientIdFlag ?? ctx?.content.clientId ?? null;
450
515
  if (stack === "react" || stack === "lib") {
451
516
  if (clientId) {
452
517
  const current = readFileSync(CONFIG_TS, "utf8");
@@ -7,6 +7,11 @@
7
7
  //
8
8
  // - `wix.config.json` present → refuses. The folder is already a Wix project; deploy.mjs adds
9
9
  // this skill's code or a solution to it, then one install, then the seed if there is content.
10
+ // One exception: a config and NO project whose `.env.local` (`wix env pull`, run here) declares a
11
+ // MIGRATION PREVIEW — the config names a site created only to host the deployment, the env names
12
+ // the site being migrated. Then → MIGRATE: the composed template is copied in around the config,
13
+ // the code deploys with the parent's app as its client, the install starts, and NO seed runs: the
14
+ // parent owns its content. Release still goes where the config says (install/context.mjs).
10
15
  // - a project but no `wix.config.json` (a package.json, or an index.html at the root) → CONNECT:
11
16
  // `npm create @wix/new@latest init` in place creates the site and the config, then deploy.
12
17
  // - otherwise (empty, or loose files such as a CSV or a brief) → CREATE: `wix create` with the
@@ -23,11 +28,12 @@
23
28
  // steps — the dependency install and the seed — running detached in the background (logs and
24
29
  // completion markers in the final event), so the caller can build the brand layer while they finish.
25
30
  import { spawn, spawnSync } from "node:child_process";
26
- import { existsSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
31
+ import { cpSync, existsSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
27
32
  import { basename, dirname, join, resolve } from "node:path";
28
33
  import { fileURLToPath } from "node:url";
29
34
  import { AGENT_CONFIG_FILES, writeAgentsMd } from "./agents-md.mjs";
30
35
  import { listVerticals, templatesDir } from "./templates.mjs";
36
+ import { siteContext } from "./context.mjs";
31
37
 
32
38
  const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
33
39
 
@@ -67,17 +73,23 @@ if (planPath && !existsSync(planPath)) fail("args", `plan file not found: ${plan
67
73
  // ---- 0 · read the folder --------------------------------------------------------------------------
68
74
  const cwd = process.cwd();
69
75
  const has = (p) => existsSync(join(cwd, p));
70
- if (has("wix.config.json")) {
71
- fail("place", "this folder is already a Wix project (wix.config.json): nothing to set up. deploy.mjs <vertical> adds this skill's code or a solution to it; then ONE npm install; then the vertical's seed module if there is content to create");
72
- }
73
76
  const pkg = has("package.json") ? JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) : null;
74
77
  const hasProject = !!pkg || has("index.html");
75
- const mode = hasProject ? "connect" : "create";
78
+ // A config here: refuse, unless it is a migration preview with no project yet (the site context
79
+ // pulls .env.local when missing — that is where the migration is declared).
80
+ let ctx = null;
81
+ if (has("wix.config.json")) {
82
+ ctx = siteContext({ cwd });
83
+ if (hasProject || !ctx.migration.active) {
84
+ fail("place", "this folder is already a Wix project (wix.config.json): nothing to set up. deploy.mjs <vertical> adds this skill's code or a solution to it; then ONE npm install; then the vertical's seed module if there is content to create. A config with no project is attach.mjs's case (SKILL.md step 3)" + (ctx.pullError ? ` — note: env pull failed here (${ctx.pullError.slice(0, 120)})` : ""));
85
+ }
86
+ }
87
+ const mode = ctx ? "migrate" : hasProject ? "connect" : "create";
76
88
  if (mode === "connect" && !stackFlag) {
77
89
  fail("args", "connecting a project on disk needs --stack astro|react|lib|static — the stack you resolved in SKILL.md step 1 for this project");
78
90
  }
79
91
  const stack = stackFlag ?? "astro";
80
- emit("folder", { mode, stack, project: hasProject ? (pkg?.name ?? basename(cwd)) : null });
92
+ emit("folder", { mode, stack, project: hasProject ? (pkg?.name ?? basename(cwd)) : null, ...(ctx ? { migration: ctx.migration, deploySiteId: ctx.deploy.siteId, warnings: ctx.warnings } : {}) });
81
93
 
82
94
  let projectDir = cwd;
83
95
  let folderName = null;
@@ -119,6 +131,25 @@ if (mode === "create") {
119
131
  fail("scaffold", (scaffold.stderr || scaffold.stdout || "scaffold produced no wix.config.json — is the Wix CLI logged in? (npx @wix/cli@latest whoami)").slice(-600));
120
132
  }
121
133
  }
134
+ } else if (mode === "migrate") {
135
+ // ---- 1 · a migration preview: the config and credentials came with the folder -------------------
136
+ // No site is created and nothing is provisioned: `wix.config.json` already names the deploy site
137
+ // and its app, `.env.local` the parent's. Managed Astro gets the vertical's composed template copied
138
+ // in around them (what `wix create` would have copied; the config and env are never overwritten);
139
+ // any other stack is code-only and deploy below adds it.
140
+ if (planPath) emit("seed_skipped", { reason: `migration preview of site ${ctx.migration.parentSiteId}: the site owns its content and nothing is seeded — the plan is ignored` });
141
+ if (stack === "astro") {
142
+ const template = join(templatesDir({ need: `${vertical}/project` }), vertical, "project");
143
+ emit("scaffolding", { folder: cwd, template, from: "migration preview" });
144
+ cpSync(template, cwd, { recursive: true, force: false, errorOnExist: false });
145
+ const pkgPath = join(cwd, "package.json");
146
+ const scaffolded = JSON.parse(readFileSync(pkgPath, "utf8"));
147
+ scaffolded.name = basename(cwd).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "site";
148
+ writeFileSync(pkgPath, JSON.stringify(scaffolded, null, 2) + "\n");
149
+ }
150
+ const gi = join(cwd, ".gitignore");
151
+ const cur = existsSync(gi) ? readFileSync(gi, "utf8") : "";
152
+ if (!/\.env/.test(cur)) writeFileSync(gi, cur + "\n# local env (pulled from Wix)\n.env.local\n.env\n");
122
153
  } else {
123
154
  // ---- 1 · init in place --------------------------------------------------------------------------
124
155
  // The CLI's `init` links the folder to a NEW site: creates the site and its OAuth app, writes
@@ -141,7 +172,10 @@ if (stack === "static") {
141
172
  wixConfig.site = { ...(wixConfig.site ?? {}), outputDirectory: `./${STATIC_OUT}` };
142
173
  writeFileSync(join(projectDir, "wix.config.json"), JSON.stringify(wixConfig, null, 2) + "\n");
143
174
  }
144
- emit(mode === "create" ? "scaffolded" : "connected", { folder: folderName ?? cwd, siteId, stack });
175
+ emit(mode === "create" ? "scaffolded" : mode === "migrate" ? "migration_preview" : "connected", {
176
+ folder: folderName ?? cwd, stack,
177
+ ...(ctx ? { deploySiteId: ctx.deploy.siteId, contentSiteId: ctx.content.siteId } : { siteId }),
178
+ });
145
179
 
146
180
  // ---- 2 · deploy shipped code + deps ---------------------------------------------------------------
147
181
  // A CREATE from the composed template already holds the code and the lock: deploy adds nothing and
@@ -189,7 +223,7 @@ if (mode === "create" && !subfolder && projectDir !== cwd) {
189
223
  // ---- 2d · the agent config files `wix create` would have written --------------------------------
190
224
  // Skipped by the CLI because of --skip-install. Fill-only: a project that has its own AGENTS.md
191
225
  // keeps it (the event says `kept`).
192
- emit("agent_configs", writeAgentsMd(projectDir, { skill: basename(SKILL_ROOT), stack }));
226
+ emit("agent_configs", writeAgentsMd(projectDir, { skill: basename(SKILL_ROOT), stack, ...(ctx ? { migration: { parentSiteId: ctx.migration.parentSiteId, deploySiteId: ctx.deploy.siteId } } : {}) }));
193
227
 
194
228
  // ---- 3 · start the dependency install, detached --------------------------------------------------
195
229
  // ONE install, here, for any project with a package.json (deploy patched it). The static stack has
@@ -215,7 +249,7 @@ if (stack !== "static" && existsSync(join(projectDir, "package.json"))) {
215
249
  // result JSON + exit-code marker land as files the caller syncs on before release. Seeding is
216
250
  // additive: it never deletes or overwrites what the site holds.
217
251
  let seed = null;
218
- if (planPath) {
252
+ if (planPath && mode !== "migrate") {
219
253
  const seedDir = join(TEMPLATES, vertical, "seed");
220
254
  const seedName = existsSync(seedDir)
221
255
  ? readdirSync(seedDir).find((f) => f.startsWith("seed-") && f.endsWith(".mjs"))
@@ -240,20 +274,26 @@ const release = {
240
274
  lib: "the project's own build, then npx @wix/cli@latest release of the build folder named in wix.config.json (SKILL.md step 1)",
241
275
  static: `npx @wix/cli@latest release — no build; it uploads ${STATIC_OUT}/ whole (wix.config.json site.outputDirectory), so the pages, styles and assets move into ${STATIC_OUT}/ first and import the modules from ./js/wix/ there; the root keeps the config, the plan, the seed output and the skills`,
242
276
  }[stack];
277
+ // On a migration preview the links and `siteId` are the PARENT's — the site whose content the pages
278
+ // show and whose dashboard manages it; `deploySiteId` is where `wix release` goes.
243
279
  emit("ready_for_brand_layer", {
244
280
  mode,
245
281
  stack,
246
282
  projectDir,
247
- siteId,
283
+ siteId: ctx ? ctx.content.siteId : siteId,
284
+ ...(ctx ? { deploySiteId: ctx.deploy.siteId, migration: { parentSiteId: ctx.migration.parentSiteId } } : {}),
248
285
  dashboardUrl: deployResult.dashboardUrl,
249
286
  productsUrl: deployResult.productsUrl,
250
287
  categoriesUrl: deployResult.categoriesUrl,
251
288
  install,
252
289
  seed,
253
290
  next:
254
- (planPath
291
+ (mode === "migrate"
292
+ ? "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; "
293
+ : planPath
255
294
  ? "theme + write the home page; "
256
295
  : "the site is new and empty — seed it (a plan per step 2, the vertical's seed module) or say so; theme + write the home page; ") +
257
296
  (install || seed ? "then wait for the done markers" + (seed ? ", verify .seed-exit is 0 (else read seed.log and re-run the seed module)" : "") + "; " : "") +
258
- `then ${release}`,
297
+ `then ${release}` +
298
+ (mode === "migrate" ? "; the release is the PREVIEW on the deploy site — close with its URL and the parent's dashboard, say the original site is unchanged and that completing the migration is the user's next step in the Wix CLI once they approve" : ""),
259
299
  });
@@ -163,7 +163,7 @@ Create and modify CMS collection structures. Covers listing collections, creatin
163
163
  Deletes multiple contacts using filter-based bulk delete. Covers safe deletion patterns, GDPR compliance, soft delete alternatives, and batch processing strategies.
164
164
 
165
165
  ### [Bulk Label and Unlabel Contacts](references/contacts/bulk-label-and-unlabel-contacts.md)
166
- Adds/removes labels from multiple contacts using Contacts API bulk operations. Covers label creation, contact filtering, batch processing, and rate limit handling.
166
+ Creates contact label definitions or adds/removes labels from matching contacts. Use Find or Create Label for label creation alone; use bulk labeling only when the user requests contact assignments.
167
167
 
168
168
  ### [Create a Contact](references/contacts/create-a-contact.md)
169
169
  Creates a contact with the Contacts API. Covers the minimum identifying fields, the single-object shape of `email` and `phone`, and adding a physical address with the ISO 3166-2 subdivision format required for state, region, and province codes.
@@ -211,7 +211,7 @@ Buy a domain through Wix or connect one the user already owns — intent, availa
211
211
  **Entry point for all eCommerce recommendation requests.** Unified skill that analyzes site data across ALL domains (discounts + shipping + gift cards), generates up to 5 cross-domain recommendations, and persists them to the tracking database. Covers discount strategies (seasonal, upsell, stock mover, bundling), shipping optimization (coverage gaps, free shipping, rate strategy, carrier backup), AND selling gift cards (denominations sized from the site's own AOV and catalog prices). Use this for business improvement requests about earning more from existing visitors. **Traffic acquisition (SEO, ads, social, content) is out of scope** — route "grow my traffic" to marketing.
212
212
 
213
213
  ### [Pricing & Promotions](references/ecommerce/ecom-pricing.md)
214
- **Dispatcher** — routes coupon/discount/sale/ribbon/bundle requests to the right leaf recipe (create coupon, create discount rule, troubleshoot discount-not-applying), and routes strategic "run a sale / boost sales" requests to `recommend-ecommerce-strategy`.
214
+ Routes discounts, coupons, sales and bundles to promotion recipes, and visual product ribbons to the Catalog Ribbons API. Load this dispatcher for mixed pricing/refund/payment/product-price/shipping requests to choose the appropriate APIs.
215
215
 
216
216
  ### [Shipping](references/ecommerce/ecom-shipping.md)
217
217
  **Dispatcher** — routes shipping-setup requests (rates, regions, pickup, free shipping, fix coverage, optimize rates) to the right leaf recipe. The Shipping Options + Delivery Profiles APIs have no public docs page; `ecom-shipping-api.md` is the authoritative inline reference.
@@ -422,7 +422,7 @@ List, count, and find the sites in a Wix account. Covers the namespace filter fo
422
422
  Probe a Wix site or account for full context in one call — installed apps by display name, locale, currency, timezone, and status. Account token + siteId targets one site; account token alone returns up to 10; site-scoped token alone returns the site it is scoped to.
423
423
 
424
424
  ### [Site Import](references/sites/site-import.md)
425
- Drive the Wix Site Import agent to migrate an existing store or site from another platform (Shopify, WooCommerce, Magento, or any URL) into Wix, or to import from CSV/TSV export files with no source site. Use this skill whenever the user wants to import, migrate, or clone a store/site into Wix, mentions moving off Shopify/WooCommerce/Magento, or gives a source store URL and asks to bring it into Wix. Covers starting the import, polling progress, answering the agent's mid-import questions, handling deploy/failure/auth-expiry states, and sending post-deploy follow-up changes.
425
+ Drive the Wix Site Import agent to migrate an existing store or site from another platform (Shopify, WooCommerce, Magento, or any URL) into Wix — as a brand-new site or into the user's existing one — or to import from CSV/TSV export files with no source site. Use this skill whenever the user wants to import, migrate, or clone a store/site into Wix, mentions moving off Shopify/WooCommerce/Magento, or gives a source store URL and asks to bring it into Wix. Covers starting the import, polling progress, answering the agent's mid-import questions, handling deploy/failure/auth-expiry states, and sending post-deploy follow-up changes.
426
426
 
427
427
  ### [Sites Dashboard Navigation](references/sites/sites-dashboard-navigation.md)
428
428
  "Builds direct links to the account-level sites pages on manage.wix.com — the My Sites list (all sites in the account) and each site's own dashboard. Pairs the site list with the Query Sites read API. Use when the user asks where something is in the Wix dashboard, wants a direct link to a dashboard page, or you need a dashboard URL to include with the result of an API operation."
@@ -107,15 +107,81 @@ Some common apps:
107
107
  - NEVER guess the `appDefId`. For Wix-built apps, use the table above. For any other app, resolve the ID using Step 0 (Search Market Listings).
108
108
  - The `tenantType` MUST be `SITE`
109
109
  - The `id` in tenant is the site's metaSiteId
110
+ - The endpoint, request body, and appDefId table above are complete and canonical for a listed Wix-built app — don't re-verify them with a separate REST/API doc search first; that's a redundant round-trip.
111
+ - Don't spend a call checking whether the app is already installed before installing it. If the task or the error you're handling already tells you it isn't installed (e.g. a fresh site, or a `*_NOT_INSTALLED` error), just call install directly.
110
112
 
111
113
  ---
112
114
 
113
115
  ## Error Handling
114
116
 
115
117
  ### App Not Installed Error
116
- If you receive an error indicating a required app is not installed, use this recipe to install it before proceeding.
118
+ If you receive an error indicating a required app is not installed (e.g. a `428` or a `*_NOT_INSTALLED` error code), install the app it names using its appDefId, then retry the call that failed. If the user's own request already implies this app is needed — i.e. doing what they asked requires a feature only that app provides — just install it as part of doing that task; don't stop to ask permission or name the app first. Confirm first only when the app isn't implied by anything the user asked for.
119
+
120
+ **Example** (install the missing app, then retry the call that surfaced the error — all in one script):
121
+ ```javascript
122
+ async function() {
123
+ const siteId = "<SITE_ID>";
124
+
125
+ await wix.request({
126
+ method: "POST",
127
+ url: "https://www.wixapis.com/apps-installer-service/v1/app-instance/install",
128
+ body: {
129
+ tenant: { tenantType: "SITE", id: siteId },
130
+ appInstance: { appDefId: "<APP_DEF_ID>" }
131
+ }
132
+ });
133
+
134
+ return await wix.request({
135
+ method: "<METHOD>",
136
+ url: "<ORIGINAL_URL_THAT_FAILED>",
137
+ body: { /* original request body */ }
138
+ });
139
+ }
140
+ ```
141
+
142
+ ### App-Dependent Call Fails Right After Install (Propagation Delay)
143
+ Installing an app and then immediately calling one of its own APIs — e.g. calling Set Multilingual Mode right after installing Wix Multilingual — can race the platform's install propagation, surfacing as a not-found error on the dependent call even though the install itself already succeeded.
144
+
145
+ **Example** (retry with backoff past the propagation delay instead of surfacing the error, all in one script):
146
+ ```javascript
147
+ async function() {
148
+ const siteId = "<SITE_ID>";
149
+
150
+ async function requestWithRetry(requestOptions, delaysMs = [1000, 2000, 4000]) {
151
+ let lastError;
152
+ try {
153
+ return await wix.request(requestOptions);
154
+ } catch (err) {
155
+ lastError = err;
156
+ }
157
+ for (const delayMs of delaysMs) {
158
+ await new Promise(resolve => setTimeout(resolve, delayMs));
159
+ try {
160
+ return await wix.request(requestOptions);
161
+ } catch (err) {
162
+ lastError = err;
163
+ }
164
+ }
165
+ throw lastError;
166
+ }
117
167
 
118
- If Locale Settings or Locales APIs return `428 MULTILINGUAL_NOT_INSTALLED`, install **Wix Multilingual** using appDefId `14d84998-ae09-1abf-c6fc-3f3cace5bf19`, then retry enabling multilingual mode or creating locales. Confirm with the user before installing unless they already explicitly asked you to install Wix Multilingual.
168
+ await wix.request({
169
+ method: "POST",
170
+ url: "https://www.wixapis.com/apps-installer-service/v1/app-instance/install",
171
+ body: {
172
+ tenant: { tenantType: "SITE", id: siteId },
173
+ appInstance: { appDefId: "14d84998-ae09-1abf-c6fc-3f3cace5bf19" }
174
+ }
175
+ });
176
+
177
+ return await requestWithRetry({
178
+ method: "POST",
179
+ url: "https://www.wixapis.com/locale-settings/v2/settings/mode",
180
+ body: { multilingualModeEnabled: true }
181
+ });
182
+ }
183
+ ```
184
+ Chain any further calls inside this same function through the same `requestWithRetry` helper, after the first one succeeds.
119
185
 
120
186
  ---
121
187
 
@@ -1,12 +1,29 @@
1
1
  ---
2
2
  name: "Bulk Label and Unlabel Contacts"
3
- description: Adds/removes labels from multiple contacts using Contacts API bulk operations. Covers label creation, contact filtering, batch processing, and rate limit handling.
3
+ description: Creates contact label definitions or adds/removes labels from matching contacts. Use Find or Create Label for label creation alone; use bulk labeling only when the user requests contact assignments.
4
4
  ---
5
5
  # Bulk Label And Unlabel Contacts
6
6
 
7
7
  ## Description
8
8
  Adds and removes labels from multiple contacts using the Wix Contacts REST API.
9
9
 
10
+ ## Create labels or assign labels?
11
+
12
+ If the user asks to **create a label** (for example, "Create VIP, Wholesale, and Newsletter labels"), use [Find or Create Label](https://dev.wix.com/docs/api-reference/crm/members-contacts/contacts/labels/find-or-create-label) directly. Creating a label definition does not require finding contacts or starting a bulk labeling job.
13
+
14
+ ```http
15
+ POST https://www.wixapis.com/contacts/v4/labels
16
+ Content-Type: application/json
17
+
18
+ {"displayName":"VIP"}
19
+ ```
20
+
21
+ Use the returned `label` and its `key`; `newLabel` distinguishes a newly created label from an existing one. For several names, make one call per name and check each result. Stop here when the request is only to create labels: do not assign them to contacts.
22
+
23
+ If the user asks to **label or unlabel contacts**, resolve the requested label keys first, then follow the bulk assignment flow below. Create a missing label only when that is part of the authorized request. An explicit request to create labels or apply them to a specified set of contacts authorizes that operation; otherwise confirm the target and intended change before mutating.
24
+
25
+ ## Bulk assignment flow
26
+
10
27
  Labels are added to and removed from all contacts that meet the specified `filter` and `search` criteria.
11
28
  The request should specify a `filter` value, a `search` value, or both.
12
29
  To perform a dry run, call [Query Contacts](https://dev.wix.com/docs/api-reference/crm/members-contacts/contacts/contacts/contact-v4/query-contacts) with the intended filter options.
@@ -17,6 +34,22 @@ The job's status can be retrieved with [Get Bulk Job](https://dev.wix.com/docs/a
17
34
 
18
35
  **IMPORTANT NOTE:** When specific contacts are to be labeled, they should be filtered by id.
19
36
 
37
+ ### Steps
38
+
39
+ 1. **Resolve label keys.** When adding, Find or Create Label (above) returns the `key`. To remove a label, or to
40
+ use only a label that already exists, look it up by name instead of creating it:
41
+ `POST https://www.wixapis.com/contacts/v4/labels/query` with
42
+ `{"query":{"filter":{"displayName":{"$eq":"Newsletter"}}}}`, and take each returned label's `key`.
43
+ 2. **Resolve named contacts to IDs** with Search Contacts, as in
44
+ [Update a Contact](update-a-contact.md) — Query Contacts cannot filter on a name:
45
+ `POST https://www.wixapis.com/contacts/v5/contacts/search` with `{"search":{"search":{"expression":"Leo Marsh"}}}`.
46
+ Each result in `contacts` has an `id` and `name.first` / `name.last`; keep only exact name matches. If none or
47
+ more than one contact matches, stop and ask the user.
48
+ 3. **Start the job** with the endpoint below, filtering by the resolved IDs:
49
+ `{"filter":{"id":{"$in":["<CONTACT_ID>"]}},"labelKeysToAdd":["<LABEL_KEY>"]}` (or `labelKeysToRemove`).
50
+ 4. **Confirm the job finished** with `GET https://www.wixapis.com/contacts/v4/bulk/jobs/{jobId}`; repeat until
51
+ `job.status` is `COMPLETED`, then report `job.successTotal` and `job.failedTotal`.
52
+
20
53
  ## API Endpoint
21
54
  `POST https://www.wixapis.com/contacts/v4/bulk/contacts/add-remove-labels`
22
55
 
@@ -29,10 +62,10 @@ curl -X POST \
29
62
  -H 'Content-Type: application/json' \
30
63
  -d '{
31
64
  "filter": {
32
- "info.name.first": "John"
65
+ "id": { "$in": ["<CONTACT_ID_1>", "<CONTACT_ID_2>"] }
33
66
  },
34
- "labelKeysToAdd": ["custom.name-john", "custom.name-starts-with-J"],
35
- "labelKeysToRemove": ["custom.last-name-smith"]
67
+ "labelKeysToAdd": ["custom.newsletter"],
68
+ "labelKeysToRemove": ["custom.prospect"]
36
69
  }'
37
70
  ```
38
71
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "Pricing & Promotions"
3
- description: Pricing & Promotions boundary owner — discounts, coupons, sales, ribbons, bundles. **Always load this dispatcher first when a question touches both discount work and refunds, payments, product-price edits, or shipping rates** — the rules for which side owns each topic live in this file, not in this README line.
3
+ description: Routes discounts, coupons, sales and bundles to promotion recipes, and visual product ribbons to the Catalog Ribbons API. Load this dispatcher for mixed pricing/refund/payment/product-price/shipping requests to choose the appropriate APIs.
4
4
  ---
5
5
 
6
6
  # Pricing & Promotions
@@ -14,7 +14,9 @@ Discount rules, coupon codes, sales, ribbons, bundles, tiered pricing, and the s
14
14
  - A standing $0 shipping option/region rate → see **Shipping & fulfillment**.
15
15
  - Refunding a previous discounted order → route to verified Get Paid/payment docs or Dashboard guidance.
16
16
 
17
- > **Before dispatching** — confirm MerchantContext is loaded. If `siteData.country` is not in your conversation context, load it via [Load Merchant Context](https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/skills/e-commerce-load-context). Skip if already loaded.
17
+ > **Ribbon-only requests** — go directly to the ribbon path below; skip MerchantContext and promotion-strategy discovery.
18
+ >
19
+ > **Before dispatching promotion requests** — confirm MerchantContext is loaded. If `siteData.country` is not in your conversation context, load it via [Load Merchant Context](https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/skills/e-commerce-load-context). Skip if already loaded.
18
20
  >
19
21
  > **Promotion dispatch.** Score each entry below by (a) the merchant's query → `intent:*` tags, (b) MerchantContext → context tags. Load the **highest-scoring** entry. Ties → highest `priority`. No match → follow the base recipe at the bottom.
20
22
  >
@@ -24,9 +26,33 @@ Discount rules, coupon codes, sales, ribbons, bundles, tiered pricing, and the s
24
26
 
25
27
  > - [Create coupon](https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/skills/pricing-create-coupon) — tags: `[intent:create-coupon]` · priority 0
26
28
  > - [Create discount rule (auto-apply)](https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/skills/pricing-create-discount-rule) — tags: `[intent:create-discount-rule]` · priority 0
27
- > - [Add sale ribbon / new ribbon](https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/skills/pricing-create-discount-rule) — tags: `[intent:add-ribbon]` · priority 0 · *ribbons are configured via Discount Rules; same recipe*
29
+ > - [Create or reuse a Catalog V3 ribbon](https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/ribbons-v3/get-or-create-ribbon) — tags: `[intent:add-ribbon]` · priority 0 · *a ribbon is a visual product label, not a discount rule; follow the ribbon path below*
28
30
  > - [Schedule a future sale](https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/skills/pricing-create-discount-rule) — tags: `[intent:schedule-sale]` · priority 0 · *uses Discount Rules with `startTime` in the future*
29
31
 
32
+ ### Ribbon path
33
+
34
+ For a Catalog V3 request such as "Create a Bestseller ribbon", use the idempotent name-based [Get or Create Ribbon](https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/ribbons-v3/get-or-create-ribbon) method:
35
+
36
+ ```http
37
+ POST https://www.wixapis.com/stores/v3/ribbons/get-or-create
38
+ Content-Type: application/json
39
+
40
+ {"ribbonName":"Bestseller"}
41
+ ```
42
+
43
+ Read the returned `ribbon.id`. If the user requested only the ribbon definition, report that result and stop. If they also requested assignment to a product, read the product's current `revision` with `GET https://www.wixapis.com/stores/v3/products/{productId}`, as in [Update Product with Options](../stores/update-product-with-options.md). Then send only the ribbon with that `revision` through [Update Product](https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/update-product); fields you omit are left unchanged:
44
+
45
+ ```http
46
+ PATCH https://www.wixapis.com/stores/v3/products/{productId}
47
+ Content-Type: application/json
48
+
49
+ {"product":{"id":"<PRODUCT_ID>","revision":"<CURRENT_REVISION>","ribbon":{"id":"<RIBBON_ID>"}}}
50
+ ```
51
+
52
+ This path covers Catalog V3.
53
+
54
+ Do not create a coupon, discount rule, or price change just because a ribbon says "Sale". Those require a separate discount request. An explicit ribbon creation or assignment request authorizes that operation; otherwise confirm the intended change first. Merchant context needed for promotion recommendations is not a prerequisite to creating a named visual ribbon.
55
+
30
56
  ### Business flows — the orchestrator
31
57
 
32
58
  The single business-flow orchestrator (`recommend-ecommerce-strategy`) handles all strategic discount intents. It classifies internally (SEASONAL / UPSELL_BOOST / STOCK_MOVER / BUNDLE_AND_SAVE / ABANDONED_CART) and loads its `goal-*` / `flow-*` support files from the kept ecommerce-root siblings.