Setting the file. One moment.
Execute Setup · Rp Execute Setup · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 24.1
Position 1 of 3
Type JavaScript
Size 50 KB
Lines 825 scripts/ execute-setup.js
JavaScript · 825 lines · 50 KB
const
{
assertCollectionCompatibility
}
=
require
(
'../../../lib/cms-collection-compatibility.js'
);
9 const { normalizeCmsCollectionDefinition } = require ( '../../../lib/cms-collection-request.js' );
10 const REAL_WRITERS = require ( '../../rp-target-wix/lib/wix-writers.js' );
11 const { SCHEMA_KIND : CONTACT_V4_SCHEMA_KIND } = require ( '../../rp-target-wix/lib/contact-extended-fields.js' );
12 const { hashArtifact } = require ( '../../../lib/artifact-freshness.js' );
13 const { validatePlanShape , validateSetupVerification } = require ( '../../../lib/setup-verification.js' );
14 const { SETUP_CHECK_METHODS } = require ( '../../../lib/setup-check-methods.js' );
15 const siteRegional = require ( '../../../lib/site-regional.js' );
16 const catalogBaseline = require ( '../../../lib/catalog-baseline.js' );
17 const wixAuth = require ( '../../../lib/wix-auth.js' );
18 const { HARMONY_EDITOR_TYPE } = require ( '../../../lib/editor-type.js' );
19
20 // 'wix-stores' is the quick-mode step id; it is Wix Stores (215238eb-...), the same app as
21 // `install-wix-stores` below. 1380b703-... is Wix eCommerce, which Stores installs as its own
22 // dependency: installing it alone leaves the site without Stores.
23 const APPS = { 'wix-stores' : '215238eb-22a5-4c36-9e7b-e7c08025e04e' , 'wix-blog' : '14bcded7-0066-7c35-14d7-466cb3f09103' };
24
25 // Additional app-install step ids, added for projects whose setup plan needs apps this
26 // executor previously had no executor for at all (a plan naming any of these used to fail
27 // closed with NO_EXECUTOR_FOR_REQUIREMENT). Ids verified live against dev.wix.com's "Apps
28 // Created by Wix" reference table, 2026-08-29 -- not from a docs example, and not reusing
29 // the known-wrong 'wix-stores' id above. Idempotent: an app already installed is a pass,
30 // not a redundant install call (see the isWixAppInstalled check in runStep below).
31 const INSTALL_STEPS = {
32 'install-wix-stores' : '215238eb-22a5-4c36-9e7b-e7c08025e04e' ,
33 'install-wix-gift-cards' : 'd80111c5-a0f4-47a8-b63a-65b54d774a27' ,
34 'install-wix-blog' : '14bcded7-0066-7c35-14d7-466cb3f09103' ,
35 'install-wix-events' : '140603ad-af8d-84a5-2c80-a0f60cb47351' ,
36 'install-wix-data' : 'e593b0bd-b783-45b8-97c2-873d42aacaf4' ,
37 // Verified live 2026-10-27: the APIs name these ids when the app is missing.
38 'install-wix-reviews' : 'a95a5fce-e370-4402-9ce4-96956acc747d' ,
39 'install-wix-back-in-stock' : '16be6c71-d061-4f56-8cda-c6aa911d1832' ,
40 };
41
42 // Any other app needs no line here: a step that carries its own app id installs it,
43 // `{ "id": "<any unique id>", "kind": "installApp", "appDefId": "<app id>" }`. The lines above stay as
44 // names the planner and the dependency gate already use.
45 const APP_DEF_ID = / ^ [0-9a-f] {8} - [0-9a-f] {4} - [0-9a-f] {4} - [0-9a-f] {4} - [0-9a-f] {12}$ / i ;
46 function appIdForStep ( step ) {
47 if (step.kind === 'installApp' ) return APP_DEF_ID . test (step.appDefId) ? step.appDefId. toLowerCase () : null ;
48 return APPS [step.id] || INSTALL_STEPS [step.id] || null ;
49 }
50
51 async function readJson ( file ) {
52 return JSON . parse ( await fs. readFile (file, 'utf8' ));
53 }
54
55 async function readJsonIfExists ( file ) {
56 try {
57 return await readJson (file);
58 } catch (error) {
59 if (error.code === 'ENOENT' ) return null ;
60 throw error;
61 }
62 }
63
64 async function readEnv ( dir ) {
65 const raw = await fs. readFile (path. join (dir, 'config' , 'wix.env' ), 'utf8' );
66 return Object. fromEntries (
67 raw
68 . split ( / \r ? \n / )
69 . map (( line ) => line. match ( / ^ ( [A-Z0-9_] + )=( . * ) $ / ))
70 . filter (Boolean)
71 . map (([, key , value ]) => [key, value. trim ()]),
72 );
73 }
74
75 async function fallbackAuthor ( dir , wix , writers ) {
76 const id = path. basename (dir);
77 const email = `replatform-${ id }@example.com` ;
78 const listed = await writers. listMembers (wix, { limit: 100 });
79 let member = (listed.members || []). find (( item ) => String (item.loginEmail || '' ). toLowerCase () === email);
80 if ( ! member) member = await (writers.ensureBlogFallbackAuthor || writers.createMember)(wix, { email, name: 'Imported content' , slug: `imported-content-${ id }` });
81 if ( ! member?.id) throw new Error ( 'fallback Blog author was not created' );
82 const created = ! (listed.members || []). includes (member);
83 const file = path. join (dir, 'config' , 'wix.env' );
84 const raw = await fs. readFile (file, 'utf8' );
85 await fs. writeFile (
86 file,
87 `${ raw . replace ( / \n ? WIX_BLOG_FALLBACK_MEMBER_ID= . * (?: \n |$ )/ g , ' \n ' ). replace ( / \s *$ / , ' \n ' ) }WIX_BLOG_FALLBACK_MEMBER_ID=${ member . id } \n ` ,
88 );
89 return { method: created ? SETUP_CHECK_METHODS . MEMBER_CREATE_RESPONSE : SETUP_CHECK_METHODS . MEMBER_READ , observed: { memberId: member.id } };
90 }
91
92 // Creates one Wix Data collection via the real, verified request shape (top-level key is
93 // `collection`, NOT `dataCollection` — confirmed live 2026-08-29 against a 400 "collection
94 // must not be empty" response from the wrong key, and against CreateDataCollectionRequest's
95 // real schema). Idempotent: a collection id that already exists (409 or an existing-id list
96 // hit) requires a full compatible definition read, never a delete-and-recreate — matches this skill's own "never delete or
97 // recreate an entity to fix a mismatch" rule.
98 async function createCmsCollectionsStep ({ dir , wix , writers }) {
99 const requirements = await readJsonIfExists (path. join (dir, 'setup' , 'setup-requirements.json' ));
100 const list = Array. isArray (requirements) ? requirements : requirements?.requirements;
101 const requirement = (list || []). find (( item ) => item && item.id === 'create-cms-collections' );
102 if ( ! requirement) throw new Error ( 'setup-requirements.json has no create-cms-collections requirement' );
103
104 const fieldSchema = requirement.fieldSchemaPerCollection;
105 const fields = Array. isArray (fieldSchema) ? fieldSchema. map (( f ) => ({
106 key: f?.key,
107 type: f?.type,
108 displayName: f?.displayName,
109 ... (f?.required ? { required: true } : {}),
110 ... (f?.typeMetadata ? {typeMetadata:f.typeMetadata} : {}),
111 })) : fieldSchema;
112
113 if ( ! Array. isArray (requirement.collections)) {
114 throw new Error ( 'create-cms-collections requirement has no collections array' );
115 }
116 // Normalize and validate every definition before the first create request. In
117 // particular, mapping permission presets are expanded to the object required by
118 // Wix Data, while malformed field/permission values fail with their exact path.
119 const expectedCollections = requirement.collections. map (( collection , index ) => normalizeCmsCollectionDefinition ({
120 id: collection?.id,
121 displayName: collection?.displayName,
122 fields: collection?.fields || fields,
123 permissions: collection?.permissions || requirement.permissions,
124 }, { path: `collections[${ index }]` }));
125
126 const existing = await wix. send (writers. buildListCmsCollectionsRequest ());
127 const existingIds = new Set ((existing.collections || []). map (( c ) => c.id));
128
129 for ( const expected of expectedCollections) {
130 if ( ! existingIds. has (expected.id)) {
131 try {
132 await writers. ensureCmsCollection (wix, expected);
133 } catch (error) {
134 if (wixAuth. isRunInterruption (error)) throw error;
135 if (error.status !== 409 && error.statusCode !== 409 ) throw error;
136 // A concurrent create is not evidence of compatible fields or permissions.
137 }
138 }
139 const current =await wix. send (writers. buildGetCmsCollectionRequest (expected.id));
140 assertCollectionCompatibility (current.collection,expected);
141 }
142 return { method: SETUP_CHECK_METHODS . COLLECTION_READ , observed: { collections: expectedCollections. map ( c => c.id) } };
143 }
144
145 // Runs one setup-plan step. Throws (with `blockerCode`/`recommendedAction` attached where
146 // the failure is a recognized, expected condition rather than a genuinely unexpected one)
147 // on anything short of full success — the caller records the failure and stops the run
148 // rather than letting a step's exception propagate past a recorded outcome.
149 // Provisions every `extendedFieldSchema` requirement in the plan before any
150 // writer can put a value in one. Blocked reasons become setup blockers rather than being retried
151 // or worked around: a breaking schema change and an archived-key collision are both decisions for
152 // a human, not for this executor.
153 // A step is a provisioning step if it SAYS so, not only if it happens to be spelled
154 // `provision-extended-fields`. Recognising one literal id meant discovery emitting a valid
155 // requirement under any other natural id -- `setup-order-extended-fields`, say -- failed closed
156 // with NO_EXECUTOR_FOR_REQUIREMENT and produced no receipt at all.
157 const EXTENDED_FIELD_STEP_ID = 'provision-extended-fields' ;
158
159 // A step provisions extended fields if it SAYS so -- by the conventional id, by declaring the
160 // kind, or by a requirement naming it. Matching one literal id meant a requirement scheduled
161 // under any other natural id (`setup-order-extended-fields`, say) was both reported as
162 // unscheduled AND unexecutable. The scheduling guard and the dispatcher share this predicate so
163 // they can never disagree about which steps count.
164 function stepProvisionsExtendedFields ( step , declaredList ) {
165 if ( ! step) return false ;
166 if (step.id === EXTENDED_FIELD_STEP_ID ) return true ;
167 if (step.kind === 'extendedFieldSchema' ) return true ;
168 return (declaredList || []). some (( item ) => item
169 && item.kind === 'extendedFieldSchema'
170 && (item.setupStepId === step.id || item.id === step.id));
171 }
172
173 async function isExtendedFieldStep ( step , dir ) {
174 const requirements = await readJsonIfExists (path. join (dir, 'setup' , 'setup-requirements.json' ));
175 const list = Array. isArray (requirements) ? requirements : requirements?.requirements;
176 return stepProvisionsExtendedFields (step, list);
177 }
178
179 // Blocker codes that name their own remedy. Everything else stays EXTENDED_FIELD_SCHEMA_BLOCKED.
180 const EXTENDED_FIELD_BLOCKERS = {
181 CONTACTS_V5_DES_UNVERIFIED: 'The Contacts V5 Data Extension Schema path is not verified on this site. Wait until it is verified, or explicitly route the mapping to contactWriteSurface "contacts-v4" (legacy), then re-run setup. Setup does not fall back automatically.' ,
182 EXTENDED_FIELD_SCHEMA_CONFLICT_EXHAUSTED: 'Another change kept updating this schema while setup ran. Re-run setup once the concurrent edits stop; nothing was overwritten.' ,
183 'schema-budget-exceeded' : 'The namespace byte budget cannot hold these fields. Route the excess fields to a CMS collection instead of extended fields, then re-run setup.' ,
184 'contact-extended-field-mismatch' : 'A legacy contact extended field on the site does not match what the migration needs (another type or namespace, or a key different from the one an earlier run verified). Wix keys cannot change, and approving a migration action cannot repair this. Decide with the site owner how to handle the existing field; do not create a second field for the same data. Then re-run setup.' ,
185 };
186
187 // Lower-case writer codes that get their own blocker code rather than the generic one.
188 const EXTENDED_FIELD_BLOCKER_CODES = {
189 'contact-extended-field-mismatch' : 'CONTACT_EXTENDED_FIELD_MISMATCH' ,
190 };
191
192 function extendedFieldBlockerCode ( code ) {
193 if ( EXTENDED_FIELD_BLOCKER_CODES [code]) return EXTENDED_FIELD_BLOCKER_CODES [code];
194 return EXTENDED_FIELD_BLOCKERS [code] && / ^ [A-Z0-9_] +$ / . test (code) ? code : 'EXTENDED_FIELD_SCHEMA_BLOCKED' ;
195 }
196
197 async function provisionExtendedFieldsStep ({ dir , wix , writers , dryRun , priorVerification = null }) {
198 const requirements = await readJsonIfExists (path. join (dir, 'setup' , 'setup-requirements.json' ));
199 const list = Array. isArray (requirements) ? requirements : requirements?.requirements;
200 const schemaRequirements = (list || []). filter (( item ) => item && item.kind === 'extendedFieldSchema' );
201 if (schemaRequirements. length === 0 ) {
202 throw new Error ( 'setup-requirements.json has no extendedFieldSchema requirement' );
203 }
204
205 const verifications = [];
206 for ( const requirement of schemaRequirements) {
207 // Routed by the requirement's own schemaKind (DES for orders/products/contacts-v5, CRM
208 // Extended Fields for contacts-v4). Older writer fixtures expose only the DES entry point.
209 const provision = writers.provisionExtendedFieldRequirement || writers.provisionExtendedFieldSchema;
210 // eslint-disable-next-line no-await-in-loop
211 const result = await provision (wix, { requirement, dryRun, priorVerification });
212 if (result.blocking. length > 0 ) {
213 const first = result.blocking[ 0 ];
214 const named = EXTENDED_FIELD_BLOCKERS [first.code];
215 const where = requirement.fqdn ? `${ requirement . fqdn } ${ requirement . namespace }` : `${ requirement . schemaKind } ${ requirement . contactWriteSurface || ''}` . trim ();
216 throw Object. assign ( new Error ( `${ where }: ${ first . detail }` ), {
217 blockerCode: extendedFieldBlockerCode (first.code),
218 recommendedAction: named || 'Resolve the schema conflict on the destination site, or approve the migration action explicitly, then re-run setup.' ,
219 });
220 }
221 if (result.verification) verifications. push (result.verification);
222 }
223
224 if ( ! dryRun) {
225 // The writer guard reads this: a value write is refused unless setup verified that field.
226 await publishAtomically (
227 path. join (dir, 'setup' , 'extended-field-verification.json' ),
228 `${ JSON . stringify ({ schemaVersion: 1 , verifications }, null , 2 ) } \n ` ,
229 );
230 }
231 return verifications;
232 }
233
234 // The regional step reads the site, decides each setting, applies the ones the plan approved, reads
235 // them back and returns the receipt object recorded on its requirement row.
236 async function isRegionalStep ( step , dir ) {
237 const requirements = await readJsonIfExists (path. join (dir, 'setup' , 'setup-requirements.json' ));
238 const list = Array. isArray (requirements) ? requirements : requirements?.requirements;
239 return siteRegional. stepConfiguresSiteRegional (step, list);
240 }
241
242 // --- Harmony: any app through the editor's installation service ------------------------------
243 // The generic app installer leaves a Harmony site without the pages an app adds. On a Harmony site
244 // setup installs every app (and the companion apps its widgets need) through the editor's own
245 // installation service. See EDITOR_INSTALL_COMPANIONS in wix-writers.js for the companion table.
246 const EDITOR_INSTALL_RETRY_STATUS = new Set ([ 409 , 428 , 503 ]);
247 const EDITOR_INSTALL_BUSY = /progress | busy | outdated | not ready/ i ;
248 const EDITOR_INSTALL_RETRIES = 3 ;
249 const EDITOR_INSTALL_DEADLINE_MS = 200_000 ; // the service's own time budget is 180 s
250 const EDITOR_READ_ATTEMPTS = 3 ;
251 const READBACK_POLLS = 8 ;
252 const READBACK_WAIT_MS = 10_000 ;
253 const realSleep = ( ms ) => new Promise (( resolve ) => setTimeout (resolve, ms));
254
255 function blockerError ( blockerCode , message , recommendedAction ) {
256 return Object. assign ( new Error (message), { blockerCode, recommendedAction });
257 }
258
259 // The wix client has no deadline for writes; a call the service never answers must end the step
260 // with an uncertain outcome, not hang setup. The listing is read first on a re-run.
261 function withDeadline ( promise , ms , onExpire ) {
262 let timer;
263 const expiry = new Promise (( _ , reject ) => { timer = setTimeout (() => reject ( onExpire ()), ms); });
264 return Promise . race ([promise, expiry]). finally (() => clearTimeout (timer));
265 }
266
267 // The editor the destination step recorded for this project's site, or null. A record that names
268 // another site than the one being set up is a leftover and is not trusted.
269 async function recordedEditorType ( dir , siteId ) {
270 const record = await readJsonIfExists (path. join (dir, 'orchestration' , 'destination.json' )). catch (() => null );
271 if ( ! record || typeof record.observedEditorType !== 'string' || ! record.observedEditorType) return null ;
272 if (record.metaSiteId && siteId && record.metaSiteId !== siteId) return null ;
273 return record.observedEditorType;
274 }
275
276 // Which editor the destination opens in: the site itself (a few reads, with short waits between
277 // them), then the destination step's record. Unknown from both blocks: guessing "not Harmony" would
278 // install the app the way that leaves a Harmony site without pages.
279 async function resolveEditorType ( dir , wix , writers , stepId , sleep , siteId ) {
280 if ( typeof writers.getEditorType !== 'function' ) throw new Error ( `${ stepId }: the writer set has no getEditorType; the step cannot choose its install route` );
281 let cause = 'the answer named no editor' ;
282 for ( let attempt = 1 ; attempt <= EDITOR_READ_ATTEMPTS ; attempt += 1 ) {
283 try {
284 const editorType = await writers. getEditorType (wix);
285 if (editorType) return editorType;
286 } catch (error) {
287 if (error.blockerCode || wixAuth. isRunInterruption (error)) throw error;
288 cause = error.status ? `HTTP ${ error . status }` : String (error.message || error). slice ( 0 , 120 );
289 // A refused or missing answer does not change on a second try.
290 if ([ 401 , 403 , 404 ]. includes (error.status)) break ;
291 }
292 if (attempt < EDITOR_READ_ATTEMPTS ) await sleep ( 2000 * attempt);
293 }
294 const recorded = await recordedEditorType (dir, siteId);
295 if (recorded) return recorded;
296 throw blockerError ( 'EDITOR_TYPE_UNKNOWN' , `${ stepId }: the site's editor type could not be read (${ cause }) and the destination step recorded none` ,
297 'Re-run setup. Apps are installed differently on a Harmony site, so setup does not guess the editor. If the read keeps failing, run the destination step so the site \' s editor is recorded, or check that the Wix CLI login can read the site.' );
298 }
299
300 // One read of the installed-apps list (the client paces every read), tested locally.
301 async function listedApps ( wix , writers , ids ) {
302 const apps = await writers. getInstalledWixApps (wix);
303 const listed = new Set ((apps || []). filter (( app ) => app && app.enabled !== false ). map (( app ) => app.appDefId));
304 return new Set (ids. filter (( id ) => listed. has (id)));
305 }
306
307 // Setup cannot read a Harmony site's pages. This durable record (setup/harmony-app-pages.json) names
308 // the apps installed on the Harmony route whose pages were not read (`apps`), the apps the service said
309 // have no editor part (`noEditorPart`, so a re-run does not turn them into pages to confirm), and the
310 // apps it refused because another app already owns their page paths (`deferred`, left uninstalled for
311 // the owner to add later). It survives the per-run rewrite of the setup receipt, so the owner's items
312 // cannot vanish because a later setup run blocked. Nothing in the pipeline removes it.
313 const APP_PAGES_RECORD = path. join ( 'setup' , 'harmony-app-pages.json' );
314 async function readAppPages ( dir , siteId ) {
315 // Only a missing file means "no record"; an unreadable one fails loudly instead of being rewritten.
316 const prior = await readJsonIfExists (path. join (dir, APP_PAGES_RECORD ));
317 const same = Boolean (prior) && prior.siteId === siteId;
318 const list = ( key ) => (same && Array. isArray (prior[key]) ? prior[key] : []);
319 return { apps: list ( 'apps' ), noEditorPart: list ( 'noEditorPart' ), deferred: list ( 'deferred' ) };
320 }
321 // `outcome` is where the app belongs: 'apps', 'noEditorPart' or 'deferred'. An app lives in one list only.
322 async function recordAppPages ( dir , siteId , prior , { appDefId , stepId , outcome }) {
323 const has = ( list ) => list. some (( row ) => (row.appDefId || row) === appDefId);
324 const next = {
325 apps: prior.apps. filter (( row ) => row.appDefId !== appDefId),
326 noEditorPart: prior.noEditorPart. filter (( id ) => id !== appDefId),
327 deferred: prior.deferred. filter (( row ) => row.appDefId !== appDefId),
328 };
329 if (outcome === 'noEditorPart' ) next.noEditorPart. push (appDefId); else next[outcome]. push ({ appDefId, stepId });
330 if ( has (prior[outcome]) && JSON . stringify (next) === JSON . stringify ({ apps: prior.apps, noEditorPart: prior.noEditorPart, deferred: prior.deferred })) return ;
331 await publishAtomically (path. join (dir, APP_PAGES_RECORD ), `${ JSON . stringify ({ schemaVersion: 1 , siteId , appPages: 'not-read' , ... next }, null , 2 ) } \n ` );
332 }
333
334 // Installs every missing app of `wanted` in one batch, then reads the listing back until all of
335 // `wanted` are listed and enabled. The listing lags the install, so an app the service already
336 // reported as installed is never sent again, whatever the listing says. Returns `sent` (the apps this
337 // run sent through the editor route and the service reported as installed), `noEditorPart` (apps
338 // the service says have no editor part; the caller installs those the ordinary way) and `deferred` (apps
339 // it refused because their page paths are taken by another app; they are left uninstalled).
340 async function installOnHarmony ({ stepId , wanted , siteId , wix , writers , timing }) {
341 const sleep = timing.sleep || realSleep;
342 const deadlineMs = timing.installDeadlineMs ?? EDITOR_INSTALL_DEADLINE_MS ;
343 const sent = new Set ();
344 const noEditorPart = new Set ();
345 const deferred = new Set ();
346 const refuse = ( detail ) => blockerError ( 'APP_INSTALL_NOT_VERIFIED' , `${ stepId }: the editor installation service did not install ${ detail }` ,
347 "Re-run setup, or add the app from the Harmony editor's app panel by hand and re-run setup." );
348 const uncertain = ( why ) => blockerError ( 'APP_INSTALL_OUTCOME_UNCERTAIN' , `${ stepId }: ${ why }, so the outcome is unknown` ,
349 'Wait a minute, then re-run setup: it reads the installed-apps list first and sends only what is missing.' );
350 for ( let attempt = 0 ; ; attempt += 1 ) {
351 const listed = await listedApps (wix, writers, wanted);
352 const missing = wanted. filter (( id ) => ! listed. has (id) && ! sent. has (id) && ! noEditorPart. has (id) && ! deferred. has (id));
353 if ( ! missing. length ) break ;
354 // true = wait and go round again; false = out of retries.
355 const again = async () => {
356 if (attempt >= EDITOR_INSTALL_RETRIES ) return false ;
357 await sleep ( 5000 * (attempt + 1 ));
358 return true ;
359 };
360 let reply;
361 try {
362 reply = await withDeadline (writers. installEditorApps (wix, { appDefIds: missing, siteId }), deadlineMs,
363 () => uncertain ( `the editor installation service did not answer within ${ Math . round ( deadlineMs / 1000 ) } s` ));
364 } catch (error) {
365 if (error.blockerCode || wixAuth. isRunInterruption (error)) throw error;
366 if ( EDITOR_INSTALL_RETRY_STATUS . has (error.status) && await again ()) continue ;
367 // A gateway timeout, any other 5xx, or a reply nobody can classify may still have installed.
368 if (error.outcomeKnown === false || error.status >= 500 ) throw uncertain ( `the install call failed with ${ error . status || 'a transport error'}` );
369 throw refuse ( `${ missing . join ( ', ' ) }: ${ String ( error . message || error ). slice ( 0 , 300 ) }` );
370 }
371 if ( ! reply || ! Array. isArray (reply.results)) throw refuse ( `${ missing . join ( ', ' ) }: the reply had no results list` );
372 const allFailures = reply.results. filter (( row ) => ! row || row.success !== true )
373 . map (( row ) => ({ appDefId: row?.appDefId ?? null , error: String (row?.error ?? 'install reported no success' ) }));
374 // An app the service says has no editor part is not a failure: it adds no pages, and the caller
375 // installs it with the generic installer.
376 // An app the service refuses because another app owns its page paths is not a failure either: it is
377 // left uninstalled and reported, so setup goes on with the apps that did install.
378 const isNoEditorPart = ( row ) => Boolean (row.appDefId) && REAL_WRITERS . NO_EDITOR_PART_ERROR . test (row.error);
379 const isPathConflict = ( row ) => Boolean (row.appDefId) && REAL_WRITERS . PAGE_PATH_CONFLICT_ERROR . test (row.error);
380 for ( const row of allFailures) {
381 if ( isNoEditorPart (row)) noEditorPart. add (row.appDefId);
382 else if ( isPathConflict (row)) deferred. add (row.appDefId);
383 }
384 const failures = allFailures. filter (( row ) => ! isNoEditorPart (row) && ! isPathConflict (row));
385 // An app counts as sent only when the reply has a success row naming it; a row without an app id,
386 // or an app with no row at all, is never read as success.
387 const okIds = new Set (reply.results. filter (( row ) => row && row.success === true && row.appDefId). map (( row ) => row.appDefId));
388 const failedIds = new Set (allFailures. map (( row ) => row.appDefId). filter (Boolean));
389 for ( const id of missing) if (okIds. has (id)) sent. add (id);
390 // A failure row that names no app speaks for every app that has no row of its own.
391 const unaccounted = failures. some (( row ) => ! row.appDefId) ? [] : missing. filter (( id ) => ! okIds. has (id) && ! failedIds. has (id));
392 if (failures. length || unaccounted. length ) {
393 const busyOnly = failures. every (( row ) => EDITOR_INSTALL_BUSY . test (row.error)) && ! unaccounted. length ;
394 if (busyOnly && await again ()) continue ;
395 throw refuse ([ ... failures. map (( row ) => `${ row . appDefId || 'unnamed'}: ${ row . error . slice ( 0 , 200 ) }` ), ... unaccounted. map (( id ) => `${ id }: the reply had no row for it` )]. join ( '; ' ));
396 }
397 break ;
398 }
399 // The listing lags the install: poll before calling an app missing.
400 const toVerify = wanted. filter (( id ) => ! noEditorPart. has (id) && ! deferred. has (id));
401 let absent = toVerify;
402 for ( let poll = 1 ; poll <= READBACK_POLLS ; poll += 1 ) {
403 const listed = await listedApps (wix, writers, toVerify);
404 absent = toVerify. filter (( id ) => ! listed. has (id));
405 if ( ! absent. length ) break ;
406 if (poll < READBACK_POLLS ) await sleep (timing.readbackWaitMs ?? READBACK_WAIT_MS );
407 }
408 if (absent. length ) {
409 throw blockerError ( 'APP_INSTALL_NOT_VERIFIED' , `${ stepId }: the install call returned but ${ absent . join ( ', ' ) } is not listed as installed and enabled on the site` ,
410 "Re-run setup; if the apps are still missing from GET /apps-installer-service/v1/app-instances, add them from the Harmony editor's app panel and re-run setup." );
411 }
412 return { sent: [ ... sent], noEditorPart: [ ... noEditorPart], deferred: [ ... deferred] };
413 }
414
415 // Returns the check that actually ran: `{ method, observed }` (see SETUP_CHECK_METHODS in
416 // lib/setup-check-methods.js). The caller stamps `checkedAt` once it returns. An install with no
417 // read-back records `install-response` -- what was observed, never a claimed read.
418 // An install call that returns 200 proves nothing about the site: the step passes only when the
419 // installed-apps list read back afterwards names the app and it is enabled. An app that is
420 // already listed is a pass without a second install call.
421 async function installAndVerifyApp ( stepId , appDefId , { dir , env , wix , writers , catalogSettle = {} }) {
422 const listed = async ( id = appDefId) => (writers.isWixAppInstalled ? writers. isWixAppInstalled (wix, id) : false );
423 // Any app takes the editor route; `companions` are the extra apps a particular app's widgets need.
424 const companions = REAL_WRITERS . editorInstallCompanions (appDefId);
425 const installed = await listed ();
426 let editorType;
427 try {
428 editorType = await resolveEditorType (dir, wix, writers, stepId, catalogSettle.sleep || realSleep, env. WIX_SITE_ID );
429 } catch (error) {
430 // An app that is already listed needs no install, so an unreadable editor does not block it; only
431 // the Harmony companion repair is skipped, and the record says the route was not determined.
432 if (error.blockerCode !== 'EDITOR_TYPE_UNKNOWN' || ! installed) throw error;
433 editorType = null ;
434 }
435 const harmony = editorType === HARMONY_EDITOR_TYPE ;
436 // The ordinary install: one call, then the listing must name the app.
437 const genericInstall = async ( id ) => {
438 await writers. installWixApp (wix, { appDefId: id, siteId: env. WIX_SITE_ID });
439 if ( ! ( await listed (id))) {
440 throw blockerError ( 'APP_INSTALL_NOT_VERIFIED' , `${ stepId }: install call returned but app ${ id } is not listed as installed and enabled on the site` ,
441 'Re-run setup; if the app is still missing from GET /apps-installer-service/v1/app-instances, install it from the Wix dashboard (Add Apps) and re-run setup.' );
442 }
443 };
444 // Harmony: the app and its companions in one batch (the service installs the app's own dependencies).
445 // An app the service says has no editor part is installed the ordinary way.
446 let sentViaEditor = [];
447 let noEditorPart = [];
448 let deferred = [];
449 const harmonyInstall = async () => {
450 const wanted = [ ... companions, appDefId];
451 const result = await installOnHarmony ({ stepId, wanted, siteId: env. WIX_SITE_ID , wix, writers, timing: catalogSettle });
452 sentViaEditor = result.sent;
453 noEditorPart = result.noEditorPart;
454 deferred = result.deferred;
455 for ( const id of noEditorPart) await genericInstall (id);
456 };
457 const install = harmony ? harmonyInstall : () => genericInstall (appDefId);
458 // On a Harmony site that already lists the app, only the missing companions are installed, and
459 // before the catalog baseline is read so the baseline sees the final catalog.
460 if (harmony && installed && companions. length ) await harmonyInstall ();
461 // Wix Stores adds sample products when it installs. The catalog is read around the install, the
462 // items it added are deleted, and the rest is recorded so no importer takes them over.
463 let catalog = {};
464 if (appDefId === catalogBaseline. STORES_APP_DEF_ID ) {
465 let baseline, unchanged;
466 try {
467 ({ baseline, unchanged } = await catalogBaseline. captureAroundInstall ({ projectDir: dir, wix, writers, installed, install, siteOrigin: siteRegional. siteOriginFor (dir, env. WIX_SITE_ID ), ... catalogSettle }));
468 } catch (error) {
469 if (error.blockerCode || wixAuth. isRunInterruption (error)) throw error;
470 throw Object. assign ( new Error ( `${ stepId }: the Stores catalog could not be read around the install: ${ error . message }` ), {
471 blockerCode: 'CATALOG_BASELINE_UNREADABLE' ,
472 recommendedAction: 'Re-run setup. Import cannot start until setup has recorded which products were on the store before it.' ,
473 });
474 }
475 catalog = { catalogBaseline: { artifact: catalogBaseline. BASELINE_PATH , sha256: catalogBaseline. digest (dir), unchanged, ... catalogBaseline. summarize (baseline) } };
476 } else if ( ! installed) await install ();
477 // An app the service says has no editor part (this run, or an earlier one on record) adds no pages,
478 // so none are unconfirmed. An app it refused over a page-path conflict is not installed: the step ends
479 // with `installed: false` (no gate reads that as an installed app) and the owner gets an item.
480 let viaEditor = false ;
481 if (harmony) {
482 const prior = await readAppPages (dir, env. WIX_SITE_ID );
483 const isDeferred = deferred. includes (appDefId);
484 viaEditor = ! isDeferred && ! noEditorPart. includes (appDefId) && ! prior.noEditorPart. includes (appDefId);
485 await recordAppPages (dir, env. WIX_SITE_ID , prior, { appDefId, stepId, outcome: isDeferred ? 'deferred' : viaEditor ? 'apps' : 'noEditorPart' });
486 if (isDeferred) return { method: SETUP_CHECK_METHODS . INSTALLED_APPS_READ , observed: { appDefId, installed: false , route: 'editor-installation' , deferred: 'page_path_conflict' } };
487 }
488 // `installCall` only when this run installed the app itself.
489 const ranInstall = harmony ? [ ... sentViaEditor, ... noEditorPart]. includes (appDefId) : ! installed;
490 const route = viaEditor ? { route: 'editor-installation' , appPages: 'not-read' }
491 : harmony ? { route: 'app-installer' , reason: 'no_editor_part' }
492 : editorType ? { route: 'app-installer' , reason: `editor_${ editorType }` } : { route: 'not-determined' };
493 return { method: SETUP_CHECK_METHODS . INSTALLED_APPS_READ , observed: { appDefId, installed: true , ... (ranInstall ? { installCall: 'succeeded' } : {}), ... catalog, ... route } };
494 }
495
496 async function runStep ( step , { dir , env , wix , writers , priorExtendedFieldVerification = null , catalogSettle }) {
497 if ( await isRegionalStep (step, dir)) {
498 const requirements = await readJsonIfExists (path. join (dir, 'setup' , 'setup-requirements.json' ));
499 const list = Array. isArray (requirements) ? requirements : requirements?.requirements;
500 const requirement = (list || []). find (( item ) => item && item.kind === 'siteLocale' ) || {};
501 const regional = await siteRegional. executeRegionalStep ({ projectDir: dir, wix, writers, siteId: env. WIX_SITE_ID , requirement });
502 // The receipt's fields are the read-back this step performed; the check records what was observed.
503 return { method: SETUP_CHECK_METHODS . SITE_PROPERTIES_READ , observed: { fields: Object. fromEntries (Object. entries (regional.fields || {}). map (([ field , row ]) => [field, row.action])) }, regional };
504 }
505 if (step.id === 'mute-site-notifications' ) {
506 const state = await writers. muteSiteNotifications (wix, { reason: `RePlatform migration — ${ path . basename ( dir ) }` });
507 if ( ! state?.muted) throw new Error ( 'notification mute verification failed' );
508 const read = await writers. getSiteMuteState (wix);
509 if (read?.muted !== true ) throw new Error ( 'notification mute verification failed: status read is not muted' );
510 return { method: SETUP_CHECK_METHODS . STATUS_READ , observed: { muted: true } };
511 }
512 if (step.id === 'start-back-in-stock-collection' ) {
513 await wix. send (writers. buildStartBackInStockCollectionRequest ());
514 const read = await wix. send (writers. buildGetBackInStockSettingsRequest ());
515 const stores = (read?.settings?.collectionStates || []). find (( row ) => row.appId === catalogBaseline. STORES_APP_DEF_ID );
516 if (stores?.collectingRequests !== true ) throw new Error ( 'back-in-stock collection verification failed: settings read does not show collecting for Wix Stores' );
517 return { method: SETUP_CHECK_METHODS . SETTINGS_READ , observed: { collectingRequests: true } };
518 }
519 if (step.kind === 'installApp' && ! appIdForStep (step)) {
520 throw blockerError ( 'INSTALL_APP_ID_INVALID' , `${ step . id }: an installApp step needs "appDefId" set to the app's id (a GUID)` , 'Fix the step in the setup plan and re-run setup.' );
521 }
522 const appDefId = appIdForStep (step);
523 if (appDefId) return installAndVerifyApp (step.id, appDefId, { dir, env, wix, writers, catalogSettle });
524 if ( await isExtendedFieldStep (step, dir)) {
525 const verifications = await provisionExtendedFieldsStep ({ dir, wix, writers, dryRun: false , priorVerification: priorExtendedFieldVerification });
526 return { method: SETUP_CHECK_METHODS . EXTENDED_FIELD_VERIFICATION , observed: { verifications: verifications. length } };
527 }
528 if (step.id === 'create-cms-collections' ) {
529 return createCmsCollectionsStep ({ dir, wix, writers });
530 }
531 if (step.id === 'stores-catalog-v3' ) {
532 const result = await wix. send (writers. buildGetStoresCatalogVersionRequest ());
533 if (result.catalogVersion !== 'V3_CATALOG' ) {
534 throw Object. assign ( new Error ( `Catalog V3 required (got ${ result . catalogVersion || 'unknown'})` ), {
535 blockerCode: 'CATALOG_V1' ,
536 recommendedAction: 'Use a destination site provisioned with Catalog V3, or omit Stores from this migration.' ,
537 });
538 }
539 return { method: SETUP_CHECK_METHODS . CATALOG_VERSION_READ , observed: { catalogVersion: result.catalogVersion } };
540 }
541 if (step.id === 'blog-fallback-author' ) {
542 return fallbackAuthor (dir, wix, writers);
543 }
544 if (step.intent?.writerId) {
545 const executor = writers. createWixSetupExecutor ({ wixClient: wix });
546 await executor. executeSetupStep (step);
547 // A declared reference is verified through its registered read builder, once per setup target.
548 if (step.referenceKeys?. length ) {
549 const read = writers[step.intent.readWriterId];
550 if ( typeof read !== 'function' || ! / ^ build(Get | Query)/ . test (step.intent.readWriterId || '' )) throw new Error ( 'setup_reference_readback_missing' );
551 await wix. send ( read ( ... (step.intent.readArgs || [])));
552 return { method: SETUP_CHECK_METHODS . REFERENCE_READ_BACK , observed: { writerId: step.intent.writerId, readWriterId: step.intent.readWriterId } };
553 }
554 return { method: SETUP_CHECK_METHODS . WRITER_RESPONSE , observed: { writerId: step.intent.writerId } };
555 }
556 throw Object. assign ( new Error ( `No executor implements setup step "${ step . id }"` ), {
557 blockerCode: 'NO_EXECUTOR_FOR_REQUIREMENT' ,
558 recommendedAction: 'Add an executor for this step id in execute-setup.js, or remove it from the setup plan.' ,
559 });
560 }
561
562 async function regionalDryRunIntent ( dir ) {
563 const source = await require ( '../../../lib/source-regional.js' ). readSourceRegional (dir). catch (() => null );
564 return source ? siteRegional. regionalDryRunIntent (source) : [];
565 }
566
567 // A dry run cannot install. The route it expects comes from the destination step's record, never from a
568 // call to the editor: the live run reads the site again, so the intent says what its basis is. For
569 // Stores it also states the sample-product rule it would apply (and makes no delete call).
570 async function appDryRunIntent ( dir , siteId , appDefId ) {
571 const recorded = await recordedEditorType (dir, siteId);
572 const route = ! recorded ? { route: 'decided-at-run-time' , routeBasis: 'no destination record' }
573 : recorded === HARMONY_EDITOR_TYPE ? { route: 'editor-installation' , routeBasis: 'destination record; the live run reads the site again' }
574 : { route: 'app-installer' , routeBasis: 'destination record; the live run reads the site again' };
575 if (appDefId !== catalogBaseline. STORES_APP_DEF_ID ) return route;
576 const prior = catalogBaseline. loadBaseline (dir);
577 return prior
578 ? { catalogBaseline: 'recorded' , wouldDelete: prior.products. concat (prior.categories). filter (( row ) => row.kind === 'install_seeded' && row.status !== 'deleted' ). map (( row ) => row.id), ... route }
579 : { catalogBaseline: 'read before and after the install; delete only items the install adds' , wouldDelete: 'unknown until install' , ... route };
580 }
581
582 async function writeBlockers ( setupDir , generatedAt , blockers , replaces = null ) {
583 await fs. writeFile (
584 path. join (setupDir, 'setup-blockers.json' ),
585 `${ JSON . stringify ({ schemaVersion: 1 , generatedAt , replaces , blockers }, null , 2 ) } \n ` ,
586 );
587 }
588
589 async function replacedReceipt ( setupDir ) {
590 let bytes;
591 try { bytes = await fs. readFile (path. join (setupDir, 'setup-verification.json' )); } catch (error) {
592 if (error.code !== 'ENOENT' ) throw error;
593 try { return JSON . parse ( await fs. readFile (path. join (setupDir, 'setup-blockers.json' ), 'utf8' )).replaces ?? null ; } catch (error) { return error.code === 'ENOENT' ? null : { sha256: null , generatedAt: null }; }
594 }
595 let generatedAt = null ;
596 try { generatedAt = JSON . parse (bytes).generatedAt ?? null ; } catch { /* unreadable: no trusted time */ }
597 return { sha256: crypto. createHash ( 'sha256' ). update (bytes). digest ( 'hex' ), generatedAt };
598 }
599
600 // Writes the final file via a temp-then-rename so a crash mid-write can never leave a
601 // corrupt or partial receipt where a trusted one is expected — the rename is atomic on the
602 // same filesystem/directory.
603 async function publishAtomically ( filePath , contents ) {
604 const tmpPath = path. join (path. dirname (filePath), `.${ path . basename ( filePath ) }.${ crypto . randomUUID () }.tmp` );
605 await fs. writeFile (tmpPath, contents);
606 await fs. rename (tmpPath, filePath);
607 }
608
609 // Core logic, independent of the CLI entry point below so tests can inject a fake
610 // `writers` module instead of making real Wix API calls. Performs every file write itself
611 // (the receipt, the blockers file) and returns a summary rather than throwing on a
612 // recognized blocker — `main()` decides how to surface that to the process exit code.
613 // `catalogSettle` ({ sleep, delayMs }) only shortens the post-install catalog re-reads in tests.
614 async function executeSetup ({ dir , dryRun = false , writers = REAL_WRITERS , catalogSettle = {} }) {
615 const env = await readEnv (dir);
616 if ( ! env. WIX_SITE_ID || ! env. WIX_AUTH_TOKEN ) throw new Error ( 'WIX_SITE_ID and WIX_AUTH_TOKEN are required' );
617
618 const setupDir = path. join (dir, 'setup' );
619 const verificationPath = path. join (setupDir, 'setup-verification.json' );
620 const plan = await readJsonIfExists (path. join (setupDir, 'setup-plan.json' ));
621 const generatedAt = new Date (). toISOString ();
622 // The receipt this attempt replaces, carried through failed attempts in setup-blockers.json, so
623 // import-plan freshness can tell the approved setup's first receipt from a later re-run.
624 const replaces = await replacedReceipt (setupDir);
625
626 // Read BEFORE the receipt is invalidated below: the legacy contacts-v4 path must prove a field's
627 // key did not change since an earlier verified run (Wix keys cannot change). Only used for that
628 // comparison, never as evidence that anything is provisioned now.
629 // Only a missing file means "no earlier run". A receipt that exists but cannot be read or
630 // parsed may hold keys a legacy contacts-v4 write already used; treating it as absent would let
631 // Find Or Create accept a different key and split old and new values across two fields.
632 let priorExtendedFieldVerification = null ;
633 let priorReceiptError = null ;
634 try {
635 priorExtendedFieldVerification = await readJsonIfExists (path. join (setupDir, 'extended-field-verification.json' ));
636 } catch (error) {
637 priorReceiptError = error;
638 }
639 if (priorReceiptError) {
640 const declaredForPrior = await readJsonIfExists (path. join (setupDir, 'setup-requirements.json' ));
641 const declaredPriorList = Array. isArray (declaredForPrior) ? declaredForPrior : declaredForPrior?.requirements;
642 const needsPriorKeys = (declaredPriorList || []). some (( item ) => item && item.kind === 'extendedFieldSchema' && item.schemaKind === CONTACT_V4_SCHEMA_KIND );
643 if (needsPriorKeys) {
644 const blocker = {
645 code: 'PRIOR_EXTENDED_FIELD_RECEIPT_UNREADABLE' ,
646 severity: 'blocker' ,
647 requirementId: null ,
648 description: `setup/extended-field-verification.json exists but could not be read: ${ String ( priorReceiptError . message ). slice ( 0 , 200 ) }` ,
649 whyBlocked: 'the legacy contact extended-field keys an earlier run verified cannot be checked, so a changed key could split contact values across two fields' ,
650 recommendedAction: 'Restore setup/extended-field-verification.json from the earlier run (it was left in place). Remove it only if no earlier import wrote legacy contact extended-field values. Then re-run setup.' ,
651 };
652 // Leave the unreadable receipt where it is for recovery; only the setup receipt is invalidated.
653 if ( ! dryRun) {
654 await fs. rm (verificationPath, { force: true });
655 await writeBlockers (setupDir, generatedAt, [blocker], replaces);
656 }
657 return { ok: false , dryRun, requirements: [], blocker };
658 }
659 // DES-only runs never read prior keys, so an unreadable earlier receipt is simply replaced.
660 }
661
662 if ( ! dryRun) {
663 // Invalidate any prior receipt the instant a new live attempt begins — before we even
664 // know whether this attempt will succeed. A crash partway through this run (or a
665 // failure to reach the blocker-write below) must never leave an earlier, possibly
666 // no-longer-true success receipt sitting there looking trusted.
667 await fs. rm (verificationPath, { force: true });
668 // Same reasoning for the extended-field receipt: the writers consult it before putting a
669 // value in one of those fields, so a receipt from an earlier run must not outlive it.
670 await fs. rm (path. join (setupDir, 'extended-field-verification.json' ), { force: true });
671 }
672
673 const planShape = validatePlanShape (plan, dir);
674 if ( ! planShape.ok) {
675 const blocker = {
676 code: 'INVALID_SETUP_PLAN' ,
677 severity: 'blocker' ,
678 requirementId: null ,
679 description: `setup-plan.json is invalid: ${ planShape . reasons . join ( ', ' ) }` ,
680 whyBlocked: planShape.reasons. join ( ', ' ),
681 recommendedAction: 'Regenerate setup/setup-plan.json with a valid schemaVersion, a steps array, unique, non-empty step ids, and the entityRefs each named step writes (create-cms-collections lists cms/collection).' ,
682 };
683 if (dryRun) throw new Error (blocker.description);
684 await writeBlockers (setupDir, generatedAt, [blocker], replaces);
685 return { ok: false , dryRun: false , requirements: [], blocker };
686 }
687
688 const planDigest = hashArtifact (dir, path. join ( 'setup' , 'setup-plan.json' ));
689 const setupRun = capabilities. startSetupRun ({ projectDir: dir, siteId: env. WIX_SITE_ID , dryRun });
690
691 // A requirement nobody scheduled provisions nothing. The writers that need those fields then
692 // refuse their writes, which is safe but fails late and for a reason that points at the wrong
693 // place -- so the mismatch is caught here, where the plan and the requirements are both in hand.
694 const declared = await readJsonIfExists (path. join (dir, 'setup' , 'setup-requirements.json' ));
695 const declaredList = Array. isArray (declared) ? declared : declared?.requirements;
696 const needsExtendedFields = (declaredList || []). some (( item ) => item && item.kind === 'extendedFieldSchema' );
697 const schedulesExtendedFields = ((plan && plan.steps) || []). some (( step ) => stepProvisionsExtendedFields (step, declaredList));
698 if (needsExtendedFields && ! schedulesExtendedFields) {
699 const blocker = {
700 code: 'EXTENDED_FIELD_REQUIREMENT_UNSCHEDULED' ,
701 severity: 'blocker' ,
702 requirementId: null ,
703 description: 'setup-requirements.json declares an extendedFieldSchema requirement but setup-plan.json has no provision-extended-fields step' ,
704 whyBlocked: 'the fields would never be provisioned, and every writer that needs them would refuse its write later in the run' ,
705 recommendedAction: 'Add a "provision-extended-fields" step to the setup plan, or remove the extendedFieldSchema requirement.' ,
706 };
707 // A dry run reports; it does not leave artifacts behind. Writing the blockers file here made
708 // `dryRun: true` mutate the project and report itself as a live run.
709 if ( ! dryRun) await writeBlockers (path. join (dir, 'setup' ), new Date (). toISOString (), [blocker], replaces);
710 return { ok: false , dryRun, requirements: [], blocker };
711 }
712
713 // Same pairing rule for the site regional settings: a requirement with no step changes nothing,
714 // and a step with no requirement has nothing to compare against.
715 const needsRegional = (declaredList || []). some (( item ) => item && item.kind === 'siteLocale' );
716 const schedulesRegional = ((plan && plan.steps) || []). some (( step ) => siteRegional. stepConfiguresSiteRegional (step, declaredList));
717 if (needsRegional !== schedulesRegional) {
718 const blocker = {
719 code: 'SITE_REGIONAL_REQUIREMENT_UNSCHEDULED' ,
720 severity: 'blocker' ,
721 requirementId: null ,
722 description: 'setup-requirements.json and setup-plan.json disagree about import-site-locale: it must be in both or in neither' ,
723 whyBlocked: 'a requirement with no step never changes the site, and a step with no requirement has no source values or entity lists to act on' ,
724 recommendedAction: 'Regenerate the setup plan and requirements together (rp-setup-discovery), or remove the stray one.' ,
725 };
726 if ( ! dryRun) await writeBlockers (path. join (dir, 'setup' ), new Date (). toISOString (), [blocker]);
727 return { ok: false , dryRun, requirements: [], blocker };
728 }
729
730 const requirements = [];
731 let blocker = null ;
732 // One renewing token for every step: setup also runs long enough to outlive a token.
733 const tokenSource = dryRun ? null : wixAuth. createWixTokenSource ({ projectDir: dir, siteId: env. WIX_SITE_ID });
734
735 for ( const step of plan.steps) {
736 if (dryRun) {
737 const intent = siteRegional. stepConfiguresSiteRegional (step, declaredList) ? await regionalDryRunIntent (dir)
738 : appIdForStep (step) ? await appDryRunIntent (dir, env. WIX_SITE_ID , appIdForStep (step)) : null ;
739 requirements. push ({ id: step.id, status: 'planned_dry_run' , ... (intent ? { intent } : {}) });
740 continue ;
741 }
742 try {
743 const stepCapability = capabilities. bindSetupStep (setupRun, step.id);
744 const wix = writers. createWixClient ({ capability: stepCapability, tokenSource });
745 const { method , observed , regional } = await runStep (step, { dir, env, wix, writers, priorExtendedFieldVerification, catalogSettle });
746 const verification = { method, checkedAt: new Date (). toISOString (), observed };
747 const referenceBindings = step.referenceKeys?. length ? writers. captureSetupReferenceBindings (wix, step) : undefined ;
748 requirements. push ({ id: step.id, status: 'passed' , verification, ... (referenceBindings ? { referenceBindings } : {}), ... (regional ? { regional } : {}) });
749 } catch (caught) {
750 // A step helper may wrap the interruption in its own error; the project's latch is the
751 // authority that this step stopped on an expired token.
752 const error = wixAuth. interruptionFor (dir) || caught;
753 requirements. push ({ id: step.id, status: 'blocked' , detail: error.message });
754 blocker = {
755 code: error.blockerCode || (wixAuth. isRunInterruption (error) ? wixAuth. WIX_AUTH_EXPIRED : 'UNEXPECTED_SETUP_ERROR' ),
756 severity: 'blocker' ,
757 requirementId: step.id,
758 description: error.message,
759 whyBlocked: error.message,
760 recommendedAction: error.recommendedAction || (wixAuth. isRunInterruption (error) ? error.message : 'Investigate the underlying error and re-run setup once resolved.' ),
761 ... (error.awaitingOwner ? { awaitingOwner: true } : {}),
762 ... (wixAuth. isRunInterruption (error) ? { reason: error.reason } : {}),
763 };
764 break ; // Stop at the first failure — later steps were never attempted, not skipped.
765 }
766 }
767
768 if (dryRun) {
769 await fs. writeFile (
770 path. join (setupDir, 'setup-dry-run.json' ),
771 `${ JSON . stringify ({ schemaVersion: 1 , generatedAt , dryRun: true , requirements }, null , 2 ) } \n ` ,
772 );
773 return { ok: true , dryRun: true , requirements, blocker: null };
774 }
775
776 if (blocker) {
777 // The old receipt (if any) was already removed above, before this attempt began.
778 await writeBlockers (setupDir, generatedAt, [blocker], replaces);
779 return { ok: false , dryRun: false , requirements, blocker };
780 }
781
782 // Full success: every planned requirement passed. Self-check the candidate against the
783 // exact same validator the router uses before publishing it — there must be exactly one
784 // definition of "valid", shared, never re-implemented separately here.
785 const candidate = { schemaVersion: 1 , generatedAt, siteId: env. WIX_SITE_ID , dryRun: false , planDigest, replaces, requirements };
786 const selfCheck = validateSetupVerification ({ verification: candidate, plan, planDigest, siteId: env. WIX_SITE_ID , projectDir:dir });
787 if ( ! selfCheck.ok) {
788 const internalBlocker = {
789 code: 'INTERNAL_VALIDATION_FAILURE' ,
790 severity: 'blocker' ,
791 requirementId: null ,
792 description: `execute-setup.js produced a receipt that failed its own validator: ${ selfCheck . reasons . join ( ', ' ) }` ,
793 whyBlocked: selfCheck.reasons. join ( ', ' ),
794 recommendedAction: 'This indicates a bug in execute-setup.js itself — investigate rather than retrying.' ,
795 };
796 await writeBlockers (setupDir, generatedAt, [internalBlocker], replaces);
797 return { ok: false , dryRun: false , requirements, blocker: internalBlocker };
798 }
799
800 await writeBlockers (setupDir, generatedAt, [], replaces);
801 await publishAtomically (verificationPath, `${ JSON . stringify ( candidate , null , 2 ) } \n ` );
802 return { ok: true , dryRun: false , requirements, blocker: null };
803 }
804
805 async function main () {
806 const dir = process.argv[ 2 ] && path. resolve (process.argv[ 2 ]);
807 if ( ! dir) throw new Error ( 'Usage: execute-setup.js <projectDir> [--dry-run]' );
808 const dryRun = process.argv. includes ( '--dry-run' );
809 const result = await executeSetup ({ dir, dryRun });
810 if (result.blocker?.code === wixAuth. WIX_AUTH_EXPIRED ) {
811 console. error (result.blocker.description);
812 process. exit ( await wixAuth. stopExitCode (Object. assign ( new wixAuth. WixAuthInterruption (result.blocker.reason), { siteId: ( await readEnv (dir)). WIX_SITE_ID }), dir));
813 }
814 if (result.blocker) throw new Error (result.blocker.description);
815 console. log ( JSON . stringify ({ ok: true , dryRun }));
816 }
817
818 if (require.main === module ) {
819 main (). catch (( error ) => {
820 console. error (error.message);
821 process. exit ( 1 );
822 });
823 }
824
825 module . exports = { executeSetup, runStep };