Setting the file. One moment.
Wix Headless Scaffold · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 45.38
Position 38 of 182
Type JavaScript
Size 35 KB
Lines 770 scripts/ wix-headless-scaffold.js
JavaScript · 770 lines · 35 KB
}
=
require
(
'../lib/config-env.js'
);
8
9 function usage () {
10 return [
11 'Usage:' ,
12 ' node scripts/wix-headless-scaffold.js --business-name <name> [--folder-name frontend] [--site-template commerce] [--project-dir <dir>]' ,
13 '' ,
14 'Runs the Wix headless scaffold non-interactively through npm create.' ,
15 'The new site is left unpublished (--no-publish); publishing is a cutover step.' ,
16 'Idempotent: safe to re-invoke against the same project — adopts an' ,
17 'existing destination receipt instead of re-scaffolding, and never spawns a second' ,
18 'site when config/wix.env or frontend/wix.config.json already name one.' ,
19 ]. join ( ' \n ' );
20 }
21
22 function parseArgs ( argv ) {
23 const args = {
24 folderName: 'frontend' ,
25 siteTemplate: 'blank' ,
26 projectDir: process. cwd (),
27 };
28 for ( let i = 0 ; i < argv. length ; i += 1 ) {
29 const token = argv[i];
30 switch (token) {
31 case '--business-name' :
32 args.businessName = argv[i + 1 ];
33 i += 1 ;
34 break ;
35 case '--folder-name' :
36 args.folderName = argv[i + 1 ];
37 i += 1 ;
38 break ;
39 case '--site-template' :
40 args.siteTemplate = argv[i + 1 ];
41 i += 1 ;
42 break ;
43 case '--project-dir' :
44 args.projectDir = argv[i + 1 ];
45 i += 1 ;
46 break ;
47 case '--help' :
48 case '-h' :
49 args.help = true ;
50 break ;
51 default :
52 throw new Error ( `Unknown argument: ${ token }` );
53 }
54 }
55 return args;
56 }
57
58 async function readWixConfig ( frontendDir ) {
59 try {
60 const raw = await fs. readFile (path. join (frontendDir, 'wix.config.json' ), 'utf8' );
61 const parsed = JSON . parse (raw);
62 if (parsed && typeof parsed.siteId === 'string' && parsed.siteId. trim () !== '' ) {
63 return parsed;
64 }
65 return null ;
66 } catch (error) {
67 if (error && (error.code === 'ENOENT' || error instanceof SyntaxError )) {
68 return null ;
69 }
70 throw error;
71 }
72 }
73
74 async function readWixEnvValues ( wixEnvPath ) {
75 try {
76 return await readEnvFile (wixEnvPath);
77 } catch (error) {
78 if (error && error.code === 'ENOENT' ) {
79 return {};
80 }
81 throw error;
82 }
83 }
84
85 async function isNonEmptyDir ( dir ) {
86 try {
87 const entries = await fs. readdir (dir);
88 return entries. length > 0 ;
89 } catch (error) {
90 if (error && error.code === 'ENOENT' ) {
91 return false ;
92 }
93 throw error;
94 }
95 }
96
97 function scaffoldCommandArgs ( args ) {
98 return [
99 '--yes' ,
100 'create' ,
101 '@wix/new@latest' ,
102 '--' ,
103 'headless' ,
104 '--business-name' ,
105 args.businessName,
106 '--folder-name' ,
107 args.folderName,
108 '--site-template' ,
109 args.siteTemplate,
110 // New sites stay unpublished until cutover; RePlatform never publishes.
111 '--no-publish' ,
112 '--skip-install' ,
113 ];
114 }
115
116 async function defaultSpawnScaffold ( args ) {
117 return new Promise (( resolve , reject ) => {
118 const child = spawn ( 'npm' , scaffoldCommandArgs (args), {
119 cwd: path. resolve (args.projectDir),
120 stdio: 'inherit' ,
121 });
122 child. on ( 'error' , reject);
123 child. on ( 'exit' , ( code , signal ) => {
124 resolve ({ code: signal ? 1 : (code == null ? 1 : code) });
125 });
126 });
127 }
128
129 // Removes WIX_SCAFFOLD_STATUS entirely (not just blanking its value, which config-env.js's
130 // upsertEnvFile cannot do and which would itself read back as a present-but-unrecognized
131 // value) — used only when a spawn-launch failure proves nothing could have been created,
132 // so the next invocation sees a genuinely untouched config/wix.env, not a lingering marker.
133 async function clearWixScaffoldStatus ( wixEnvPath ) {
134 let raw;
135 try {
136 raw = await fs. readFile (wixEnvPath, 'utf8' );
137 } catch (error) {
138 if (error && error.code === 'ENOENT' ) {
139 return ;
140 }
141 throw error;
142 }
143 const hadTrailingNewline = / \r ? \n $ / . test (raw);
144 const lines = raw. split ( / \r ? \n / );
145 if (hadTrailingNewline && lines[lines. length - 1 ] === '' ) {
146 lines. pop ();
147 }
148 const kept = lines. filter (( line ) => {
149 const trimmed = line. trim ();
150 if ( ! trimmed || trimmed. startsWith ( '#' )) {
151 return true ;
152 }
153 const idx = line. indexOf ( '=' );
154 return idx === - 1 || line. slice ( 0 , idx). trim () !== 'WIX_SCAFFOLD_STATUS' ;
155 });
156 const out = kept. join ( ' \n ' );
157 await fs. writeFile (wixEnvPath, out === '' ? '' : `${ out } \n ` , 'utf8' );
158 }
159
160 // Persists WIX_SITE_ID from a confirmed receipt, re-checking for a conflict immediately
161 // before the write (config/wix.env can change between the top-of-invocation read and now).
162 // Uses statEnvKeys' placeholder-aware classification for the conflict check —
163 // comparing the raw value directly treated a template placeholder
164 // (e.g. WIX_SITE_ID=REPLACE_WITH_REAL_VALUE) as a real, conflicting destination, which
165 // made this throw instead of persisting on the very first scaffold of a freshly
166 // templated project, silently discarding the receipt this function exists to confirm.
167 async function persistSiteId ( wixEnvPath , siteId , scaffoldStatus ) {
168 const currentStatus = ( await statEnvKeys (wixEnvPath, [ 'WIX_SITE_ID' ])).keys. WIX_SITE_ID ;
169 if (currentStatus === 'present' ) {
170 const current = await readWixEnvValues (wixEnvPath);
171 if (current. WIX_SITE_ID !== siteId) {
172 throw new Error (
173 `config/wix.env's WIX_SITE_ID (${ current . WIX_SITE_ID }) no longer matches the scaffold's own wix.config.json (${ siteId }); refusing to overwrite a different destination.` ,
174 );
175 }
176 }
177 await upsertEnvFile (wixEnvPath, { WIX_SITE_ID: siteId, WIX_SCAFFOLD_STATUS: scaffoldStatus });
178 }
179
180 // A locally-run `npm run dev` (or equivalent) against a scaffolded
181 // `website`-mode project, driven from a Wix Remote Machine by any
182 // LLM-based coding client (Claude Code or otherwise), hits two problems
183 // that are properties of the PROJECT, not of any one client or run:
184 //
185 // 1. `@wix/astro`'s auto-injected middleware validates an env schema
186 // (WIX_CLIENT_INSTANCE_ID etc.) that only lives in gitignored
187 // `.env.local` — wiped whenever the remote machine environment
188 // restarts, so the next `npm run dev` throws "MiddlewareCantBeLoaded"
189 // until `.env.local` is manually regenerated via `wix env pull`. Fixed
190 // durably by making the project re-pull it itself before every
191 // dev/build/release, rather than assuming it survives a restart.
192 // 2. The dev server's own Vite config needs `server.allowedHosts` (a
193 // LEADING-DOT suffix, e.g. ".remote-machine.wix-code.com" — never a
194 // literal full hostname, which changes on every fresh remote-machine
195 // instance and would go stale the moment it's committed) and
196 // `server.host: true` (bind all interfaces) — without the latter, a
197 // reverse proxy in front of the dev server can't reach it, surfacing as
198 // an unexplained 504.
199 //
200 // Both are properties of the scaffolded PROJECT (checkpointed, reused
201 // across every future local run), not of any one invocation — so they're
202 // applied once, right after a destination is confirmed (see main()), not
203 // re-derived by hand every time this happens.
204 const REMOTE_MACHINE_HOST_SUFFIX = '.remote-machine.wix-code.com' ;
205 const ENV_PULL_HOOK_SCRIPTS = {
206 predev: 'wix env pull' ,
207 prebuild: 'wix env pull' ,
208 prerelease: 'wix env pull' ,
209 };
210
211 // Same-length "search text" with every comment and string/template literal
212 // blanked out (non-newline characters replaced with spaces) — so none of
213 // the structural scanning below can mistake a `{`/`}` or a `vite`/`server`-
214 // shaped substring living inside a comment or string for real object
215 // structure. A REAL failure this caught before shipping: `// vite: {}`
216 // (a commented-out key, sitting elsewhere in the same object) was matched
217 // as if it were live, and injecting a multiline block right after its `{`
218 // terminated the `//` comment at the injected text's own leading newline,
219 // turning a perfectly valid file into one with a syntax error. Positions
220 // in the returned text line up EXACTLY with the input (only content is
221 // replaced, never length), so any offset found via the masked text is
222 // valid for slicing the ORIGINAL text — every call site below uses the
223 // masked text purely to decide WHERE things are, never to read what they
224 // actually say. The filler is a non-whitespace character (not a space):
225 // a value-span scan's trailing `\s*` must stop at the real edge of a
226 // masked string/comment rather than reading straight through it as if it
227 // were legitimate padding.
228 function maskCommentsAndStrings ( text ) {
229 let out = '' ;
230 let i = 0 ;
231 const n = text. length ;
232 while (i < n) {
233 const ch = text[i];
234 const next = text[i + 1 ];
235 if (ch === '/' && next === '/' ) {
236 const start = i;
237 while (i < n && text[i] !== ' \n ' ) i += 1 ;
238 out += text. slice (start, i). replace ( / [ ^ \n] / g , 'x' );
239 continue ;
240 }
241 if (ch === '/' && next === '*' ) {
242 const start = i;
243 i += 2 ;
244 while (i < n && ! (text[i] === '*' && text[i + 1 ] === '/' )) i += 1 ;
245 i = Math. min (i + 2 , n);
246 out += text. slice (start, i). replace ( / [ ^ \n] / g , 'x' );
247 continue ;
248 }
249 if (ch === "'" || ch === '"' || ch === '`' ) {
250 // Template literals are blanked out WHOLESALE, `${...}` interpolation
251 // included — this script has no reason to understand one, only to
252 // never mistake its contents for real object structure elsewhere.
253 // EXCEPTION: a quoted OBJECT KEY (`"vite": {...}`) must survive
254 // masking untouched — it's the very thing keyName lookups need to
255 // find — so anything immediately followed by `:` (the only shape a
256 // quoted key ever takes here) is left as-is instead of blanked.
257 const quote = ch;
258 const start = i;
259 i += 1 ;
260 while (i < n && text[i] !== quote) {
261 if (text[i] === ' \\ ' ) i += 1 ; // skip an escaped character, e.g. \' or \\
262 i += 1 ;
263 }
264 i = Math. min (i + 1 , n);
265 let j = i;
266 while (j < n && / \s / . test (text[j])) j += 1 ;
267 if (text[j] === ':' ) {
268 out += text. slice (start, i);
269 } else {
270 out += text. slice (start, i). replace ( / [ ^ \n] / g , 'x' );
271 }
272 continue ;
273 }
274 out += ch;
275 i += 1 ;
276 }
277 return out;
278 }
279
280 // Finds the index of the `}` that closes the `{` at openBraceIndex, by
281 // brace-depth counting rather than a full parse. Callers pass the MASKED
282 // text (see maskCommentsAndStrings) so a `{`/`}` inside a comment or
283 // string is never miscounted as real structure; every call site below
284 // only acts when it found a real, load-bearing structural brace via a
285 // preceding regex match, and returns -1 (skip, never guess) on anything
286 // unbalanced.
287 function findMatchingBrace ( text , openBraceIndex ) {
288 let depth = 0 ;
289 for ( let i = openBraceIndex; i < text. length ; i += 1 ) {
290 if (text[i] === '{' ) depth += 1 ;
291 else if (text[i] === '}' ) {
292 depth -= 1 ;
293 if (depth === 0 ) return i;
294 }
295 }
296 return - 1 ;
297 }
298
299 // Matches keyName EXACTLY — as a bare identifier with no adjoining
300 // identifier/hyphen characters on either side, or as a fully quoted
301 // key whose entire quoted content is keyName (same quote character on
302 // both ends, via the backreference). A plain `["']?\bkeyName["']?` (what
303 // this replaced) only checks for a quote immediately around keyName, so
304 // it isn't anchored to the KEY'S own boundaries: "legacy-vite": {...} has
305 // a real word boundary right before "vite" (a hyphen is a non-word
306 // character) and no quote is required there since it's optional, so it
307 // matched as though the key were "vite". Requiring an EXACT bare match or
308 // an exact quoted match rules that out.
309 function exactKeyPattern ( keyName ) {
310 return `(?:(['"])${ keyName } \\ 1|(?<![ \\ w$-])${ keyName }(?![ \\ w$-]))` ;
311 }
312
313 // Finds `keyName: {` (or the quoted form, `"keyName": {`/`'keyName': {'})
314 // as a DIRECT child of the object spanning parentOpen..parentClose
315 // (brace-DEPTH 1 relative to parentOpen) — never a same-named key nested
316 // deeper (e.g. inside a plugin call's own options object), which a plain
317 // "search anywhere in this span" regex would wrongly match and then
318 // corrupt. `text` must be the MASKED text (maskCommentsAndStrings), not
319 // the original source — this only ever returns POSITIONS, valid against
320 // either text since masking preserves length.
321 function findDirectChildBrace ( text , parentOpen , parentClose , keyName ) {
322 const pattern = new RegExp ( `${ exactKeyPattern ( keyName ) } \\ s*: \\ s* \\ {` , 'g' );
323 pattern.lastIndex = parentOpen + 1 ;
324 let match;
325 while ((match = pattern. exec (text)) !== null ) {
326 if (match.index >= parentClose) return - 1 ;
327 let depth = 0 ;
328 for ( let i = parentOpen; i < match.index; i += 1 ) {
329 if (text[i] === '{' ) depth += 1 ;
330 else if (text[i] === '}' ) depth -= 1 ;
331 }
332 if (depth === 1 ) return match.index + match[ 0 ]. length - 1 ;
333 }
334 return - 1 ;
335 }
336
337 // Finds `keyName: <value>` (or the quoted key form) as a DIRECT child of
338 // the object spanning parentOpen..parentClose and returns { start, end }
339 // bounding <value>'s own source text, or 'ambiguous' if a value was found
340 // but its extent can't be safely bounded (an odd bracket run), or null if
341 // the key isn't a direct child at all. A bracketed value ([...]/{...}/
342 // (...)) is bounded by matching brackets; anything else runs to the next
343 // same-depth comma or the parent's own closing brace. `text` must be the
344 // MASKED text — this only ever returns POSITIONS; callers read the actual
345 // value text back out of the ORIGINAL source at those same positions.
346 function findDirectChildValueSpan ( text , parentOpen , parentClose , keyName ) {
347 const pattern = new RegExp ( `${ exactKeyPattern ( keyName ) } \\ s*: \\ s*` , 'g' );
348 pattern.lastIndex = parentOpen + 1 ;
349 let match;
350 while ((match = pattern. exec (text)) !== null ) {
351 if (match.index >= parentClose) return null ;
352 let depth = 0 ;
353 for ( let i = parentOpen; i < match.index; i += 1 ) {
354 if (text[i] === '{' ) depth += 1 ;
355 else if (text[i] === '}' ) depth -= 1 ;
356 }
357 if (depth !== 1 ) continue ; // a same-named key nested deeper — keep looking
358 const valueStart = match.index + match[ 0 ]. length ;
359 const openChar = text[valueStart];
360 if (openChar === '[' || openChar === '{' || openChar === '(' ) {
361 const closeChar = openChar === '[' ? ']' : openChar === '{' ? '}' : ')' ;
362 let bracketDepth = 0 ;
363 for ( let i = valueStart; i < parentClose; i += 1 ) {
364 if (text[i] === openChar) bracketDepth += 1 ;
365 else if (text[i] === closeChar) {
366 bracketDepth -= 1 ;
367 if (bracketDepth === 0 ) return { start: valueStart, end: i + 1 };
368 }
369 }
370 return 'ambiguous' ;
371 }
372 let bracketDepth = 0 ;
373 // INCLUSIVE of parentClose itself: a value with no trailing comma
374 // right before the parent's own closing brace (the common single-line
375 // case, e.g. `{ host: false }`) must have that `}` actually examined,
376 // not stop one character short of it.
377 for ( let i = valueStart; i <= parentClose; i += 1 ) {
378 const ch = text[i];
379 if (ch === '[' || ch === '{' || ch === '(' ) bracketDepth += 1 ;
380 else if (ch === ']' || ch === '}' || ch === ')' ) {
381 if (bracketDepth === 0 ) return { start: valueStart, end: i }; // hit the parent's own close
382 bracketDepth -= 1 ;
383 } else if (ch === ',' && bracketDepth === 0 ) {
384 return { start: valueStart, end: i };
385 }
386 }
387 return 'ambiguous' ;
388 }
389 return null ;
390 }
391
392 function quotedStringInner ( valueText ) {
393 const match = valueText. match ( / ^ '( [ ^ '] * )' $|^ "( [ ^ "] * )" $ / );
394 return match ? (match[ 1 ] ?? match[ 2 ]) : null ;
395 }
396
397 // Best-effort, conservative: never touches a file it can't recognize the
398 // shape of. Merges into an EXISTING `vite`/`vite.server` block rather than
399 // skipping outright just because some unrelated vite config (e.g. plugins,
400 // or an unrelated nested `server:` inside a plugin's OWN options — never
401 // matched, since lookups here are depth-scoped to direct children only) is
402 // already present — a project with any of that must not be left
403 // permanently unpatched. Same treatment for the two settings themselves:
404 // presence alone isn't "already handled" — `host: false` and an
405 // `allowedHosts` array missing our own entry both still reproduce the
406 // original failure, so an already-effective VALUE is what's left alone,
407 // not just an already-present key. Only a value this can't safely
408 // interpret (some other expression — not a literal true/false/string or a
409 // bracketed array) is warned and left untouched rather than guessed at.
410 async function patchAstroConfigForRemoteMachinePreview ( frontendDir ) {
411 const configPath = path. join (frontendDir, 'astro.config.mjs' );
412 let original;
413 try {
414 original = await fs. readFile (configPath, 'utf8' );
415 } catch (error) {
416 if (error && error.code === 'ENOENT' ) {
417 console. warn ( `[wix-headless-scaffold] no astro.config.mjs at ${ configPath } — skipping preview-host patch.` );
418 return ;
419 }
420 throw error;
421 }
422 // Used for ALL structural navigation below (finding keys, matching
423 // braces) — never for reading actual content, which always comes from
424 // `original` at the same positions (masking preserves length exactly).
425 const masked = maskCommentsAndStrings (original);
426
427 const allowedHostsComment =
428 `// Any remote machine's dynamic preview hostname (the hash/port prefix \n ` +
429 ` // changes per session) — a literal full hostname here would go \n ` +
430 ` // stale the moment a remote machine restarts. \n ` +
431 ` ` ;
432 const hostComment =
433 `// Bind all interfaces — Vite defaults to 127.0.0.1 only, which the \n ` +
434 ` // platform's reverse proxy cannot reach (surfaces as a 504). \n ` +
435 ` ` ;
436 const allowedHostsProp = `${ allowedHostsComment }allowedHosts: ['${ REMOTE_MACHINE_HOST_SUFFIX }'],` ;
437 const hostProp = `${ hostComment }host: true,` ;
438
439 const defineMatch = masked. match ( /defineConfig \( \s * \{ / );
440 if ( ! defineMatch) {
441 console. warn ( '[wix-headless-scaffold] astro.config.mjs is not a recognizable `defineConfig({...})` — leaving it alone.' );
442 return ;
443 }
444 const defineOpenBrace = defineMatch.index + defineMatch[ 0 ]. length - 1 ;
445 const defineCloseBrace = findMatchingBrace (masked, defineOpenBrace);
446 if (defineCloseBrace === - 1 ) {
447 console. warn ( '[wix-headless-scaffold] astro.config.mjs has an unbalanced defineConfig({...}) call — leaving it alone.' );
448 return ;
449 }
450
451 // Depth-scoped to defineConfig's OWN direct children — never a `vite:`
452 // key nested inside some other option's value at any deeper level.
453 const viteOpenBrace = findDirectChildBrace (masked, defineOpenBrace, defineCloseBrace, 'vite' );
454
455 let patched;
456 if (viteOpenBrace === - 1 ) {
457 const insertAt = defineOpenBrace + 1 ;
458 const injected = ` \n vite: { \n server: { \n ${ allowedHostsProp } \n ${ hostProp } \n }, \n },` ;
459 patched = original. slice ( 0 , insertAt) + injected + original. slice (insertAt);
460 } else {
461 const viteCloseBrace = findMatchingBrace (masked, viteOpenBrace);
462 if (viteCloseBrace === - 1 ) {
463 console. warn ( '[wix-headless-scaffold] astro.config.mjs has an unbalanced vite: {...} block — leaving it alone.' );
464 return ;
465 }
466 // Depth-scoped to vite's OWN direct children — never a `server:` key
467 // nested inside a plugin's own options within the same vite block.
468 const serverOpenBrace = findDirectChildBrace (masked, viteOpenBrace, viteCloseBrace, 'server' );
469
470 if (serverOpenBrace === - 1 ) {
471 const insertAt = viteOpenBrace + 1 ;
472 const injected = ` \n server: { \n ${ allowedHostsProp } \n ${ hostProp } \n },` ;
473 patched = original. slice ( 0 , insertAt) + injected + original. slice (insertAt);
474 } else {
475 const serverCloseBrace = findMatchingBrace (masked, serverOpenBrace);
476 if (serverCloseBrace === - 1 ) {
477 console. warn ( '[wix-headless-scaffold] astro.config.mjs has an unbalanced vite.server: {...} block — leaving it alone.' );
478 return ;
479 }
480
481 // Collected as {start, end, replacement} spans against the ORIGINAL
482 // text and applied in one pass, highest offset first, so neither
483 // edit invalidates the other's already-computed positions.
484 const edits = [];
485 let touchedSomething = false ;
486
487 const allowedHostsValue = findDirectChildValueSpan (masked, serverOpenBrace, serverCloseBrace, 'allowedHosts' );
488 if (allowedHostsValue === null ) {
489 edits. push ({ start: serverOpenBrace + 1 , end: serverOpenBrace + 1 , replacement: ` \n ${ allowedHostsProp }` });
490 touchedSomething = true ;
491 } else if (allowedHostsValue === 'ambiguous' ) {
492 console. warn ( '[wix-headless-scaffold] vite.server.allowedHosts is set to something this script cannot safely interpret — leaving it alone.' );
493 } else {
494 const valueText = original. slice (allowedHostsValue.start, allowedHostsValue.end);
495 const trimmed = valueText. trim ();
496 if (trimmed === 'true' ) {
497 // Vite treats `allowedHosts: true` as "disable the check
498 // entirely" — already strictly more permissive than what this
499 // needs, nothing to do.
500 } else if (trimmed. startsWith ( '[' ) && trimmed. endsWith ( ']' )) {
501 if (trimmed. includes ( REMOTE_MACHINE_HOST_SUFFIX )) {
502 // Already covers it.
503 } else {
504 const closeBracketAt = allowedHostsValue.start + trimmed. lastIndexOf ( ']' );
505 const beforeClose = original. slice (allowedHostsValue.start, closeBracketAt). replace ( / \s +$ / , '' );
506 const isEmpty = / \[ \s *$ / . test (beforeClose);
507 const hasTrailingComma = /, \s *$ / . test (beforeClose);
508 const sep = isEmpty ? '' : hasTrailingComma ? ' ' : ', ' ;
509 edits. push ({
510 start: closeBracketAt,
511 end: closeBracketAt,
512 replacement: `${ sep }'${ REMOTE_MACHINE_HOST_SUFFIX }'` ,
513 });
514 touchedSomething = true ;
515 }
516 } else {
517 console. warn ( '[wix-headless-scaffold] vite.server.allowedHosts is set to something this script cannot safely interpret — leaving it alone.' );
518 }
519 }
520
521 const hostValue = findDirectChildValueSpan (masked, serverOpenBrace, serverCloseBrace, 'host' );
522 if (hostValue === null ) {
523 edits. push ({ start: serverOpenBrace + 1 , end: serverOpenBrace + 1 , replacement: ` \n ${ hostProp }` });
524 touchedSomething = true ;
525 } else if (hostValue === 'ambiguous' ) {
526 console. warn ( '[wix-headless-scaffold] vite.server.host is set to something this script cannot safely interpret — leaving it alone.' );
527 } else {
528 const trimmed = original. slice (hostValue.start, hostValue.end). trim ();
529 const quotedInner = quotedStringInner (trimmed);
530 const isIneffective = trimmed === 'false' || (quotedInner !== null && [ 'localhost' , '127.0.0.1' ]. includes (quotedInner));
531 const isKnownEffective = trimmed === 'true' || quotedInner !== null ;
532 if (isIneffective) {
533 edits. push ({ start: hostValue.start, end: hostValue.end, replacement: 'true' });
534 touchedSomething = true ;
535 } else if ( ! isKnownEffective) {
536 console. warn ( '[wix-headless-scaffold] vite.server.host is set to something this script cannot safely interpret — leaving it alone.' );
537 }
538 // else: true, or a real, non-localhost bind address — already effective.
539 }
540
541 if ( ! touchedSomething) {
542 console. log ( '[wix-headless-scaffold] vite.server.allowedHosts and host are already effective — nothing to patch.' );
543 return ;
544 }
545
546 edits. sort (( a , b ) => b.start - a.start);
547 patched = original;
548 for ( const edit of edits) {
549 patched = patched. slice ( 0 , edit.start) + edit.replacement + patched. slice (edit.end);
550 }
551 }
552 }
553
554 await fs. writeFile (configPath, patched);
555 console. log ( '[wix-headless-scaffold] patched astro.config.mjs for remote-machine preview (allowedHosts + host).' );
556 }
557
558 async function patchPackageJsonEnvPullHooks ( frontendDir ) {
559 const pkgPath = path. join (frontendDir, 'package.json' );
560 let pkg;
561 try {
562 pkg = JSON . parse ( await fs. readFile (pkgPath, 'utf8' ));
563 } catch (error) {
564 if (error && error.code === 'ENOENT' ) {
565 console. warn ( `[wix-headless-scaffold] no package.json at ${ pkgPath } — skipping env-pull hooks.` );
566 return ;
567 }
568 throw error;
569 }
570 pkg.scripts = pkg.scripts || {};
571 let changed = false ;
572 for ( const [ hook , command ] of Object. entries ( ENV_PULL_HOOK_SCRIPTS )) {
573 const existing = pkg.scripts[hook];
574 if (existing === undefined ) {
575 pkg.scripts[hook] = command;
576 changed = true ;
577 } else if ( ! existing. includes (command)) {
578 // A template's own hook (e.g. a codegen step) does something unrelated
579 // to env-pulling — compose rather than skip, or it silently never runs
580 // at all, defeating the whole point of this patch. Idempotent: a hook
581 // that already contains the command is left alone, never re-composed.
582 pkg.scripts[hook] = `${ command } && ${ existing }` ;
583 changed = true ;
584 }
585 }
586 if ( ! changed) {
587 console. warn ( '[wix-headless-scaffold] package.json already defines all env-pull hooks — leaving it alone.' );
588 return ;
589 }
590 await fs. writeFile (pkgPath, `${ JSON . stringify ( pkg , null , 2 ) } \n ` );
591 console. log ( '[wix-headless-scaffold] added predev/prebuild/prerelease "wix env pull" hooks to package.json.' );
592 }
593
594 // Safe to call more than once against the same project directory, including
595 // after its own prior partial failure. Every branch below either confirms a destination
596 // receipt before returning success, or fails closed without touching config/wix.env.
597 async function ensureDestinationReceipt ( args , { spawnScaffold = defaultSpawnScaffold } = {}) {
598 const projectDir = path. resolve (args.projectDir);
599 const frontendDir = path. join (projectDir, args.folderName);
600 const wixEnvPath = path. join (projectDir, 'config' , 'wix.env' );
601
602 const siteIdStatus = ( await statEnvKeys (wixEnvPath, [ 'WIX_SITE_ID' ])).keys. WIX_SITE_ID ;
603 const envValues = await readWixEnvValues (wixEnvPath);
604 const wixConfig = await readWixConfig (frontendDir);
605
606 if (siteIdStatus === 'present' ) {
607 // Case 1: config/wix.env already names a destination — checked first, independent of
608 // wix.config.json, so an env-only receipt (adopted/existing destination, or a lost
609 // frontend folder) can never fall through to spawning a second site.
610 if (wixConfig && wixConfig.siteId !== envValues. WIX_SITE_ID ) {
611 // 1b — conflicting.
612 throw new Error (
613 `config/wix.env already has WIX_SITE_ID=${ envValues . WIX_SITE_ID }, but ${ frontendDir }/wix.config.json names a different siteId=${ wixConfig . siteId }. Refusing to create or adopt a second destination — resolve the conflict manually.` ,
614 );
615 }
616 // 1a — consistent (wix.config.json is absent, has no siteId, or matches).
617 const status = envValues. WIX_SCAFFOLD_STATUS ;
618 if (status === undefined || status === 'complete' ) {
619 return { siteId: envValues. WIX_SITE_ID };
620 }
621 if (status === 'incomplete' ) {
622 throw new Error (
623 `Destination ${ envValues . WIX_SITE_ID } already exists, but a prior scaffold attempt did not complete (WIX_SCAFFOLD_STATUS=incomplete). Refusing to spawn again — that would risk a duplicate site. Resolve manually before retrying.` ,
624 );
625 }
626 throw new Error (
627 `config/wix.env has an unrecognized WIX_SCAFFOLD_STATUS value (${ JSON . stringify ( status ) }); expected "complete", "incomplete", or the key to be absent. Refusing to proceed.` ,
628 );
629 }
630
631 // Case 2: no confirmed real WIX_SITE_ID (missing, blank, or a placeholder — all treated
632 // alike here, matching case 1's classification). This branch
633 // must also inspect WIX_SCAFFOLD_STATUS, not just wix.config.json's existence — a bare
634 // "wix.config.json exists" check cannot tell a genuinely legacy/host-adopted receipt
635 // apart from one left behind by a scaffold attempt this script itself started but never
636 // finished recording (the wrapper crashed, or was killed, after the CLI created the site
637 // but before persistSiteId ran). Silently adopting the latter as "complete" is the same
638 // laundering bug as case 1 exists to prevent, just reached from the opposite direction.
639 const status = envValues. WIX_SCAFFOLD_STATUS ;
640 if (status === 'in_progress' ) {
641 if (wixConfig) {
642 // A prior invocation marked in_progress before spawning (see 2c below) and never
643 // got to record a final outcome. The CLI may well have finished — never infer
644 // "complete" from an orphaned receipt like this. Persist it as incomplete (the same
645 // durable, fail-closed record a known non-zero exit produces) so the *next*
646 // invocation adopts and blocks via case 1's existing, tested handling instead of
647 // this ambiguity recurring indefinitely.
648 await persistSiteId (wixEnvPath, wixConfig.siteId, 'incomplete' );
649 throw new Error (
650 `A prior scaffold attempt was interrupted before it could record its outcome (WIX_SCAFFOLD_STATUS=in_progress), but a destination was found (WIX_SITE_ID=${ wixConfig . siteId }). Persisted as incomplete — resolve manually before retrying.` ,
651 );
652 }
653 // No local receipt either does NOT mean nothing
654 // was created remotely — a crash could have happened after a remote site was created
655 // but before wix.config.json was written locally (or even before the frontend folder
656 // existed at all), which isNonEmptyDir below cannot detect. Fail closed
657 // unconditionally rather than falling through to 2b/2c; the one case that clears this
658 // marker for a safe automatic retry is a genuine spawn-launch failure (see 2c) — that
659 // is handled before this marker is ever left behind, not by re-reading it here.
660 throw new Error (
661 'A prior scaffold attempt was interrupted before it could record any outcome (WIX_SCAFFOLD_STATUS=in_progress) and no local destination receipt (wix.config.json) exists either. This cannot be safely distinguished from a remote site having been created before a local receipt was written. Refusing to retry automatically — confirm whether a destination already exists, then either record its WIX_SITE_ID manually or clear WIX_SCAFFOLD_STATUS from config/wix.env if nothing was actually created.' ,
662 );
663 }
664 if (status !== undefined ) {
665 // WIX_SCAFFOLD_STATUS is set to something else (a known-ambiguous marker from this
666 // script, or corrupted state) while no real WIX_SITE_ID exists. Fail closed rather
667 // than silently proceeding as though nothing were recorded.
668 throw new Error (
669 `config/wix.env has WIX_SCAFFOLD_STATUS=${ JSON . stringify ( status ) } but no valid WIX_SITE_ID; this cannot be automatically resolved. Refusing to proceed automatically — resolve manually.` ,
670 );
671 }
672
673 if (wixConfig) {
674 // 2a — adopt a receipt with no status marker at all: a genuinely legacy/host-adopted
675 // destination, or one scaffolded before this script wrote WIX_SCAFFOLD_STATUS.
676 await upsertEnvFile (wixEnvPath, { WIX_SITE_ID: wixConfig.siteId, WIX_SCAFFOLD_STATUS: 'complete' });
677 return { siteId: wixConfig.siteId };
678 }
679
680 if ( await isNonEmptyDir (frontendDir)) {
681 // 2b — genuinely ambiguous state this script cannot resolve on its own.
682 throw new Error (
683 `${ frontendDir } already exists and is not empty, but no valid wix.config.json was found there and config/wix.env has no WIX_SITE_ID. Refusing to scaffold into this folder automatically — resolve manually before retrying.` ,
684 );
685 }
686
687 // 2c — nothing exists yet; mark in_progress before spawning so a crash between now and
688 // persisting a final outcome always leaves a distinguishable trace instead of silently
689 // looking like "nothing was ever attempted."
690 await upsertEnvFile (wixEnvPath, { WIX_SCAFFOLD_STATUS: 'in_progress' });
691 let code;
692 try {
693 ({ code } = await spawnScaffold (args));
694 } catch (spawnError) {
695 // A rejection alone isn't proof nothing was created — spawnScaffold could fail for
696 // reasons other than a launch failure after the child already did real work. Check for
697 // a receipt regardless of why this rejected: if one exists, leave the in_progress
698 // marker in place so the next invocation's self-heal branch above handles it (never
699 // clear a marker out from under a receipt that might exist). Only when there is
700 // genuinely no receipt is it safe to clear — for the real spawnScaffold, which only
701 // rejects via child_process's 'error' event (emitted specifically when the OS-level
702 // process itself could never be launched, before any exit code exists), that absence
703 // is deterministic: nothing could have been created remotely.
704 const orphanedConfig = await readWixConfig (frontendDir);
705 if ( ! orphanedConfig) {
706 await clearWixScaffoldStatus (wixEnvPath);
707 }
708 throw spawnError;
709 }
710 const postConfig = await readWixConfig (frontendDir);
711 if (postConfig) {
712 // Persisted even on a non-zero exit: site creation and later template/release work are
713 // separate phases of the CLI's run, so discarding the id here is exactly how a retry
714 // would spawn a second site.
715 await persistSiteId (wixEnvPath, postConfig.siteId, code === 0 ? 'complete' : 'incomplete' );
716 if (code === 0 ) {
717 return { siteId: postConfig.siteId };
718 }
719 throw new Error (
720 `Scaffold process exited with code ${ code }, but a destination was created (WIX_SITE_ID=${ postConfig . siteId }); persisted as incomplete. Do not retry automatically — resolve the underlying failure first.` ,
721 );
722 }
723 // The child process definitely ran to completion (it returned an exit code), but no
724 // valid local receipt was found. This must NOT be left as a retryable in_progress
725 // marker: a remote site could still have been created before a local write failed.
726 // Convert it to a durable, always-blocked marker instead.
727 await upsertEnvFile (wixEnvPath, { WIX_SCAFFOLD_STATUS: 'ambiguous' });
728 if (code === 0 ) {
729 throw new Error ( 'Scaffold process exited 0, but no wix.config.json with a siteId was found afterward — cannot confirm whether a destination was created. Marked ambiguous; resolve manually before retrying.' );
730 }
731 throw new Error ( `Scaffold process exited with code ${ code } and no destination receipt was found — cannot confirm whether a destination was created. Marked ambiguous; resolve manually before retrying.` );
732 }
733
734 async function main () {
735 const args = parseArgs (process.argv. slice ( 2 ));
736 if (args.help) {
737 console. log ( usage ());
738 return ;
739 }
740 if ( ! args.businessName) {
741 console. error ( usage ());
742 process. exit ( 1 );
743 return ;
744 }
745 const result = await ensureDestinationReceipt (args);
746 // Applied after ANY successful resolution (a fresh scaffold, or an
747 // idempotent adopt of an existing receipt) — both patch functions are
748 // themselves idempotent and cheap, so an already-patched or pre-existing
749 // destination is a safe, fast no-op, not just the first-scaffold path.
750 const frontendDir = path. join (path. resolve (args.projectDir), args.folderName);
751 await patchAstroConfigForRemoteMachinePreview (frontendDir);
752 await patchPackageJsonEnvPullHooks (frontendDir);
753 console. log ( JSON . stringify ({ ok: true , siteId: result.siteId }));
754 }
755
756 if (require.main === module ) {
757 main (). catch (( error ) => {
758 console. error (error && error.message ? error.message : error);
759 process. exit ( 1 );
760 });
761 }
762
763 module . exports = {
764 ensureDestinationReceipt,
765 parseArgs,
766 scaffoldCommandArgs,
767 usage,
768 patchAstroConfigForRemoteMachinePreview,
769 patchPackageJsonEnvPullHooks,
770 };