@elitedcs/ghl-mcp 3.55.0 → 3.57.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.
- package/CHANGELOG.md +125 -0
- package/README.md +37 -5
- package/dist/index.js +194 -50
- package/package.json +2 -2
- package/skills/clone-site/README.md +42 -0
- package/skills/clone-site/SKILL.md +247 -0
- package/skills/clone-site/references/facts-and-substitution.md +96 -0
- package/skills/clone-site/references/hosting-verify-and-audit.md +101 -0
- package/skills/clone-site/references/rights-and-lanes.md +72 -0
- package/skills/clone-site/scripts/audit.mjs +364 -0
- package/skills/clone-site/scripts/extract-design.mjs +372 -0
- package/skills/clone-site/scripts/lib.mjs +262 -0
- package/skills/clone-site/scripts/mirror.mjs +489 -0
- package/skills/clone-site/scripts/repair.mjs +113 -0
- package/skills/clone-site/scripts/substitute.mjs +418 -0
- package/skills/clone-site/scripts/verify.mjs +193 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* substitute.mjs — rebrand by literal string substitution across HTML, CSS and
|
|
4
|
+
* JS bundles. The model builds the facts map; this script does the replacing,
|
|
5
|
+
* so no page content is ever regenerated from memory.
|
|
6
|
+
*
|
|
7
|
+
* Derives the tokens that naive clones leak (all live-proven failures):
|
|
8
|
+
* - the BARE brand word ("Genesis" survived 41 times after "Genesis Red Light")
|
|
9
|
+
* - every phone format, including tel: links
|
|
10
|
+
* - the address as a UNIT (city-only replacement produced new city + old street)
|
|
11
|
+
* - domain / www / bare-domain / email
|
|
12
|
+
*
|
|
13
|
+
* Asset filenames are PROTECTED: replacing "genesis" inside /genesis-logo.png
|
|
14
|
+
* would break a ref to a file that is on disk under its original name.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node substitute.mjs --dir <clone-dir> --facts facts.json # dry run (default)
|
|
18
|
+
* node substitute.mjs --dir <clone-dir> --facts facts.json --apply
|
|
19
|
+
* node substitute.mjs --dir <clone-dir> --facts facts.json --emit tokens.json
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { parseArgs, readJson, writeJson, walk, isTextFile, ext, nowIso, resolveRun } from "./lib.mjs";
|
|
25
|
+
|
|
26
|
+
const args = parseArgs(process.argv.slice(2));
|
|
27
|
+
if (!args.dir || !args.facts) {
|
|
28
|
+
console.error("usage: substitute.mjs --dir <clone-dir> --facts <facts.json> [--apply] [--emit tokens.json]");
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const RUN = resolveRun(String(args.dir));
|
|
32
|
+
const DIR = RUN.site; // only the deployable site is ever rewritten
|
|
33
|
+
const REPORTS = RUN.reports; // tool artifacts live outside the deploy folder
|
|
34
|
+
const APPLY = args.apply === true;
|
|
35
|
+
const facts = readJson(path.resolve(String(args.facts)));
|
|
36
|
+
|
|
37
|
+
const GENERIC_WORDS = new Set([
|
|
38
|
+
"the", "and", "for", "llc", "inc", "co", "company", "group", "clinic", "center", "centre",
|
|
39
|
+
"med", "spa", "wellness", "health", "care", "studio", "labs", "lab", "solutions", "services",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const tokens = [];
|
|
43
|
+
function push(label, oldVal, newVal, opts = {}) {
|
|
44
|
+
if (!oldVal || typeof oldVal !== "string") return;
|
|
45
|
+
const o = oldVal.trim();
|
|
46
|
+
if (!o) return;
|
|
47
|
+
const n = typeof newVal === "string" ? newVal : "";
|
|
48
|
+
tokens.push({ label, old: o, new: n, ...opts });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function phoneFormats(raw) {
|
|
52
|
+
const d = String(raw || "").replace(/\D/g, "");
|
|
53
|
+
const ten = d.length === 11 && d.startsWith("1") ? d.slice(1) : d;
|
|
54
|
+
if (ten.length !== 10) return [];
|
|
55
|
+
const [a, b, c] = [ten.slice(0, 3), ten.slice(3, 6), ten.slice(6)];
|
|
56
|
+
return [
|
|
57
|
+
`+1${ten}`, `+1 ${a} ${b} ${c}`, `1${ten}`, ten,
|
|
58
|
+
`(${a}) ${b}-${c}`, `(${a})${b}-${c}`, `(${a})-${b}-${c}`, `(${a}) ${b}.${c}`,
|
|
59
|
+
`${a}-${b}-${c}`, `${a}.${b}.${c}`, `${a} ${b} ${c}`,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function addressUnit(addr) {
|
|
64
|
+
if (!addr || typeof addr !== "object") return [];
|
|
65
|
+
const { street, suite, city, state, zip } = addr;
|
|
66
|
+
const out = [];
|
|
67
|
+
const full = [street, suite, city, [state, zip].filter(Boolean).join(" ")].filter(Boolean).join(", ");
|
|
68
|
+
if (full) out.push({ v: full, k: "address (full unit)" });
|
|
69
|
+
if (street) out.push({ v: suite ? `${street} ${suite}` : street, k: "street" });
|
|
70
|
+
if (street) out.push({ v: street, k: "street (bare)" });
|
|
71
|
+
if (city && state) out.push({ v: `${city}, ${state}`, k: "city, state" });
|
|
72
|
+
if (city) out.push({ v: city, k: "city" });
|
|
73
|
+
if (zip) out.push({ v: String(zip), k: "zip" });
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const oldF = facts.old || {};
|
|
78
|
+
const newF = facts.new || {};
|
|
79
|
+
|
|
80
|
+
// --- company + bare brand word ---------------------------------------------
|
|
81
|
+
push("company", oldF.company, newF.company);
|
|
82
|
+
if (oldF.company) {
|
|
83
|
+
const bare = String(oldF.company).split(/[\s,]+/)[0];
|
|
84
|
+
const newBare = String(newF.company || "").split(/[\s,]+/)[0];
|
|
85
|
+
if (facts.deriveBareWord !== false && bare.length >= 4 && !GENERIC_WORDS.has(bare.toLowerCase()) && bare !== oldF.company) {
|
|
86
|
+
push("company (bare word — the one that leaks)", bare, newBare, { bareWord: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const alias of oldF.aliases || []) push("company alias", alias, newF.company);
|
|
90
|
+
|
|
91
|
+
// --- owner / staff names ----------------------------------------------------
|
|
92
|
+
for (const [i, name] of (oldF.people || (oldF.owner ? [oldF.owner] : [])).entries()) {
|
|
93
|
+
const replacement = (newF.people || (newF.owner ? [newF.owner] : []))[i] || newF.owner || "";
|
|
94
|
+
push("person name", name, replacement, { review: !replacement });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- phone ------------------------------------------------------------------
|
|
98
|
+
const oldPhones = oldF.phones || (oldF.phone ? [oldF.phone] : []);
|
|
99
|
+
const newPhones = newF.phones || (newF.phone ? [newF.phone] : []);
|
|
100
|
+
for (const [i, p] of oldPhones.entries()) {
|
|
101
|
+
const oldFmts = phoneFormats(p);
|
|
102
|
+
const newFmts = phoneFormats(newPhones[i] || newPhones[0] || "");
|
|
103
|
+
for (const [j, of_] of oldFmts.entries()) push("phone", of_, newFmts[j] || "");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- email / domain ---------------------------------------------------------
|
|
107
|
+
for (const [i, e] of (oldF.emails || (oldF.email ? [oldF.email] : [])).entries()) {
|
|
108
|
+
push("email", e, (newF.emails || (newF.email ? [newF.email] : []))[i] || newF.email || "");
|
|
109
|
+
}
|
|
110
|
+
if (oldF.domain) {
|
|
111
|
+
const od = String(oldF.domain).replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
112
|
+
const nd = String(newF.domain || "").replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
113
|
+
push("domain (www)", `www.${od}`, nd ? `www.${nd}` : "");
|
|
114
|
+
push("domain", od, nd);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- address ----------------------------------------------------------------
|
|
118
|
+
{
|
|
119
|
+
const oldParts = addressUnit(oldF.address);
|
|
120
|
+
const newParts = addressUnit(newF.address);
|
|
121
|
+
for (const [i, part] of oldParts.entries()) push(part.k, part.v, (newParts[i] || {}).v || "");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- explicit extras, colors, fonts ----------------------------------------
|
|
125
|
+
for (const pair of facts.extra || []) push(pair.label || "extra", pair.old, pair.new);
|
|
126
|
+
for (const [o, n] of Object.entries(facts.colors || {})) {
|
|
127
|
+
push("color", o, n);
|
|
128
|
+
push("color", o.toUpperCase(), n.toUpperCase());
|
|
129
|
+
push("color", o.toLowerCase(), n.toLowerCase());
|
|
130
|
+
}
|
|
131
|
+
for (const [o, n] of Object.entries(facts.fonts || {})) push("font", o, n);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* HTML-entity variants. A brand containing & " ' < > is stored encoded in the
|
|
135
|
+
* markup — `Arizona AC & Heating` — so the plain token matches nothing and
|
|
136
|
+
* the brand survives untouched. Live-caught 2026-07-30 on a real HVAC site.
|
|
137
|
+
*/
|
|
138
|
+
function htmlEncode(v) {
|
|
139
|
+
return v.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
140
|
+
}
|
|
141
|
+
for (const t of [...tokens]) {
|
|
142
|
+
const encOld = htmlEncode(t.old);
|
|
143
|
+
if (encOld !== t.old) tokens.push({ ...t, old: encOld, new: htmlEncode(t.new), label: `${t.label} (HTML-encoded)` });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Longest first so "Genesis Red Light" is consumed before bare "Genesis".
|
|
147
|
+
tokens.sort((a, b) => b.old.length - a.old.length);
|
|
148
|
+
const seen = new Set();
|
|
149
|
+
const plan = tokens.filter((t) => {
|
|
150
|
+
const k = t.old;
|
|
151
|
+
if (seen.has(k)) return false;
|
|
152
|
+
seen.add(k);
|
|
153
|
+
return true;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// --- protected strings: never rewrite a path to a file on disk --------------
|
|
157
|
+
const protectedStrings = new Set();
|
|
158
|
+
const reportPath = path.join(REPORTS, "run-report.json");
|
|
159
|
+
if (fs.existsSync(reportPath)) {
|
|
160
|
+
const rep = readJson(reportPath);
|
|
161
|
+
for (const a of rep.assets || []) {
|
|
162
|
+
protectedStrings.add("/" + a.local);
|
|
163
|
+
protectedStrings.add(a.local);
|
|
164
|
+
protectedStrings.add(path.posix.basename(a.local));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const protectedList = [...protectedStrings].filter(Boolean).sort((a, b) => b.length - a.length);
|
|
168
|
+
|
|
169
|
+
function protect(text) {
|
|
170
|
+
const marks = [];
|
|
171
|
+
let out = text;
|
|
172
|
+
protectedList.forEach((p, i) => {
|
|
173
|
+
if (!out.includes(p)) return;
|
|
174
|
+
const mark = `__CLONE_PROTECT_${i}__`;
|
|
175
|
+
out = out.split(p).join(mark);
|
|
176
|
+
marks.push([mark, p]);
|
|
177
|
+
});
|
|
178
|
+
return { out, marks };
|
|
179
|
+
}
|
|
180
|
+
function unprotect(text, marks) {
|
|
181
|
+
let out = text;
|
|
182
|
+
for (const [mark, original] of marks) out = out.split(mark).join(original);
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Rendered text of an HTML file, with every character mapped back to its offset
|
|
188
|
+
* in the source. A brand mark is very often split across a tag for two-tone
|
|
189
|
+
* styling — `GHL <span style="color:#D4AF37">Command</span>` reads as
|
|
190
|
+
* "GHL Command" on screen but contains no such string in the file, so literal
|
|
191
|
+
* replacement can never reach it and a naive zero-check reports a false clean.
|
|
192
|
+
* Live-caught 2026-07-30 on a real proof run against a production page.
|
|
193
|
+
*
|
|
194
|
+
* Counting occurrences before/after tag-stripping does NOT work: text inside
|
|
195
|
+
* attributes disappears when tags are stripped, cancelling out the split gain.
|
|
196
|
+
* The offset map makes the test exact.
|
|
197
|
+
*/
|
|
198
|
+
function renderedWithMap(html) {
|
|
199
|
+
// Blank script/style/comment bodies, preserving length so offsets stay true.
|
|
200
|
+
const blanked = html.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<!--[\s\S]*?-->/gi, (m) =>
|
|
201
|
+
" ".repeat(m.length)
|
|
202
|
+
);
|
|
203
|
+
const chars = [];
|
|
204
|
+
const map = [];
|
|
205
|
+
let inTag = false;
|
|
206
|
+
for (let i = 0; i < blanked.length; i++) {
|
|
207
|
+
const c = blanked[i];
|
|
208
|
+
if (inTag) {
|
|
209
|
+
if (c === ">") inTag = false;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (c === "<") {
|
|
213
|
+
inTag = true;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
chars.push(c);
|
|
217
|
+
map.push(i);
|
|
218
|
+
}
|
|
219
|
+
return { text: chars.join(""), map };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Occurrences a visitor reads as `token` but which are NOT a contiguous literal
|
|
224
|
+
* run in the file — i.e. markup sits inside the brand name.
|
|
225
|
+
*/
|
|
226
|
+
function splitAcrossTags(html, token) {
|
|
227
|
+
const { text, map } = renderedWithMap(html);
|
|
228
|
+
const snippets = [];
|
|
229
|
+
let count = 0;
|
|
230
|
+
let idx = text.indexOf(token);
|
|
231
|
+
while (idx !== -1) {
|
|
232
|
+
const srcStart = map[idx];
|
|
233
|
+
const srcEnd = map[idx + token.length - 1];
|
|
234
|
+
if (srcEnd - srcStart + 1 !== token.length) {
|
|
235
|
+
count++;
|
|
236
|
+
if (snippets.length < 3) {
|
|
237
|
+
snippets.push(html.slice(srcStart, srcEnd + 1).replace(/\s+/g, " ").trim().slice(0, 150));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
idx = text.indexOf(token, idx + token.length);
|
|
241
|
+
}
|
|
242
|
+
return count > 0 ? { extra: count, snippets } : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- scan / apply -----------------------------------------------------------
|
|
246
|
+
const files = walk(DIR).filter((f) => isTextFile(f));
|
|
247
|
+
const counts = new Map(plan.map((t) => [t.old, { token: t, total: 0, files: [] }]));
|
|
248
|
+
|
|
249
|
+
let changedFiles = 0;
|
|
250
|
+
const splitHits = [];
|
|
251
|
+
for (const file of files) {
|
|
252
|
+
let text = fs.readFileSync(file, "utf8");
|
|
253
|
+
if ([".html", ".htm"].includes(ext(file))) {
|
|
254
|
+
for (const t of plan) {
|
|
255
|
+
if (!t.new) continue;
|
|
256
|
+
const split = splitAcrossTags(text, t.old);
|
|
257
|
+
if (split) splitHits.push({ file: path.relative(DIR, file), token: t, ...split });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const { out: guarded, marks } = protect(text);
|
|
261
|
+
let working = guarded;
|
|
262
|
+
let fileTouched = false;
|
|
263
|
+
for (const t of plan) {
|
|
264
|
+
const parts = working.split(t.old);
|
|
265
|
+
const hits = parts.length - 1;
|
|
266
|
+
if (hits === 0) continue;
|
|
267
|
+
const rec = counts.get(t.old);
|
|
268
|
+
rec.total += hits;
|
|
269
|
+
rec.files.push({ file: path.relative(DIR, file), hits });
|
|
270
|
+
if (APPLY && t.new) {
|
|
271
|
+
working = parts.join(t.new);
|
|
272
|
+
fileTouched = true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (APPLY && fileTouched) {
|
|
276
|
+
fs.writeFileSync(file, unprotect(working, marks));
|
|
277
|
+
changedFiles++;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- output -----------------------------------------------------------------
|
|
282
|
+
const rows = [...counts.values()].filter((r) => r.total > 0);
|
|
283
|
+
const zero = [...counts.values()].filter((r) => r.total === 0);
|
|
284
|
+
const missingNew = rows.filter((r) => !r.token.new);
|
|
285
|
+
|
|
286
|
+
const lines = [];
|
|
287
|
+
lines.push("");
|
|
288
|
+
lines.push(APPLY ? "SUBSTITUTION APPLIED" : "SUBSTITUTION DRY RUN (nothing written — add --apply to write)");
|
|
289
|
+
lines.push(` site dir: ${DIR}`);
|
|
290
|
+
lines.push(` text files scanned: ${files.length} protected asset paths: ${protectedList.length}`);
|
|
291
|
+
lines.push("");
|
|
292
|
+
lines.push(" hits label old → new");
|
|
293
|
+
lines.push(" ---- ----------------------------------- ----------------------------------------");
|
|
294
|
+
for (const r of rows.sort((a, b) => b.total - a.total)) {
|
|
295
|
+
const arrow = r.token.new ? `${r.token.old} → ${r.token.new}` : `${r.token.old} → ⚠ NO REPLACEMENT GIVEN`;
|
|
296
|
+
lines.push(` ${String(r.total).padStart(4)} ${r.token.label.padEnd(35).slice(0, 35)} ${arrow}`);
|
|
297
|
+
}
|
|
298
|
+
const bareRows = rows.filter((r) => r.token.bareWord && r.total > 0);
|
|
299
|
+
if (bareRows.length) {
|
|
300
|
+
lines.push("");
|
|
301
|
+
lines.push(" ⚠ BARE BRAND WORD — check this one before approving. It catches the occurrences a full-");
|
|
302
|
+
lines.push(" phrase list misses, but if the word is also a place or a common noun it will rewrite");
|
|
303
|
+
lines.push(" ordinary sentences (\"Arizona homeowners\" → \"Northgate homeowners\").");
|
|
304
|
+
for (const r of bareRows) lines.push(` "${r.token.old}" → "${r.token.new}" — ${r.total} occurrence(s). Set "deriveBareWord": false in facts.json to skip it.`);
|
|
305
|
+
}
|
|
306
|
+
if (missingNew.length) {
|
|
307
|
+
lines.push("");
|
|
308
|
+
lines.push(` ⚠ ${missingNew.length} token(s) present in the page with no replacement value.`);
|
|
309
|
+
lines.push(" They will be LEFT AS THE ORIGINAL OWNER'S — supply a value or accept them in the audit.");
|
|
310
|
+
}
|
|
311
|
+
// Near-miss check: a zero-hit token that DOES appear case-insensitively (or with
|
|
312
|
+
// different whitespace/punctuation) means the fact is slightly wrong — the exact
|
|
313
|
+
// way brands leak through a "complete" substitution list.
|
|
314
|
+
const nearMisses = [];
|
|
315
|
+
if (zero.length) {
|
|
316
|
+
const corpus = files.map((f) => protect(fs.readFileSync(f, "utf8")).out).join("\n");
|
|
317
|
+
const loose = (s) => s.toLowerCase().replace(/[\s,.'’-]+/g, " ").trim();
|
|
318
|
+
const looseCorpus = loose(corpus);
|
|
319
|
+
for (const z of zero) {
|
|
320
|
+
const needle = loose(z.token.old);
|
|
321
|
+
if (!needle) continue;
|
|
322
|
+
// Suppress the noise case: a shorter token whose loose form is already
|
|
323
|
+
// contained in a token that DID match exactly (e.g. "Elite DCs" inside
|
|
324
|
+
// "Elite DCS LLC") is covered, not leaking.
|
|
325
|
+
const coveredByMatched = rows.some((r) => loose(r.token.old).includes(needle));
|
|
326
|
+
if (coveredByMatched) continue;
|
|
327
|
+
const hits = looseCorpus.split(needle).length - 1;
|
|
328
|
+
if (hits > 0) nearMisses.push({ token: z.token, hits });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (nearMisses.length) {
|
|
332
|
+
lines.push("");
|
|
333
|
+
lines.push(` ⚠ NEAR MISS — ${nearMisses.length} token(s) do not match exactly but DO appear in the page`);
|
|
334
|
+
lines.push(" with different case, spacing or punctuation. Fix the fact or they will survive:");
|
|
335
|
+
for (const n of nearMisses) lines.push(` ${n.token.label}: "${n.token.old}" — ${n.hits} loose match(es)`);
|
|
336
|
+
}
|
|
337
|
+
const trulyAbsent = zero.filter((z) => !nearMisses.some((n) => n.token.old === z.token.old));
|
|
338
|
+
if (trulyAbsent.length) {
|
|
339
|
+
lines.push("");
|
|
340
|
+
lines.push(` ${trulyAbsent.length} token(s) never appear in the page (fine — or the fact is wrong):`);
|
|
341
|
+
for (const z of trulyAbsent.slice(0, 12)) lines.push(` ${z.token.label}: ${z.token.old}`);
|
|
342
|
+
}
|
|
343
|
+
if (splitHits.length) {
|
|
344
|
+
lines.push("");
|
|
345
|
+
lines.push(" ⚠ SPLIT ACROSS TAGS — these read as the old brand ON SCREEN but are broken up by");
|
|
346
|
+
lines.push(" markup in the file (a two-tone brand mark, usually). String replacement CANNOT");
|
|
347
|
+
lines.push(" reach them. Edit these by hand before deploying:");
|
|
348
|
+
for (const h of splitHits) {
|
|
349
|
+
lines.push(` ${h.file}: "${h.token.old}" ×${h.extra} → should read "${h.token.new}"`);
|
|
350
|
+
for (const sn of h.snippets) lines.push(` …${sn}…`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (APPLY) {
|
|
354
|
+
lines.push("");
|
|
355
|
+
lines.push(` files rewritten: ${changedFiles}`);
|
|
356
|
+
// zero-check: any old token still present?
|
|
357
|
+
const residual = [];
|
|
358
|
+
for (const file of files) {
|
|
359
|
+
const text = fs.readFileSync(file, "utf8");
|
|
360
|
+
const { out: guarded } = protect(text);
|
|
361
|
+
for (const t of plan) {
|
|
362
|
+
if (!t.new) continue;
|
|
363
|
+
const hits = guarded.split(t.old).length - 1;
|
|
364
|
+
if (hits > 0) residual.push({ file: path.relative(DIR, file), token: t.old, hits });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Re-check the RENDERED text too — a tag-split brand mark survives replacement
|
|
368
|
+
// and would otherwise pass a literal-only zero-check.
|
|
369
|
+
const residualSplit = [];
|
|
370
|
+
for (const file of files) {
|
|
371
|
+
if (![".html", ".htm"].includes(ext(file))) continue;
|
|
372
|
+
const text = fs.readFileSync(file, "utf8");
|
|
373
|
+
for (const t of plan) {
|
|
374
|
+
if (!t.new) continue;
|
|
375
|
+
const split = splitAcrossTags(text, t.old);
|
|
376
|
+
if (split) residualSplit.push({ file: path.relative(DIR, file), token: t.old, hits: split.extra });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const clean = residual.length === 0 && residualSplit.length === 0;
|
|
380
|
+
lines.push(
|
|
381
|
+
clean
|
|
382
|
+
? " ZERO-CHECK: no replaced token survives in the clone, in the file OR on screen. ✅"
|
|
383
|
+
: ` ZERO-CHECK FAILED: ${residual.length + residualSplit.length} residual occurrence(s) — investigate before deploying:`
|
|
384
|
+
);
|
|
385
|
+
for (const r of residual.slice(0, 15)) lines.push(` ${r.file}: "${r.token}" ×${r.hits}`);
|
|
386
|
+
for (const r of residualSplit.slice(0, 15)) lines.push(` ${r.file}: "${r.token}" ×${r.hits} (SPLIT ACROSS TAGS — visible on screen, needs a hand edit)`);
|
|
387
|
+
}
|
|
388
|
+
lines.push("");
|
|
389
|
+
lines.push(" NEXT: audit.mjs — the pre-launch REVIEW REQUIRED report (mandatory, never skipped).");
|
|
390
|
+
lines.push("");
|
|
391
|
+
console.log(lines.join("\n"));
|
|
392
|
+
|
|
393
|
+
if (args.emit && args.emit !== true) {
|
|
394
|
+
writeJson(path.resolve(String(args.emit)), { generatedAt: nowIso(), applied: APPLY, tokens: plan, counts: rows });
|
|
395
|
+
}
|
|
396
|
+
// A re-run over already-substituted files finds zero hits. Never let that erase
|
|
397
|
+
// the record of what WAS replaced — verify.mjs checks the live page against it.
|
|
398
|
+
const reportFile = path.join(REPORTS, "substitution-report.json");
|
|
399
|
+
let priorHits = [];
|
|
400
|
+
try {
|
|
401
|
+
priorHits = readJson(reportFile).hits || [];
|
|
402
|
+
} catch {}
|
|
403
|
+
const freshHits = rows.map((r) => ({ label: r.token.label, old: r.token.old, new: r.token.new, total: r.total, files: r.files }));
|
|
404
|
+
const mergedHits = [...freshHits];
|
|
405
|
+
for (const prior of priorHits) {
|
|
406
|
+
if (!mergedHits.some((h) => h.old === prior.old)) mergedHits.push({ ...prior, fromEarlierRun: true });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
writeJson(reportFile, {
|
|
410
|
+
generatedAt: nowIso(),
|
|
411
|
+
applied: APPLY,
|
|
412
|
+
dir: DIR,
|
|
413
|
+
tokens: plan,
|
|
414
|
+
hits: mergedHits,
|
|
415
|
+
splitAcrossTags: splitHits.map((h) => ({ file: h.file, old: h.token.old, new: h.token.new, count: h.extra, snippets: h.snippets })),
|
|
416
|
+
nearMisses: nearMisses.map((n) => ({ label: n.token.label, old: n.token.old, looseHits: n.hits })),
|
|
417
|
+
neverFound: trulyAbsent.map((z) => ({ label: z.token.label, old: z.token.old })),
|
|
418
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* verify.mjs — verify the DEPLOYED clone, not the local folder.
|
|
4
|
+
*
|
|
5
|
+
* Static hosts return 200 + the SPA shell for a missing asset, so a status code
|
|
6
|
+
* proves nothing (live-proven on Cloudflare Pages). Every asset is checked by
|
|
7
|
+
* CONTENT-TYPE. Old brand tokens must be gone; new ones must be present.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node verify.mjs --url https://<deployed> [--dir <clone-dir>] [--json out.json]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
parseArgs, fetchText, fetchRaw, mapLimit, ext, isAssetUrl, contentTypeMatches, readJson, writeJson, nowIso, resolveRun,
|
|
17
|
+
} from "./lib.mjs";
|
|
18
|
+
|
|
19
|
+
const args = parseArgs(process.argv.slice(2));
|
|
20
|
+
if (!args.url) {
|
|
21
|
+
console.error("usage: verify.mjs --url <deployed-url> [--dir <clone-dir>] [--json out.json]");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const URL_ = String(args.url);
|
|
25
|
+
const RUN = args.dir && args.dir !== true ? resolveRun(String(args.dir)) : null;
|
|
26
|
+
const REPORTS = RUN ? RUN.reports : null;
|
|
27
|
+
|
|
28
|
+
function refsFrom(text, kind) {
|
|
29
|
+
const refs = new Set();
|
|
30
|
+
if (kind === "html") {
|
|
31
|
+
for (const m of text.matchAll(/(?:src|href|poster)=["']([^"']+)["']/gi)) refs.add(m[1]);
|
|
32
|
+
for (const m of text.matchAll(/srcset=["']([^"']+)["']/gi))
|
|
33
|
+
for (const part of m[1].split(",")) { const u = part.trim().split(/\s+/)[0]; if (u) refs.add(u); }
|
|
34
|
+
}
|
|
35
|
+
for (const m of text.matchAll(/url\(([^)]+)\)/gi)) refs.add(m[1].trim().replace(/^['"]|['"]$/g, ""));
|
|
36
|
+
if (kind === "js") {
|
|
37
|
+
const mediaExt = "png|jpe?g|webp|gif|svg|avif|ico|mp4|webm|mov|mp3|woff2?|ttf|otf";
|
|
38
|
+
for (const m of text.matchAll(new RegExp(`["'\`](/[A-Za-z0-9._/-]+?\\.(?:${mediaExt}))["'\`]`, "gi"))) refs.add(m[1]);
|
|
39
|
+
}
|
|
40
|
+
return [...refs];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
const base = new URL(URL_);
|
|
45
|
+
const page = await fetchText(URL_);
|
|
46
|
+
if (!page.ok || !page.text) {
|
|
47
|
+
console.error(`FAIL: could not fetch ${URL_} (status ${page.status})`);
|
|
48
|
+
process.exit(2);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Collect refs from the deployed HTML, then from every deployed CSS/JS.
|
|
52
|
+
const candidates = new Map(); // absolute url -> where it came from
|
|
53
|
+
for (const r of refsFrom(page.text, "html")) {
|
|
54
|
+
if (!r || r.startsWith("data:") || r.startsWith("#") || r.startsWith("mailto:") || r.startsWith("tel:")) continue;
|
|
55
|
+
try {
|
|
56
|
+
const a = new URL(r, URL_).toString();
|
|
57
|
+
if (isAssetUrl(a)) candidates.set(a, "html");
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const texts = [{ url: URL_, text: page.text }];
|
|
62
|
+
const codeUrls = [...candidates.keys()].filter((u) => [".css", ".js", ".mjs"].includes(ext(u)));
|
|
63
|
+
const codeBodies = await mapLimit(codeUrls, 6, async (u) => ({ u, r: await fetchText(u) }));
|
|
64
|
+
for (const { u, r } of codeBodies) {
|
|
65
|
+
if (!r.ok) continue;
|
|
66
|
+
texts.push({ url: u, text: r.text });
|
|
67
|
+
for (const ref of refsFrom(r.text, ext(u) === ".css" ? "css" : "js")) {
|
|
68
|
+
try {
|
|
69
|
+
const a = new URL(ref, ext(u) === ".css" ? u : base.origin + "/").toString();
|
|
70
|
+
if (isAssetUrl(a) && new URL(a).origin === base.origin) candidates.set(a, path.basename(u));
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Refs the SOURCE page was already serving badly — inherited, not caused by
|
|
76
|
+
// the clone. Reported, but they do not fail the run: redeploying cannot fix
|
|
77
|
+
// a file the original site does not have either.
|
|
78
|
+
const inheritedBad = new Set();
|
|
79
|
+
if (REPORTS) {
|
|
80
|
+
try {
|
|
81
|
+
const mirror = readJson(path.join(REPORTS, "run-report.json"));
|
|
82
|
+
for (const f of [...(mirror.failed || []), ...(mirror.skippedForeignOrMissing || [])]) {
|
|
83
|
+
try { inheritedBad.add(new URL(f.url).pathname); } catch {}
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Content-type check on every same-origin asset. A single transient answer
|
|
89
|
+
// (CDN rollout, a burst rate-limit) must not be reported as a broken asset —
|
|
90
|
+
// check twice before calling it, and keep concurrency modest.
|
|
91
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
92
|
+
const probe = async (u) => {
|
|
93
|
+
const res = await fetchRaw(u, 30000);
|
|
94
|
+
const e = ext(u);
|
|
95
|
+
return { res, ok: res.ok && contentTypeMatches(e, res.contentType) && res.buf.length > 0 };
|
|
96
|
+
};
|
|
97
|
+
const list = [...candidates.entries()].filter(([u]) => new URL(u).origin === base.origin);
|
|
98
|
+
const checked = await mapLimit(list, 4, async ([u, from]) => {
|
|
99
|
+
let attempt = await probe(u);
|
|
100
|
+
let retried = false;
|
|
101
|
+
if (!attempt.ok) {
|
|
102
|
+
retried = true;
|
|
103
|
+
await sleep(1200);
|
|
104
|
+
attempt = await probe(u);
|
|
105
|
+
}
|
|
106
|
+
const { res, ok } = attempt;
|
|
107
|
+
let inherited = false;
|
|
108
|
+
try { inherited = !ok && inheritedBad.has(new URL(u).pathname); } catch {}
|
|
109
|
+
return {
|
|
110
|
+
url: u, from, status: res.status, contentType: res.contentType,
|
|
111
|
+
bytes: res.buf.length, ok, retried, inherited,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
const broken = checked.filter((c) => !c.ok && !c.inherited);
|
|
115
|
+
const inherited = checked.filter((c) => !c.ok && c.inherited);
|
|
116
|
+
|
|
117
|
+
// Token checks against the substitution report, if present.
|
|
118
|
+
const tokenRows = [];
|
|
119
|
+
if (REPORTS) {
|
|
120
|
+
let subs = null;
|
|
121
|
+
try { subs = readJson(path.join(REPORTS, "substitution-report.json")); } catch {}
|
|
122
|
+
if (subs) {
|
|
123
|
+
const allText = texts.map((t) => t.text).join("\n");
|
|
124
|
+
const assetPaths = new Set();
|
|
125
|
+
try {
|
|
126
|
+
const mirror = readJson(path.join(REPORTS, "run-report.json"));
|
|
127
|
+
for (const a of mirror.assets || []) assetPaths.add("/" + a.local);
|
|
128
|
+
} catch {}
|
|
129
|
+
let guarded = allText;
|
|
130
|
+
for (const p of [...assetPaths].sort((a, b) => b.length - a.length)) guarded = guarded.split(p).join(" ");
|
|
131
|
+
// Rendered text catches a brand mark split across tags — invisible to a
|
|
132
|
+
// literal search, plainly visible to a visitor.
|
|
133
|
+
const renderedAll = texts
|
|
134
|
+
.map((t) => t.text)
|
|
135
|
+
.join("\n")
|
|
136
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
137
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
138
|
+
.replace(/<[^>]+>/g, "");
|
|
139
|
+
for (const h of subs.hits || []) {
|
|
140
|
+
if (!h.new) continue;
|
|
141
|
+
const oldHits = guarded.split(h.old).length - 1;
|
|
142
|
+
const newHits = guarded.split(h.new).length - 1;
|
|
143
|
+
const renderedOld = renderedAll.split(h.old).length - 1;
|
|
144
|
+
tokenRows.push({
|
|
145
|
+
label: h.label, old: h.old, new: h.new, oldHits, newHits,
|
|
146
|
+
renderedOld,
|
|
147
|
+
ok: oldHits === 0 && renderedOld === 0 && newHits > 0,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const tokenFails = tokenRows.filter((t) => !t.ok);
|
|
153
|
+
|
|
154
|
+
const out = [];
|
|
155
|
+
out.push("");
|
|
156
|
+
out.push(`VERIFY ${URL_}`);
|
|
157
|
+
out.push(` assets checked (content-type, not status code): ${checked.length}`);
|
|
158
|
+
out.push(` broken: ${broken.length}`);
|
|
159
|
+
for (const b of broken.slice(0, 20)) out.push(` [${b.status} ${b.contentType || "no content-type"}] ${b.url} (referenced by ${b.from})`);
|
|
160
|
+
if (inherited.length) {
|
|
161
|
+
out.push(` inherited (the SOURCE page has the same broken reference — not caused by the clone): ${inherited.length}`);
|
|
162
|
+
for (const b of inherited.slice(0, 10)) out.push(` ${b.url}`);
|
|
163
|
+
}
|
|
164
|
+
if (tokenRows.length) {
|
|
165
|
+
out.push("");
|
|
166
|
+
out.push(" brand tokens on the LIVE page:");
|
|
167
|
+
out.push(" old→0? new>0? token");
|
|
168
|
+
for (const t of tokenRows.slice(0, 30)) {
|
|
169
|
+
const flag = t.oldHits === 0 && t.renderedOld === 0 ? " ok " : ` ${String(t.oldHits || t.renderedOld).padStart(3)}✗ `;
|
|
170
|
+
const note = t.oldHits === 0 && t.renderedOld > 0 ? " ← SPLIT ACROSS TAGS: visible on the live page" : "";
|
|
171
|
+
out.push(` ${flag} ${t.newHits > 0 ? `${String(t.newHits).padStart(3)}✓` : " 0✗"} ${t.label}: "${t.old}" → "${t.new}"${note}`);
|
|
172
|
+
}
|
|
173
|
+
if (tokenFails.length) out.push(` token failures: ${tokenFails.length}`);
|
|
174
|
+
}
|
|
175
|
+
out.push("");
|
|
176
|
+
const pass = broken.length === 0 && tokenFails.length === 0;
|
|
177
|
+
out.push(pass ? " RESULT: PASS ✅" : " RESULT: FAIL ❌ — fix and redeploy before showing anyone.");
|
|
178
|
+
out.push("");
|
|
179
|
+
out.push(" Content-type checking cannot see a broken RENDER. Also open the URL in a browser and");
|
|
180
|
+
out.push(" check every image reports naturalWidth > 0 and every video has no error.");
|
|
181
|
+
out.push("");
|
|
182
|
+
console.log(out.join("\n"));
|
|
183
|
+
|
|
184
|
+
if (args.json && args.json !== true) {
|
|
185
|
+
writeJson(path.resolve(String(args.json)), { generatedAt: nowIso(), url: URL_, checked, broken, inherited, tokenRows, pass });
|
|
186
|
+
}
|
|
187
|
+
process.exit(pass ? 0 : 5);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
main().catch((e) => {
|
|
191
|
+
console.error("verify failed:", e);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
});
|