/** * verify-live.mjs — 驗「線上網址現在真的在用的組態」=「deploy.targets.json 宣告的目標」。 * * 用法: * node scripts/verify-live.mjs 驗全部目標的全部對外網址 * node scripts/verify-live.mjs personal 只驗某個目標 * node scripts/verify-live.mjs --wait 容忍 CF Pages 生效延遲(重試) * npm run verify * * 為什麼要這支(2026-08-08):部署腳本印出來的值,過去只是「它打算做什麼」, * 沒有任何一步回頭確認「站上真的變成那樣」。deploy.mjs 推完會自動叫它; * 它也可以獨立跑,用來回答「現在哪一台躺在錯的狀態」。 * * 🔴 一律帶 no-cache(快取害人誤判過)。curl|grep 不算驗前端,但 config.js 是 * 純文字資產、VIEWS/HOME 是 HTML 內嵌常數,抓原始碼比對是「這一項」的正確驗法; * 「頁面真的能用」另外走瀏覽器實載。 */ import { loadTargets, resolveTarget, parseApiBase } from './targets.mjs'; const NOCACHE = { 'Cache-Control': 'no-cache', Pragma: 'no-cache' }; async function get(url) { const res = await fetch(`${url}${url.includes('?') ? '&' : '?'}_nc=${Date.now()}`, { headers: NOCACHE, cache: 'no-store', redirect: 'follow', }); return { status: res.status, text: await res.text() }; } /** 驗一個網址。回傳 { url, ok, checks:[{name, ok, want, got}] } */ export async function verifyUrl(t, url) { const checks = []; try { const cfg = await get(`${url}/config.js`); const got = cfg.status === 200 ? parseApiBase(cfg.text) : `HTTP ${cfg.status}`; checks.push({ name: 'apiBase', ok: got === t.apiBase, want: t.apiBase, got: got ?? '(config.js 裡找不到 apiBase)' }); } catch (e) { checks.push({ name: 'apiBase', ok: false, want: t.apiBase, got: `連線失敗:${e.message}` }); } try { const con = await get(`${url}/console/`); const views = con.text.match(/var VIEWS = (\[[^\]]*\]);/); const home = con.text.match(/var HOME = "([^"]*)";/); const gotViews = con.status === 200 ? (views ? views[1] : '(找不到 VIEWS)') : `HTTP ${con.status}`; const gotHome = con.status === 200 ? (home ? home[1] : '(找不到 HOME)') : `HTTP ${con.status}`; checks.push({ name: `profile(${t.profile}).views`, ok: gotViews === JSON.stringify(t.views), want: JSON.stringify(t.views), got: gotViews, }); checks.push({ name: `profile(${t.profile}).home`, ok: gotHome === t.home, want: t.home, got: gotHome }); } catch (e) { checks.push({ name: `profile(${t.profile})`, ok: false, want: t.profile, got: `連線失敗:${e.message}` }); } return { url, ok: checks.every((c) => c.ok), checks }; } export async function verifyTarget(name, { wait = false } = {}) { const t = resolveTarget(name); const attempts = wait ? 8 : 1; let results = []; for (let i = 1; i <= attempts; i++) { results = []; for (const url of t.verifyUrls) results.push(await verifyUrl(t, url)); if (results.every((r) => r.ok) || i === attempts) break; process.stdout.write(` … 尚未生效,${5}s 後重試(${i}/${attempts - 1})\n`); await new Promise((r) => setTimeout(r, 5000)); } return { name, target: t, ok: results.every((r) => r.ok), results }; } export function printReport(reports) { for (const r of reports) { console.log(`\n【${r.name}】${r.target.description}`); console.log(` 宣告:profile=${r.target.profile} apiBase=${r.target.apiBase}`); for (const u of r.results) { console.log(` ${u.ok ? '✅' : '❌'} ${u.url}`); for (const c of u.checks) { if (c.ok) console.log(` ✓ ${c.name} = ${c.got}`); else console.log(` ✗ ${c.name}\n 宣告:${c.want}\n 線上:${c.got}`); } } } } export async function verifyAll(names, opts) { const reports = []; for (const n of names) reports.push(await verifyTarget(n, opts)); return reports; } const isCli = process.argv[1] && import.meta.url === `file://${process.argv[1]}`; if (isCli) { const args = process.argv.slice(2); const wait = args.includes('--wait'); const picked = args.filter((a) => !a.startsWith('--')); const names = picked.length ? picked : loadTargets().names; const reports = await verifyAll(names, { wait }); printReport(reports); const bad = reports.filter((r) => !r.ok); if (bad.length) { console.error(`\n❌ ${bad.length} 個目標的線上組態與宣告不符:${bad.map((b) => b.name).join('、')}`); console.error(' (站上實際在用的值 ≠ deploy.targets.json 宣告的值——這正是要被擋掉的那個病)'); process.exit(1); } console.log('\n✅ 所有目標:線上組態=宣告值。'); }