@elitedcs/ghl-mcp 3.56.0 → 3.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mirror.mjs — deterministic site mirror. Copies bytes; never regenerates.
4
+ *
5
+ * GUARDRAIL: --rights is REQUIRED. Without it the script prints the step-0
6
+ * question and exits 2. With --rights none it refuses to copy bytes and points
7
+ * at the style lane (extract-design.mjs), exit 3. The declaration is written
8
+ * into run-report.json, which every downstream script reads.
9
+ *
10
+ * Usage:
11
+ * node mirror.mjs --url <url> --out <dir> --rights <own|client-authorized|written-permission|none>
12
+ * [--declared "free-text note"] [--max-mb 25] [--no-external-media]
13
+ */
14
+
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import {
18
+ parseArgs, fetchRaw, fetchText, headSize, mapLimit, ensureDir, ext, isAssetUrl,
19
+ humanBytes, writeJson, nowIso, RIGHTS_CHOICES, RIGHTS_QUESTION, siteDir, reportsDir,
20
+ } from "./lib.mjs";
21
+
22
+ const args = parseArgs(process.argv.slice(2));
23
+
24
+ if (!args.url || !args.out) {
25
+ console.error("usage: mirror.mjs --url <url> --out <dir> --rights <own|client-authorized|written-permission|none>");
26
+ process.exit(1);
27
+ }
28
+ if (!args.rights || args.rights === true) {
29
+ console.error(RIGHTS_QUESTION);
30
+ process.exit(2);
31
+ }
32
+ if (!RIGHTS_CHOICES.includes(args.rights)) {
33
+ console.error(`Unknown --rights "${args.rights}". One of: ${RIGHTS_CHOICES.join(", ")}`);
34
+ process.exit(2);
35
+ }
36
+ if (args.rights === "none") {
37
+ console.error(
38
+ [
39
+ "RIGHTS = none — full byte-for-byte cloning is not the right lane here.",
40
+ "",
41
+ "Run the style lane instead. It extracts the page's real design system",
42
+ "(palette, type scale, spacing, section structure) so the rebuild matches",
43
+ "the original's quality, and you supply fresh copy and licensed media:",
44
+ "",
45
+ ` node extract-design.mjs --url "${args.url}" --out <dir>`,
46
+ "",
47
+ "No page bytes, images, video, or copy are copied in that lane.",
48
+ ].join("\n")
49
+ );
50
+ process.exit(3);
51
+ }
52
+
53
+ const SRC = args.url;
54
+ const RUN_DIR = path.resolve(String(args.out));
55
+ // site/ is the deployable folder; reports/ never ships with it.
56
+ const OUT = siteDir(RUN_DIR);
57
+ const REPORTS = reportsDir(RUN_DIR);
58
+ const MAX_BYTES = Math.round(Number(args["max-mb"] || 25) * 1024 * 1024);
59
+ const REHOST_EXTERNAL = args["no-external-media"] !== true;
60
+
61
+ const srcUrl = new URL(SRC);
62
+ const ORIGIN = srcUrl.origin;
63
+
64
+ /**
65
+ * Cross-origin re-hosting covers MEDIA only. Third-party JS/CSS (analytics
66
+ * beacons, chat widgets, payment SDKs) stays external: re-hosting a loader
67
+ * breaks it, copies someone else's code, and hides it from the audit — those
68
+ * URLs must stay visible in the HTML so audit.mjs can flag them as MUST REPOINT.
69
+ */
70
+ const REHOST_EXT = [
71
+ ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".ico", ".avif", ".bmp",
72
+ ".mp4", ".webm", ".mov", ".m4v", ".mp3", ".wav", ".ogg",
73
+ ".woff", ".woff2", ".ttf", ".otf", ".eot", ".pdf",
74
+ ];
75
+ const isRehostable = (u) => REHOST_EXT.includes(ext(u));
76
+
77
+ fs.mkdirSync(OUT, { recursive: true });
78
+ fs.mkdirSync(REPORTS, { recursive: true });
79
+
80
+ /** absolute url -> { local, bytes, contentType, source, status } */
81
+ const downloaded = new Map();
82
+ const skipped = [];
83
+ const oversized = [];
84
+ const failed = [];
85
+ const externalRefsLeft = new Set();
86
+
87
+ function localPathFor(absUrl, kind) {
88
+ const u = new URL(absUrl);
89
+ if (u.origin === ORIGIN) {
90
+ const p = u.pathname.replace(/^\/+/, "");
91
+ return p || "index-asset";
92
+ }
93
+ // External CDN asset — re-host under media/<host-slug>/<path>
94
+ const hostSlug = u.hostname.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
95
+ const p = u.pathname.replace(/^\/+/, "") || "asset";
96
+ return path.posix.join("media", hostSlug, p);
97
+ }
98
+
99
+ async function download(absUrl, kind) {
100
+ if (downloaded.has(absUrl)) return downloaded.get(absUrl);
101
+ const e = ext(absUrl);
102
+ const head = await headSize(absUrl);
103
+ if (head.bytes !== null && head.bytes > MAX_BYTES) {
104
+ oversized.push({ url: absUrl, bytes: head.bytes, contentType: head.contentType });
105
+ return null;
106
+ }
107
+ const res = await fetchRaw(absUrl);
108
+ if (!res.ok || res.buf.length === 0) {
109
+ failed.push({ url: absUrl, status: res.status, error: res.error || null });
110
+ return null;
111
+ }
112
+ if (res.buf.length > MAX_BYTES) {
113
+ oversized.push({ url: absUrl, bytes: res.buf.length, contentType: res.contentType });
114
+ return null;
115
+ }
116
+ // A page-shaped body where an image belongs = the origin soft-404'd.
117
+ const looksHtml = /^\s*(<!doctype html|<html)/i.test(res.buf.slice(0, 200).toString("utf8"));
118
+ const wantsHtml = e === "" || e === ".html";
119
+ if (looksHtml && !wantsHtml) {
120
+ skipped.push({ url: absUrl, reason: "origin returned HTML where an asset was expected (asset does not exist / belongs to another tenant)" });
121
+ return null;
122
+ }
123
+ const local = localPathFor(absUrl, kind);
124
+ const dest = path.join(OUT, local);
125
+ ensureDir(dest);
126
+ fs.writeFileSync(dest, res.buf);
127
+ const rec = { local, bytes: res.buf.length, contentType: res.contentType, kind, status: res.status };
128
+ downloaded.set(absUrl, rec);
129
+ return rec;
130
+ }
131
+
132
+ function abs(ref, base) {
133
+ try {
134
+ return new URL(ref, base).toString();
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ function skipRef(ref) {
141
+ return (
142
+ !ref ||
143
+ ref.startsWith("data:") ||
144
+ ref.startsWith("#") ||
145
+ ref.startsWith("mailto:") ||
146
+ ref.startsWith("tel:") ||
147
+ ref.startsWith("javascript:") ||
148
+ ref.startsWith("blob:")
149
+ );
150
+ }
151
+
152
+ /**
153
+ * URLs buried in JSON-encoded attributes and inline JSON — the miss that leaves
154
+ * a page-builder hero blank.
155
+ *
156
+ * Elementor background slideshows, WP block attributes, __NEXT_DATA__ and
157
+ * Shopify section JSON all store media as escaped JSON inside the markup:
158
+ *
159
+ * data-settings="{&quot;url&quot;:&quot;https:\/\/host\/img.jpg&quot;}"
160
+ *
161
+ * The slashes are backslash-escaped and the quotes are entities, so neither the
162
+ * src/href scan nor a plain URL regex finds them. They are never downloaded —
163
+ * and once the domain is rebranded those URLs point at a domain that does not
164
+ * exist, so the hero renders empty. Live-caught 2026-07-30.
165
+ *
166
+ * Returns the raw matched text (escaping intact, so it can be rewritten in
167
+ * place) alongside the real absolute URL.
168
+ */
169
+ function escapedJsonRefs(text) {
170
+ const mediaExt = "png|jpe?g|webp|gif|svg|avif|ico|bmp|mp4|webm|mov|m4v|mp3|wav|ogg|pdf";
171
+ const out = [];
172
+ const re = new RegExp(`https?:(?:\\\\?/){2}[^\\s"'<>]+?\\.(?:${mediaExt})`, "gi");
173
+ for (const m of text.matchAll(re)) {
174
+ const raw = m[0];
175
+ if (!raw.includes("\\/")) continue; // plain URLs are already handled elsewhere
176
+ out.push({ raw, abs: raw.replace(/\\\//g, "/") });
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /** Refs in an HTML document: src, href, poster, srcset, inline url(). */
182
+ function htmlRefs(html) {
183
+ const refs = new Set();
184
+ for (const m of html.matchAll(/(?:src|href|poster|data-src|content)=["']([^"']+)["']/gi)) refs.add(m[1]);
185
+ for (const m of html.matchAll(/srcset=["']([^"']+)["']/gi)) {
186
+ for (const part of m[1].split(",")) {
187
+ const u = part.trim().split(/\s+/)[0];
188
+ if (u) refs.add(u);
189
+ }
190
+ }
191
+ for (const m of html.matchAll(/url\(([^)]+)\)/gi)) refs.add(m[1].trim().replace(/^['"]|['"]$/g, ""));
192
+ return [...refs];
193
+ }
194
+
195
+ /** Refs inside a stylesheet. */
196
+ function cssRefs(css) {
197
+ const refs = new Set();
198
+ for (const m of css.matchAll(/url\(([^)]+)\)/gi)) refs.add(m[1].trim().replace(/^['"]|['"]$/g, ""));
199
+ for (const m of css.matchAll(/@import\s+["']([^"']+)["']/gi)) refs.add(m[1]);
200
+ return [...refs];
201
+ }
202
+
203
+ /**
204
+ * Refs inside a JS bundle — the two forms HTML-only scanners miss:
205
+ * 1. full CDN URLs baked into the bundle
206
+ * 2. ROOT-RELATIVE paths ("/logo.png") — the one that caused 6 broken images
207
+ * in the 2026-07-28 proving run.
208
+ */
209
+ function jsRefs(js) {
210
+ const refs = new Set();
211
+ const mediaExt = "png|jpe?g|webp|gif|svg|avif|ico|bmp|mp4|webm|mov|m4v|mp3|wav|ogg|woff2?|ttf|otf|pdf";
212
+ for (const m of js.matchAll(new RegExp(`https?://[A-Za-z0-9._~:/?#\\[\\]@!$&'*+,;%=-]+?\\.(?:${mediaExt})`, "gi"))) {
213
+ refs.add(m[0]);
214
+ }
215
+ for (const m of js.matchAll(new RegExp(`["'\`](/[A-Za-z0-9._/-]+?\\.(?:${mediaExt}))["'\`]`, "gi"))) {
216
+ refs.add(m[1]);
217
+ }
218
+ return [...refs];
219
+ }
220
+
221
+ /** Replace every occurrence of `from` with `to`, literal (no regex surprises). */
222
+ function replaceAllLiteral(text, from, to) {
223
+ if (!from || from === to) return text;
224
+ return text.split(from).join(to);
225
+ }
226
+
227
+ const log = (...m) => console.error(...m);
228
+
229
+ /** Same-origin PAGE links (not assets, not feeds/admin endpoints). */
230
+ function pageLinks(html, baseUrl) {
231
+ const out = new Set();
232
+ for (const m of html.matchAll(/href=["']([^"'#]+)["']/gi)) {
233
+ const href = m[1].trim();
234
+ if (/^(mailto:|tel:|javascript:|data:)/i.test(href)) continue;
235
+ let u;
236
+ try { u = new URL(href, baseUrl); } catch { continue; }
237
+ if (u.origin !== ORIGIN) continue;
238
+ const p = u.pathname;
239
+ if (/\.(png|jpe?g|webp|gif|svg|ico|avif|css|js|mjs|pdf|zip|xml|json|mp4|webm|mov|mp3|woff2?|ttf|otf)$/i.test(p)) continue;
240
+ if (/^\/wp-(json|admin|login|content|includes)|\/feed\/?$|\/comments\/feed/i.test(p)) continue;
241
+ out.add(canonPage(u.origin + p));
242
+ }
243
+ return [...out];
244
+ }
245
+
246
+ /** Canonical page key so "/x" and "/x/" (and the bare origin) are one page. */
247
+ function canonPage(u) {
248
+ const x = new URL(u);
249
+ x.hash = ""; x.search = "";
250
+ const p = x.pathname.replace(/\/+$/, "");
251
+ return x.origin + (p || "/");
252
+ }
253
+
254
+ /** "/contact/" -> "contact/index.html"; "/" -> "index.html". */
255
+ function pageSavePath(pageUrl) {
256
+ const p = new URL(pageUrl).pathname.replace(/^\/+|\/+$/g, "");
257
+ return p ? path.posix.join(p, "index.html") : "index.html";
258
+ }
259
+
260
+ const processedCss = new Set();
261
+ const processedJs = new Set();
262
+
263
+ /** Mirror ONE page: assets, escaped JSON, origin-localisation, write to disk. */
264
+ async function mirrorPage(pageUrl) {
265
+ const page = await fetchText(pageUrl);
266
+ if (!page.ok || !page.text) {
267
+ failed.push({ url: pageUrl, status: page.status, error: "page fetch failed" });
268
+ return null;
269
+ }
270
+ let html = page.text;
271
+
272
+ // pass 1 — refs in this page's HTML
273
+ const pass1 = [];
274
+ for (const ref of htmlRefs(html)) {
275
+ if (skipRef(ref)) continue;
276
+ const a = abs(ref, pageUrl);
277
+ if (!a) continue;
278
+ const u = new URL(a);
279
+ if (!isAssetUrl(a)) {
280
+ if (u.origin !== ORIGIN) externalRefsLeft.add(a);
281
+ continue;
282
+ }
283
+ if (u.origin !== ORIGIN && (!REHOST_EXTERNAL || !isRehostable(a))) {
284
+ externalRefsLeft.add(a);
285
+ continue;
286
+ }
287
+ pass1.push({ ref, abs: a });
288
+ }
289
+ await mapLimit(pass1, 6, async (r) => download(r.abs, "html"));
290
+
291
+ // pass 2 — refs inside newly downloaded CSS
292
+ for (const [cssAbs, rec] of [...downloaded.entries()].filter(([u]) => ext(u) === ".css")) {
293
+ if (processedCss.has(cssAbs)) continue;
294
+ processedCss.add(cssAbs);
295
+ const file = path.join(OUT, rec.local);
296
+ let css = fs.readFileSync(file, "utf8");
297
+ const targets = [];
298
+ for (const ref of cssRefs(css).filter((r) => !skipRef(r))) {
299
+ const a = abs(ref, cssAbs);
300
+ if (!a || !isAssetUrl(a)) continue;
301
+ const u = new URL(a);
302
+ if (u.origin !== ORIGIN && (!REHOST_EXTERNAL || !isRehostable(a))) { externalRefsLeft.add(a); continue; }
303
+ targets.push({ ref, abs: a });
304
+ }
305
+ await mapLimit(targets, 6, async (t) => download(t.abs, "css"));
306
+ for (const t of targets) {
307
+ const got = downloaded.get(t.abs);
308
+ if (got) css = replaceAllLiteral(css, t.ref, "/" + got.local);
309
+ }
310
+ fs.writeFileSync(file, css);
311
+ }
312
+
313
+ // pass 3 — refs inside newly downloaded JS bundles
314
+ for (const [jsAbs, rec] of [...downloaded.entries()].filter(([u]) => [".js", ".mjs"].includes(ext(u)))) {
315
+ if (processedJs.has(jsAbs)) continue;
316
+ processedJs.add(jsAbs);
317
+ const file = path.join(OUT, rec.local);
318
+ let js = fs.readFileSync(file, "utf8");
319
+ const targets = [];
320
+ for (const ref of jsRefs(js)) {
321
+ const a = abs(ref, ORIGIN + "/");
322
+ if (!a) continue;
323
+ const u = new URL(a);
324
+ if (u.origin !== ORIGIN && (!REHOST_EXTERNAL || !isRehostable(a))) { externalRefsLeft.add(a); continue; }
325
+ targets.push({ ref, abs: a });
326
+ }
327
+ await mapLimit(targets, 6, async (t) => download(t.abs, "js"));
328
+ for (const t of targets) {
329
+ const got = downloaded.get(t.abs);
330
+ if (got && t.ref !== "/" + got.local) js = replaceAllLiteral(js, t.ref, "/" + got.local);
331
+ }
332
+ fs.writeFileSync(file, js);
333
+ }
334
+
335
+ // pass 4 — URLs hidden in escaped JSON attributes
336
+ {
337
+ const unique = new Map();
338
+ for (const r of escapedJsonRefs(html)) if (!unique.has(r.abs)) unique.set(r.abs, r);
339
+ const targets = [];
340
+ for (const [absUrl, r] of unique) {
341
+ const u = new URL(absUrl);
342
+ if (u.origin !== ORIGIN && (!REHOST_EXTERNAL || !isRehostable(absUrl))) { externalRefsLeft.add(absUrl); continue; }
343
+ targets.push(r);
344
+ }
345
+ await mapLimit(targets, 6, async (t) => download(t.abs, "json-attr"));
346
+ for (const t of targets) {
347
+ const got = downloaded.get(t.abs);
348
+ if (got) html = replaceAllLiteral(html, t.raw, ("/" + got.local).replace(/\//g, "\\/"));
349
+ }
350
+ }
351
+
352
+ // localise same-origin ASSET urls (plain + escaped), incl. inline JS config
353
+ {
354
+ const ASSET_DIR = /\/(?:wp-content|wp-includes|assets|static|_next|dist|build|media|uploads|files|cdn)\//i;
355
+ const originForms = [...new Set([ORIGIN, ORIGIN.replace(/^https:/, "http:"), ORIGIN.replace(/^http:/, "https:")])];
356
+ for (const form of originForms) {
357
+ for (const needle of [form, form.replace(/\//g, "\\/")]) {
358
+ let idx = html.indexOf(needle);
359
+ while (idx !== -1) {
360
+ const after = html.slice(idx + needle.length, idx + needle.length + 300);
361
+ const stop = after.search(/["'\s<>)]|\\"/);
362
+ const tail = (stop === -1 ? after : after.slice(0, stop)).replace(/\\\//g, "/");
363
+ if (tail && (ASSET_DIR.test(tail) || isAssetUrl(tail))) {
364
+ html = html.slice(0, idx) + html.slice(idx + needle.length);
365
+ idx = html.indexOf(needle, idx);
366
+ } else {
367
+ idx = html.indexOf(needle, idx + needle.length);
368
+ }
369
+ }
370
+ }
371
+ }
372
+ }
373
+
374
+ // rewrite this page's own asset refs to local paths
375
+ for (const { ref, abs: a } of pass1) {
376
+ const got = downloaded.get(a);
377
+ if (!got) continue;
378
+ const localRef = "/" + got.local;
379
+ if (ref !== localRef) html = replaceAllLiteral(html, ref, localRef);
380
+ }
381
+
382
+ // <a href> to our own pages -> root-relative, so the nav works locally AND
383
+ // after deploy. canonical/og:url keep their host so the rebrand can set the
384
+ // client's real domain.
385
+ const hostPattern = ORIGIN.replace(/^https?:\/\//, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
386
+ html = html.replace(new RegExp(`(<a\\b[^>]*?href=["'])https?://(?:www\\.)?${hostPattern}`, "gi"), "$1");
387
+
388
+ const savedAs = pageSavePath(pageUrl);
389
+ const dest = path.join(OUT, savedAs);
390
+ ensureDir(dest);
391
+ fs.writeFileSync(dest, html);
392
+ return { savedAs, links: pageLinks(page.text, pageUrl) };
393
+ }
394
+
395
+ async function main() {
396
+ const CRAWL = args.crawl === true || args.pages !== undefined || args.depth !== undefined;
397
+ const MAX_PAGES = Number(args["max-pages"] || args.pages || 25);
398
+ const DEPTH = Number(args.depth || 1);
399
+
400
+ const queue = [{ url: SRC, depth: 0 }];
401
+ const seen = new Set([canonPage(SRC)]);
402
+ const pages = [];
403
+
404
+ while (queue.length && pages.length < (CRAWL ? MAX_PAGES : 1)) {
405
+ const { url, depth } = queue.shift();
406
+ log(`→ ${url}`);
407
+ const res = await mirrorPage(url);
408
+ if (!res) continue;
409
+ pages.push({ url, savedAs: res.savedAs });
410
+ if (!CRAWL || depth >= DEPTH) continue;
411
+ for (const link of res.links) {
412
+ const key = canonPage(link);
413
+ if (seen.has(key) || seen.size >= MAX_PAGES * 4) continue;
414
+ seen.add(key);
415
+ queue.push({ url: link, depth: depth + 1 });
416
+ }
417
+ }
418
+
419
+ if (pages.length === 0) {
420
+ console.error(`FAILED to fetch ${SRC}. Nothing was written — do not reconstruct the page from memory.`);
421
+ process.exit(4);
422
+ }
423
+ // Guarantee a root document even when the entry URL was a sub-page.
424
+ if (!fs.existsSync(path.join(OUT, "index.html"))) {
425
+ fs.copyFileSync(path.join(OUT, pages[0].savedAs), path.join(OUT, "index.html"));
426
+ }
427
+
428
+ const byType = {};
429
+ let totalBytes = 0;
430
+ for (const rec of downloaded.values()) {
431
+ const e = ext(rec.local) || "(none)";
432
+ byType[e] = byType[e] || { count: 0, bytes: 0 };
433
+ byType[e].count++; byType[e].bytes += rec.bytes; totalBytes += rec.bytes;
434
+ }
435
+
436
+ const report = {
437
+ tool: "clone-site/mirror.mjs",
438
+ generatedAt: nowIso(),
439
+ rights: {
440
+ declaration: args.rights,
441
+ note: typeof args.declared === "string" ? args.declared : null,
442
+ recordedBy: "user declaration at step 0 — this tool does not verify ownership",
443
+ },
444
+ source: { url: SRC, origin: ORIGIN },
445
+ output: { runDir: RUN_DIR, siteDir: OUT, reportsDir: REPORTS, entry: "index.html" },
446
+ pages,
447
+ crawl: { enabled: CRAWL, depth: CRAWL ? DEPTH : 0, maxPages: CRAWL ? MAX_PAGES : 1, discovered: seen.size },
448
+ inventory: { files: downloaded.size + pages.length, totalBytes, totalHuman: humanBytes(totalBytes), byType },
449
+ oversized,
450
+ skippedForeignOrMissing: skipped,
451
+ failed,
452
+ externalRefsLeft: [...externalRefsLeft].sort(),
453
+ assets: [...downloaded.entries()].map(([url, rec]) => ({ url, ...rec })),
454
+ };
455
+ writeJson(path.join(REPORTS, "run-report.json"), report);
456
+
457
+ const lines = [];
458
+ lines.push("");
459
+ lines.push(`MIRROR COMPLETE ${SRC}`);
460
+ lines.push(` rights declared : ${args.rights}${args.declared && args.declared !== true ? ` — "${args.declared}"` : ""}`);
461
+ lines.push(` pages mirrored : ${pages.length}${CRAWL ? ` (crawl depth ${DEPTH}, cap ${MAX_PAGES}, ${seen.size} discovered)` : " — single page. Add --crawl to follow the site's own navigation."}`);
462
+ for (const p of pages.slice(0, 15)) lines.push(` ${p.savedAs}`);
463
+ if (pages.length > 15) lines.push(` …and ${pages.length - 15} more`);
464
+ lines.push(` deployable site : ${OUT}`);
465
+ lines.push(` reports (never deploy these): ${REPORTS}/`);
466
+ lines.push(` files stored : ${downloaded.size + pages.length} (${humanBytes(totalBytes)})`);
467
+ const typeLine = Object.entries(byType).sort((a, b) => b[1].count - a[1].count).map(([e, v]) => `${e} ${v.count}`).join(" ");
468
+ if (typeLine) lines.push(` by type : ${typeLine}`);
469
+ if (oversized.length) {
470
+ lines.push(` OVERSIZED (>${args["max-mb"] || 25}MB, NOT downloaded — route to R2/Stream): ${oversized.length}`);
471
+ for (const o of oversized.slice(0, 10)) lines.push(` ${humanBytes(o.bytes)} ${o.url}`);
472
+ }
473
+ if (skipped.length) lines.push(` EXCLUDED (origin served HTML — missing or another tenant's asset): ${skipped.length}`);
474
+ if (failed.length) {
475
+ lines.push(` FAILED to fetch: ${failed.length}`);
476
+ for (const f of failed.slice(0, 10)) lines.push(` [${f.status}] ${f.url}`);
477
+ }
478
+ if (externalRefsLeft.size) lines.push(` third-party refs left as-is (fonts/analytics/SDKs): ${externalRefsLeft.size}`);
479
+ lines.push("");
480
+ lines.push(` run report: ${path.join(REPORTS, "run-report.json")}`);
481
+ lines.push(` NEXT: substitute.mjs --dir ${RUN_DIR} (plan) → audit.mjs → render+repair.mjs → deploy site/ → verify.mjs`);
482
+ lines.push("");
483
+ console.log(lines.join("\n"));
484
+ }
485
+
486
+ main().catch((e) => {
487
+ console.error("mirror failed:", e);
488
+ process.exit(1);
489
+ });
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * repair.mjs — fetch assets the clone only asks for AT RUNTIME.
4
+ *
5
+ * Static scanning cannot see a URL that JavaScript builds on the fly. The
6
+ * common case is webpack lazy chunks: a page-builder stores a base path plus a
7
+ * chunk→hash map and concatenates them at runtime, so the filename exists in no
8
+ * attribute, stylesheet or literal string. Those files are never mirrored, and
9
+ * after rebranding they resolve against a domain that does not exist.
10
+ *
11
+ * Live-caught 2026-07-30: six Elementor handler chunks 503'd on a rebranded
12
+ * clone. `section-frontend-handlers` is what paints section backgrounds — with
13
+ * it missing the hero rendered blank and white headline text sat on white, so
14
+ * the "byte-for-byte" clone looked nothing like the original. Nothing in the
15
+ * file-level checks could see it; only loading the page could.
16
+ *
17
+ * The loop: render the clone → capture failed requests → repair → render again,
18
+ * until nothing fails.
19
+ *
20
+ * Usage:
21
+ * node repair.mjs --dir <run-dir> --origin https://original.example --urls failed.txt
22
+ * node repair.mjs --dir <run-dir> --origin https://original.example --url "/path/a.js" --url "/path/b.js"
23
+ *
24
+ * `failed.txt` is one URL or path per line; the clone's own host is stripped, so
25
+ * you can paste what the browser reported verbatim.
26
+ */
27
+
28
+ import * as fs from "node:fs";
29
+ import * as path from "node:path";
30
+ import { parseArgs, fetchRaw, mapLimit, ensureDir, ext, humanBytes, readJson, writeJson, nowIso, resolveRun } from "./lib.mjs";
31
+
32
+ const args = parseArgs(process.argv.slice(2));
33
+ if (!args.dir || !args.origin) {
34
+ console.error("usage: repair.mjs --dir <run-dir> --origin <original-site-url> (--urls failed.txt | --url <path> ...)");
35
+ process.exit(1);
36
+ }
37
+ const RUN = resolveRun(String(args.dir));
38
+ const SITE = RUN.site;
39
+ const REPORTS = RUN.reports;
40
+ const ORIGIN = String(args.origin).replace(/\/+$/, "");
41
+
42
+ // Collect the failing references.
43
+ const raw = [];
44
+ if (args.urls && args.urls !== true) {
45
+ raw.push(...fs.readFileSync(path.resolve(String(args.urls)), "utf8").split(/\r?\n/));
46
+ }
47
+ const single = process.argv.reduce((acc, a, i, arr) => (a === "--url" && arr[i + 1] ? [...acc, arr[i + 1]] : acc), []);
48
+ raw.push(...single);
49
+
50
+ /** Reduce anything the browser reported to an origin-relative path. */
51
+ function toPath(entry) {
52
+ let v = String(entry).trim();
53
+ if (!v || v.startsWith("#")) return null;
54
+ if (/^chrome-extension:/i.test(v)) return null; // the user's own extensions
55
+ if (/^https?:\/\//i.test(v)) {
56
+ try {
57
+ v = new URL(v).pathname + new URL(v).search;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+ if (!v.startsWith("/")) v = "/" + v;
63
+ return v;
64
+ }
65
+
66
+ const paths = [...new Set(raw.map(toPath).filter(Boolean))];
67
+ if (paths.length === 0) {
68
+ console.error("No usable URLs given. Pass --urls <file> or one or more --url <path>.");
69
+ process.exit(1);
70
+ }
71
+
72
+ const results = await mapLimit(paths, 6, async (p) => {
73
+ const clean = p.split("?")[0];
74
+ const local = clean.replace(/^\/+/, "");
75
+ const dest = path.join(SITE, local);
76
+ if (fs.existsSync(dest)) return { path: p, status: "already present" };
77
+
78
+ const res = await fetchRaw(ORIGIN + p);
79
+ if (!res.ok || res.buf.length === 0) {
80
+ return { path: p, status: `FAILED (${res.status}${res.error ? " " + res.error : ""})` };
81
+ }
82
+ const looksHtml = /^\s*(<!doctype html|<html)/i.test(res.buf.slice(0, 200).toString("utf8"));
83
+ if (looksHtml && ![".html", ".htm", ""].includes(ext(clean))) {
84
+ return { path: p, status: "SKIPPED — origin served HTML (the file does not exist there either)" };
85
+ }
86
+ ensureDir(dest);
87
+ fs.writeFileSync(dest, res.buf);
88
+ return { path: p, status: "repaired", bytes: res.buf.length, local };
89
+ });
90
+
91
+ const repaired = results.filter((r) => r.status === "repaired");
92
+ const failed = results.filter((r) => String(r.status).startsWith("FAILED"));
93
+ const skipped = results.filter((r) => String(r.status).startsWith("SKIPPED"));
94
+ const present = results.filter((r) => r.status === "already present");
95
+
96
+ // Record the repair in the run's reports so the audit can mention it.
97
+ const file = path.join(REPORTS, "repair-report.json");
98
+ let prior = [];
99
+ try { prior = readJson(file).runs || []; } catch {}
100
+ writeJson(file, { runs: [...prior, { at: nowIso(), origin: ORIGIN, results }] });
101
+
102
+ const out = [];
103
+ out.push("");
104
+ out.push(`RUNTIME REPAIR ${ORIGIN}`);
105
+ out.push(` requested: ${paths.length} repaired: ${repaired.length} already present: ${present.length} skipped: ${skipped.length} failed: ${failed.length}`);
106
+ for (const r of repaired) out.push(` + ${humanBytes(r.bytes)} ${r.local}`);
107
+ for (const r of skipped) out.push(` · ${r.path} — ${r.status}`);
108
+ for (const r of failed) out.push(` ! ${r.path} — ${r.status}`);
109
+ out.push("");
110
+ out.push(" These files are only requested once the page RUNS, so re-render the clone and capture");
111
+ out.push(" failed requests again. Repeat until nothing fails — one repair pass often reveals the next.");
112
+ out.push("");
113
+ console.log(out.join("\n"));