Setting the file. One moment.
Deploy · Wix Headless Fast · wix/skills · Skills Docs
ContentsBack to the top of the page (opens in a new tab)
install/ deploy.mjs
JavaScript · 433 lines · 15 KB
14 // a re-run restores missing files without clobbering edits, and a later call can add a
15 // vertical safely. Verticals ship at disjoint paths (src/wix/<vertical>/,
16 // src/components/<vertical>/, …), so they never collide with each other.
17 // 2. package.json dependency patch — after the copy, any dependency the copied code imports
18 // that is absent from BOTH dependencies and devDependencies is added to dependencies
19 // (fill-only: an existing version range always wins). The script never runs npm install —
20 // it only makes package.json truthful about what src/ imports; installing is the caller's
21 // one command afterwards.
22 import {
23 cpSync,
24 existsSync,
25 readFileSync,
26 writeFileSync,
27 readdirSync,
28 } from "node:fs" ;
29 import { dirname, join, resolve } from "node:path" ;
30 import { fileURLToPath } from "node:url" ;
31
32 const SKILL_ROOT = resolve ( dirname ( fileURLToPath ( import . meta .url)), ".." );
33 const REF = join ( SKILL_ROOT , "references" );
34 const PROJECT = process. cwd ();
35 const SRC = join ( PROJECT , "src" );
36 const CONFIG_TS = join ( SRC , "wix" , "config.ts" );
37 const PKG_JSON = join ( PROJECT , "package.json" );
38
39 // The vertical registry: every directory under references/ that ships an app/ is a vertical.
40 const VERTICALS = readdirSync ( REF , { withFileTypes: true })
41 . filter (
42 ( d ) =>
43 d. isDirectory () &&
44 existsSync ( join ( REF , d.name, "app" )) &&
45 d.name !== "shared" ,
46 )
47 . map (( d ) => d.name);
48
49 // What the shipped code imports, declared per layer (version ranges are the ones the shipped
50 // code was verified against). react/astro/typescript/@wix/astro* are scaffold-owned — never
51 // listed here. A new vertical adds its own entry; the patch logic below never changes.
52 const SHARED_DEPS = {
53 "@wix/sdk" : "^1.21.5" ,
54 // The shipped components style themselves with Tailwind v4 utilities reading the @theme
55 // tokens in styles/global.css (the same system the official Wix headless templates use).
56 tailwindcss: "^4.1.7" ,
57 "@tailwindcss/vite" : "^4.1.7" ,
58 };
59 const CAPABILITY_DEPS = {
60 "media-upload" : {
61 // All shipped Astro locks already carry @wix/media >= 1.0.271 transitively. Keep this
62 // range compatible with them, then promote it to a root lock dependency below.
63 "@wix/media" : "^1.0.271" ,
64 "@wix/essentials" : "^1.0.10" ,
65 },
66 };
67 const VERTICAL_DEPS = {
68 storefront: {
69 core: {
70 "@wix/stores" : "^1.0.888" ,
71 "@wix/categories" : "^1.0.220" ,
72 "@wix/ecom" : "^1.0.2451" ,
73 "@wix/redirects" : "^1.0.125" ,
74 },
75 astro: {
76 "@wix/seo" : "^1.0.79" ,
77 "@wix/essentials" : "^1.0.6" ,
78 },
79 },
80 bookings: {
81 core: {
82 "@wix/bookings" : "^1.0.1650" ,
83 "@wix/auto_sdk_ecom_cart-v-2" : "^1.0.192" ,
84 "@wix/forms" : "^1.0.500" ,
85 "@wix/redirects" : "^1.0.125" ,
86 },
87 astro: {
88 "@wix/seo" : "^1.0.79" ,
89 "@wix/essentials" : "^1.0.6" ,
90 },
91 },
92 blog: {
93 core: {
94 "@wix/blog" : "^1.0.645" ,
95 "@wix/ricos" : "^11.12.0" ,
96 },
97 astro: {
98 "@wix/seo" : "^1.0.79" ,
99 "@wix/essentials" : "^1.0.10" , // WIX_APPS.blogs needs ≥1.0.10
100 },
101 },
102 cms: {
103 core: {
104 "@wix/data" : "^1.0.512" ,
105 },
106 },
107 forms: {
108 core: {
109 "@wix/forms" : "^1.0.501" ,
110 },
111 },
112 events: {
113 core: {
114 "@wix/events" : "^1.0.860" ,
115 "@wix/redirects" : "^1.0.125" ,
116 },
117 astro: {
118 "@wix/seo" : "^1.0.79" ,
119 "@wix/essentials" : "^1.0.10" ,
120 },
121 },
122 members: {
123 core: {
124 "@wix/members" : "^1.0.511" ,
125 },
126 },
127 portfolio: {
128 core: {
129 "@wix/portfolio" : "^1.0.229" ,
130 },
131 },
132 "pricing-plans" : {
133 core: {
134 "@wix/pricing-plans" : "^1.0.378" ,
135 "@wix/redirects" : "^1.0.125" ,
136 },
137 },
138 restaurants: {
139 core: {
140 "@wix/restaurants" : "^1.0.525" ,
141 "@wix/table-reservations" : "^1.0.397" ,
142 "@wix/ecom" : "^1.0.2454" ,
143 "@wix/redirects" : "^1.0.125" ,
144 },
145 },
146 };
147
148 const COPY = { recursive: true , force: false , errorOnExist: false };
149
150 // ---- args ---------------------------------------------------------------------------------------
151 const argv = process.argv. slice ( 2 );
152 const flag = ( name ) => {
153 const i = argv. indexOf ( `--${ name }` );
154 return i !== - 1 && argv[i + 1 ] && ! argv[i + 1 ]. startsWith ( "--" )
155 ? argv[i + 1 ]
156 : null ;
157 };
158 const stack = flag ( "stack" ) ?? "astro" ;
159 const clientIdFlag = flag ( "client-id" );
160 const planPath = flag ( "plan" );
161 const flagArgs = new Set (
162 [ "--stack" , "--client-id" , "--plan" , stack, clientIdFlag, planPath]. filter (Boolean),
163 );
164 const requested = [ ...new Set (argv. filter (( a ) => ! flagArgs. has (a)))];
165
166 const result = { stack, verticals: [], skillRoot: SKILL_ROOT };
167
168 if ( ! [ "astro" , "react" ]. includes (stack)) {
169 console. log (
170 JSON . stringify ({
171 error: `unknown --stack "${ stack }" — expected astro|react` ,
172 }),
173 );
174 process. exit ( 1 );
175 }
176
177 let plan = {};
178 if (planPath) {
179 if ( ! existsSync (planPath)) {
180 console. log ( JSON . stringify ({ error: `plan file not found: ${ planPath }` }));
181 process. exit ( 1 );
182 }
183 try {
184 plan = JSON . parse ( readFileSync (planPath, "utf8" ));
185 } catch (error) {
186 console. log ( JSON . stringify ({ error: `invalid --plan JSON: ${ error . message }` }));
187 process. exit ( 1 );
188 }
189 }
190
191 const uploadPolicies = plan.capabilities?.mediaUpload?.policies ?? [];
192 if ( ! Array. isArray (uploadPolicies)) {
193 console. log ( JSON . stringify ({ error: "plan.capabilities.mediaUpload.policies must be an array" }));
194 process. exit ( 1 );
195 }
196 if (uploadPolicies. length && stack !== "astro" ) {
197 console. log ( JSON . stringify ({ error: "mediaUpload currently requires --stack astro because it ships a validated server endpoint" }));
198 process. exit ( 1 );
199 }
200 for ( const policy of uploadPolicies) {
201 if (
202 ! policy ||
203 typeof policy.id !== "string" ||
204 ! Array. isArray (policy.accept) ||
205 ! policy.accept. every (( mime ) => typeof mime === "string" ) ||
206 ! Number. isSafeInteger (policy.maxBytes) ||
207 policy.maxBytes < 1
208 ) {
209 console. log ( JSON . stringify ({ error: "each mediaUpload policy needs id, accept: string[], and positive integer maxBytes" }));
210 process. exit ( 1 );
211 }
212 }
213
214 // ---- copy ---------------------------------------------------------------------------------------
215 // Shared core — always.
216 cpSync ( join ( REF , "shared" , "app" ), SRC , COPY );
217
218 if (uploadPolicies. length ) {
219 cpSync ( join ( REF , "shared" , "capabilities" , "media-upload" , "app" ), SRC , COPY );
220 cpSync ( join ( REF , "shared" , "capabilities" , "media-upload" , "app-astro" ), SRC , COPY );
221 }
222
223 const unknown = requested. filter (( v ) => ! VERTICALS . includes (v));
224 const wantedDeps = { ... SHARED_DEPS };
225 if (uploadPolicies. length ) Object. assign (wantedDeps, CAPABILITY_DEPS [ "media-upload" ]);
226 for ( const vertical of requested. filter (( v ) => VERTICALS . includes (v))) {
227 cpSync ( join ( REF , vertical, "app" ), SRC , COPY );
228 if (stack === "astro" && existsSync ( join ( REF , vertical, "app-astro" ))) {
229 cpSync ( join ( REF , vertical, "app-astro" ), SRC , COPY );
230 }
231 const deps = VERTICAL_DEPS [vertical] ?? {};
232 Object. assign (
233 wantedDeps,
234 deps.core,
235 stack === "astro" ? deps.astro : undefined ,
236 );
237 result.verticals. push (vertical);
238 }
239
240 if (uploadPolicies. length ) {
241 const configDir = join ( SRC , "wix" , "media-upload" );
242 const configFile = join (configDir, "policies.generated.ts" );
243 // The generated file is data only. The shipped endpoint owns validation and elevation.
244 writeFileSync (
245 configFile,
246 `// Generated by wix-headless-fast install/deploy.mjs from plan.capabilities.mediaUpload. \n ` +
247 `// Edit the plan and redeploy; do not add browser-controlled policy selection here. \n ` +
248 `export const mediaUploadPolicies = ${ JSON . stringify ( uploadPolicies , null , 2 ) } as const; \n ` ,
249 );
250 result.capabilities = { mediaUpload: uploadPolicies. map (( policy ) => policy.id) };
251 }
252
253 // ---- dependency patch (fill-only) ----------------------------------------------------------------
254 if (result.verticals. length && existsSync ( PKG_JSON )) {
255 const pkg = JSON . parse ( readFileSync ( PKG_JSON , "utf8" ));
256 pkg.dependencies ??= {};
257 const present = { ... pkg.devDependencies, ... pkg.dependencies };
258 const added = Object. entries (wantedDeps). filter (
259 ([ name ]) => ! (name in present),
260 );
261 for ( const [ name , range ] of added) pkg.dependencies[name] = range;
262 if (added. length )
263 writeFileSync ( PKG_JSON , JSON . stringify (pkg, null , 2 ) + " \n " );
264 result.depsAdded = added. map (([ name ]) => name);
265 if (added. length )
266 result.note = [
267 result.note,
268 "run `npm install --ignore-scripts` to pick up depsAdded" ,
269 ]
270 . filter (Boolean)
271 . join ( "; " );
272 } else if (result.verticals. length ) {
273 result.depsAdded = [];
274 result.note = [
275 result.note,
276 "no package.json here — run deploy from the project root" ,
277 ]
278 . filter (Boolean)
279 . join ( "; " );
280 }
281
282 // ---- shipped lockfile (single vertical only) ------------------------------------------------------
283 // A pre-resolved package-lock.json covering the scaffold + this vertical's deps ships at
284 // references/<vertical>/lock/<stack>/package-lock.json. Placing it lets `npm ci` skip the whole
285 // resolution phase (seconds instead of minutes). URLs are canonical registry.npmjs.org form —
286 // npm substitutes the locally configured registry (mirrors/proxies) at fetch time. Placed only
287 // when the project has no lock of its own; if it ever drifts out of sync, `npm ci` fails fast
288 // and the `|| npm install` fallback self-heals.
289 const PROJECT_LOCK = join ( PROJECT , "package-lock.json" );
290 if (
291 result.verticals. length === 1 &&
292 existsSync ( PKG_JSON ) &&
293 ! existsSync ( PROJECT_LOCK )
294 ) {
295 const shippedLock = join (
296 REF ,
297 result.verticals[ 0 ],
298 "lock" ,
299 stack,
300 "package-lock.json" ,
301 );
302 if ( existsSync (shippedLock)) {
303 cpSync (shippedLock, PROJECT_LOCK );
304 if (uploadPolicies. length ) {
305 // The supplied per-vertical lock already resolves Media + Essentials transitively. Add
306 // them to its root package entry too, so npm ci accepts package.json's new direct
307 // dependency without falling back to a slow dependency-resolution install.
308 const lock = JSON . parse ( readFileSync ( PROJECT_LOCK , "utf8" ));
309 lock.packages ??= {};
310 lock.packages[ "" ] ??= {};
311 lock.packages[ "" ].dependencies ??= {};
312 const rootDeps = lock.packages[ "" ].dependencies;
313 for ( const [ name , range ] of Object. entries ( CAPABILITY_DEPS [ "media-upload" ])) {
314 if ( ! lock.packages?.[ `node_modules/${ name }` ]) {
315 result.lockCapability = `UNPATCHED — supplied lock lacks ${ name }; npm install will resolve it` ;
316 break ;
317 }
318 rootDeps[name] = range;
319 }
320 writeFileSync ( PROJECT_LOCK , JSON . stringify (lock, null , 2 ) + " \n " );
321 if ( ! result.lockCapability) result.lockCapability = "media_upload_promoted" ;
322 }
323 result.lockDeployed = true ;
324 result.note = [
325 result.note,
326 "install with `npm ci --ignore-scripts || npm install --ignore-scripts`" ,
327 ]
328 . filter (Boolean)
329 . join ( "; " );
330 }
331 }
332
333 // ---- astro config: wire the Tailwind vite plugin ---------------------------------------------------
334 // The blank scaffold's astro.config.mjs has no Tailwind wiring. Fill-only, like everything
335 // else: patch only when the plugin isn't referenced yet, and only when both anchors are found.
336 const ASTRO_CONFIG = join ( PROJECT , "astro.config.mjs" );
337 if (stack === "astro" && result.verticals. length && existsSync ( ASTRO_CONFIG )) {
338 const config = readFileSync ( ASTRO_CONFIG , "utf8" );
339 if (config. includes ( "@tailwindcss/vite" )) {
340 result.astroConfig = "tailwind_already_wired" ;
341 } else if (config. includes ( "export default defineConfig({" )) {
342 const patched =
343 `import tailwindcss from "@tailwindcss/vite"; \n ` +
344 config. replace (
345 "export default defineConfig({" ,
346 "export default defineConfig({ \n vite: { plugins: [tailwindcss()] }," ,
347 );
348 writeFileSync ( ASTRO_CONFIG , patched);
349 result.astroConfig = "tailwind_wired" ;
350 } else {
351 result.astroConfig =
352 "UNPATCHED — add `vite: { plugins: [tailwindcss()] }` (import from @tailwindcss/vite) to astro.config.mjs by hand" ;
353 }
354 }
355
356 // ---- ready-made links -----------------------------------------------------------------------------
357 // Emit the siteId and the dashboard deep links so nothing downstream re-derives or retypes them.
358 const WIX_CONFIG = join ( PROJECT , "wix.config.json" );
359 if ( existsSync ( WIX_CONFIG )) {
360 const config = JSON . parse ( readFileSync ( WIX_CONFIG , "utf8" ));
361 const siteId = config.siteId ?? config.projectId;
362 if (siteId) {
363 result.siteId = siteId;
364 result.dashboardUrl = `https://manage.wix.com/dashboard/${ siteId }` ;
365 if (result.verticals. includes ( "storefront" )) {
366 result.productsUrl = `https://manage.wix.com/dashboard/${ siteId }/wix-stores/products` ;
367 result.categoriesUrl = `https://manage.wix.com/dashboard/${ siteId }/wix-stores/categories/list` ;
368 }
369 }
370 }
371
372 if (unknown. length ) {
373 result.error = `unknown vertical(s) ${ unknown . map (( v ) => `"${ v }"` ). join ( ", " ) } — available: ${ VERTICALS . join ( ", " ) }` ;
374 }
375 if ( ! requested. length ) {
376 result.note = `no vertical given — deployed the shared core only. Available: ${ VERTICALS . join ( ", " ) }` ;
377 }
378
379 // ---- client ids ---------------------------------------------------------------------------------
380 // The shared data client is ambient on managed Astro. Members are different: their shipped
381 // custom credential flow always needs an explicit public OAuth client, including on Astro.
382 let clientId = clientIdFlag;
383 if ( ! clientId && existsSync ( join ( PROJECT , "wix.config.json" ))) {
384 // On a Wix-managed project the public OAuth client id IS the appId.
385 clientId =
386 JSON . parse ( readFileSync ( join ( PROJECT , "wix.config.json" ), "utf8" )).appId ??
387 null ;
388 }
389 if (stack === "react" ) {
390 if (clientId) {
391 const current = readFileSync ( CONFIG_TS , "utf8" );
392 // A non-null id already set wins; only fill the shipped null placeholder.
393 if ( /WIX_CLIENT_ID: \s * string \s * \| \s * null \s * = \s * null/ . test (current)) {
394 writeFileSync (
395 CONFIG_TS ,
396 current. replace (
397 /WIX_CLIENT_ID: \s * string \s * \| \s * null \s * = \s * null/ ,
398 `WIX_CLIENT_ID: string | null = "${ clientId }"` ,
399 ),
400 );
401 result.clientId = "written" ;
402 } else {
403 result.clientId = "already_set" ;
404 }
405 } else {
406 result.clientId =
407 "missing — pass --client-id (react stack needs the public OAuth client id)" ;
408 }
409 }
410 if (requested. includes ( "members" )) {
411 if ( ! clientId) {
412 result.membersClientId =
413 "missing — pass --client-id (custom members login needs the public OAuth client id)" ;
414 } else {
415 const current = readFileSync ( CONFIG_TS , "utf8" );
416 if (
417 /WIX_MEMBERS_CLIENT_ID: \s * string \s * \| \s * null \s * = \s * null/ . test (current)
418 ) {
419 writeFileSync (
420 CONFIG_TS ,
421 current. replace (
422 /WIX_MEMBERS_CLIENT_ID: \s * string \s * \| \s * null \s * = \s * null/ ,
423 `WIX_MEMBERS_CLIENT_ID: string | null = "${ clientId }"` ,
424 ),
425 );
426 result.membersClientId = "written" ;
427 } else {
428 result.membersClientId = "already_set" ;
429 }
430 }
431 }
432
433 console. log ( JSON . stringify (result, null , 2 ));