/** * verify-live.mjs — 驗「線上網址現在真的在跑的那一份」=「我們手上這一份」。 * * 用法: * node scripts/verify-live.mjs 驗全部服役中目標的全部對外網址 * node scripts/verify-live.mjs personal 只驗某個目標 * node scripts/verify-live.mjs --wait 容忍 CF Pages 生效延遲(重試) * node scripts/verify-live.mjs --url <網址> 只對某個網址驗世代(不需要是宣告目標) * npm run verify * * 兩層,缺一不可: * ① 組態層:apiBase/profile 的 views/home = deploy.targets.json 宣告值 * ② 世代層:線上資產的位元組指紋 = repo public/ 的指紋 * * 為什麼要第二層(2026-08-08,leo:「已經發生過一次這個錯誤,把舊版界面上到 prod, * 你要確定不可再犯」):當天實測三個對外網址,第一層**三項全過**, * 而它們跑的是 07-22 那一代的 portal(82,911 bytes、金色 serif 舊品牌), * repo 是 343,969 bytes 的新品牌世代。 * ⇒ **組態可以完全正確,同時展示一套早就被淘汰的介面,而機械檢查一片綠。** * 第二層就是為了讓這個狀態不可能無聲存在。 * * 🔴 一律帶 no-cache(快取害人誤判過)。curl|grep 不算驗前端,但 config.js/VIEWS/HOME * 與世代指紋都是**純文字資產比對**,抓原始碼比對是這幾項的正確驗法; * 「頁面真的能用」另外走瀏覽器實載。 * 🔴 frozen 目標(見 deploy.targets.json)連抓都不抓——不是我們的帳號,不碰。 */ import { GENERATION_ASSETS, fingerprintOf, generationOfDir, loadTargets, parseApiBase, readState, resolveTarget, } 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', }); const buf = Buffer.from(await res.arrayBuffer()); return { status: res.status, bytes: buf, text: buf.toString('utf8') }; } /** 抓線上的世代資產,算指紋。抓不到的當 MISSING(照樣算,缺檔本來就是另一代)。 */ async function liveGeneration(base) { const entries = []; const detail = {}; for (const { file, urlPath } of GENERATION_ASSETS) { try { const r = await get(`${base.replace(/\/$/, '')}${urlPath}`); const ok = r.status === 200; entries.push({ file, bytes: ok ? r.bytes : null }); detail[file] = { status: r.status, text: ok ? r.text : null }; } catch (e) { entries.push({ file, bytes: null }); detail[file] = { status: `連線失敗:${e.message}`, text: null }; } } return { ...fingerprintOf(entries), detail }; } /** 驗一個網址。t 給 null=只驗世代(ad-hoc 模式)。 */ export async function verifyUrl(t, url, want) { const checks = []; const base = url.replace(/\/$/, ''); const live = await liveGeneration(base); // ── 世代層 ────────────────────────────────────────────── const genOk = live.digest === want.digest; const diffs = Object.entries(want.assets) .filter(([f, a]) => live.assets[f]?.sha !== a.sha) .map(([f, a]) => { const l = live.assets[f] ?? {}; const st = live.detail[f]?.status; return `${f}:repo ${a.size ?? '缺'} bytes / 線上 ${l.missing ? `抓不到(${st})` : `${l.size} bytes`}`; }); checks.push({ name: '世代', ok: genOk, want: `${want.digest.slice(0, 12)}(repo public/)`, got: genOk ? `${live.digest.slice(0, 12)}` : `${live.digest.slice(0, 12)}\n 不同的資產:\n ${diffs.join('\n ')}`, }); if (!t) return { url, ok: genOk, checks }; // ── 組態層 ────────────────────────────────────────────── try { const cfg = await get(`${base}/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}` }); } const con = live.detail['console/index.html']; const conText = con?.text; const views = conText?.match(/var VIEWS = (\[[^\]]*\]);/); const home = conText?.match(/var HOME = "([^"]*)";/); const gotViews = conText ? (views ? views[1] : '(找不到 VIEWS)') : `HTTP ${con?.status}`; const gotHome = conText ? (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 }); return { url, ok: checks.every((c) => c.ok), checks }; } export async function verifyTarget(name, { wait = false } = {}) { const t = resolveTarget(name); if (t.frozen) return { name, target: t, skipped: true, ok: true, results: [] }; const want = generationOfDir(); 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, want)); if (results.every((r) => r.ok) || i === attempts) break; process.stdout.write(` … 尚未生效,5s 後重試(${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}`); if (r.skipped) { console.log(` ⏸️ 已凍結,不抓不驗:${r.target.frozen}`); continue; } 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 urlIdx = args.indexOf('--url'); if (args.includes('--offline-lag')) { // 不連網,只問一句:「我手上這一代,有沒有真的送出去過?」 // 給 Stop hook 用(每回合都跑,所以不准連網、不准慢)。 // 唯一的事實來源是 .deploy-state.json,而它**只在線上實測通過後**才被寫(見 deploy.mjs) // ⇒ 它說綠就是真的有人驗過線上,不是「我跑過部署指令」。 const here = generationOfDir().digest; const state = readState(); const stale = []; for (const n of loadTargets().active) { const s = state[n]; if (!s) stale.push(`${n}:沒有任何一次通過線上實測的部署紀錄(線上是哪一代,現在沒人知道)`); else if (s.generation !== here) { stale.push(`${n}:最後一次驗過的是 ${s.generation.slice(0, 12)}(${s.verifiedAt.slice(0, 10)}),現在手上是 ${here.slice(0, 12)}`); } } if (stale.length) { console.log(stale.join('\n')); process.exit(1); } process.exit(0); } if (urlIdx !== -1) { // ad-hoc:只問「這個網址上跑的是不是當代的」——不需要它是宣告過的目標。 const url = args[urlIdx + 1]; if (!url) { console.error('用法:node scripts/verify-live.mjs --url <網址>'); process.exit(2); } const want = generationOfDir(); const r = await verifyUrl(null, url, want); console.log(`\n【世代檢查】${url}`); for (const c of r.checks) { if (c.ok) console.log(` ✅ ${c.name} = ${c.got}`); else console.log(` ❌ ${c.name}\n 我們手上:${c.want}\n 線上跑的:${c.got}`); } if (!r.ok) { console.error('\n❌ 這個網址上跑的不是當代的前端——它展示的是一套已經被淘汰的介面。'); process.exit(1); } console.log('\n✅ 這個網址上跑的=我們手上這一份。'); process.exit(0); } 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(' (線上實際在跑的 ≠ 我們手上這一份——這正是要被擋掉的那個病)'); process.exit(1); } console.log('\n✅ 所有服役中目標:線上組態=宣告值,線上世代=repo 當代。'); }