// Deploy the shipped code into the project. Run from the PROJECT ROOT: // // node /install/deploy.mjs [ …] --stack astro|react [--client-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// (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 // /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. Every vertical // with a rest/ folder is supported. `--out ` (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//, // src/components//, …), 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 { blankStarter, 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.17.0", // the viewer-only entry @wix/ricos/viewer }, 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 { npmSync } = await import("./proc.mjs"); 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); // The Ricos renderer the cores import (../rich-content in the app layout), the same file on every stack. cpSync(join(REF, "shared", "app", "wix", "rich-content.ts"), join(JS, "rich-content.ts"), 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// — 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// — 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 // -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"); // The rest/ files import their real paths in the templates (../../shared/rest/client.js, // ../app/wix//cart-core.js); flat in one folder, every import is a sibling. const fixed = src .replace(/from\s+"\.{1,2}\/(?:[^"]*\/)([^"/]+)"/g, 'from "./$1"') .replace(/from\s+"\.\.\/([^"/]+)"/g, 'from "./$1"') .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+"\.\/[^"]+?)(? f.endsWith(".ts")).map((f) => join(JS, f)); const tsc = npmSync( "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 || tsc.error?.message || `exit ${tsc.status}`).slice(-800)}` })); process.exit(1); } rmSync(join(JS, "tsconfig.json"), { force: true }); // Each .js opens with its declarations as // lines: the exports, their arguments and what they // return, so the file carries its own contract where a host serves no .ts (a Wix drop keeps // only listed extensions, and .ts is not one). The code below keeps its comments, so the header // is the bare signatures; a *-types file has no code, so its header keeps the field comments. const { mkdtempSync } = await import("node:fs"); const { tmpdir } = await import("node:os"); const declare = (removeComments) => { const dir = mkdtempSync(join(tmpdir(), "wix-decl-")); const run = npmSync( "npx", ["-y", "-p", `typescript@${PINS.typescript}`, "tsc", ...sources, "--declaration", "--emitDeclarationOnly", "--outDir", dir, "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--lib", "es2022,dom", "--strict", "--skipLibCheck", "--removeComments", String(removeComments)], { encoding: "utf8", timeout: 180_000 }, ); if (run.status !== 0) { console.log(JSON.stringify({ ...result, error: `tsc declarations failed: ${(run.stdout || run.stderr || run.error?.message || `exit ${run.status}`).slice(-800)}` })); process.exit(1); } return dir; }; const bare = declare(true); const commented = declare(false); for (const f of readdirSync(JS).filter((f) => f.endsWith(".js") && f !== "config.js")) { const name = f.replace(/\.js$/, ".d.ts"); const from = join(name.endsWith("-types.d.ts") ? commented : bare, name); if (!existsSync(from)) continue; const lines = readFileSync(from, "utf8").trimEnd().split("\n").filter((l) => l.trim() !== "export {};"); if (!lines.length) continue; const header = [`// Declarations of ${f.replace(/\.js$/, ".ts")}: what this file exports, their arguments and what they return.`, ...lines.map((l) => (l ? `// ${l}` : "//")), ""].join("\n"); writeFileSync(join(JS, f), header + readFileSync(join(JS, f), "utf8")); } rmSync(bare, { recursive: true, force: true }); rmSync(commented, { recursive: true, 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