/** * targets.mjs — 部署目標的唯一讀取點(deploy.mjs 與 verify-live.mjs 共用)。 * * 存在的理由:宣告值(deploy.targets.json)只准被解讀一次。 * 「部署時印在終端機的值」「寫進產物的值」「事後驗線上的值」若各自去讀、各自算, * 三者就會漂移——2026-08-08 那場事故的形狀正是「印的是 A、推的是 B」。 * 這支把「一個目標展開成期望的產物長相」定死成一個函式,三邊共用同一個答案。 * * 🔴 2026-08-08 第二層(leo:「已經發生過一次這個錯誤,把舊版界面上到 prod, * 你要確定不可再犯」):組態對 ≠ 世代對。 * 一個網址可以 apiBase/profile 全部正確,卻對外展示一套早就被淘汰的介面, * 而所有只驗組態的檢查都說它綠。故本檔另外定義「世代指紋」(見下半段): * 把「線上這一份是不是當代的」變成一個可機械比對的值。 */ import { createHash } from 'node:crypto'; import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); export const PUBLIC_DIR = join(ROOT, 'public'); export function loadTargets() { const raw = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8')); const profiles = raw._profiles; if (!profiles) throw new Error('deploy.targets.json 缺 _profiles(profile → views/home 對照)'); const names = Object.keys(raw).filter((k) => !k.startsWith('_')); const active = names.filter((n) => !raw[n].frozen); return { raw, profiles, names, active }; } export function resolveTarget(name) { const { raw, profiles, names } = loadTargets(); const t = raw[name]; if (!t) { const err = new Error(`未知的部署目標:"${name}"。可用:${names.join(' / ')}`); err.usage = true; throw err; } // 凍結目標:連讀都不准碰(frozen.reason 說明是誰、何時、為什麼)。 // 這不是「壞掉所以跳過」,是「這個帳號的資源不歸我們動」——工具自己守,不靠人記得。 if (t.frozen) return { name, ...t, frozen: t.frozen, views: profiles[t.profile]?.views, home: profiles[t.profile]?.home }; const p = profiles[t.profile]; if (!p) { throw new Error( `目標 ${name} 的 profile="${t.profile}" 在 _profiles 裡沒有定義(可用:${Object.keys(profiles).join(' / ')})。` + '\n宣告了一個沒人知道怎麼落地的 profile ⇒ 拒絕部署,不要猜。', ); } if (!t.apiBase) throw new Error(`目標 ${name} 沒有 apiBase——空值會讓前端安靜地連不上,拒絕部署。`); if (!t.accountId) throw new Error(`目標 ${name} 沒有 accountId——不指定帳號可能部到別人的站上,拒絕部署。`); if (!Array.isArray(t.verifyUrls) || t.verifyUrls.length === 0) { throw new Error(`目標 ${name} 沒有 verifyUrls——沒有對外網址就無法驗「站上跑的=宣告的」,拒絕部署。`); } return { name, ...t, views: p.views, home: p.home }; } /** 這個目標「應該長成什麼樣」——產物閘與線上閘都比對這一份。 */ export function expected(t) { return { configJs: configJsFor(t), apiBase: t.apiBase, viewsLine: ` var VIEWS = ${JSON.stringify(t.views)};`, homeLine: ` var HOME = ${JSON.stringify(t.home)};`, }; } export function configJsFor(t) { return ( '// 由 console-ui/scripts/deploy.mjs 於部署時依 deploy.targets.json 產生——請勿手改,也不進 git。\n' + `// 目標:${t.name}(${t.description})\n` + `window.ARCRUN_CONFIG = { apiBase: ${JSON.stringify(t.apiBase)} };\n` ); } /** 從 config.js 的文字裡取出 apiBase(線上/產物共用同一個解析法)。 */ export function parseApiBase(text) { const m = text.match(/apiBase\s*:\s*"([^"]*)"/); return m ? m[1] : null; } // ───────────────────────────────────────────────────────────────────────────── // 世代指紋(2026-08-08 第二層) // // 問題:verify-live 原本只驗組態(apiBase / VIEWS / HOME)。實測當天三個對外網址 // 這三項全綠,但線上跑的是 2026-07-22 那一代的 portal(82,911 bytes、 // 金色 serif「Arcrun」品牌、Songti 12 處),repo 是 343,969 bytes 的 // 「arc >> run」新代——**組態全對、介面整整落後半個月,機械檢查一片綠**。 // // 判準:「線上這一份,是不是我們手上這一份?」不加解釋、不留模糊地帶—— // 逐一抓下線上資產、遮掉「本來就該隨部署目標不同」的那幾行,其餘按位元組比對。 // // 為什麼是位元組而不是「找幾個關鍵字」: // 關鍵字清單要人維護,而人只會在「這次剛好想到」時更新它。舊世代之所以能無聲上線, // 正是因為沒有人記得去更新那張清單。位元組比對不需要任何人記得任何事: // repo 改了一個字,指紋就不同,線上沒跟上就是 ❌。 // // 誠實的 trade-off(mindset §7,不假裝完美): // ① 只要 repo 動過而還沒部署,這個檢查就會說「線上落後」——那是**正確的**, // 因為那時線上確實不是當代的。它會吵,但吵的是真的。 // ② 若哪天 CF 邊緣開始改寫 HTML(Rocket Loader 之類),會出現假 ❌。 // 2026-08-08 實測 mira.uncle6.me 與 pages.dev 回傳位元組完全相同(sha 一致), // 證明目前沒有改寫。真出現時它會大聲壞掉、有人來查—— // **假 ❌ 的代價遠低於假 ✅**(假 ✅ 就是這次事故本身)。 // ───────────────────────────────────────────────────────────────────────────── /** 納入世代指紋的資產:file=public/ 底下的路徑,urlPath=線上要抓的位址。 */ export const GENERATION_ASSETS = [ { file: 'index.html', urlPath: '/' }, { file: 'portal/index.html', urlPath: '/portal/' }, { file: 'console/index.html', urlPath: '/console/' }, { file: 'favicon.svg', urlPath: '/favicon.svg' }, ]; /** * 「本來就該隨部署目標不同」的行——比世代時遮掉,否則個人版與企業版永遠指紋不同。 * 遮的只有這兩行;其餘全部按原樣比對。 * config.js 整支不納入世代(它是純產物,由 apiBase 那一項單獨驗)。 */ const TARGET_DEPENDENT_LINES = [ { file: 'console/index.html', re: /^[ \t]*var VIEWS = .*$/m, tag: '«VIEWS:由部署目標決定»' }, { file: 'console/index.html', re: /^[ \t]*var HOME = .*$/m, tag: '«HOME:由部署目標決定»' }, ]; /** 遮掉目標相依的行。抓不到就原樣回傳(線上是舊世代時本來就可能沒有那幾行 → 該判 ❌)。 */ export function maskTargetValues(file, bytes) { const rules = TARGET_DEPENDENT_LINES.filter((r) => r.file === file); if (!rules.length) return bytes; let text = Buffer.from(bytes).toString('utf8'); for (const r of rules) text = text.replace(r.re, r.tag); return Buffer.from(text, 'utf8'); } export function sha256(bytes) { return createHash('sha256').update(bytes).digest('hex'); } /** * 由「檔名 → 位元組(抓不到給 null)」算出世代指紋。 * @param {Array<{file:string, bytes:Buffer|null}>} entries */ export function fingerprintOf(entries) { const assets = {}; const lines = []; for (const { file, bytes } of entries) { if (bytes == null) { assets[file] = { sha: null, size: null, missing: true }; lines.push(`${file}\tMISSING`); continue; } const masked = maskTargetValues(file, bytes); const sha = sha256(masked); assets[file] = { sha, size: Buffer.from(bytes).length, missing: false }; lines.push(`${file}\t${sha}`); } return { assets, digest: sha256(Buffer.from(lines.join('\n'), 'utf8')) }; } /** repo(或某個產物目錄)現在這一代長什麼樣。這就是「當代」的定義。 */ export function generationOfDir(dir = PUBLIC_DIR) { return fingerprintOf( GENERATION_ASSETS.map(({ file }) => { let bytes = null; try { bytes = readFileSync(join(dir, file)); } catch { bytes = null; } return { file, bytes }; }), ); } // ───────────────────────────────────────────────────────────────────────────── // 產物:把宣告值真的寫進去(e730b3f 標的 WIP,本次收掉) // ───────────────────────────────────────────────────────────────────────────── /** * 依目標把 public/ 展開成「要推上去的那一份」。 * 🔴 覆寫沒命中就中止——宣告了卻沒寫進產物,正是這串事故的根。 */ export function buildArtifact(t, outDir) { rmSync(outDir, { recursive: true, force: true }); mkdirSync(outDir, { recursive: true }); cpSync(PUBLIC_DIR, outDir, { recursive: true }); const exp = expected(t); // ① config.js:產物,不是原始碼(public/ 裡不留) writeFileSync(join(outDir, 'config.js'), exp.configJs, 'utf8'); // ② console 的 VIEWS/HOME:public/ 裡那兩行只是本機 preview 的預設值 const consolePath = join(outDir, 'console', 'index.html'); let html = readFileSync(consolePath, 'utf8'); for (const [re, line, what] of [ [/^[ \t]*var VIEWS = .*$/m, exp.viewsLine, 'VIEWS'], [/^[ \t]*var HOME = .*$/m, exp.homeLine, 'HOME'], ]) { if (!re.test(html)) { throw new Error( `產物覆寫沒命中:console/index.html 找不到 ${what} 那一行 ⇒ 中止部署。\n` + '(前端改版把那行換了寫法時會發生。宣告值寫不進去就不准推——這正是 2026-08-08 事故的形狀。)', ); } html = html.replace(re, line); } writeFileSync(consolePath, html, 'utf8'); return outDir; } /** * 產物閘:推之前,回頭讀「真的要被推上去的那些檔案」,確認=宣告值。 * 不看 deploy.mjs 自己印了什麼——只看磁碟上那份。 */ export function assertArtifact(t, outDir) { const exp = expected(t); const problems = []; const cfg = readFileSync(join(outDir, 'config.js'), 'utf8'); const gotApiBase = parseApiBase(cfg); if (gotApiBase !== t.apiBase) problems.push(`config.js 的 apiBase:宣告 ${t.apiBase},產物 ${gotApiBase}`); const html = readFileSync(join(outDir, 'console', 'index.html'), 'utf8'); const gotViews = html.match(/^[ \t]*var VIEWS = .*$/m)?.[0]; const gotHome = html.match(/^[ \t]*var HOME = .*$/m)?.[0]; if (gotViews !== exp.viewsLine) problems.push(`console VIEWS:宣告 ${exp.viewsLine.trim()},產物 ${gotViews?.trim()}`); if (gotHome !== exp.homeLine) problems.push(`console HOME:宣告 ${exp.homeLine.trim()},產物 ${gotHome?.trim()}`); // 世代閘(產物側):注入不得改動世代相關位元組 const src = generationOfDir(PUBLIC_DIR); const art = generationOfDir(outDir); if (src.digest !== art.digest) { problems.push(`產物世代指紋 ${art.digest.slice(0, 12)} ≠ public/ 的 ${src.digest.slice(0, 12)}(注入改到了不該改的位元組)`); } // 世代閘(內容側,沿用 t160 的文字指紋——擋「整份 public 被換成舊代」) // // 🔴 只看「使用者看得到的內容」,比對前先剝掉 HTML 註解。 // 2026-08-08 實撞:原版直接對全文比對「登記新庫」,而 66f1b59(08-03)在 portal 裡 // 加了一則**說明「已經把登記新庫拿掉了」的註解** ⇒ 這道閘從那天起每次都誤判, // `npm run deploy:personal` 連續五天推不出去、而錯誤訊息說的是「你的 UI 是舊代」。 // ⇒ 手工維護的關鍵字清單會腐爛,這就是實例;世代的主判準因此改用位元組指紋, // 這道文字閘只留來擋「整份 public 被換成舊代」,且必須剝註解才不會自傷。 const portalRaw = readFileSync(join(outDir, 'portal', 'index.html'), 'utf8'); const portal = portalRaw.replace(//g, ''); if (!portal.includes('不需要人工新增') || portal.includes('登記新庫')) { problems.push('portal/index.html 不是現行世代(可見內容缺「不需要人工新增」或仍有「登記新庫」)'); } return { ok: problems.length === 0, problems, generation: art.digest }; } /** 部署狀態記錄檔(只在「線上實測通過」之後才寫,見 deploy.mjs)。 */ export const STATE_FILE = join(ROOT, '.deploy-state.json'); export function readState() { try { return JSON.parse(readFileSync(STATE_FILE, 'utf8')); } catch { return {}; } } export function writeState(name, record) { const state = readState(); state[name] = record; writeFileSync(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); }