Setting the file. One moment.
Deploy · Wix Headless Kit · wix/skills · Skills Docs
ContentsBack to the top of the page install/ deploy.mjs
JavaScript · 641 lines · 31 KB
14 // none of which imports React; writes the client id like react. No hooks, no
15 // components, no global.css, no Tailwind deps. The agent writes its framework's
16 // stores and components against the same DTO contracts.
17 // --stack static — a site with NO bundler (plain HTML/CSS/JS): composes the REST layer flat into
18 // <out>/js/wix/ — shared/rest/ (client, media, config) + each vertical's rest/ + the
19 // vertical's transport-agnostic core files (types, *-core) from its app/ — writes
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. Every vertical
22 // with a rest/ folder is supported. `--out <dir>` (default: the project root)
23 // is the folder the site is served from — the one wix.config.json's
24 // site.outputDirectory points at — so the modules land where the pages import
25 // them and nothing else in the project gets uploaded.
26 //
27 // TWO mechanisms, driven by the same vertical arguments:
28 // 1. Recursive file copy with force:false — only files that AREN'T there yet are written, so
29 // a re-run restores missing files without clobbering edits, and a later call can add a
30 // vertical safely. Verticals ship at disjoint paths (src/wix/<vertical>/,
31 // src/components/<vertical>/, …), so they never collide with each other.
32 // 2. package.json dependency patch — after the copy, any dependency the copied code imports
33 // that is absent from BOTH dependencies and devDependencies is added to dependencies
34 // (fill-only: an existing version range always wins). The script never runs npm install —
35 // it only makes package.json truthful about what src/ imports; installing is the caller's
36 // one command afterwards.
37 import {
38 cpSync,
39 existsSync,
40 readFileSync,
41 writeFileSync,
42 readdirSync,
43 } from "node:fs" ;
44 import { dirname, join, resolve } from "node:path" ;
45 import { fileURLToPath } from "node:url" ;
46 import { templatesDir } from "./templates.mjs" ;
47 import { syncLockRoot } from "./lock.mjs" ;
48 import { blankStarter, siteContext } from "./context.mjs" ;
49 const PINS = JSON . parse ( readFileSync ( new URL ( "./pins.json" , import . meta .url), "utf8" ));
50 const WIX_CLI = `@wix/cli@${ PINS [ "@wix/cli" ] }` ;
51
52 const SKILL_ROOT = resolve ( dirname ( fileURLToPath ( import . meta .url)), ".." );
53 // The shipped code: the repository's templates/ (a checkout, the cache, or fetched now).
54 const REF = templatesDir ();
55 const PROJECT = process. cwd ();
56 const SRC = join ( PROJECT , "src" );
57 const CONFIG_TS = join ( SRC , "wix" , "config.ts" );
58 const PKG_JSON = join ( PROJECT , "package.json" );
59
60 // The vertical registry: every directory under templates/ that ships an app/ is a vertical.
61 const VERTICALS = readdirSync ( REF , { withFileTypes: true })
62 . filter (
63 ( d ) =>
64 d. isDirectory () &&
65 existsSync ( join ( REF , d.name, "app" )) &&
66 d.name !== "shared" &&
67 d.name !== "blank" ,
68 )
69 . map (( d ) => d.name);
70
71 // What the shipped code imports, declared per layer (version ranges are the ones the shipped
72 // code was verified against). react/astro/typescript/@wix/astro* are scaffold-owned — never
73 // listed here. A new vertical adds its own entry; the patch logic below never changes.
74 const SHARED_DEPS = {
75 "@wix/sdk" : "^1.21.5" ,
76 // The shipped components style themselves with Tailwind v4 utilities reading the @theme
77 // tokens in styles/global.css (the same system the official Wix headless templates use).
78 tailwindcss: "^4.1.7" ,
79 "@tailwindcss/vite" : "^4.1.7" ,
80 };
81 const CAPABILITY_DEPS = {
82 "media-upload" : {
83 "@wix/media" : "^1.0.271" ,
84 "@wix/essentials" : "^1.0.10" ,
85 // the endpoint reads the caller's own session for a members policy
86 "@wix/members" : "^1.0.511" ,
87 },
88 "site-search" : {
89 "@wix/search" : "^1.0.90" ,
90 },
91 };
92 const SITE_SEARCH = join ( "shared" , "capabilities" , "site-search" );
93 const VERTICAL_DEPS = {
94 storefront: {
95 core: {
96 "@wix/stores" : "^1.0.888" ,
97 "@wix/categories" : "^1.0.220" ,
98 "@wix/ecom" : "^1.0.2451" ,
99 "@wix/redirects" : "^1.0.125" ,
100 },
101 astro: {
102 "@wix/seo" : "^1.0.79" ,
103 "@wix/essentials" : "^1.0.6" ,
104 },
105 },
106 bookings: {
107 core: {
108 "@wix/bookings" : "^1.0.1650" ,
109 "@wix/auto_sdk_ecom_cart-v-2" : "^1.0.192" ,
110 "@wix/forms" : "^1.0.500" ,
111 "@wix/redirects" : "^1.0.125" ,
112 },
113 astro: {
114 "@wix/seo" : "^1.0.79" ,
115 "@wix/essentials" : "^1.0.6" ,
116 },
117 },
118 rentals: {
119 // A rental is a Bookings service with rentals-specific values: the same SDK modules as bookings.
120 core: {
121 "@wix/bookings" : "^1.0.1650" ,
122 "@wix/auto_sdk_ecom_cart-v-2" : "^1.0.192" ,
123 "@wix/redirects" : "^1.0.125" ,
124 },
125 astro: {
126 "@wix/seo" : "^1.0.79" ,
127 "@wix/essentials" : "^1.0.6" ,
128 },
129 },
130 blog: {
131 core: {
132 "@wix/blog" : "^1.0.645" ,
133 "@wix/ricos" : "^11.17.0" , // the viewer-only entry @wix/ricos/viewer
134 },
135 astro: {
136 "@wix/seo" : "^1.0.79" ,
137 "@wix/essentials" : "^1.0.10" , // WIX_APPS.blogs needs ≥1.0.10
138 },
139 },
140 cms: {
141 core: {
142 "@wix/data" : "^1.0.512" ,
143 },
144 },
145 forms: {
146 core: {
147 "@wix/forms" : "^1.0.501" ,
148 },
149 },
150 events: {
151 core: {
152 "@wix/events" : "^1.0.860" ,
153 "@wix/redirects" : "^1.0.125" ,
154 },
155 astro: {
156 "@wix/seo" : "^1.0.79" ,
157 "@wix/essentials" : "^1.0.10" ,
158 },
159 },
160 members: {
161 core: {
162 "@wix/members" : "^1.0.511" ,
163 },
164 },
165 portfolio: {
166 core: {
167 "@wix/portfolio" : "^1.0.229" ,
168 },
169 },
170 "pricing-plans" : {
171 core: {
172 "@wix/pricing-plans" : "^1.0.378" ,
173 "@wix/redirects" : "^1.0.125" ,
174 },
175 },
176 restaurants: {
177 core: {
178 "@wix/restaurants" : "^1.0.525" ,
179 "@wix/table-reservations" : "^1.0.397" ,
180 "@wix/ecom" : "^1.0.2454" ,
181 "@wix/redirects" : "^1.0.125" ,
182 },
183 },
184 faq: {
185 core: {
186 "@wix/faq" : "^1.0.76" ,
187 },
188 // no astro block: the FAQ page is a plain page (no wixMetadata item routing); its JSON-LD is inline
189 },
190 donations: {
191 core: {
192 "@wix/donations" : "^1.0.64" ,
193 "@wix/ecom" : "^1.0.2454" ,
194 "@wix/redirects" : "^1.0.125" ,
195 },
196 },
197 };
198
199 const COPY = { recursive: true , force: false , errorOnExist: false };
200
201 // ---- args ---------------------------------------------------------------------------------------
202 const argv = process.argv. slice ( 2 );
203 const flag = ( name ) => {
204 const i = argv. indexOf ( `--${ name }` );
205 return i !== - 1 && argv[i + 1 ] && ! argv[i + 1 ]. startsWith ( "--" )
206 ? argv[i + 1 ]
207 : null ;
208 };
209 const stack = flag ( "stack" ) ?? "astro" ;
210 const clientIdFlag = flag ( "client-id" );
211 const planPath = flag ( "plan" );
212 const outDir = flag ( "out" );
213 const flagArgs = new Set (
214 [ "--stack" , "--client-id" , "--plan" , "--out" , stack, clientIdFlag, planPath, outDir]. filter (Boolean),
215 );
216 const requested = [ ...new Set (argv. filter (( a ) => ! flagArgs. has (a)))];
217
218 const result = { stack, verticals: [], skillRoot: SKILL_ROOT , templates: REF };
219
220 if ( ! [ "astro" , "react" , "lib" , "static" ]. includes (stack)) {
221 console. log (
222 JSON . stringify ({
223 error: `unknown --stack "${ stack }" — expected astro|react|lib|static` ,
224 }),
225 );
226 process. exit ( 1 );
227 }
228
229 let plan = {};
230 if (planPath) {
231 if ( ! existsSync (planPath)) {
232 console. log ( JSON . stringify ({ error: `plan file not found: ${ planPath }` }));
233 process. exit ( 1 );
234 }
235 try {
236 plan = JSON . parse ( readFileSync (planPath, "utf8" ));
237 } catch (error) {
238 console. log ( JSON . stringify ({ error: `invalid --plan JSON: ${ error . message }` }));
239 process. exit ( 1 );
240 }
241 }
242
243 const uploadPolicies = plan.capabilities?.mediaUpload?.policies ?? [];
244 if ( ! Array. isArray (uploadPolicies)) {
245 console. log ( JSON . stringify ({ error: "plan.capabilities.mediaUpload.policies must be an array" }));
246 process. exit ( 1 );
247 }
248 // Site search is on when the plan carries the entry (`{}` is enough; `true` too). Its fields are
249 // data for the generated config; the shipped core drops unknown type names, so only shapes are checked.
250 const siteSearchEntry = plan.capabilities?.siteSearch;
251 const siteSearch = siteSearchEntry === true ? {} : siteSearchEntry && typeof siteSearchEntry === "object" ? siteSearchEntry : null ;
252 if (siteSearchEntry != null && siteSearchEntry !== false && ! siteSearch) {
253 console. log ( JSON . stringify ({ error: "plan.capabilities.siteSearch must be an object (or true)" }));
254 process. exit ( 1 );
255 }
256 if (siteSearch) {
257 const strings = ( v ) => Array. isArray (v) && v. every (( x ) => typeof x === "string" );
258 const map = ( v ) => v === undefined || (v && typeof v === "object" && ! Array. isArray (v) && Object. values (v). every (( x ) => typeof x === "string" ));
259 if ((siteSearch.types !== undefined && ! strings (siteSearch.types)) || ! map (siteSearch.routes) || ! map (siteSearch.labels)) {
260 console. log ( JSON . stringify ({ error: "plan.capabilities.siteSearch: types must be string[]; routes and labels must be objects of strings" }));
261 process. exit ( 1 );
262 }
263 }
264 // The generated file is data only; the shipped core resolves defaults (types, routes, labels) from it.
265 const siteSearchConfigSource = () =>
266 `// Generated by wix-headless-kit install/deploy.mjs from plan.capabilities.siteSearch. \n ` +
267 `// Edit the plan and redeploy. Defaults for anything omitted are in search-core.ts (resolveConfig). \n ` +
268 `export const siteSearchPlan = ${ JSON . stringify ( siteSearch , null , 2 ) }; \n ` ;
269 // ---- static stack: the REST layer, composed flat and stripped ------------------------------------
270 if (stack === "static" ) {
271 const { npmSync } = await import ( "./proc.mjs" );
272 const { rmSync } = await import ( "node:fs" );
273 const OUT = outDir ? resolve ( PROJECT , outDir) : PROJECT ;
274 const JS = join ( OUT , "js" , "wix" );
275 result.out = outDir ?? "." ;
276 // The client id the browser mints visitor tokens with: --client-id, else the CONTENT app from
277 // .env.local (`wix env pull`; on a migration preview the parent site's app), else the config's appId.
278 let clientId = clientIdFlag ?? ( existsSync ( join ( PROJECT , "wix.config.json" )) ? siteContext ({ cwd: PROJECT }).content.clientId : null );
279 if ( ! clientId) {
280 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` }));
281 process. exit ( 1 );
282 }
283 const missing = requested. filter (( v ) => ! existsSync ( join ( REF , v, "rest" )));
284 if (missing. length ) {
285 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 ( ", " ) }` }));
286 process. exit ( 1 );
287 }
288 // Only files that aren't there yet — a re-run restores, never clobbers (same as the copy below).
289 cpSync ( join ( REF , "shared" , "rest" ), JS , COPY );
290 // The Ricos renderer the cores import (../rich-content in the app layout), the same file on every stack.
291 cpSync ( join ( REF , "shared" , "app" , "wix" , "rich-content.ts" ), join ( JS , "rich-content.ts" ), COPY );
292 // Every layer with a rest/ twin: the requested verticals, plus the site-search capability when the
293 // plan turns it on (same shape — rest/ beside app/wix/<name>/ — at templates/shared/capabilities).
294 const layers = requested. map (( vertical ) => [vertical, join ( REF , vertical)]);
295 if (siteSearch) layers. push ([ "site-search" , join ( REF , SITE_SEARCH )]);
296 for ( const [ vertical , root ] of layers) {
297 cpSync ( join (root, "rest" ), JS , COPY );
298 // The vertical's transport-agnostic core: types, every *-core.ts, and every *-store.ts under its
299 // app/wix/<vertical>/ — the stores are the hooks' logic without React, over the same function
300 // names the REST twin exports, so they run against it unchanged.
301 const appWix = join (root, "app" , "wix" , vertical);
302 for ( const f of readdirSync (appWix)) {
303 // Every vertical has a types.ts; flat in one folder they would collide, so each lands as
304 // <vertical>-types.ts and the vertical's own files are pointed at it below.
305 if (f === "types.ts" ) cpSync ( join (appWix, f), join ( JS , `${ vertical }-types.ts` ), COPY );
306 // *.generated.ts: data a core file imports (the forms' per-country address templates).
307 else if (f. endsWith ( "-core.ts" ) || f. endsWith ( "-store.ts" ) || f. endsWith ( ".generated.ts" )) cpSync ( join (appWix, f), join ( JS , f), COPY );
308 }
309 const own = new Set ([ ... readdirSync ( join (root, "rest" )), ... readdirSync (appWix)]. filter (( f ) => f. endsWith ( ".ts" ) && f !== "types.ts" ));
310 for ( const f of own) {
311 const fp = join ( JS , f);
312 if ( ! existsSync (fp)) continue ;
313 const src = readFileSync (fp, "utf8" );
314 // The rest/ files import their real paths in the templates (../../shared/rest/client.js,
315 // ../app/wix/<vertical>/cart-core.js); flat in one folder, every import is a sibling.
316 const fixed = src
317 . replace ( /from \s + " \. {1,2} \/ (?: [ ^ "] * \/ )( [ ^ "/] + )"/ g , 'from "./$1"' )
318 . replace ( /from \s + " \.\.\/ ( [ ^ "/] + )"/ g , 'from "./$1"' )
319 . replace ( /from \s + " \.\/ types(?: \. js) ? "/ g , `from "./${ vertical }-types.js"` );
320 if (fixed !== src) writeFileSync (fp, fixed);
321 }
322 if ( VERTICALS . includes (vertical)) result.verticals. push (vertical);
323 }
324 if (siteSearch) {
325 // The REST twin imports the plan as data from beside it (shared/rest already owns config.ts).
326 writeFileSync ( join ( JS , "site-search-config.generated.ts" ), siteSearchConfigSource ());
327 result.capabilities = { ... result.capabilities, siteSearch: true };
328 }
329 // Browser ESM resolves nothing: every relative import needs its .js. The rest/ files are written
330 // that way; the files borrowed from app/wix/ are bundler-style and get the suffix here.
331 for ( const f of readdirSync ( JS ). filter (( f ) => f. endsWith ( ".ts" ))) {
332 const src = readFileSync ( join ( JS , f), "utf8" );
333 const fixed = src. replace ( /(from \s + " \.\/ [ ^ "] +? )(?<! \. js)"/ g , '$1.js"' );
334 if (fixed !== src) writeFileSync ( join ( JS , f), fixed);
335 }
336 const cfg = join ( JS , "config.ts" );
337 const current = readFileSync (cfg, "utf8" );
338 if ( /WIX_CLIENT_ID: string = ""/ . test (current)) {
339 writeFileSync (cfg, current. replace ( /WIX_CLIENT_ID: string = ""/ , `WIX_CLIENT_ID: string = "${ clientId }"` ));
340 result.clientId = "written" ;
341 } else {
342 result.clientId = "already_set" ;
343 }
344 // Strip to ESM with tsc — comments are the spec for whoever reads js/wix/, so they stay.
345 const sources = readdirSync ( JS ). filter (( f ) => f. endsWith ( ".ts" )). map (( f ) => join ( JS , f));
346 const tsc = npmSync (
347 "npx" ,
348 [ "-y" , "-p" , `typescript@${ PINS . typescript }` , "tsc" , ... sources, "--outDir" , JS , "--module" , "esnext" , "--target" , "es2022" ,
349 "--moduleResolution" , "bundler" , "--lib" , "es2022,dom" , "--strict" , "--skipLibCheck" , "--removeComments" , "false" ],
350 { encoding: "utf8" , timeout: 180_000 },
351 );
352 if (tsc.status !== 0 ) {
353 console. log ( JSON . stringify ({ ... result, error: `tsc strip failed: ${ ( tsc . stdout || tsc . stderr || tsc . error ?. message || `exit ${ tsc . status }` ). slice ( - 800 ) }` }));
354 process. exit ( 1 );
355 }
356 rmSync ( join ( JS , "tsconfig.json" ), { force: true });
357 // Each .js opens with its declarations as // lines: the exports, their arguments and what they
358 // return, so the file carries its own contract where a host serves no .ts (a Wix drop keeps
359 // only listed extensions, and .ts is not one). The code below keeps its comments, so the header
360 // is the bare signatures; a *-types file has no code, so its header keeps the field comments.
361 const { mkdtempSync } = await import ( "node:fs" );
362 const { tmpdir } = await import ( "node:os" );
363 const declare = ( removeComments ) => {
364 const dir = mkdtempSync ( join ( tmpdir (), "wix-decl-" ));
365 const run = npmSync (
366 "npx" ,
367 [ "-y" , "-p" , `typescript@${ PINS . typescript }` , "tsc" , ... sources, "--declaration" , "--emitDeclarationOnly" , "--outDir" , dir,
368 "--module" , "esnext" , "--target" , "es2022" , "--moduleResolution" , "bundler" , "--lib" , "es2022,dom" , "--strict" ,
369 "--skipLibCheck" , "--removeComments" , String (removeComments)],
370 { encoding: "utf8" , timeout: 180_000 },
371 );
372 if (run.status !== 0 ) {
373 console. log ( JSON . stringify ({ ... result, error: `tsc declarations failed: ${ ( run . stdout || run . stderr || run . error ?. message || `exit ${ run . status }` ). slice ( - 800 ) }` }));
374 process. exit ( 1 );
375 }
376 return dir;
377 };
378 const bare = declare ( true );
379 const commented = declare ( false );
380 for ( const f of readdirSync ( JS ). filter (( f ) => f. endsWith ( ".js" ) && f !== "config.js" )) {
381 const name = f. replace ( / \. js $ / , ".d.ts" );
382 const from = join (name. endsWith ( "-types.d.ts" ) ? commented : bare, name);
383 if ( ! existsSync (from)) continue ;
384 const lines = readFileSync (from, "utf8" ). trimEnd (). split ( " \n " ). filter (( l ) => l. trim () !== "export {};" );
385 if ( ! lines. length ) continue ;
386 const header = [ `// Declarations of ${ f . replace ( / \. js $ / , ".ts" ) }: what this file exports, their arguments and what they return.` ,
387 ... lines. map (( l ) => (l ? `// ${ l }` : "//" )), "" ]. join ( " \n " );
388 writeFileSync ( join ( JS , f), header + readFileSync ( join ( JS , f), "utf8" ));
389 }
390 rmSync (bare, { recursive: true , force: true });
391 rmSync (commented, { recursive: true , force: true });
392 result.js = `${ outDir ? outDir . replace ( / \/ $ / , "" ) + "/" : ""}js/wix/*.js` ;
393 // The note lists what actually landed, so it holds for every vertical (and several at once).
394 const stores = readdirSync ( JS ). filter (( f ) => f. endsWith ( "-store.js" )). map (( f ) => `./js/wix/${ f }` );
395 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 }` );
396 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">; each .js opens with its declarations (the .ts beside it is the same file with types); point wix.config.json site.outputDirectory at "./${ outDir ?? "."}"` ;
397 console. log ( JSON . stringify (result));
398 process. exit ( 0 );
399 }
400
401 if (uploadPolicies. length && stack !== "astro" ) {
402 console. log ( JSON . stringify ({ error: "mediaUpload currently requires --stack astro because it ships a validated server endpoint" }));
403 process. exit ( 1 );
404 }
405 for ( const policy of uploadPolicies) {
406 if (
407 ! policy ||
408 typeof policy.id !== "string" ||
409 ! Array. isArray (policy.accept) ||
410 ! policy.accept. every (( mime ) => typeof mime === "string" ) ||
411 ! Number. isSafeInteger (policy.maxBytes) ||
412 policy.maxBytes < 1 ||
413 (policy.audience !== undefined && policy.audience !== "members" && policy.audience !== "visitors" )
414 ) {
415 console. log ( JSON . stringify ({ error: 'each mediaUpload policy needs id, accept: string[], positive integer maxBytes, and optionally audience: "members" (default) | "visitors"' }));
416 process. exit ( 1 );
417 }
418 }
419
420 // ---- copy ---------------------------------------------------------------------------------------
421 // Shared core — always. The lib stack takes only the framework-free wix/ part (no styles).
422 if (stack === "lib" ) cpSync ( join ( REF , "shared" , "app" , "wix" ), join ( SRC , "wix" ), COPY );
423 else cpSync ( join ( REF , "shared" , "app" ), SRC , COPY );
424
425 if (uploadPolicies. length ) {
426 cpSync ( join ( REF , "shared" , "capabilities" , "media-upload" , "app" ), SRC , COPY );
427 cpSync ( join ( REF , "shared" , "capabilities" , "media-upload" , "app-astro" ), SRC , COPY );
428 }
429 // Site search ships like a vertical (data layer, stores, hooks, components, a search page) at a
430 // disjoint path (src/wix/site-search/, src/components/site-search/, …), so the same per-stack rule
431 // applies: lib takes only app/wix/, astro adds app-astro/.
432 if (siteSearch) {
433 if (stack === "lib" ) cpSync ( join ( REF , SITE_SEARCH , "app" , "wix" ), join ( SRC , "wix" ), COPY );
434 else cpSync ( join ( REF , SITE_SEARCH , "app" ), SRC , COPY );
435 if (stack === "astro" ) cpSync ( join ( REF , SITE_SEARCH , "app-astro" ), SRC , COPY );
436 }
437
438 const unknown = requested. filter (( v ) => ! VERTICALS . includes (v));
439 // lib: only @wix/sdk from the shared deps — the Tailwind pair serves the components, which don't ship there.
440 const wantedDeps = stack === "lib" ? { "@wix/sdk" : SHARED_DEPS [ "@wix/sdk" ] } : { ... SHARED_DEPS };
441 if (uploadPolicies. length ) Object. assign (wantedDeps, CAPABILITY_DEPS [ "media-upload" ]);
442 if (siteSearch) Object. assign (wantedDeps, CAPABILITY_DEPS [ "site-search" ]);
443 for ( const vertical of requested. filter (( v ) => VERTICALS . includes (v))) {
444 if (stack === "lib" ) cpSync ( join ( REF , vertical, "app" , "wix" ), join ( SRC , "wix" ), COPY );
445 else cpSync ( join ( REF , vertical, "app" ), SRC , COPY );
446 if (stack === "astro" && existsSync ( join ( REF , vertical, "app-astro" ))) {
447 cpSync ( join ( REF , vertical, "app-astro" ), SRC , COPY );
448 }
449 const deps = VERTICAL_DEPS [vertical] ?? {};
450 Object. assign (
451 wantedDeps,
452 deps.core,
453 stack === "astro" ? deps.astro : undefined ,
454 );
455 result.verticals. push (vertical);
456 }
457
458 if (uploadPolicies. length ) {
459 const configDir = join ( SRC , "wix" , "media-upload" );
460 const configFile = join (configDir, "policies.generated.ts" );
461 // The generated file is data only. The shipped endpoint owns validation and elevation.
462 writeFileSync (
463 configFile,
464 `// Generated by wix-headless-kit install/deploy.mjs from plan.capabilities.mediaUpload. \n ` +
465 `// Edit the plan and redeploy; do not add browser-controlled policy selection here. \n ` +
466 `export const mediaUploadPolicies = ${ JSON . stringify ( uploadPolicies , null , 2 ) } as const; \n ` ,
467 );
468 result.capabilities = { mediaUpload: uploadPolicies. map (( policy ) => policy.id) };
469 }
470 if (siteSearch) {
471 // The SDK transport imports the plan as data from beside it; defaults resolve in the shipped core.
472 writeFileSync ( join ( SRC , "wix" , "site-search" , "config.generated.ts" ), siteSearchConfigSource ());
473 result.capabilities = { ... result.capabilities, siteSearch: true };
474 if (siteSearch.install) {
475 // Indexing needs the Wix Site Search app on the site — a site mutation the deploy never makes.
476 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 ( "; " );
477 }
478 }
479
480 // ---- the blank starter takes the vertical's composed template --------------------------------
481 // A project that is still the blank starter (context.mjs blankStarter: a Dev Machine's, or a bare
482 // `wix create`) holds only the blank template's packages. Deploying one vertical onto it makes
483 // exactly that vertical's composed template (templates/<vertical>/project: the blank scaffold plus
484 // the vertical), so the starter takes the composed package.json dependencies and lockfile, and the
485 // install reads the lock instead of resolving the vertical's tree. A project with any package of its
486 // own, a second vertical, or two verticals in one run is left alone.
487 if (stack === "astro" && result.verticals. length === 1 && blankStarter ( PROJECT , join ( REF , "blank" , "project" , "package.json" ))) {
488 // The composed project/ is left out of a committed templates copy; templatesDir fetches it on need.
489 const composed = join ( templatesDir ({ need: `${ result . verticals [ 0 ] }/project` }), result.verticals[ 0 ], "project" );
490 if ( existsSync ( join (composed, "package-lock.json" ))) {
491 const pkg = JSON . parse ( readFileSync ( PKG_JSON , "utf8" ));
492 const template = JSON . parse ( readFileSync ( join (composed, "package.json" ), "utf8" ));
493 pkg.dependencies = template.dependencies;
494 pkg.devDependencies = template.devDependencies;
495 writeFileSync ( PKG_JSON , JSON . stringify (pkg, null , 2 ) + " \n " );
496 cpSync ( join (composed, "package-lock.json" ), join ( PROJECT , "package-lock.json" ));
497 result.lockFromTemplate = result.verticals[ 0 ];
498 }
499 }
500
501 // ---- dependency patch (fill-only) ----------------------------------------------------------------
502 if (result.verticals. length && existsSync ( PKG_JSON )) {
503 const pkg = JSON . parse ( readFileSync ( PKG_JSON , "utf8" ));
504 pkg.dependencies ??= {};
505 const present = { ... pkg.devDependencies, ... pkg.dependencies };
506 const added = Object. entries (wantedDeps). filter (
507 ([ name ]) => ! (name in present),
508 );
509 for ( const [ name , range ] of added) pkg.dependencies[name] = range;
510 if (added. length )
511 writeFileSync ( PKG_JSON , JSON . stringify (pkg, null , 2 ) + " \n " );
512 result.depsAdded = added. map (([ name ]) => name);
513 } else if (result.verticals. length ) {
514 result.depsAdded = [];
515 result.note = [
516 result.note,
517 "no package.json here — run deploy from the project root" ,
518 ]
519 . filter (Boolean)
520 . join ( "; " );
521 }
522
523 // ---- the project's lockfile ---------------------------------------------------------------------
524 // A project created from a composed template (templates/<vertical>/project) carries that
525 // template's package-lock.json, so `npm ci` installs it without resolving. When this run added
526 // dependencies to package.json, the lock's root entry is brought in line where the tree already
527 // holds the package (install/lock.mjs); anything the tree lacks makes `npm ci` fail fast and the
528 // `|| npm install` fallback resolve it. A project without a lock installs with `npm install`.
529 const PROJECT_LOCK = join ( PROJECT , "package-lock.json" );
530 if (result.verticals. length && existsSync ( PKG_JSON )) {
531 if ( existsSync ( PROJECT_LOCK )) {
532 const sync = syncLockRoot ( PROJECT );
533 if (sync && (sync.promoted. length || sync.missing. length )) result.lock = sync;
534 result.note = [result.note, "install with node <SKILL_ROOT>/install/install.mjs (nothing when node_modules already satisfies the lock)" ]. filter (Boolean). join ( "; " );
535 } else if (result.depsAdded?. length ) {
536 result.note = [result.note, "run node <SKILL_ROOT>/install/install.mjs to pick up depsAdded" ]. filter (Boolean). join ( "; " );
537 }
538 }
539
540 // ---- astro config: wire the Tailwind vite plugin ---------------------------------------------------
541 // The blank scaffold's astro.config.mjs has no Tailwind wiring. Fill-only, like everything
542 // else: patch only when the plugin isn't referenced yet, and only when both anchors are found.
543 const ASTRO_CONFIG = join ( PROJECT , "astro.config.mjs" );
544 if (stack === "astro" && result.verticals. length && existsSync ( ASTRO_CONFIG )) {
545 const config = readFileSync ( ASTRO_CONFIG , "utf8" );
546 if (config. includes ( "@tailwindcss/vite" )) {
547 result.astroConfig = "tailwind_already_wired" ;
548 } else if (config. includes ( "export default defineConfig({" )) {
549 const patched =
550 `import tailwindcss from "@tailwindcss/vite"; \n ` +
551 config. replace (
552 "export default defineConfig({" ,
553 "export default defineConfig({ \n vite: { plugins: [tailwindcss()] }," ,
554 );
555 writeFileSync ( ASTRO_CONFIG , patched);
556 result.astroConfig = "tailwind_wired" ;
557 } else {
558 result.astroConfig =
559 "UNPATCHED — add `vite: { plugins: [tailwindcss()] }` (import from @tailwindcss/vite) to astro.config.mjs by hand" ;
560 }
561 }
562
563 // ---- ready-made links -----------------------------------------------------------------------------
564 // Emit the siteId and the dashboard deep links so nothing downstream re-derives or retypes them.
565 // The site the links point at is the CONTENT site — the parent on a migration preview, where the
566 // config's site only hosts the deployment (install/context.mjs).
567 const WIX_CONFIG = join ( PROJECT , "wix.config.json" );
568 const ctx = existsSync ( WIX_CONFIG ) ? siteContext ({ cwd: PROJECT }) : null ;
569 if (ctx) {
570 const siteId = ctx.content.siteId;
571 if (siteId) {
572 result.siteId = siteId;
573 if (ctx.migration.active) result.migration = { parentSiteId: ctx.migration.parentSiteId, deploySiteId: ctx.deploy.siteId };
574 result.dashboardUrl = `https://manage.wix.com/dashboard/${ siteId }` ;
575 if (result.verticals. includes ( "storefront" )) {
576 result.productsUrl = `https://manage.wix.com/dashboard/${ siteId }/wix-stores/products` ;
577 result.categoriesUrl = `https://manage.wix.com/dashboard/${ siteId }/wix-stores/categories/list` ;
578 }
579 }
580 }
581
582 if (unknown. length ) {
583 result.error = `unknown vertical(s) ${ unknown . map (( v ) => `"${ v }"` ). join ( ", " ) } — available: ${ VERTICALS . join ( ", " ) }` ;
584 }
585 if ( ! requested. length ) {
586 result.note = `no vertical given — deployed the shared core only. Available: ${ VERTICALS . join ( ", " ) }` ;
587 }
588
589 // ---- client ids ---------------------------------------------------------------------------------
590 // The shared data client is ambient on managed Astro, and so is the members flow there (the
591 // integration's built-in /api/auth routes). WIX_MEMBERS_CLIENT_ID is still filled for projects whose
592 // members code predates the shared seam; the shipped code reads WIX_CLIENT_ID only.
593 // --client-id, else the CONTENT app: .env.local's WIX_CLIENT_ID (what `wix env pull` writes — on a
594 // migration preview the parent site's app, the one the Astro integration itself runs as), else the
595 // config's appId. Never the config first: on a preview that is the deploy-only child app.
596 const clientId = clientIdFlag ?? ctx?.content.clientId ?? null ;
597 if (stack === "react" || stack === "lib" ) {
598 if (clientId) {
599 const current = readFileSync ( CONFIG_TS , "utf8" );
600 // A non-null id already set wins; only fill the shipped null placeholder.
601 if ( /WIX_CLIENT_ID: \s * string \s * \| \s * null \s * = \s * null/ . test (current)) {
602 writeFileSync (
603 CONFIG_TS ,
604 current. replace (
605 /WIX_CLIENT_ID: \s * string \s * \| \s * null \s * = \s * null/ ,
606 `WIX_CLIENT_ID: string | null = "${ clientId }"` ,
607 ),
608 );
609 result.clientId = "written" ;
610 } else {
611 result.clientId = "already_set" ;
612 }
613 } else {
614 result.clientId =
615 `missing — pass --client-id (the ${ stack } stack needs the public OAuth client id)` ;
616 }
617 }
618 if (requested. includes ( "members" )) {
619 if ( ! clientId) {
620 result.membersClientId =
621 "missing — pass --client-id (custom members login needs the public OAuth client id)" ;
622 } else {
623 const current = readFileSync ( CONFIG_TS , "utf8" );
624 if (
625 /WIX_MEMBERS_CLIENT_ID: \s * string \s * \| \s * null \s * = \s * null/ . test (current)
626 ) {
627 writeFileSync (
628 CONFIG_TS ,
629 current. replace (
630 /WIX_MEMBERS_CLIENT_ID: \s * string \s * \| \s * null \s * = \s * null/ ,
631 `WIX_MEMBERS_CLIENT_ID: string | null = "${ clientId }"` ,
632 ),
633 );
634 result.membersClientId = "written" ;
635 } else {
636 result.membersClientId = "already_set" ;
637 }
638 }
639 }
640
641 console. log ( JSON . stringify (result, null , 2 ));