Setting the file. One moment. Execute Setup · Rp Execute Setup · wix/skills · Skills Docs 9 KBscripts/execute-setup.js
JavaScript·222 lines·9 KB
const
{
hashArtifact
}
=
require
(
'../../../lib/artifact-freshness.js'
);
9const { validatePlanShape, validateSetupVerification } = require('../../../lib/setup-verification.js');
10
11// NOTE: 'wix-stores' intentionally still maps to 1380b703-... (Wix eCommerce, not Wix
12// Stores) here — a separate, independently-tracked defect, not part of this change. See
13// wix-writers.js's WIX_STORES_APP_ID (215238eb-...) for the correct id.
14const APPS = { 'wix-stores': '1380b703-ce81-ff05-f115-39571d94dfcd', 'wix-blog': '14bcded7-0066-7c35-14d7-466cb3f09103' };
15
16async function readJson(file) {
17 return JSON.parse(await fs.readFile(file, 'utf8'));
18}
19
20async function readJsonIfExists(file) {
21 try {
22 return await readJson(file);
23 } catch (error) {
24 if (error.code === 'ENOENT') return null;
25 throw error;
26 }
27}
28
29async function readEnv(dir) {
30 const raw = await fs.readFile(path.join(dir, 'config', 'wix.env'), 'utf8');
31 return Object.fromEntries(
32 raw
33 .split(/\r?\n/)
34 .map((line) => line.match(/^([A-Z0-9_]+)=(.*)$/))
35 .filter(Boolean)
36 .map(([, key, value]) => [key, value.trim()]),
37 );
38}
39
40async function fallbackAuthor(dir, wix, writers) {
41 const id = path.basename(dir);
42 const email = `replatform-${id}@example.com`;
43 const listed = await writers.listMembers(wix, { limit: 100 });
44 let member = (listed.members || []).find((item) => String(item.loginEmail || '').toLowerCase() === email);
45 if (!member) member = await writers.createMember(wix, { email, name: 'Imported content', slug: `imported-content-${id}` });
46 if (!member?.id) throw new Error('fallback Blog author was not created');
47 const file = path.join(dir, 'config', 'wix.env');
48 const raw = await fs.readFile(file, 'utf8');
49 await fs.writeFile(
50 file,
51 `${raw.replace(/\n?WIX_BLOG_FALLBACK_MEMBER_ID=.*(?:\n|$)/g, '\n').replace(/\s*$/, '\n')}WIX_BLOG_FALLBACK_MEMBER_ID=${member.id}\n`,
52 );
53}
54
55// Runs one setup-plan step. Throws (with `blockerCode`/`recommendedAction` attached where
56// the failure is a recognized, expected condition rather than a genuinely unexpected one)
57// on anything short of full success — the caller records the failure and stops the run
58// rather than letting a step's exception propagate past a recorded outcome.
59async function runStep(step, { dir, env, wix, writers }) {
60 if (step.id === 'mute-site-notifications') {
61 const state = await writers.muteSiteNotifications(wix, { reason: `RePlatform migration — ${path.basename(dir)}` });
62 if (!state?.muted) throw new Error('notification mute verification failed');
63 return;
64 }
65 if (APPS[step.id]) {
66 await writers.installWixApp(wix, { appDefId: APPS[step.id], siteId: env.WIX_SITE_ID });
67 return;
68 }
69 if (step.id === 'stores-catalog-v3') {
70 const result = await writers.sendDirectRest(wix, { method: 'GET', path: '/stores/v3/provision/version' });
71 if (result.catalogVersion !== 'V3_CATALOG') {
72 throw Object.assign(new Error(`Catalog V3 required (got ${result.catalogVersion || 'unknown'})`), {
73 blockerCode: 'CATALOG_V1',
74 recommendedAction: 'Use a destination site provisioned with Catalog V3, or omit Stores from this migration.',
75 });
76 }
77 return;
78 }
79 if (step.id === 'blog-fallback-author') {
80 await fallbackAuthor(dir, wix, writers);
81 return;
82 }
83 throw Object.assign(new Error(`No executor implements setup step "${step.id}"`), {
84 blockerCode: 'NO_EXECUTOR_FOR_REQUIREMENT',
85 recommendedAction: 'Add an executor for this step id in execute-setup.js, or remove it from the setup plan.',
86 });
87}
88
89async function writeBlockers(setupDir, generatedAt, blockers) {
90 await fs.writeFile(
91 path.join(setupDir, 'setup-blockers.json'),
92 `${JSON.stringify({ schemaVersion: 1, generatedAt, blockers }, null, 2)}\n`,
93 );
94}
95
96// Writes the final file via a temp-then-rename so a crash mid-write can never leave a
97// corrupt or partial receipt where a trusted one is expected — the rename is atomic on the
98// same filesystem/directory.
99async function publishAtomically(filePath, contents) {
100 const tmpPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
101 await fs.writeFile(tmpPath, contents);
102 await fs.rename(tmpPath, filePath);
103}
104
105// Core logic, independent of the CLI entry point below so tests can inject a fake
106// `writers` module instead of making real Wix API calls. Performs every file write itself
107// (the receipt, the blockers file) and returns a summary rather than throwing on a
108// recognized blocker — `main()` decides how to surface that to the process exit code.
109async function executeSetup({ dir, dryRun = false, writers = REAL_WRITERS }) {
110 const env = await readEnv(dir);
111 if (!env.WIX_SITE_ID || !env.WIX_AUTH_TOKEN) throw new Error('WIX_SITE_ID and WIX_AUTH_TOKEN are required');
112
113 const setupDir = path.join(dir, 'setup');
114 const verificationPath = path.join(setupDir, 'setup-verification.json');
115 const plan = await readJsonIfExists(path.join(setupDir, 'setup-plan.json'));
116 const generatedAt = new Date().toISOString();
117
118 if (!dryRun) {
119 // Invalidate any prior receipt the instant a new live attempt begins — before we even
120 // know whether this attempt will succeed. A crash partway through this run (or a
121 // failure to reach the blocker-write below) must never leave an earlier, possibly
122 // no-longer-true success receipt sitting there looking trusted.
123 await fs.rm(verificationPath, { force: true });
124 }
125
126 const planShape = validatePlanShape(plan);
127 if (!planShape.ok) {
128 const blocker = {
129 code: 'INVALID_SETUP_PLAN',
130 severity: 'blocker',
131 requirementId: null,
132 description: `setup-plan.json is invalid: ${planShape.reasons.join(', ')}`,
133 whyBlocked: planShape.reasons.join(', '),
134 recommendedAction: 'Regenerate setup/setup-plan.json with a valid schemaVersion, a steps array, and unique, non-empty step ids.',
135 };
136 if (dryRun) throw new Error(blocker.description);
137 await writeBlockers(setupDir, generatedAt, [blocker]);
138 return { ok: false, dryRun: false, requirements: [], blocker };
139 }
140
141 const planDigest = hashArtifact(dir, path.join('setup', 'setup-plan.json'));
142 const wix = writers.createWixClient({ dryRun, authToken: env.WIX_AUTH_TOKEN, siteId: env.WIX_SITE_ID, projectDir: dir });
143
144 const requirements = [];
145 let blocker = null;
146
147 for (const step of plan.steps) {
148 if (dryRun) {
149 requirements.push({ id: step.id, status: 'planned_dry_run' });
150 continue;
151 }
152 try {
153 await runStep(step, { dir, env, wix, writers });
154 requirements.push({ id: step.id, status: 'passed' });
155 } catch (error) {
156 requirements.push({ id: step.id, status: 'blocked', detail: error.message });
157 blocker = {
158 code: error.blockerCode || 'UNEXPECTED_SETUP_ERROR',
159 severity: 'blocker',
160 requirementId: step.id,
161 description: error.message,
162 whyBlocked: error.message,
163 recommendedAction: error.recommendedAction || 'Investigate the underlying error and re-run setup once resolved.',
164 };
165 break; // Stop at the first failure — later steps were never attempted, not skipped.
166 }
167 }
168
169 if (dryRun) {
170 await fs.writeFile(
171 path.join(setupDir, 'setup-dry-run.json'),
172 `${JSON.stringify({ schemaVersion: 1, generatedAt, dryRun: true, requirements }, null, 2)}\n`,
173 );
174 return { ok: true, dryRun: true, requirements, blocker: null };
175 }
176
177 if (blocker) {
178 // The old receipt (if any) was already removed above, before this attempt began.
179 await writeBlockers(setupDir, generatedAt, [blocker]);
180 return { ok: false, dryRun: false, requirements, blocker };
181 }
182
183 // Full success: every planned requirement passed. Self-check the candidate against the
184 // exact same validator the router uses before publishing it — there must be exactly one
185 // definition of "valid", shared, never re-implemented separately here.
186 const candidate = { schemaVersion: 1, generatedAt, dryRun: false, planDigest, requirements };
187 const selfCheck = validateSetupVerification({ verification: candidate, plan, planDigest });
188 if (!selfCheck.ok) {
189 const internalBlocker = {
190 code: 'INTERNAL_VALIDATION_FAILURE',
191 severity: 'blocker',
192 requirementId: null,
193 description: `execute-setup.js produced a receipt that failed its own validator: ${selfCheck.reasons.join(', ')}`,
194 whyBlocked: selfCheck.reasons.join(', '),
195 recommendedAction: 'This indicates a bug in execute-setup.js itself — investigate rather than retrying.',
196 };
197 await writeBlockers(setupDir, generatedAt, [internalBlocker]);
198 return { ok: false, dryRun: false, requirements, blocker: internalBlocker };
199 }
200
201 await writeBlockers(setupDir, generatedAt, []);
202 await publishAtomically(verificationPath, `${JSON.stringify(candidate, null, 2)}\n`);
203 return { ok: true, dryRun: false, requirements, blocker: null };
204}
205
206async function main() {
207 const dir = process.argv[2] && path.resolve(process.argv[2]);
208 if (!dir) throw new Error('Usage: execute-setup.js <projectDir> [--dry-run]');
209 const dryRun = process.argv.includes('--dry-run');
210 const result = await executeSetup({ dir, dryRun });
211 if (result.blocker) throw new Error(result.blocker.description);
212 console.log(JSON.stringify({ ok: true, dryRun }));
213}
214
215if (require.main === module) {
216 main().catch((error) => {
217 console.error(error.message);
218 process.exit(1);
219 });
220}
221
222module.exports = { executeSetup, runStep };