Setting the file. One moment.
Destination · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page const redact
— line 123
This file
Number 45.69
Position 69 of 182
Type JavaScript
Size 62 KB
Lines 1,140 lib/ destination.js
JavaScript · 1,140 lines · 62 KB
=
require
(
'node:path'
);
11
12 const { readEnvFile , upsertEnvFile } = require ( './config-env.js' );
13 const { CallTimeoutError , boundedRequest , isTransientStatus , retryAfterMs } = require ( './destination-http.js' );
14 const { editorTypeFromUrls , HARMONY_EDITOR_TYPE } = require ( './editor-type.js' );
15
16 const {
17 getDecisionValue ,
18 getEffectiveDeliveryMode ,
19 isEditorSiteType ,
20 isNewSiteStrategy ,
21 readSiteTypeDecision ,
22 } = require ( './orchestration-decisions.js' );
23 const { loadArtifacts , writeJsonAtomic } = require ( './orchestration-state.js' );
24 const { createUserAction , readUserAction , listUserActions } = require ( './user-actions.js' );
25 const { editorWebsiteRequestInput , siteTypeRequestInput , KNOWN_BUSINESS_TYPES } = require ( './destination-choice.js' );
26 const { tokenExpiresAt } = require ( './wix-auth.js' );
27
28 const RECEIPT_SCHEMA_VERSION = 1 ;
29 const RECEIPT_STATUSES = Object. freeze ([ 'in_progress' , 'created' , 'verified' , 'failed_before_create' , 'ambiguous' , 'blocked' ]);
30
31 const BLANK_TEMPLATES = Object. freeze ({
32 studio: 'fe86a14e-ef67-49b4-a409-d086f3abaa1a' ,
33 harmony: '91532250-8835-46ac-9666-e4db8770b275' ,
34 });
35 // "Developer Tools" gives a site its Secrets Manager and the `secrets` object. Headless sites
36 // come with it; Harmony and Studio sites do not.
37 const DEVELOPER_TOOLS_APP_DEF_ID = '4b10fcce-732d-4be3-9d46-801d271acda9' ;
38 const EXPECTED_EDITOR = Object. freeze ({ studio: 'WIX_STUDIO' , harmony: HARMONY_EDITOR_TYPE , headless: 'EDITORLESS' });
39
40 const URLS = Object. freeze ({
41 createFromTemplate: 'https://www.wixapis.com/msm/v1/meta-site/create-from-template' ,
42 editorUrls: 'https://www.wixapis.com/editor-urls' ,
43 installApp: 'https://www.wixapis.com/apps-installer-service/v1/app-instance/install' ,
44 installedApps: 'https://www.wixapis.com/apps-installer-service/v1/app-instances' ,
45 publishedSiteUrls: 'https://www.wixapis.com/urls-server/v2/published-site-urls' ,
46 });
47
48 // Per-call timeouts, polling bounds and publication readback bounds.
49 const LIMITS = Object. freeze ({
50 createTimeoutMs: 60_000 ,
51 editorTimeoutMs: 20_000 ,
52 installTimeoutMs: 30_000 ,
53 publicationTimeoutMs: 15_000 ,
54 publicationAttempts: 3 ,
55 publicationWaitsMs: [ 2_000 , 8_000 ],
56 publicationRetryAfterCapMs: 30_000 ,
57 publicationBudgetMs: 90_000 ,
58 publicationMinLeftMs: 1_000 ,
59 });
60
61 const UUID_RE = / ^ [0-9a-f] {8} - [0-9a-f] {4} - [0-9a-f] {4} - [0-9a-f] {4} - [0-9a-f] {12}$ / i ;
62 const IMPORT_OPERATOR_PROMPT = 'Requires Import Operator. In the original migration workspace, reconcile the destination creation attempt against the intended account, preserve the returned metasite ID, rerun editor verification and the published-URLs readback, and resume only after recording the resolved receipt. Do not create a replacement site, and do not publish or unpublish it without an explicit human publication decision.' ;
63
64 function receiptPath ( projectDir ) { return path. join (projectDir, 'orchestration' , 'destination.json' ); }
65 function lockPath ( projectDir ) { return path. join (projectDir, 'orchestration' , 'destination.lock' ); }
66 function wixEnvPath ( projectDir ) { return path. join (projectDir, 'config' , 'wix.env' ); }
67
68 async function readReceipt ( projectDir ) {
69 try {
70 return JSON . parse ( await fs. readFile ( receiptPath (projectDir), 'utf8' ));
71 } catch (error) {
72 if (error && error.code === 'ENOENT' ) return null ;
73 if (error instanceof SyntaxError ) return { invalid: true , error: error.message };
74 throw error;
75 }
76 }
77
78 async function readWixEnv ( projectDir ) {
79 try {
80 return await readEnvFile ( wixEnvPath (projectDir));
81 } catch (error) {
82 if (error && error.code === 'ENOENT' ) return {};
83 throw error;
84 }
85 }
86
87 function validateReceipt ( receipt ) {
88 const errors = [];
89 if ( ! receipt || typeof receipt !== 'object' || receipt.invalid) return [ 'orchestration/destination.json is not valid JSON' ];
90 if (receipt.schemaVersion !== RECEIPT_SCHEMA_VERSION ) errors. push ( `destination receipt schemaVersion must be ${ RECEIPT_SCHEMA_VERSION }` );
91 if ( ! RECEIPT_STATUSES . includes (receipt.status)) errors. push ( `destination receipt status must be one of ${ RECEIPT_STATUSES . join ( ', ' ) }` );
92 if ( ! [ 'headless' , 'studio' , 'harmony' ]. includes (receipt.requestedSiteType)) errors. push ( 'destination receipt requestedSiteType is invalid' );
93 if (receipt.metaSiteId != null && ! UUID_RE . test ( String (receipt.metaSiteId))) errors. push ( 'destination receipt metaSiteId is not a metasite id' );
94 return errors;
95 }
96
97 // 4–20 chars of [a-z0-9_-], ending in a letter, digit or underscore.
98 function normalizeSiteName ( value , { fallback = 'replatform-site' , suffix = '' } = {}) {
99 const clean = ( input ) => String (input || '' ). toLowerCase (). normalize ( 'NFKD' ). replace ( / [ ^ a-z0-9_-] + / g , '-' ). replace ( /- + / g , '-' ). replace ( / ^ [-_] + / , '' );
100 let base = clean (value);
101 const tail = suffix ? `-${ clean ( suffix ). replace ( / [-_] +$ / , '' ) }` : '' ;
102 base = base. slice ( 0 , 20 - tail. length ). replace ( /- +$ / , '' );
103 let name = `${ base }${ tail }` ;
104 if (name. length < 4 ) name = `${ clean ( fallback ) }${ tail }` . slice ( 0 , 20 );
105 name = name. replace ( /- +$ / , '' );
106 while (name. length < 4 ) name += '0' ;
107 return name;
108 }
109
110 function identityRef ( identity ) {
111 return identity ? `sha256:${ crypto . createHash ( 'sha256' ). update ( String ( identity )). digest ( 'hex' ). slice ( 0 , 16 ) }` : null ;
112 }
113
114 function isoAt ( clock ) { return new Date (clock. now ()). toISOString (); }
115
116 const CREDENTIAL_ERROR_MAX = 1000 ;
117
118 function credentialError ( error ) {
119 const rawMessage = error && error.message ? error.message : String (error || 'site credential mint returned no token' );
120 const rawStderr = error && error.stderr ? String (error.stderr) : '' ;
121 // A CLI failure may include a token in a command's diagnostic output. Keep the cause useful
122 // without allowing a credential to enter the durable receipt.
123 const redact = ( value ) => clip (value, CREDENTIAL_ERROR_MAX ). replace ( /(?:OauthNG \. ) ? JWS \. [A-Za-z0-9_-] + (?: \. [A-Za-z0-9_-] + ) {2} / g , '[credential redacted]' );
124 return {
125 ... (error && error.name ? { name: clip (error.name, 120 ) } : {}),
126 ... (error && error.code ? { code: clip (error.code, 120 ) } : {}),
127 message: redact (rawMessage),
128 ... (rawStderr && redact (rawStderr) !== redact (rawMessage) ? { stderr: redact (rawStderr) } : {}),
129 };
130 }
131
132 function isTransientCredentialExpiry ( error ) {
133 const detail = credentialError (error);
134 const text = `${ detail . code || ''} ${ detail . message } ${ detail . stderr || ''}` ;
135 return /(?:expired | expiration | token \s + (?:refresh | renew) | (?:refresh | renew) \s + token | status \s * [:=] ? \s * 401 | unauthori [sz] ed)/ i . test (text);
136 }
137
138 // Each CLI invocation asks the installed Wix CLI for its current token; the CLI refreshes its
139 // login session when needed. A second invocation is therefore the one bounded refresh/retry for
140 // an expiry race. All failures are returned for the receipt instead of being swallowed.
141 async function mintSiteToken ( deps , metaSiteId ) {
142 const failures = [];
143 for ( let attempt = 0 ; attempt < 2 ; attempt += 1 ) {
144 try {
145 const token = await deps.credentials. siteToken (metaSiteId);
146 if (token) {
147 const expiresAt = tokenExpiresAt (token);
148 if (expiresAt === null || expiresAt > deps.clock. now ()) return { token, failures };
149 const error = new Error ( 'the Wix CLI returned an expired site credential' );
150 error.code = 'TOKEN_EXPIRED' ;
151 failures. push ( credentialError (error));
152 if (attempt === 0 ) continue ;
153 break ;
154 }
155 failures. push ( credentialError ( null ));
156 } catch (error) {
157 failures. push ( credentialError (error));
158 if (attempt === 0 && isTransientCredentialExpiry (error)) continue ;
159 }
160 break ;
161 }
162 return { token: null , failures, error: failures. at ( - 1 ) || credentialError ( null ) };
163 }
164
165 // A site read can refuse a token after it was minted (for example, when the four-hour
166 // credential expired between the CLI call and the HTTP request). Ask the installed CLI for a
167 // replacement once, then repeat only that read. The caller persists the mint failure if the
168 // replacement cannot be obtained.
169 async function readWithCredentialRetry ( deps , metaSiteId , siteToken , read ) {
170 const first = await read (siteToken);
171 if (first.httpStatus !== 401 ) return { observation: first, siteToken, refresh: null };
172 const refresh = await mintSiteToken (deps, metaSiteId);
173 if ( ! refresh.token) {
174 return {
175 observation: {
176 ... first,
177 reason: 'site_credential_unavailable' ,
178 credentialError: refresh.error,
179 credentialAttempts: refresh.failures,
180 },
181 siteToken,
182 refresh,
183 };
184 }
185 return { observation: await read (refresh.token), siteToken: refresh.token, refresh };
186 }
187
188 function siteCredentialUnavailable ( message , mint , nextAction ) {
189 return {
190 code: 'site_credential_unavailable' ,
191 message: `${ message } Cause: ${ mint . error . message }` ,
192 nextAction,
193 credentialError: mint.error,
194 credentialAttempts: mint.failures,
195 };
196 }
197
198 // --- lock -------------------------------------------------------------------------------
199
200 function pidAlive ( pid ) {
201 if ( ! Number. isInteger (pid) || pid <= 0 ) return false ;
202 try { process. kill (pid, 0 ); return true ; } catch (error) { return error && error.code === 'EPERM' ; }
203 }
204
205 async function readRaw ( filePath ) {
206 try { return await fs. readFile (filePath, 'utf8' ); } catch (error) { if (error && error.code === 'ENOENT' ) return null ; throw error; }
207 }
208
209 function lockHolderAlive ( raw ) {
210 let holder = null ;
211 try { holder = JSON . parse (raw); } catch { holder = null ; }
212 return Boolean (holder && pidAlive (holder.pid));
213 }
214
215 // Creates `filePath` with its full content in one atomic step (hard link of a private temp
216 // file), so no contender ever observes an empty or half-written lock. Returns false on EEXIST.
217 async function createExclusive ( filePath , content ) {
218 const temp = `${ filePath }.${ process . pid }.${ crypto . randomUUID () }.tmp` ;
219 await fs. writeFile (temp, content, { flag: 'wx' });
220 try {
221 await fs. link (temp, filePath);
222 return true ;
223 } catch (error) {
224 if (error && error.code === 'EEXIST' ) return false ;
225 throw error;
226 } finally {
227 await fs. rm (temp, { force: true });
228 }
229 }
230
231 // Project-local exclusive lock. Every lock carries a unique owner token. A lock left by
232 // a dead process is reclaimed only under a second exclusive "reclaim" lock, and only if the
233 // lock file still holds the exact stale content this contender read, so two contenders can
234 // never both reclaim (or one delete the other's live lock). Release removes the lock only
235 // while it still holds this owner's token. A live holder, or a reclaim lock left by a dead
236 // process, makes this invocation stop without touching anything.
237 async function acquireLock ( projectDir ) {
238 const filePath = lockPath (projectDir);
239 const reclaimPath = `${ filePath }.reclaim` ;
240 await fs. mkdir (path. dirname (filePath), { recursive: true });
241 const token = crypto. randomUUID ();
242 const content = JSON . stringify ({ pid: process.pid, token, acquiredAt: new Date (). toISOString () });
243 const release = async () => {
244 if (( await readRaw (filePath)) === content) await fs. rm (filePath, { force: true });
245 };
246 for ( let attempt = 0 ; attempt < 2 ; attempt += 1 ) {
247 if ( await createExclusive (filePath, content)) return release;
248 const seen = await readRaw (filePath);
249 if (seen === null ) continue ;
250 if ( lockHolderAlive (seen)) return null ;
251 if (attempt > 0 ) return null ;
252 const reclaimContent = JSON . stringify ({ pid: process.pid, token, at: new Date (). toISOString () });
253 if ( ! ( await createExclusive (reclaimPath, reclaimContent))) return null ;
254 try {
255 if (( await readRaw (filePath)) === seen) await fs. rm (filePath, { force: true });
256 } finally {
257 if (( await readRaw (reclaimPath)) === reclaimContent) await fs. rm (reclaimPath, { force: true });
258 }
259 }
260 return null ;
261 }
262
263 // --- receipt writes -----------------------------------------------------------------------
264
265 async function saveReceipt ( projectDir , receipt , clock ) {
266 receipt.updatedAt = isoAt (clock);
267 await writeJsonAtomic ( receiptPath (projectDir), receipt);
268 return receipt;
269 }
270
271 // Keys rp-execute-import requires that a created site settles. Each value is what the runtime
272 // already assumes when the key is absent, so writing it changes no behavior; a present value is kept.
273 const BINDING_DEFAULTS = Object. freeze ({ WIX_SITE_STRATEGY: 'new' , DRY_RUN: 'false' });
274
275 // Persists WIX_SITE_ID only after the receipt holds it, and never over a different value.
276 // Every site type binds through here, Headless included (its wrapper writes only WIX_SITE_ID).
277 async function persistSiteBinding ( projectDir , metaSiteId ) {
278 const env = await readWixEnv (projectDir);
279 const current = env. WIX_SITE_ID && String (env. WIX_SITE_ID ). trim ();
280 if (current && current !== 'REPLACE_WITH_REAL_VALUE' && current !== metaSiteId) {
281 return { ok: false , conflict: current };
282 }
283 const updates = current === metaSiteId ? {} : { WIX_SITE_ID: metaSiteId };
284 for ( const [ key , value ] of Object. entries ( BINDING_DEFAULTS )) if ( ! env[key]) updates[key] = value;
285 if (Object. keys (updates). length ) await upsertEnvFile ( wixEnvPath (projectDir), updates);
286 return { ok: true };
287 }
288
289 // --- recovery requests --------------------------------------------------------------------
290
291 const RECOVERY = Object. freeze ({
292 failed_before_create: {
293 title: 'Fix the site-creation prerequisite' ,
294 choices: [{ id: 'retry_create' , label: 'Prerequisite fixed — create the site' , description: 'The stated cause (access, site limit, template or name) has been corrected; make one new creation attempt.' }],
295 },
296 ambiguous: {
297 title: 'Reconcile an uncertain site creation' ,
298 mode: 'text' ,
299 },
300 editor_unverified: {
301 title: 'Editor verification did not pass' ,
302 choices: [{ id: 'recheck' , label: 'Re-run verification' , description: 'Run one new editor readback and published-URLs readback for the saved site; no site is created.' }],
303 },
304 publication_unknown: {
305 title: 'Publication state of the new site could not be read' ,
306 choices: [{ id: 'recheck' , label: 'Read the publication state again' , description: 'The stated cause (for example access) is fixed; run one new published-URLs readback. No site is created, published or unpublished.' }],
307 },
308 publication_published: {
309 title: 'The new site is already published' ,
310 choices: [{ id: 'recheck_after_unpublish' , label: 'Site unpublished in Wix — read again' , description: 'A person has unpublished the site in Wix; run one new published-URLs readback. RePlatform never unpublishes it.' }],
311 },
312 ownership_unverified: {
313 title: 'Confirm which account owns the new site' ,
314 mode: 'text' ,
315 },
316 });
317
318 function recoveryPrompt ( kind , receipt ) {
319 const site = receipt.metaSiteId ? ` Site: ${ receipt . metaSiteId }.` : '' ;
320 const cause = receipt.error ? ` Cause: ${ receipt . error . message }` : '' ;
321 switch (kind) {
322 case 'failed_before_create' :
323 return `The ${ receipt . requestedSiteType } site was not created: the request was rejected before creation.${ cause } Next: ${ receipt . error ? receipt . error . nextAction : 'fix the prerequisite'}. Nothing was created, so one new attempt is safe after the fix.` ;
324 case 'ambiguous' :
325 return `The outcome of the site-creation attempt ${ receipt . attemptId } is unknown; a site may or may not exist.${ site }${ cause } ${ IMPORT_OPERATOR_PROMPT } Reply with the metasite ID of the site this attempt created (it is then verified before use), or with "none-created: <evidence>" when an independently verified account site list shows that no site was created. Names alone are not identity proof.` ;
326 case 'editor_unverified' :
327 return `The new site's editor could not be verified as ${ EXPECTED_EDITOR [ receipt . requestedSiteType ] }.${ site }${ cause } The site is kept and import stays blocked. ${ IMPORT_OPERATOR_PROMPT }` ;
328 case 'ownership_unverified' :
329 return `The new site was created, but its owner could not be matched to the confirmed Wix account.${ site }${ cause } The site is kept and not bound for import. ${ IMPORT_OPERATOR_PROMPT } Reply "owner-verified: <evidence>" once the intended account's site list shows this site; editor verification and the published-URLs readback then run. Any other reply asks again. Names alone are not identity proof.` ;
330 case 'publication_unknown' :
331 return `The publication state of this new site could not be read.${ site }${ cause } Import stays blocked until a fresh readback shows the site unpublished; answering only triggers that readback.` ;
332 case 'publication_published' :
333 return `This new site is already published. RePlatform imports only into an unpublished new site.${ site } A person may unpublish it in Wix (an explicit human publication decision); RePlatform never does. Answering only triggers a new readback.` ;
334 default :
335 return 'Destination recovery is required.' ;
336 }
337 }
338
339 async function raiseRecovery ( projectDir , receipt , kind , clock , observation = null ) {
340 const spec = RECOVERY [kind];
341 const request = await createUserAction (projectDir, {
342 kind: 'manual_action' ,
343 stage: 'destination' ,
344 title: spec.title,
345 prompt: recoveryPrompt (kind, receipt),
346 response: { mode: spec.mode || 'choice' , required: true },
347 choices: spec.choices || [],
348 subject: {
349 type: 'destination_recovery' ,
350 recovery: kind,
351 attemptId: receipt.attemptId,
352 metaSiteId: receipt.metaSiteId || null ,
353 ... (observation ? { observation } : {}),
354 },
355 });
356 receipt.recovery = { kind, requestId: request.requestId, raisedAt: isoAt (clock) };
357 return request;
358 }
359
360 async function readRecoveryAnswer ( projectDir , receipt ) {
361 if ( ! receipt.recovery || ! receipt.recovery.requestId) return null ;
362 let request;
363 try { request = await readUserAction (projectDir, receipt.recovery.requestId); } catch { return null ; }
364 return request && request.status === 'answered' ? request : null ;
365 }
366
367 // --- HTTP steps -------------------------------------------------------------------------
368
369 function errorFromCreateResponse ( res ) {
370 const body = res.json || {};
371 const detail = body.details && body.details.applicationError ? body.details.applicationError : {};
372 const code = detail.code || body.code || null ;
373 const message = String (detail.description || body.message || `HTTP ${ res . status }` ). slice ( 0 , 500 );
374 if (res.status === 401 || res.status === 403 ) return { code: 'access_denied' , message, nextAction: 'Log the Wix CLI into the intended account with site-creation permission (access recovery).' };
375 if (code === 'TOO_MANY_SITES_FOR_FREE_USER' ) return { code: 'site_limit' , message, nextAction: 'Free a site slot or upgrade the account, then retry.' };
376 if (code === 'NOT_A_TEMPLATE' || code === 'APP_NOT_ON_TEMPLATE' ) return { code: 'template_unavailable' , message, nextAction: 'Template support is missing (capability recovery); do not switch editor or fall back to Headless.' };
377 if ( /name/ i . test (message) || /NAME/ . test ( String (code))) return { code: 'name_rejected' , message, nextAction: 'Choose another site name, then retry.' };
378 return { code: code ? String (code). toLowerCase () : `http_${ res . status }` , message, nextAction: 'Correct the rejected request, then retry.' };
379 }
380
381 async function createFromTemplate ( deps , { accountToken , templateId , siteName }) {
382 let res;
383 try {
384 res = await boundedRequest (deps, URLS .createFromTemplate, {
385 method: 'POST' ,
386 headers: { Authorization: bearer (accountToken), 'Content-Type' : 'application/json' },
387 body: JSON . stringify ({ originTemplateId: templateId, siteName }),
388 }, LIMITS .createTimeoutMs);
389 } catch (error) {
390 return { outcome: 'ambiguous' , error: { code: error instanceof CallTimeoutError ? 'create_timeout' : 'create_transport' , message: error.message } };
391 }
392 if (res.status === 200 ) {
393 const metaSite = res.json && res.json.metaSite;
394 const metaSiteId = metaSite && metaSite.metaSiteId;
395 if ( ! metaSiteId || ! UUID_RE . test ( String (metaSiteId))) {
396 return { outcome: 'ambiguous' , error: { code: 'create_response_invalid' , message: 'HTTP 200 without a valid metaSite.metaSiteId' } };
397 }
398 return { outcome: 'created' , metaSiteId: String (metaSiteId), ownerId: metaSite.ownerId || null };
399 }
400 if (res.status >= 500 || res.status === 408 ) {
401 return { outcome: 'ambiguous' , error: { code: `create_http_${ res . status }` , message: `HTTP ${ res . status }; the remote outcome is unknown` } };
402 }
403 return { outcome: 'failed_before_create' , error: errorFromCreateResponse (res) };
404 }
405
406 function bearer ( token ) {
407 const text = String (token). trim ();
408 return / ^ Bearer \s / i . test (text) ? text : `Bearer ${ text }` ;
409 }
410
411 function siteHeaders ( siteToken , metaSiteId ) {
412 return { Authorization: bearer (siteToken), 'wix-site-id' : metaSiteId };
413 }
414
415 async function readEditor ( deps , { siteToken , metaSiteId }) {
416 try {
417 const res = await boundedRequest (deps, URLS .editorUrls, { method: 'GET' , headers: siteHeaders (siteToken, metaSiteId) }, LIMITS .editorTimeoutMs);
418 const urls = res.json && res.json.urls;
419 const editorType = editorTypeFromUrls (res.json);
420 if (res.status !== 200 || editorType === null ) {
421 return { ok: false , httpStatus: res.status, reason: res.status === 200 ? 'malformed_editor_readback' : `editor_readback_http_${ res . status }` };
422 }
423 return { ok: true , httpStatus: 200 , editorType, editorUrl: typeof urls.editorUrl === 'string' ? urls.editorUrl : null };
424 } catch (error) {
425 return { ok: false , httpStatus: null , reason: error instanceof CallTimeoutError ? 'editor_readback_timeout' : 'editor_readback_transport' };
426 }
427 }
428
429 // Returns the observation recorded in the receipt's `publication` field.
430 async function readPublication ( deps , { siteToken , metaSiteId }) {
431 const { clock } = deps;
432 const startedAt = clock. now ();
433 const budgetEnd = startedAt + LIMITS .publicationBudgetMs;
434 let attempts = 0 ;
435 let lastStatus = null ;
436 const finish = ( state , extra = {}) => ({
437 state,
438 checkedAt: new Date (clock. now ()). toISOString (),
439 httpStatus: lastStatus,
440 urlCount: null ,
441 attempts,
442 ... extra,
443 });
444 while (attempts < LIMITS .publicationAttempts) {
445 const left = budgetEnd - clock. now ();
446 if (left < LIMITS .publicationMinLeftMs) return finish ( 'unknown' , { reason: 'readback_budget_exhausted' });
447 attempts += 1 ;
448 let retryAfter = null ;
449 try {
450 const res = await boundedRequest (deps, URLS .publishedSiteUrls, { method: 'GET' , headers: siteHeaders (siteToken, metaSiteId) }, Math. min ( LIMITS .publicationTimeoutMs, left));
451 lastStatus = res.status;
452 if (res.status === 200 ) {
453 const urls = res.json && res.json.urls;
454 if ( ! Array. isArray (urls)) return finish ( 'unknown' , { reason: 'malformed_readback' });
455 const primary = urls. find (( entry ) => entry && entry.primary) || urls[ 0 ];
456 return finish (urls. length ? 'published' : 'unpublished' , { urlCount: urls. length , ... (primary && typeof primary.url === 'string' ? { primaryUrl: primary.url } : {}) });
457 }
458 if (res.status === 401 || res.status === 403 ) {
459 return finish ( 'unknown' , { reason: 'access_denied' , accessNote: 'The site-scoped credential was refused; restore access for the bound site (access recovery), then recheck.' });
460 }
461 if ( ! isTransientStatus (res.status)) return finish ( 'unknown' , { reason: `readback_http_${ res . status }` });
462 retryAfter = retryAfterMs (res.retryAfter, clock. now ());
463 } catch {
464 // Network failure or per-call timeout: transient, retried within the budget.
465 lastStatus = null ;
466 }
467 if (attempts >= LIMITS .publicationAttempts) break ;
468 const scheduled = LIMITS .publicationWaitsMs[attempts - 1 ] || 0 ;
469 const wait = Math. max (scheduled, retryAfter === null ? 0 : Math. min (retryAfter, LIMITS .publicationRetryAfterCapMs));
470 const remaining = budgetEnd - clock. now ();
471 await clock. sleep (Math. max ( 0 , Math. min (wait, remaining)));
472 }
473 return finish ( 'unknown' , { reason: attempts >= LIMITS .publicationAttempts ? 'retries_exhausted' : 'readback_budget_exhausted' });
474 }
475
476 function clip ( value , max ) { return String (value). replace ( / [\r\n] + / g , ' ' ). trim (). slice ( 0 , max); }
477
478 // Business context the run already has (type and the evidence behind it); it only pre-fills the
479 // site-type question and never reaches a Wix call.
480 async function readDestinationBrief ( projectDir ) {
481 try {
482 const value = JSON . parse ( await fs. readFile (path. join (projectDir, 'orchestration' , 'destination-brief.json' ), 'utf8' ));
483 return value && typeof value === 'object' && ! Array. isArray (value) ? value : {};
484 } catch {
485 return {};
486 }
487 }
488
489 // --- coordinator --------------------------------------------------------------------------
490
491 function newReceipt ({ projectId , siteType , pair , clock , randomUUID }) {
492 return {
493 schemaVersion: RECEIPT_SCHEMA_VERSION ,
494 projectId,
495 attemptId: `dst_${ randomUUID () }` ,
496 createdAt: isoAt (clock),
497 updatedAt: isoAt (clock),
498 strategy: 'new' ,
499 requestedSiteType: siteType,
500 decisionRef: 'orchestration/decisions.json#targetSiteType' ,
501 businessType: pair.businessType || null ,
502 businessTypeSource: pair.businessTypeSource || null ,
503 status: 'in_progress' ,
504 // Only a receipt made by this version installs Developer Tools when it verifies: a receipt written
505 // earlier belongs to a site that may already be in use, and is left alone.
506 ... (siteType === 'headless' ? {} : { developerToolsOnVerify: true }),
507 creationMethod: null ,
508 templateId: null ,
509 siteName: null ,
510 accountIdentityRef: null ,
511 intendedAccount: null ,
512 metaSiteId: null ,
513 ownerId: null ,
514 ownerCheck: null ,
515 observedEditorType: null ,
516 editorUrl: null ,
517 editorReadback: null ,
518 publication: null ,
519 error: null ,
520 recovery: null ,
521 attempts: [],
522 };
523 }
524
525 async function pendingRequestFor ( projectDir , predicate ) {
526 const pending = await listUserActions (projectDir, { status: 'pending' });
527 for ( const row of pending) {
528 const request = await readUserAction (projectDir, row.requestId);
529 if ( predicate (request)) return request;
530 }
531 return null ;
532 }
533
534 function result ( outcome , receipt , extra = {}) {
535 return { outcome, status: receipt ? receipt.status : null , metaSiteId: receipt ? receipt.metaSiteId : null , receipt, ... extra };
536 }
537
538 // Verification after a metasite ID is known: binding, site credential, editor, publication.
539 async function verifyCreatedSite ( deps , projectDir , receipt ) {
540 const { clock } = deps;
541 const binding = await persistSiteBinding (projectDir, receipt.metaSiteId);
542 if ( ! binding.ok) {
543 receipt.status = 'blocked' ;
544 receipt.error = { code: 'binding_conflict' , message: `config/wix.env names a different WIX_SITE_ID (${ binding . conflict }); refusing to overwrite it.` , nextAction: 'Resolve which destination is authoritative; never retarget on a guess.' };
545 await raiseRecovery (projectDir, receipt, 'editor_unverified' , clock);
546 await saveReceipt (projectDir, receipt, clock);
547 return result ( 'blocked' , receipt);
548 }
549 receipt.envPersisted = true ;
550 const mint = await mintSiteToken (deps, receipt.metaSiteId);
551 if ( ! mint.token) {
552 receipt.status = 'blocked' ;
553 receipt.error = siteCredentialUnavailable ( 'A site-scoped credential could not be minted for the new site through the Wix CLI login.' , mint, 'Confirm the Wix CLI is logged into the account that owns the site (access recovery), then recheck.' );
554 await raiseRecovery (projectDir, receipt, 'editor_unverified' , clock);
555 await saveReceipt (projectDir, receipt, clock);
556 return result ( 'blocked' , receipt);
557 }
558 const editorRead = await readWithCredentialRetry (deps, receipt.metaSiteId, mint.token,
559 ( siteToken ) => readEditor (deps, { siteToken, metaSiteId: receipt.metaSiteId }));
560 const siteToken = editorRead.siteToken;
561 const editor = editorRead.observation;
562 receipt.editorReadback = {
563 checkedAt: isoAt (clock), httpStatus: editor.httpStatus, ... (editor.ok ? {} : { reason: editor.reason }),
564 ... (editor.credentialError ? { credentialError: editor.credentialError, credentialAttempts: editor.credentialAttempts } : {}),
565 };
566 receipt.observedEditorType = editor.ok ? editor.editorType : null ;
567 if (editor.ok && editor.editorUrl) receipt.editorUrl = editor.editorUrl;
568 const expected = EXPECTED_EDITOR [receipt.requestedSiteType];
569 if ( ! editor.ok || editor.editorType !== expected) {
570 receipt.status = 'blocked' ;
571 receipt.error = editor.credentialError
572 ? siteCredentialUnavailable ( 'A site-scoped credential was refused during the new-site editor readback.' , {
573 error: editor.credentialError, failures: editor.credentialAttempts || [],
574 }, 'Restore access, then recheck.' )
575 : editor.ok
576 ? { code: 'editor_mismatch' , message: `The site's editor is ${ editor . editorType }, not the requested ${ expected }.` , nextAction: 'Keep the site; reconcile the choice with the Import Operator. Never create another site to fix this.' }
577 : { code: 'editor_unverified' , message: `Editor readback failed (${ editor . reason }).` , nextAction: 'Fix the stated cause, then recheck.' };
578 await raiseRecovery (projectDir, receipt, 'editor_unverified' , clock);
579 await saveReceipt (projectDir, receipt, clock);
580 return result ( 'blocked' , receipt);
581 }
582 return checkPublication (deps, projectDir, receipt, siteToken);
583 }
584
585 async function checkPublication ( deps , projectDir , receipt , siteToken , { recheckRequestId = null } = {}) {
586 const { clock } = deps;
587 const publicationRead = await readWithCredentialRetry (deps, receipt.metaSiteId, siteToken,
588 ( readToken ) => readPublication (deps, { siteToken: readToken, metaSiteId: receipt.metaSiteId }));
589 const observation = publicationRead.observation;
590 receipt.publication = { ... observation, ... (recheckRequestId ? { recheckRequestId } : {}) };
591 if (observation.state === 'unpublished' ) {
592 // First time this editor site verifies: install the app once, then save `verified` with the outcome.
593 // `publicationRead.siteToken` is the credential the read succeeded with: after a 401 refresh it is the
594 // replacement, and the expired `siteToken` would fail the install and the read-back for the same reason.
595 if (receipt.developerToolsOnVerify === true && ! receipt.developerTools) await installDeveloperTools (deps, receipt, publicationRead.siteToken);
596 receipt.status = 'verified' ;
597 receipt.error = null ;
598 receipt.recovery = null ;
599 await saveReceipt (projectDir, receipt, clock);
600 return result ( 'verified' , receipt);
601 }
602 receipt.status = 'blocked' ;
603 receipt.error = observation.reason === 'site_credential_unavailable'
604 ? siteCredentialUnavailable ( 'A site-scoped credential could not be refreshed after the publication readback refused it.' , {
605 error: observation.credentialError, failures: observation.credentialAttempts || [],
606 }, 'Restore access, then recheck.' )
607 : observation.state === 'published'
608 ? { code: 'publication_published' , message: `The published-URLs readback returned ${ observation . urlCount } URL(s).` , nextAction: 'A person may unpublish the site in Wix; then recheck. RePlatform never unpublishes it.' }
609 : { code: 'publication_unknown' , message: `The published-URLs readback was not conclusive (${ observation . reason }).${ observation . accessNote ? ` ${ observation . accessNote }` : ''}` , nextAction: 'Fix the stated cause, then recheck.' };
610 await raiseRecovery (projectDir, receipt, observation.state === 'published' ? 'publication_published' : 'publication_unknown' , clock, {
611 state: observation.state, checkedAt: observation.checkedAt, httpStatus: observation.httpStatus, urlCount: observation.urlCount,
612 });
613 await saveReceipt (projectDir, receipt, clock);
614 return result ( 'blocked' , receipt);
615 }
616
617 // One new readback for the bound site, minted fresh; no create call. Used by a recovery
618 // answer and by every resume of a `verified` receipt, so an earlier `unpublished` is never
619 // reused as proof before setup/import. Anything but a new `unpublished` blocks (fail-closed).
620 async function freshPublicationReadback ( deps , projectDir , receipt , { recheckRequestId = null } = {}) {
621 const { clock } = deps;
622 const mint = await mintSiteToken (deps, receipt.metaSiteId);
623 if ( ! mint.token) {
624 // Being unable to obtain the readback is itself an `unknown` observation. Record it so
625 // the receipt never keeps an earlier `unpublished` beside a publication_unknown block.
626 receipt.status = 'blocked' ;
627 receipt.publication = {
628 state: 'unknown' , checkedAt: isoAt (clock), httpStatus: null , urlCount: null , attempts: 0 , reason: 'site_credential_unavailable' ,
629 credentialError: mint.error, credentialAttempts: mint.failures,
630 ... (recheckRequestId ? { recheckRequestId } : {}),
631 };
632 receipt.error = siteCredentialUnavailable ( 'A site-scoped credential could not be minted for the readback.' , mint, 'Restore access, then recheck.' );
633 await raiseRecovery (projectDir, receipt, 'publication_unknown' , clock, {
634 state: 'unknown' , checkedAt: receipt.publication.checkedAt, httpStatus: null , urlCount: null ,
635 });
636 await saveReceipt (projectDir, receipt, clock);
637 return result ( 'blocked' , receipt);
638 }
639 return checkPublication (deps, projectDir, receipt, mint.token, { recheckRequestId });
640 }
641
642 // --- intended account (confirm the intended account before mutation) ----------------
643
644 const ACCOUNT_CONFIRMATION = 'destination_account_confirmation' ;
645 const EDITOR_LABEL = Object. freeze ({ studio: 'Wix Studio' , harmony: 'Wix Harmony' });
646
647 // The account the Wix CLI is logged into right now: the `whoami` identity (shown to the
648 // operator, stored only as a hash) and the login's user id, which the creation response's
649 // `metaSite.ownerId` is checked against.
650 async function readCurrentAccount ( deps ) {
651 let identity = null ;
652 try { identity = await deps.credentials. identity (); } catch { identity = null ; }
653 if ( ! identity || ! identity.authenticated || ! identity.identity) {
654 return { ok: false , error: { code: 'account_unconfirmed' , message: 'The Wix CLI account identity could not be confirmed before creation.' , nextAction: 'Log the Wix CLI into the intended account (replatform account), then retry.' } };
655 }
656 let userId = null ;
657 try { userId = deps.credentials.userId ? await deps.credentials. userId () : null ; } catch { userId = null ; }
658 if ( ! userId || ! UUID_RE . test ( String (userId))) {
659 return { ok: false , error: { code: 'account_unconfirmed' , message: 'The Wix CLI login \' s user id could not be read, so the new site \' s ownership could not be checked.' , nextAction: 'Log the Wix CLI into the intended account again (wix login), then retry.' } };
660 }
661 return { ok: true , identity: clip (identity.identity, 200 ), identityRef: identityRef (identity.identity), userId: String (userId). toLowerCase () };
662 }
663
664 function sameAccount ( left , right ) {
665 return Boolean (left && right && left.identityRef === right.identityRef && left.userId === right.userId);
666 }
667
668 async function latestAccountConfirmation ( projectDir ) {
669 const rows = await listUserActions (projectDir);
670 for ( let index = rows. length - 1 ; index >= 0 ; index -= 1 ) {
671 const request = await readUserAction (projectDir, rows[index].requestId);
672 if (request.subject && request.subject.type === ACCOUNT_CONFIRMATION ) return request;
673 }
674 return null ;
675 }
676
677 // The operator confirms the account actually logged in, shown by name, before the first
678 // create. The newest confirmation request decides: pending waits; `confirm_account` binds the
679 // account it showed, but only while that same account is still logged in; anything else
680 // (switched login, `switched_account`) raises a new request that shows the current account.
681 async function confirmIntendedAccount ( projectDir , receipt , account ) {
682 const latest = await latestAccountConfirmation (projectDir);
683 if (latest && latest.status === 'pending' ) return { ok: false , awaiting: true , requestId: latest.requestId };
684 if (latest && latest.status === 'answered' && latest.answer && latest.answer.choice === 'confirm_account' && sameAccount (latest.subject, account)) {
685 return { ok: true , intended: { identityRef: account.identityRef, userId: account.userId, confirmationRequestId: latest.requestId, confirmedAt: latest.answer.answeredAt || null } };
686 }
687 const siteName = normalizeSiteName (receipt.siteName || receipt.projectId, { suffix: receipt.nameSuffix || '' });
688 const request = await createUserAction (projectDir, {
689 kind: 'question' ,
690 stage: 'destination' ,
691 title: 'Confirm the Wix account for the new site' ,
692 prompt: `RePlatform is about to create the new ${ EDITOR_LABEL [ receipt . requestedSiteType ] } site "${ siteName }" in the Wix account the Wix CLI is logged into: ${ account . identity }. Confirm that this is the intended account; nothing is created until you do. If it is not, log the Wix CLI into the intended account first, then choose "I switched accounts" so the account is shown again.` ,
693 response: { mode: 'choice' , required: true },
694 choices: [
695 { id: 'confirm_account' , label: 'Create the site in this account' , description: 'This is the intended Wix account. The creation response must name this login as the site owner.' },
696 { id: 'switched_account' , label: 'I switched accounts — show it again' , description: 'The Wix CLI is now logged into the intended account; show that account for confirmation. Nothing is created.' },
697 ],
698 subject: { type: ACCOUNT_CONFIRMATION , identityRef: account.identityRef, userId: account.userId, siteType: receipt.requestedSiteType },
699 });
700 return { ok: false , awaiting: true , requestId: request.requestId };
701 }
702
703 // Before any create: the logged-in account must be the confirmed intended account. With
704 // `confirm: false` it only re-checks a recorded binding.
705 async function requireAccount ( deps , projectDir , receipt , { confirm = true } = {}) {
706 const account = await readCurrentAccount (deps);
707 if ( ! account.ok) return account;
708 receipt.accountIdentityRef = account.identityRef;
709 if (receipt.intendedAccount) {
710 if ( ! sameAccount (receipt.intendedAccount, account)) {
711 return { ok: false , error: { code: 'account_mismatch' , message: 'The Wix CLI is logged into a different account from the one confirmed for this site.' , nextAction: 'Log the Wix CLI back into the confirmed account, then retry. To use another account instead, retry while logged into it and confirm that account when asked.' } };
712 }
713 } else if (confirm) {
714 const confirmation = await confirmIntendedAccount (projectDir, receipt, account);
715 if ( ! confirmation.ok) return confirmation;
716 receipt.intendedAccount = confirmation.intended;
717 }
718 let accountToken = null ;
719 try { accountToken = await deps.credentials. accountToken (); } catch { accountToken = null ; }
720 if ( ! accountToken) {
721 return { ok: false , error: { code: 'account_credential_unavailable' , message: 'No account credential is available from the Wix CLI login.' , nextAction: 'Log the Wix CLI into the intended account, then retry.' } };
722 }
723 return { ok: true , accountToken };
724 }
725
726 function awaitingAccount ( confirmation ) {
727 return { outcome: 'awaiting_account_confirmation' , status: null , metaSiteId: null , receipt: null , requestId: confirmation.requestId };
728 }
729
730 async function failBeforeCreate ( deps , projectDir , receipt , error ) {
731 receipt.status = 'failed_before_create' ;
732 receipt.error = error;
733 await raiseRecovery (projectDir, receipt, 'failed_before_create' , deps.clock);
734 await saveReceipt (projectDir, receipt, deps.clock);
735 return result ( 'failed_before_create' , receipt);
736 }
737
738 async function markAmbiguous ( deps , projectDir , receipt , error ) {
739 receipt.status = 'ambiguous' ;
740 receipt.error = { ... error, nextAction: 'Reconcile with the Import Operator; never repeat the create.' };
741 await raiseRecovery (projectDir, receipt, 'ambiguous' , deps.clock);
742 await saveReceipt (projectDir, receipt, deps.clock);
743 return result ( 'ambiguous' , receipt);
744 }
745
746 async function createEditorSite ( deps , projectDir , receipt , decisions ) {
747 const { clock } = deps;
748 const account = await requireAccount (deps, projectDir, receipt);
749 // Awaiting the account confirmation writes nothing: no receipt status changes, no attempt.
750 if ( ! account.ok && account.awaiting) return awaitingAccount (account);
751 if ( ! account.ok) return failBeforeCreate (deps, projectDir, receipt, account.error);
752 receipt.attempts. push ({ attemptId: receipt.attemptId, startedAt: isoAt (clock) });
753 // Harmony and Studio sites are both created from the public blank template. Harmony's AI site
754 // generation lives behind a private service with no stability promise, so it is not used.
755 receipt.creationMethod = 'blank_template' ;
756 receipt.templateId = BLANK_TEMPLATES [receipt.requestedSiteType];
757 receipt.siteName = normalizeSiteName (receipt.siteName || receipt.projectId, { suffix: receipt.nameSuffix || '' });
758 await saveReceipt (projectDir, receipt, clock);
759 const created = await createFromTemplate (deps, { accountToken: account.accountToken, templateId: receipt.templateId, siteName: receipt.siteName });
760 if (created.outcome === 'failed_before_create' ) return failBeforeCreate (deps, projectDir, receipt, created.error);
761 if (created.outcome === 'ambiguous' ) return markAmbiguous (deps, projectDir, receipt, created.error);
762 receipt.status = 'created' ;
763 receipt.metaSiteId = created.metaSiteId;
764 receipt.ownerId = created.ownerId;
765 // The response's owner must be the confirmed account's login. A missing or different
766 // owner keeps the site (ID saved above) but never binds it for import.
767 const intendedUserId = receipt.intendedAccount && receipt.intendedAccount.userId;
768 const owner = created.ownerId ? String (created.ownerId). toLowerCase () : null ;
769 receipt.ownerCheck = { state: ! owner ? 'missing' : owner === intendedUserId ? 'matched' : 'mismatch' , checkedAt: isoAt (clock) };
770 if (receipt.ownerCheck.state !== 'matched' ) {
771 receipt.status = 'blocked' ;
772 receipt.error = owner
773 ? { code: 'owner_mismatch' , message: `The creation response names owner ${ owner }, not the confirmed Wix CLI login.` , nextAction: 'Keep the site; reconcile its account with the Import Operator. Never create another site to fix this.' }
774 : { code: 'owner_missing' , message: 'The creation response did not name the new site \' s owner, so the intended account could not be checked.' , nextAction: 'Keep the site; confirm its account with the Import Operator.' };
775 await raiseRecovery (projectDir, receipt, 'ownership_unverified' , clock);
776 await saveReceipt (projectDir, receipt, clock);
777 return result ( 'blocked' , receipt);
778 }
779 await saveReceipt (projectDir, receipt, clock);
780 return verifyCreatedSite (deps, projectDir, receipt);
781 }
782
783 // Installs the Developer Tools app the first time an editor site's receipt becomes `verified`: one
784 // attempt with the credential the verification just used, bounded by the client timeout, never
785 // retried. The result is written to `receipt.developerTools` (the caller saves the receipt). A
786 // failure never changes the destination status: the site is kept and the owner adds the app from the
787 // dashboard. The app has no editor part, so the editor installer does not apply. A 200 answer proves
788 // nothing about the site (setup's install step treats it the same way), so success is the installed-apps
789 // list read back afterwards naming this app as enabled.
790 async function installDeveloperTools ( deps , receipt , siteToken ) {
791 const record = { appDefId: DEVELOPER_TOOLS_APP_DEF_ID , status: 'failed' , checkedAt: isoAt (deps.clock) };
792 try {
793 const res = await boundedRequest (deps, URLS .installApp, {
794 method: 'POST' ,
795 headers: { ... siteHeaders (siteToken, receipt.metaSiteId), 'Content-Type' : 'application/json' },
796 body: JSON . stringify ({
797 appInstance: { appDefId: DEVELOPER_TOOLS_APP_DEF_ID , enabled: true },
798 tenant: { tenantType: 'SITE' , id: receipt.metaSiteId },
799 installType: 'INSTALL_TYPE_SITE' ,
800 appsInstallOptions: {},
801 }),
802 }, LIMITS .installTimeoutMs);
803 const answered = res.json && res.json.appInstance && res.json.appInstance.appDefId;
804 if (res.status !== 200 ) record.reason = `install_http_${ res . status }` ;
805 else if (answered !== DEVELOPER_TOOLS_APP_DEF_ID ) record.reason = 'install_response_without_app' ;
806 else {
807 const listed = await boundedRequest (deps, URLS .installedApps, { method: 'GET' , headers: siteHeaders (siteToken, receipt.metaSiteId) }, LIMITS .installTimeoutMs)
808 . catch (( error ) => ({ error }));
809 if (listed.error) record.reason = listed.error instanceof CallTimeoutError ? 'install_readback_timeout' : 'install_readback_transport' ;
810 else if (listed.status !== 200 ) record.reason = `install_readback_http_${ listed . status }` ;
811 else {
812 const apps = listed.json && Array. isArray (listed.json.appInstances) ? listed.json.appInstances : [];
813 // Same rule as setup's isWixAppInstalled: listed, and not disabled.
814 if (apps. some (( app ) => app && app.appDefId === DEVELOPER_TOOLS_APP_DEF_ID && app.enabled !== false )) record.status = 'installed' ;
815 else record.reason = 'install_not_listed' ;
816 }
817 }
818 } catch (error) {
819 record.reason = error instanceof CallTimeoutError ? 'install_timeout' : 'install_transport' ;
820 }
821 record.checkedAt = isoAt (deps.clock);
822 receipt.developerTools = record;
823 }
824
825 // Acts on an answered recovery request. Each answer triggers exactly one bounded step.
826 async function resumeFromRecovery ( deps , projectDir , receipt , request , decisions ) {
827 const { clock } = deps;
828 const kind = receipt.recovery.kind;
829 const choice = request.answer && request.answer.choice;
830 if (kind === 'failed_before_create' && choice === 'retry_create' ) {
831 const previous = receipt.attempts. length ;
832 receipt.attemptId = `dst_${ deps . randomUUID () }` ;
833 if (receipt.error && receipt.error.code === 'name_rejected' ) receipt.nameSuffix = String (previous + 1 );
834 // Retrying after a login change means the account must be confirmed again before a create.
835 if (receipt.error && receipt.error.code === 'account_mismatch' ) receipt.intendedAccount = null ;
836 receipt.status = 'in_progress' ;
837 receipt.error = null ;
838 receipt.recovery = null ;
839 return createEditorSite (deps, projectDir, receipt, decisions);
840 }
841 if (kind === 'ambiguous' ) {
842 const text = String (request.answer && request.answer.text || '' ). trim ();
843 const idMatch = text. match ( / [0-9a-f] {8} - [0-9a-f] {4} - [0-9a-f] {4} - [0-9a-f] {4} - [0-9a-f] {12} / i );
844 if ( / ^ none-created: \s * \S / i . test (text)) {
845 // Explicit recovery decision with evidence that the prior request created nothing.
846 receipt.attempts. push ({ attemptId: receipt.attemptId, resolvedAt: isoAt (clock), resolution: 'none_created' , evidence: clip (text. replace ( / ^ none-created: \s * / i , '' ), 500 ), requestId: request.requestId });
847 receipt.attemptId = `dst_${ deps . randomUUID () }` ;
848 receipt.status = 'in_progress' ;
849 receipt.error = null ;
850 receipt.recovery = null ;
851 return createEditorSite (deps, projectDir, receipt, decisions);
852 }
853 if (idMatch) {
854 receipt.attempts. push ({ attemptId: receipt.attemptId, resolvedAt: isoAt (clock), resolution: 'adopted' , requestId: request.requestId });
855 receipt.metaSiteId = idMatch[ 0 ]. toLowerCase ();
856 receipt.status = 'created' ;
857 receipt.error = null ;
858 receipt.recovery = null ;
859 await saveReceipt (projectDir, receipt, clock);
860 return verifyCreatedSite (deps, projectDir, receipt);
861 }
862 // Unusable answer: ask again; never guess.
863 receipt.error = { code: 'reconciliation_answer_invalid' , message: 'The reconciliation answer named neither a metasite ID nor "none-created: <evidence>".' , nextAction: 'Answer the new reconciliation request.' };
864 await raiseRecovery (projectDir, receipt, 'ambiguous' , clock);
865 await saveReceipt (projectDir, receipt, clock);
866 return result ( 'ambiguous' , receipt);
867 }
868 if (kind === 'ownership_unverified' ) {
869 const text = String (request.answer && request.answer.text || '' ). trim ();
870 if ( / ^ owner-verified: \s * \S / i . test (text)) {
871 receipt.ownerCheck = { ... receipt.ownerCheck, state: 'operator_verified' , evidence: clip (text. replace ( / ^ owner-verified: \s * / i , '' ), 500 ), requestId: request.requestId, checkedAt: isoAt (clock) };
872 receipt.status = 'created' ;
873 receipt.error = null ;
874 receipt.recovery = null ;
875 await saveReceipt (projectDir, receipt, clock);
876 return verifyCreatedSite (deps, projectDir, receipt);
877 }
878 receipt.error = { code: 'ownership_answer_invalid' , message: 'The ownership answer was not "owner-verified: <evidence>".' , nextAction: 'Answer the new ownership request.' };
879 await raiseRecovery (projectDir, receipt, 'ownership_unverified' , clock);
880 await saveReceipt (projectDir, receipt, clock);
881 return result ( 'blocked' , receipt);
882 }
883 if (kind === 'editor_unverified' && choice === 'recheck' ) {
884 receipt.status = 'created' ;
885 receipt.error = null ;
886 receipt.recovery = null ;
887 return verifyCreatedSite (deps, projectDir, receipt);
888 }
889 if ((kind === 'publication_unknown' && choice === 'recheck' ) || (kind === 'publication_published' && choice === 'recheck_after_unpublish' )) {
890 // The answer itself changes neither status nor `publication`; only the new readback can.
891 return freshPublicationReadback (deps, projectDir, receipt, { recheckRequestId: request.requestId });
892 }
893 return result ( 'blocked' , receipt, { reason: 'unsupported_recovery_answer' });
894 }
895
896 // Headless evidence after the wrapper's site is confirmed: editor type and publication state.
897 // Read-only and never a gate: it never changes the status, sets `error` or raises
898 // a recovery request. A site that is not `published` becomes a cutover item in
899 // destination-handoff.js.
900 async function readBackHeadlessSite ( deps , receipt ) {
901 const { clock } = deps;
902 const mint = await mintSiteToken (deps, receipt.metaSiteId);
903 if ( ! mint.token) {
904 const checkedAt = isoAt (clock);
905 receipt.editorReadback = { checkedAt, httpStatus: null , reason: 'site_credential_unavailable' , credentialError: mint.error, credentialAttempts: mint.failures };
906 receipt.observedEditorType = null ;
907 receipt.publication = { state: 'unknown' , checkedAt, httpStatus: null , urlCount: null , attempts: 0 , reason: 'site_credential_unavailable' , credentialError: mint.error, credentialAttempts: mint.failures };
908 return ;
909 }
910 const siteToken = mint.token;
911 const editorRead = await readWithCredentialRetry (deps, receipt.metaSiteId, siteToken,
912 ( readToken ) => readEditor (deps, { siteToken: readToken, metaSiteId: receipt.metaSiteId }));
913 const currentToken = editorRead.siteToken;
914 const editor = editorRead.observation;
915 receipt.editorReadback = {
916 checkedAt: isoAt (clock), httpStatus: editor.httpStatus, ... (editor.ok ? {} : { reason: editor.reason }),
917 ... (editor.credentialError ? { credentialError: editor.credentialError, credentialAttempts: editor.credentialAttempts } : {}),
918 };
919 receipt.observedEditorType = editor.ok ? editor.editorType : null ;
920 if (editor.ok && editor.editorUrl) receipt.editorUrl = editor.editorUrl;
921 const publicationRead = await readWithCredentialRetry (deps, receipt.metaSiteId, currentToken,
922 ( readToken ) => readHeadlessPublication (deps, { siteToken: readToken, metaSiteId: receipt.metaSiteId }));
923 receipt.publication = publicationRead.observation;
924 }
925
926 // For a Headless site the published-URLs readback lists the free URL whether or not the site
927 // is published (observed live: the same URL before and after a release), so it cannot decide
928 // the state. The public URL decides: 2xx is published, 404 is unpublished, anything else is
929 // unknown. Redirects are not followed: a 3xx to some other page proves nothing, so it is
930 // unknown. An empty URL list stays unpublished.
931 async function readHeadlessPublication ( deps , { siteToken , metaSiteId }) {
932 const listed = await readPublication (deps, { siteToken, metaSiteId });
933 if (listed.state !== 'published' ) return listed;
934 if ( ! listed.primaryUrl || ! / ^ https: \/\/ / . test (listed.primaryUrl)) return { ... listed, state: 'unknown' , reason: 'public_url_missing' };
935 let publicHttpStatus = null ;
936 try {
937 const res = await boundedRequest (deps, listed.primaryUrl, { method: 'GET' , redirect: 'manual' }, LIMITS .publicationTimeoutMs);
938 publicHttpStatus = res.status;
939 } catch (error) {
940 return { ... listed, state: 'unknown' , publicHttpStatus, reason: error instanceof CallTimeoutError ? 'public_url_timeout' : 'public_url_transport' , checkedAt: isoAt (deps.clock) };
941 }
942 const state = publicHttpStatus >= 200 && publicHttpStatus < 300 ? 'published' : publicHttpStatus === 404 ? 'unpublished' : 'unknown' ;
943 return { ... listed, state, publicHttpStatus, checkedAt: isoAt (deps.clock), ... (state === 'unknown' ? { reason: `public_url_http_${ publicHttpStatus }` } : {}) };
944 }
945
946 async function runHeadless ( deps , projectDir , receipt , clock ) {
947 receipt.creationMethod = 'headless_cli' ;
948 receipt.attempts. push ({ attemptId: receipt.attemptId, startedAt: isoAt (clock) });
949 await saveReceipt (projectDir, receipt, clock);
950 const outcome = await deps. headless ({ projectDir, businessName: receipt.projectId });
951 const env = await readWixEnv (projectDir);
952 const siteId = env. WIX_SITE_ID ? String (env. WIX_SITE_ID ). trim () : '' ;
953 // A truthy placeholder or malformed id is not a binding: only a UUID-shaped site id counts as created.
954 if (outcome && outcome.ok && UUID_RE . test (siteId)) {
955 receipt.status = 'created' ;
956 receipt.metaSiteId = siteId;
957 receipt.error = null ;
958 await persistSiteBinding (projectDir, siteId);
959 await readBackHeadlessSite (deps, receipt);
960 } else {
961 // The wrapper's own WIX_SCAFFOLD_STATUS safeguards decide recovery; the receipt only records.
962 receipt.status = 'blocked' ;
963 receipt.error = { code: 'headless_scaffold_failed' , message: clip (outcome && outcome.message ? outcome.message : 'headless scaffold did not confirm a destination' , 500 ), nextAction: 'Follow the wrapper \' s WIX_SCAFFOLD_STATUS recovery; never retry blindly.' };
964 }
965 await saveReceipt (projectDir, receipt, clock);
966 return result (receipt.status === 'created' ? 'created' : 'blocked' , receipt);
967 }
968
969 // A Headless receipt is resumed through the wrapper's own WIX_SCAFFOLD_STATUS contract and is
970 // never a reason to delegate again: a confirmed site is adopted, and any status marker the
971 // wrapper left (in_progress, incomplete, ambiguous, ...) is that wrapper's recovery to own.
972 // Only an untouched wix.env (no WIX_SITE_ID, no marker), which the wrapper itself treats as
973 // "nothing was attempted" because it writes its marker before any create, allows one new
974 // delegation (for example after an operator cleared the marker per the wrapper's guidance).
975 async function resumeHeadless ( deps , projectDir , receipt , env ) {
976 const { clock } = deps;
977 const siteId = env. WIX_SITE_ID && env. WIX_SITE_ID !== 'REPLACE_WITH_REAL_VALUE' ? String (env. WIX_SITE_ID ). trim () : null ;
978 const marker = env. WIX_SCAFFOLD_STATUS ;
979 if (siteId && (marker === undefined || marker === 'complete' )) {
980 // Same gate as runHeadless: a malformed id is never adopted as a created site.
981 if ( ! UUID_RE . test (siteId)) {
982 if (receipt.status !== 'blocked' || ! receipt.error || receipt.error.code !== 'headless_site_id_invalid' ) {
983 receipt.status = 'blocked' ;
984 receipt.error = { code: 'headless_site_id_invalid' , message: 'config/wix.env WIX_SITE_ID is not a metasite id, so the Headless scaffold result cannot be adopted.' , nextAction: 'Correct WIX_SITE_ID to the real site id, or follow the wrapper \' s WIX_SCAFFOLD_STATUS recovery; never retry blindly.' };
985 await saveReceipt (projectDir, receipt, clock);
986 }
987 return result ( 'blocked' , receipt, { reason: 'headless_site_id_invalid' });
988 }
989 receipt.status = 'created' ;
990 receipt.metaSiteId = siteId;
991 receipt.error = null ;
992 await persistSiteBinding (projectDir, siteId);
993 // Every rerun reads the site back again, so the cutover handoff reflects the latest state.
994 await readBackHeadlessSite (deps, receipt);
995 await saveReceipt (projectDir, receipt, clock);
996 return result ( 'created' , receipt);
997 }
998 if (marker !== undefined ) {
999 return result ( 'blocked' , receipt, { reason: 'destination_scaffold_state_requires_recovery' });
1000 }
1001 return runHeadless (deps, projectDir, receipt, clock);
1002 }
1003
1004 // Entry point. Returns `{ outcome, ... }` where outcome is one of: not_new_site,
1005 // existing_destination, awaiting_site_type, awaiting_editor_website_decision,
1006 // awaiting_account_confirmation, awaiting_recovery,
1007 // verified, created, failed_before_create, ambiguous, blocked, locked, invalid.
1008 async function ensureDestination ( projectDir , deps ) {
1009 const release = await acquireLock (projectDir);
1010 if ( ! release) {
1011 const reclaimLeft = ( await readRaw ( `${ lockPath ( projectDir ) }.reclaim` )) !== null ;
1012 return {
1013 outcome: 'locked' ,
1014 reason: reclaimLeft
1015 ? 'orchestration/destination.lock.reclaim exists: a lock reclamation did not finish. If no destination command is running, remove that file, then retry.'
1016 : 'another destination invocation holds orchestration/destination.lock' ,
1017 };
1018 }
1019 try {
1020 return await ensureDestinationLocked (projectDir, deps);
1021 } finally {
1022 await release ();
1023 }
1024 }
1025
1026 async function ensureDestinationLocked ( projectDir , deps ) {
1027 const { clock } = deps;
1028 const artifacts = await loadArtifacts (projectDir);
1029 if ( ! artifacts.decisions) return { outcome: 'invalid' , reason: 'orchestration decisions are not initialized' };
1030 const decisions = artifacts.decisions;
1031 const strategy = getDecisionValue (decisions, 'targetSiteStrategy' );
1032 // Recheck receipts and config while holding the lock, before every create.
1033 let receipt = await readReceipt (projectDir);
1034 const env = await readWixEnv (projectDir);
1035 if (receipt) {
1036 const errors = validateReceipt (receipt);
1037 if (errors. length ) return { outcome: 'invalid' , reason: 'destination_receipt_invalid' , errors };
1038 }
1039 if ( ! isNewSiteStrategy (strategy) && ! receipt) return { outcome: 'not_new_site' };
1040
1041 const envSiteId = env. WIX_SITE_ID && env. WIX_SITE_ID !== 'REPLACE_WITH_REAL_VALUE' ? String (env. WIX_SITE_ID ). trim () : null ;
1042 if ( ! receipt && envSiteId) {
1043 // Authoritative existing or legacy destination: zero creates, no retroactive type gate.
1044 return { outcome: 'existing_destination' , metaSiteId: envSiteId };
1045 }
1046 if ( ! receipt && env. WIX_SCAFFOLD_STATUS !== undefined ) {
1047 // A prior Headless wrapper attempt left a marker: its own safeguards own recovery.
1048 return { outcome: 'blocked' , reason: 'destination_scaffold_state_requires_recovery' };
1049 }
1050
1051 const choice = readSiteTypeDecision (decisions);
1052 if ( ! receipt) {
1053 if ( ! choice) {
1054 const pending = await pendingRequestFor (projectDir, ( request ) => request.effect && request.effect.key === 'targetSiteType' );
1055 if (pending) return { outcome: 'awaiting_site_type' , requestId: pending.requestId };
1056 const brief = await readDestinationBrief (projectDir);
1057 const contextBusinessType = KNOWN_BUSINESS_TYPES . includes (brief.businessType) && brief.businessTypeEvidenceRef ? brief.businessType : null ;
1058 // The account is confirmed by name in its own request just before the first create.
1059 const request = await createUserAction (projectDir, siteTypeRequestInput ({
1060 contextBusinessType,
1061 evidenceRef: contextBusinessType ? clip (brief.businessTypeEvidenceRef, 200 ) : null ,
1062 siteName: normalizeSiteName (artifacts.run && artifacts.run.projectId),
1063 }));
1064 return { outcome: 'awaiting_site_type' , requestId: request.requestId };
1065 }
1066 if (choice.errors. length ) return { outcome: 'invalid' , reason: 'site_type_decision_invalid' , errors: choice.errors };
1067 } else if (choice && ! choice.errors. length && choice.siteType !== receipt.requestedSiteType) {
1068 // A decision change never retargets a bound attempt (body item 3).
1069 return { outcome: 'invalid' , reason: 'destination_type_conflict' , errors: [ `receipt requested ${ receipt . requestedSiteType }; decisions now say ${ choice . siteType }` ] };
1070 }
1071
1072 const siteType = receipt ? receipt.requestedSiteType : choice.siteType;
1073 if (receipt && receipt.metaSiteId && envSiteId && envSiteId !== receipt.metaSiteId) {
1074 return { outcome: 'invalid' , reason: 'destination_binding_conflict' , errors: [ `config/wix.env WIX_SITE_ID differs from the receipt's metaSiteId` ] };
1075 }
1076
1077 if (siteType === 'headless' ) {
1078 if ( ! receipt) {
1079 receipt = newReceipt ({ projectId: (artifacts.run && artifacts.run.projectId) || path. basename (projectDir), siteType, pair: choice, clock, randomUUID: deps.randomUUID });
1080 return runHeadless (deps, projectDir, receipt, clock);
1081 }
1082 return resumeHeadless (deps, projectDir, receipt, env);
1083 }
1084
1085 // Editor branch. Scope guard first, before any site exists.
1086 if ( ! receipt && getEffectiveDeliveryMode (decisions) === 'management_and_website' && ! getDecisionValue (decisions, 'editorWebsiteDecision' )) {
1087 const pending = await pendingRequestFor (projectDir, ( request ) => request.effect && request.effect.key === 'editorWebsiteDecision' );
1088 if (pending) return { outcome: 'awaiting_editor_website_decision' , requestId: pending.requestId };
1089 const request = await createUserAction (projectDir, editorWebsiteRequestInput ({ siteType }));
1090 return { outcome: 'awaiting_editor_website_decision' , requestId: request.requestId };
1091 }
1092
1093 if ( ! receipt) {
1094 receipt = newReceipt ({ projectId: (artifacts.run && artifacts.run.projectId) || path. basename (projectDir), siteType, pair: choice, clock, randomUUID: deps.randomUUID });
1095 return createEditorSite (deps, projectDir, receipt, decisions);
1096 }
1097
1098 switch (receipt.status) {
1099 case 'verified' : {
1100 const binding = await persistSiteBinding (projectDir, receipt.metaSiteId);
1101 if ( ! binding.ok) return { outcome: 'invalid' , reason: 'destination_binding_conflict' , errors: [ `config/wix.env WIX_SITE_ID differs from the receipt's metaSiteId` ] };
1102 // A resume repeats the readback before setup/import; never recreates the site.
1103 return freshPublicationReadback (deps, projectDir, receipt);
1104 }
1105 case 'in_progress' :
1106 // The attempt marker exists but no outcome was recorded: the request may have been sent.
1107 return markAmbiguous (deps, projectDir, receipt, { code: 'attempt_interrupted' , message: 'A creation attempt was interrupted before its outcome was recorded.' });
1108 case 'created' :
1109 // A receipt written by an older version that used Harmony site generation cannot be resumed:
1110 // the generation service is no longer called. Reconcile the site manually instead.
1111 if (receipt.creationMethod === 'harmony_generation' && ! receipt.metaSiteId) {
1112 return markAmbiguous (deps, projectDir, receipt, { code: 'harmony_generation_unsupported' , message: 'This destination was started with Harmony site generation, which RePlatform no longer uses, so the generation job cannot be resumed.' });
1113 }
1114 return verifyCreatedSite (deps, projectDir, receipt);
1115 case 'failed_before_create' :
1116 case 'ambiguous' :
1117 case 'blocked' : {
1118 const answered = await readRecoveryAnswer (projectDir, receipt);
1119 if ( ! answered) return result ( 'awaiting_recovery' , receipt, { requestId: receipt.recovery ? receipt.recovery.requestId : null });
1120 return resumeFromRecovery (deps, projectDir, receipt, answered, decisions);
1121 }
1122 default :
1123 return { outcome: 'invalid' , reason: 'destination_receipt_invalid' };
1124 }
1125 }
1126
1127 module . exports = {
1128 BLANK_TEMPLATES,
1129 acquireLock,
1130 EXPECTED_EDITOR,
1131 LIMITS,
1132 RECEIPT_STATUSES,
1133 URLS,
1134 ensureDestination,
1135 normalizeSiteName,
1136 readPublication,
1137 readReceipt,
1138 receiptPath,
1139 validateReceipt,
1140 };