← Files WixARCHIVED FILE

skills/wix-headless-kit/install/deploy.mjs

26.4 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// Deploy the shipped code into the project. Run from the PROJECT ROOT:
//
//   node <SKILL_ROOT>/install/deploy.mjs <vertical> [<vertical> …] --stack astro|react [--client-id <id>] [--plan plan.json]
//
//   --stack astro  (default) — copies each vertical's framework-agnostic core (app/) AND its
//                  Astro overlay (app-astro/: pages, layouts) into src/. Ambient auth: no
//                  client id is needed or written.
//   --stack react  — copies only the core (app/) into src/, and writes the public OAuth
//                  client id into src/wix/config.ts (pass --client-id, or it's read from
//                  wix.config.json's appId when present).
//   --stack lib    — a bundled JS project that isn't React (Vue, Svelte, Solid, plain Vite): copies
//                  ONLY the data layer — shared app/wix/ (sdk, media, money, config) and each
//                  vertical's app/wix/<vertical>/ (data layer, cart store, types, *-core rules) —
//                  none of which imports React; writes the client id like react. No hooks, no
//                  components, no global.css, no Tailwind deps. The agent writes its framework's
//                  stores and components against the same DTO contracts.
//   --stack static — a site with NO bundler (plain HTML/CSS/JS): composes the REST layer flat into
//                  <out>/js/wix/ — shared/rest/ (client, media, config) + each vertical's rest/ + the
//                  vertical's transport-agnostic core files (types, *-core) from its app/ — writes
//                  the client id, and strips it to browser-ready ESM with tsc (comments kept, the
//                  .ts sources kept beside the .js). No package.json is touched. Storefront only
//                  until other verticals ship a rest/. `--out <dir>` (default: the project root)
//                  is the folder the site is served from — the one wix.config.json's
//                  site.outputDirectory points at — so the modules land where the pages import
//                  them and nothing else in the project gets uploaded.
//
// TWO mechanisms, driven by the same vertical arguments:
// 1. Recursive file copy with force:false — only files that AREN'T there yet are written, so
//    a re-run restores missing files without clobbering edits, and a later call can add a
//    vertical safely. Verticals ship at disjoint paths (src/wix/<vertical>/,
//    src/components/<vertical>/, …), so they never collide with each other.
// 2. package.json dependency patch — after the copy, any dependency the copied code imports
//    that is absent from BOTH dependencies and devDependencies is added to dependencies
//    (fill-only: an existing version range always wins). The script never runs npm install —
//    it only makes package.json truthful about what src/ imports; installing is the caller's
//    one command afterwards.
import {
  cpSync,
  existsSync,
  readFileSync,
  writeFileSync,
  readdirSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { templatesDir } from "./templates.mjs";
import { syncLockRoot } from "./lock.mjs";
import { siteContext } from "./context.mjs";
const PINS = JSON.parse(readFileSync(new URL("./pins.json", import.meta.url), "utf8"));
const WIX_CLI = `@wix/cli@${PINS["@wix/cli"]}`;

const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
// The shipped code: the repository's templates/ (a checkout, the cache, or fetched now).
const REF = templatesDir();
const PROJECT = process.cwd();
const SRC = join(PROJECT, "src");
const CONFIG_TS = join(SRC, "wix", "config.ts");
const PKG_JSON = join(PROJECT, "package.json");

// The vertical registry: every directory under templates/ that ships an app/ is a vertical.
const VERTICALS = readdirSync(REF, { withFileTypes: true })
  .filter(
    (d) =>
      d.isDirectory() &&
      existsSync(join(REF, d.name, "app")) &&
      d.name !== "shared" &&
      d.name !== "blank",
  )
  .map((d) => d.name);

// What the shipped code imports, declared per layer (version ranges are the ones the shipped
// code was verified against). react/astro/typescript/@wix/astro* are scaffold-owned — never
// listed here. A new vertical adds its own entry; the patch logic below never changes.
const SHARED_DEPS = {
  "@wix/sdk": "^1.21.5",
  // The shipped components style themselves with Tailwind v4 utilities reading the @theme
  // tokens in styles/global.css (the same system the official Wix headless templates use).
  tailwindcss: "^4.1.7",
  "@tailwindcss/vite": "^4.1.7",
};
const CAPABILITY_DEPS = {
  "media-upload": {
    "@wix/media": "^1.0.271",
    "@wix/essentials": "^1.0.10",
    // the endpoint reads the caller's own session for a members policy
    "@wix/members": "^1.0.511",
  },
  "site-search": {
    "@wix/search": "^1.0.90",
  },
};
const SITE_SEARCH = join("shared", "capabilities", "site-search");
const VERTICAL_DEPS = {
  storefront: {
    core: {
      "@wix/stores": "^1.0.888",
      "@wix/categories": "^1.0.220",
      "@wix/ecom": "^1.0.2451",
      "@wix/redirects": "^1.0.125",
    },
    astro: {
      "@wix/seo": "^1.0.79",
      "@wix/essentials": "^1.0.6",
    },
  },
  bookings: {
    core: {
      "@wix/bookings": "^1.0.1650",
      "@wix/auto_sdk_ecom_cart-v-2": "^1.0.192",
      "@wix/forms": "^1.0.500",
      "@wix/redirects": "^1.0.125",
    },
    astro: {
      "@wix/seo": "^1.0.79",
      "@wix/essentials": "^1.0.6",
    },
  },
  rentals: {
    // A rental is a Bookings service with rentals-specific values: the same SDK modules as bookings.
    core: {
      "@wix/bookings": "^1.0.1650",
      "@wix/auto_sdk_ecom_cart-v-2": "^1.0.192",
      "@wix/redirects": "^1.0.125",
    },
    astro: {
      "@wix/seo": "^1.0.79",
      "@wix/essentials": "^1.0.6",
    },
  },
  blog: {
    core: {
      "@wix/blog": "^1.0.645",
      "@wix/ricos": "^11.12.0",
    },
    astro: {
      "@wix/seo": "^1.0.79",
      "@wix/essentials": "^1.0.10", // WIX_APPS.blogs needs ≥1.0.10
    },
  },
  cms: {
    core: {
      "@wix/data": "^1.0.512",
    },
  },
  forms: {
    core: {
      "@wix/forms": "^1.0.501",
    },
  },
  events: {
    core: {
      "@wix/events": "^1.0.860",
      "@wix/redirects": "^1.0.125",
    },
    astro: {
      "@wix/seo": "^1.0.79",
      "@wix/essentials": "^1.0.10",
    },
  },
  members: {
    core: {
      "@wix/members": "^1.0.511",
    },
  },
  portfolio: {
    core: {
      "@wix/portfolio": "^1.0.229",
    },
  },
  "pricing-plans": {
    core: {
      "@wix/pricing-plans": "^1.0.378",
      "@wix/redirects": "^1.0.125",
    },
  },
  restaurants: {
    core: {
      "@wix/restaurants": "^1.0.525",
      "@wix/table-reservations": "^1.0.397",
      "@wix/ecom": "^1.0.2454",
      "@wix/redirects": "^1.0.125",
    },
  },
  faq: {
    core: {
      "@wix/faq": "^1.0.76",
    },
    // no astro block: the FAQ page is a plain page (no wixMetadata item routing); its JSON-LD is inline
  },
  donations: {
    core: {
      "@wix/donations": "^1.0.64",
      "@wix/ecom": "^1.0.2454",
      "@wix/redirects": "^1.0.125",
    },
  },
};

const COPY = { recursive: true, force: false, errorOnExist: false };

// ---- args ---------------------------------------------------------------------------------------
const argv = process.argv.slice(2);
const flag = (name) => {
  const i = argv.indexOf(`--${name}`);
  return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith("--")
    ? argv[i + 1]
    : null;
};
const stack = flag("stack") ?? "astro";
const clientIdFlag = flag("client-id");
const planPath = flag("plan");
const outDir = flag("out");
const flagArgs = new Set(
  ["--stack", "--client-id", "--plan", "--out", stack, clientIdFlag, planPath, outDir].filter(Boolean),
);
const requested = [...new Set(argv.filter((a) => !flagArgs.has(a)))];

const result = { stack, verticals: [], skillRoot: SKILL_ROOT, templates: REF };

if (!["astro", "react", "lib", "static"].includes(stack)) {
  console.log(
    JSON.stringify({
      error: `unknown --stack "${stack}" — expected astro|react|lib|static`,
    }),
  );
  process.exit(1);
}

let plan = {};
if (planPath) {
  if (!existsSync(planPath)) {
    console.log(JSON.stringify({ error: `plan file not found: ${planPath}` }));
    process.exit(1);
  }
  try {
    plan = JSON.parse(readFileSync(planPath, "utf8"));
  } catch (error) {
    console.log(JSON.stringify({ error: `invalid --plan JSON: ${error.message}` }));
    process.exit(1);
  }
}

const uploadPolicies = plan.capabilities?.mediaUpload?.policies ?? [];
if (!Array.isArray(uploadPolicies)) {
  console.log(JSON.stringify({ error: "plan.capabilities.mediaUpload.policies must be an array" }));
  process.exit(1);
}
// Site search is on when the plan carries the entry (`{}` is enough; `true` too). Its fields are
// data for the generated config; the shipped core drops unknown type names, so only shapes are checked.
const siteSearchEntry = plan.capabilities?.siteSearch;
const siteSearch = siteSearchEntry === true ? {} : siteSearchEntry && typeof siteSearchEntry === "object" ? siteSearchEntry : null;
if (siteSearchEntry != null && siteSearchEntry !== false && !siteSearch) {
  console.log(JSON.stringify({ error: "plan.capabilities.siteSearch must be an object (or true)" }));
  process.exit(1);
}
if (siteSearch) {
  const strings = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
  const map = (v) => v === undefined || (v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string"));
  if ((siteSearch.types !== undefined && !strings(siteSearch.types)) || !map(siteSearch.routes) || !map(siteSearch.labels)) {
    console.log(JSON.stringify({ error: "plan.capabilities.siteSearch: types must be string[]; routes and labels must be objects of strings" }));
    process.exit(1);
  }
}
// The generated file is data only; the shipped core resolves defaults (types, routes, labels) from it.
const siteSearchConfigSource = () =>
  `// Generated by wix-headless-kit install/deploy.mjs from plan.capabilities.siteSearch.\n` +
  `// Edit the plan and redeploy. Defaults for anything omitted are in search-core.ts (resolveConfig).\n` +
  `export const siteSearchPlan = ${JSON.stringify(siteSearch, null, 2)};\n`;
// ---- static stack: the REST layer, composed flat and stripped ------------------------------------
if (stack === "static") {
  const { spawnSync } = await import("node:child_process");
  const { rmSync } = await import("node:fs");
  const OUT = outDir ? resolve(PROJECT, outDir) : PROJECT;
  const JS = join(OUT, "js", "wix");
  result.out = outDir ?? ".";
  // The client id the browser mints visitor tokens with: --client-id, else the CONTENT app from
  // .env.local (`wix env pull`; on a migration preview the parent site's app), else the config's appId.
  let clientId = clientIdFlag ?? (existsSync(join(PROJECT, "wix.config.json")) ? siteContext({ cwd: PROJECT }).content.clientId : null);
  if (!clientId) {
    console.log(JSON.stringify({ error: `static stack needs the public OAuth client id — run \`npm create @wix/new@${PINS["@wix/create-new"]} init\` here first, or pass --client-id` }));
    process.exit(1);
  }
  const missing = requested.filter((v) => !existsSync(join(REF, v, "rest")));
  if (missing.length) {
    console.log(JSON.stringify({ error: `no rest/ layer yet for ${missing.map((v) => `"${v}"`).join(", ")} — the static stack ships: ${VERTICALS.filter((v) => existsSync(join(REF, v, "rest"))).join(", ")}` }));
    process.exit(1);
  }
  // Only files that aren't there yet — a re-run restores, never clobbers (same as the copy below).
  cpSync(join(REF, "shared", "rest"), JS, COPY);
  // Every layer with a rest/ twin: the requested verticals, plus the site-search capability when the
  // plan turns it on (same shape — rest/ beside app/wix/<name>/ — at templates/shared/capabilities).
  const layers = requested.map((vertical) => [vertical, join(REF, vertical)]);
  if (siteSearch) layers.push(["site-search", join(REF, SITE_SEARCH)]);
  for (const [vertical, root] of layers) {
    cpSync(join(root, "rest"), JS, COPY);
    // The vertical's transport-agnostic core: types, every *-core.ts, and every *-store.ts under its
    // app/wix/<vertical>/ — the stores are the hooks' logic without React, over the same function
    // names the REST twin exports, so they run against it unchanged.
    const appWix = join(root, "app", "wix", vertical);
    for (const f of readdirSync(appWix)) {
      // Every vertical has a types.ts; flat in one folder they would collide, so each lands as
      // <vertical>-types.ts and the vertical's own files are pointed at it below.
      if (f === "types.ts") cpSync(join(appWix, f), join(JS, `${vertical}-types.ts`), COPY);
      // *.generated.ts: data a core file imports (the forms' per-country address templates).
      else if (f.endsWith("-core.ts") || f.endsWith("-store.ts") || f.endsWith(".generated.ts")) cpSync(join(appWix, f), join(JS, f), COPY);
    }
    const own = new Set([...readdirSync(join(root, "rest")), ...readdirSync(appWix)].filter((f) => f.endsWith(".ts") && f !== "types.ts"));
    for (const f of own) {
      const fp = join(JS, f);
      if (!existsSync(fp)) continue;
      const src = readFileSync(fp, "utf8");
      const fixed = src.replace(/from\s+"\.\/types(?:\.js)?"/g, `from "./${vertical}-types.js"`);
      if (fixed !== src) writeFileSync(fp, fixed);
    }
    if (VERTICALS.includes(vertical)) result.verticals.push(vertical);
  }
  if (siteSearch) {
    // The REST twin imports the plan as data from beside it (shared/rest already owns config.ts).
    writeFileSync(join(JS, "site-search-config.generated.ts"), siteSearchConfigSource());
    result.capabilities = { ...result.capabilities, siteSearch: true };
  }
  // Browser ESM resolves nothing: every relative import needs its .js. The rest/ files are written
  // that way; the files borrowed from app/wix/ are bundler-style and get the suffix here.
  for (const f of readdirSync(JS).filter((f) => f.endsWith(".ts"))) {
    const src = readFileSync(join(JS, f), "utf8");
    const fixed = src.replace(/(from\s+"\.\/[^"]+?)(?<!\.js)"/g, '$1.js"');
    if (fixed !== src) writeFileSync(join(JS, f), fixed);
  }
  const cfg = join(JS, "config.ts");
  const current = readFileSync(cfg, "utf8");
  if (/WIX_CLIENT_ID: string = ""/.test(current)) {
    writeFileSync(cfg, current.replace(/WIX_CLIENT_ID: string = ""/, `WIX_CLIENT_ID: string = "${clientId}"`));
    result.clientId = "written";
  } else {
    result.clientId = "already_set";
  }
  // Strip to ESM with tsc — comments are the spec for whoever reads js/wix/, so they stay.
  const sources = readdirSync(JS).filter((f) => f.endsWith(".ts")).map((f) => join(JS, f));
  const tsc = spawnSync(
    "npx",
    ["-y", "-p", `typescript@${PINS.typescript}`, "tsc", ...sources, "--outDir", JS, "--module", "esnext", "--target", "es2022",
     "--moduleResolution", "bundler", "--lib", "es2022,dom", "--strict", "--skipLibCheck", "--removeComments", "false"],
    { encoding: "utf8", timeout: 180_000 },
  );
  if (tsc.status !== 0) {
    console.log(JSON.stringify({ ...result, error: `tsc strip failed: ${(tsc.stdout || tsc.stderr || "").slice(-800)}` }));
    process.exit(1);
  }
  rmSync(join(JS, "tsconfig.json"), { force: true });
  result.js = `${outDir ? outDir.replace(/\/$/, "") + "/" : ""}js/wix/*.js`;
  // The note lists what actually landed, so it holds for every vertical (and several at once).
  const stores = readdirSync(JS).filter((f) => f.endsWith("-store.js")).map((f) => `./js/wix/${f}`);
  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}`);
  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 ?? "."}"`;
  console.log(JSON.stringify(result));
  process.exit(0);
}

if (uploadPolicies.length && stack !== "astro") {
  console.log(JSON.stringify({ error: "mediaUpload currently requires --stack astro because it ships a validated server endpoint" }));
  process.exit(1);
}
for (const policy of uploadPolicies) {
  if (
    !policy ||
    typeof policy.id !== "string" ||
    !Array.isArray(policy.accept) ||
    !policy.accept.every((mime) => typeof mime === "string") ||
    !Number.isSafeInteger(policy.maxBytes) ||
    policy.maxBytes < 1 ||
    (policy.audience !== undefined && policy.audience !== "members" && policy.audience !== "visitors")
  ) {
    console.log(JSON.stringify({ error: 'each mediaUpload policy needs id, accept: string[], positive integer maxBytes, and optionally audience: "members" (default) | "visitors"' }));
    process.exit(1);
  }
}

// ---- copy ---------------------------------------------------------------------------------------
// Shared core — always. The lib stack takes only the framework-free wix/ part (no styles).
if (stack === "lib") cpSync(join(REF, "shared", "app", "wix"), join(SRC, "wix"), COPY);
else cpSync(join(REF, "shared", "app"), SRC, COPY);

if (uploadPolicies.length) {
  cpSync(join(REF, "shared", "capabilities", "media-upload", "app"), SRC, COPY);
  cpSync(join(REF, "shared", "capabilities", "media-upload", "app-astro"), SRC, COPY);
}
// Site search ships like a vertical (data layer, stores, hooks, components, a search page) at a
// disjoint path (src/wix/site-search/, src/components/site-search/, …), so the same per-stack rule
// applies: lib takes only app/wix/, astro adds app-astro/.
if (siteSearch) {
  if (stack === "lib") cpSync(join(REF, SITE_SEARCH, "app", "wix"), join(SRC, "wix"), COPY);
  else cpSync(join(REF, SITE_SEARCH, "app"), SRC, COPY);
  if (stack === "astro") cpSync(join(REF, SITE_SEARCH, "app-astro"), SRC, COPY);
}

const unknown = requested.filter((v) => !VERTICALS.includes(v));
// lib: only @wix/sdk from the shared deps — the Tailwind pair serves the components, which don't ship there.
const wantedDeps = stack === "lib" ? { "@wix/sdk": SHARED_DEPS["@wix/sdk"] } : { ...SHARED_DEPS };
if (uploadPolicies.length) Object.assign(wantedDeps, CAPABILITY_DEPS["media-upload"]);
if (siteSearch) Object.assign(wantedDeps, CAPABILITY_DEPS["site-search"]);
for (const vertical of requested.filter((v) => VERTICALS.includes(v))) {
  if (stack === "lib") cpSync(join(REF, vertical, "app", "wix"), join(SRC, "wix"), COPY);
  else cpSync(join(REF, vertical, "app"), SRC, COPY);
  if (stack === "astro" && existsSync(join(REF, vertical, "app-astro"))) {
    cpSync(join(REF, vertical, "app-astro"), SRC, COPY);
  }
  const deps = VERTICAL_DEPS[vertical] ?? {};
  Object.assign(
    wantedDeps,
    deps.core,
    stack === "astro" ? deps.astro : undefined,
  );
  result.verticals.push(vertical);
}

if (uploadPolicies.length) {
  const configDir = join(SRC, "wix", "media-upload");
  const configFile = join(configDir, "policies.generated.ts");
  // The generated file is data only. The shipped endpoint owns validation and elevation.
  writeFileSync(
    configFile,
    `// Generated by wix-headless-kit install/deploy.mjs from plan.capabilities.mediaUpload.\n` +
      `// Edit the plan and redeploy; do not add browser-controlled policy selection here.\n` +
      `export const mediaUploadPolicies = ${JSON.stringify(uploadPolicies, null, 2)} as const;\n`,
  );
  result.capabilities = { mediaUpload: uploadPolicies.map((policy) => policy.id) };
}
if (siteSearch) {
  // The SDK transport imports the plan as data from beside it; defaults resolve in the shipped core.
  writeFileSync(join(SRC, "wix", "site-search", "config.generated.ts"), siteSearchConfigSource());
  result.capabilities = { ...result.capabilities, siteSearch: true };
  if (siteSearch.install) {
    // Indexing needs the Wix Site Search app on the site — a site mutation the deploy never makes.
    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("; ");
  }
}

// ---- dependency patch (fill-only) ----------------------------------------------------------------
if (result.verticals.length && existsSync(PKG_JSON)) {
  const pkg = JSON.parse(readFileSync(PKG_JSON, "utf8"));
  pkg.dependencies ??= {};
  const present = { ...pkg.devDependencies, ...pkg.dependencies };
  const added = Object.entries(wantedDeps).filter(
    ([name]) => !(name in present),
  );
  for (const [name, range] of added) pkg.dependencies[name] = range;
  if (added.length)
    writeFileSync(PKG_JSON, JSON.stringify(pkg, null, 2) + "\n");
  result.depsAdded = added.map(([name]) => name);
} else if (result.verticals.length) {
  result.depsAdded = [];
  result.note = [
    result.note,
    "no package.json here — run deploy from the project root",
  ]
    .filter(Boolean)
    .join("; ");
}

// ---- the project's lockfile ---------------------------------------------------------------------
// A project created from a composed template (templates/<vertical>/project) carries that
// template's package-lock.json, so `npm ci` installs it without resolving. When this run added
// dependencies to package.json, the lock's root entry is brought in line where the tree already
// holds the package (install/lock.mjs); anything the tree lacks makes `npm ci` fail fast and the
// `|| npm install` fallback resolve it. A project without a lock installs with `npm install`.
const PROJECT_LOCK = join(PROJECT, "package-lock.json");
if (result.verticals.length && existsSync(PKG_JSON)) {
  if (existsSync(PROJECT_LOCK)) {
    const sync = syncLockRoot(PROJECT);
    if (sync && (sync.promoted.length || sync.missing.length)) result.lock = sync;
    result.note = [result.note, "install with `npm ci --ignore-scripts || npm install --ignore-scripts`"].filter(Boolean).join("; ");
  } else if (result.depsAdded?.length) {
    result.note = [result.note, "run `npm install --ignore-scripts` to pick up depsAdded"].filter(Boolean).join("; ");
  }
}

// ---- astro config: wire the Tailwind vite plugin ---------------------------------------------------
// The blank scaffold's astro.config.mjs has no Tailwind wiring. Fill-only, like everything
// else: patch only when the plugin isn't referenced yet, and only when both anchors are found.
const ASTRO_CONFIG = join(PROJECT, "astro.config.mjs");
if (stack === "astro" && result.verticals.length && existsSync(ASTRO_CONFIG)) {
  const config = readFileSync(ASTRO_CONFIG, "utf8");
  if (config.includes("@tailwindcss/vite")) {
    result.astroConfig = "tailwind_already_wired";
  } else if (config.includes("export default defineConfig({")) {
    const patched =
      `import tailwindcss from "@tailwindcss/vite";\n` +
      config.replace(
        "export default defineConfig({",
        "export default defineConfig({\n  vite: { plugins: [tailwindcss()] },",
      );
    writeFileSync(ASTRO_CONFIG, patched);
    result.astroConfig = "tailwind_wired";
  } else {
    result.astroConfig =
      "UNPATCHED — add `vite: { plugins: [tailwindcss()] }` (import from @tailwindcss/vite) to astro.config.mjs by hand";
  }
}

// ---- ready-made links -----------------------------------------------------------------------------
// Emit the siteId and the dashboard deep links so nothing downstream re-derives or retypes them.
// The site the links point at is the CONTENT site — the parent on a migration preview, where the
// config's site only hosts the deployment (install/context.mjs).
const WIX_CONFIG = join(PROJECT, "wix.config.json");
const ctx = existsSync(WIX_CONFIG) ? siteContext({ cwd: PROJECT }) : null;
if (ctx) {
  const siteId = ctx.content.siteId;
  if (siteId) {
    result.siteId = siteId;
    if (ctx.migration.active) result.migration = { parentSiteId: ctx.migration.parentSiteId, deploySiteId: ctx.deploy.siteId };
    result.dashboardUrl = `https://manage.wix.com/dashboard/${siteId}`;
    if (result.verticals.includes("storefront")) {
      result.productsUrl = `https://manage.wix.com/dashboard/${siteId}/wix-stores/products`;
      result.categoriesUrl = `https://manage.wix.com/dashboard/${siteId}/wix-stores/categories/list`;
    }
  }
}

if (unknown.length) {
  result.error = `unknown vertical(s) ${unknown.map((v) => `"${v}"`).join(", ")} — available: ${VERTICALS.join(", ")}`;
}
if (!requested.length) {
  result.note = `no vertical given — deployed the shared core only. Available: ${VERTICALS.join(", ")}`;
}

// ---- client ids ---------------------------------------------------------------------------------
// The shared data client is ambient on managed Astro, and so is the members flow there (the
// integration's built-in /api/auth routes). WIX_MEMBERS_CLIENT_ID is still filled for projects whose
// members code predates the shared seam; the shipped code reads WIX_CLIENT_ID only.
// --client-id, else the CONTENT app: .env.local's WIX_CLIENT_ID (what `wix env pull` writes — on a
// migration preview the parent site's app, the one the Astro integration itself runs as), else the
// config's appId. Never the config first: on a preview that is the deploy-only child app.
const clientId = clientIdFlag ?? ctx?.content.clientId ?? null;
if (stack === "react" || stack === "lib") {
  if (clientId) {
    const current = readFileSync(CONFIG_TS, "utf8");
    // A non-null id already set wins; only fill the shipped null placeholder.
    if (/WIX_CLIENT_ID:\s*string\s*\|\s*null\s*=\s*null/.test(current)) {
      writeFileSync(
        CONFIG_TS,
        current.replace(
          /WIX_CLIENT_ID:\s*string\s*\|\s*null\s*=\s*null/,
          `WIX_CLIENT_ID: string | null = "${clientId}"`,
        ),
      );
      result.clientId = "written";
    } else {
      result.clientId = "already_set";
    }
  } else {
    result.clientId =
      `missing — pass --client-id (the ${stack} stack needs the public OAuth client id)`;
  }
}
if (requested.includes("members")) {
  if (!clientId) {
    result.membersClientId =
      "missing — pass --client-id (custom members login needs the public OAuth client id)";
  } else {
    const current = readFileSync(CONFIG_TS, "utf8");
    if (
      /WIX_MEMBERS_CLIENT_ID:\s*string\s*\|\s*null\s*=\s*null/.test(current)
    ) {
      writeFileSync(
        CONFIG_TS,
        current.replace(
          /WIX_MEMBERS_CLIENT_ID:\s*string\s*\|\s*null\s*=\s*null/,
          `WIX_MEMBERS_CLIENT_ID: string | null = "${clientId}"`,
        ),
      );
      result.membersClientId = "written";
    } else {
      result.membersClientId = "already_set";
    }
  }
}

console.log(JSON.stringify(result, null, 2));

SHA-256: 3ed9d5aeb530624597e15837f7d9e7abd0c917b09095dacfb58e779961f86886