From e730b3f831b3a5ba7ce731f23b8959152d192124 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 8 Aug 2026 20:14:20 +0800 Subject: [PATCH] =?UTF-8?q?console-ui=20=E9=83=A8=E7=BD=B2=EF=BC=9A?= =?UTF-8?q?=E5=AE=A3=E5=91=8A=E5=80=BC=E5=8F=AA=E8=A7=A3=E8=AE=80=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=EF=BC=8C=E4=B8=A6=E8=A3=9C=E4=B8=8A=E7=B7=9A=E4=B8=8A?= =?UTF-8?q?=E5=AF=A6=E6=B3=81=E9=A9=97=E8=AD=89=EF=BC=88WIP=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同源於 2026-08-08 的 apiBase 事故:部署腳本把 apiBase/profile 印在 終端機上,卻沒有寫進推上去的產物 ⇒ 印對的、推錯的。 根因比想像深:deploy.targets.json 原本靠 build.mjs 在 build 期把 profile/apiBase 烤進產物,但 t160(e744ad1)清世代債時把 build.mjs 整支刪掉改成直接託管 public/,沒有人接手「把宣告值寫進產物」這件事 ⇒ 前兩次事故的解等於被還原,只剩下「印出來給人看」。 本次落地: - targets.mjs:宣告值的唯一讀取點,把「一個目標展開成期望的產物長相」 定死成函式,供部署/驗證共用同一個答案,杜絕三邊各自解讀而漂移。 缺 apiBase/accountId/verifyUrls/未定義的 profile 一律拒絕部署。 - verify-live.mjs:獨立可跑,驗「線上網址真的在用的組態」=「宣告值」。 - deploy.targets.json:補 _profiles(profile → views/home)與 verifyUrls。 WIP:deploy.mjs 尚未改接 targets.mjs,public/config.js 尚未退役。 Co-Authored-By: Claude Opus 5 --- console-ui/deploy.targets.json | 41 +++++++++-- console-ui/scripts/targets.mjs | 68 ++++++++++++++++++ console-ui/scripts/verify-live.mjs | 111 +++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 console-ui/scripts/targets.mjs create mode 100644 console-ui/scripts/verify-live.mjs diff --git a/console-ui/deploy.targets.json b/console-ui/deploy.targets.json index d6d28f3..41c2743 100644 --- a/console-ui/deploy.targets.json +++ b/console-ui/deploy.targets.json @@ -1,22 +1,53 @@ { "_readme": [ - "部署目標定義檔(leo 2026-07-22 立)。一個目標=一組『帳號+profile+apiBase+專案名』。", + "部署目標定義檔(leo 2026-07-22 立)。一個目標=一組『帳號+profile+apiBase+專案名+對外網址』。", "", - "為什麼要這個檔:5a16484 把 UI 搬 CF Pages 後,這些值從 worker 環境變數變成 build 期參數。", - "誰部署誰要記得帶 → 帶漏了就退回預設,而預設值對兩邊都不對。今天實際踩到的:", + "為什麼要這個檔:5a16484 把 UI 搬 CF Pages 後,這些值從 worker 環境變數變成部署期參數。", + "誰部署誰要記得帶 → 帶漏了就退回預設,而預設值對兩邊都不對。實際踩過的:", " · demo 站漏 CONSOLE_PROFILE=rag → 顯示個人版 7 頁駕駛艙(leo 看到『Mira 介面』的真因)", " · 兩站都漏 ARCRUN_API_BASE → apiBase 空字串 → 前端打自己回 405 → 登不進去", " · 兩個帳號有同名 arcrun-console-ui 專案,wrangler 又登入在 uncle6", " → 不指定帳號直接 deploy 會部到 demo 站上(差點蓋掉)", "", + "🔴 第四次(2026-08-08 發現,同一種病換了形式):", + " 上面三次的『解』是 deploy.targets.json + build.mjs 在 build 時把 profile/apiBase", + " 烤進產物。但 t160(e744ad1)為了清世代債把 build.mjs 整支刪掉、改成直接託管 public/,", + " **沒有人把『把宣告值寫進產物』這件事接手過去** ⇒ deploy.mjs 照樣在終端機印", + " 『profile:full / apiBase:…leo21c…』,推上去的卻是 public/config.js 裡凍住的", + " cypher.arcrun.dev + 凍在 4 頁的 VIEWS。也就是說:", + " **`npm run deploy:personal` 會把個人站的 API 打到企業 demo 的後端、頁面砍成 4 頁**,", + " 而終端機從頭到尾顯示『成功』。(第三次的 accountId 是靠 env 傳的,倖存;前兩次的解等於被還原。)", + "", + " → 現在的規矩:**產物由 deploy.mjs 依本檔即時產生(staging 目錄),", + " 推之前驗產物、推之後驗線上網址**。public/ 裡不再放任何跟目標有關的值。", + " · public/config.js 已刪除——它是產物不是原始碼(自架站的 /config.js 由", + " arcrun-rag 的 build-ui-bundle 動態產生,不吃這個檔)", + " · public/console/index.html 的 VIEWS/HOME 只是本機 preview 的預設值,", + " 部署時一律被 _profiles 覆寫,覆寫沒命中就中止部署", + "", "版本差異(leo 2026-07-22 定調):頁面都存在,由 profile 決定顯示哪些。", " personal(full) 個人版:7 頁全開,落地駕駛艙", " enterprise(rag) 企業版:只留 搜尋/工作流/設定/card,落地搜尋頁", " 未來擴充:個人版新用戶上限 1、知識庫權限不可用 → 加在對應目標的欄位裡,別再散進部署指令。", "", - "用法:npm run deploy:personal / npm run deploy:enterprise" + "用法:npm run deploy:personal / npm run deploy:enterprise", + " npm run deploy:personal -- --dry-run (只產出並驗產物,不推)", + " npm run verify (不部署,只驗線上各站現況=宣告值)" ], + "_profiles": { + "full": { + "description": "個人版:7 頁全開,落地駕駛艙", + "views": ["cockpit", "search", "card", "workflows", "creds", "inbox", "settings"], + "home": "cockpit" + }, + "rag": { + "description": "企業版:搜尋/card/工作流/設定,落地搜尋頁", + "views": ["search", "card", "workflows", "settings"], + "home": "search" + } + }, + "personal": { "description": "leo 私人實例(原 Mira)。入口 mira.uncle6.me → leo21c worker。", "accountId": "51a01bfa2665bd7bc3fd080dc40cf3e1", @@ -24,6 +55,7 @@ "profile": "full", "brand": "Arcrun", "apiBase": "https://arcrun-cypher-executor.leo21c.workers.dev", + "verifyUrls": ["https://mira.uncle6.me", "https://arcrun-console-ui.pages.dev"], "limits": { "maxUsers": 1, "libraryPermissions": false @@ -37,6 +69,7 @@ "profile": "rag", "brand": "Arcrun", "apiBase": "https://cypher.arcrun.dev", + "verifyUrls": ["https://rag-demo.arcrun.dev"], "limits": { "maxUsers": null, "libraryPermissions": true diff --git a/console-ui/scripts/targets.mjs b/console-ui/scripts/targets.mjs new file mode 100644 index 0000000..e21f132 --- /dev/null +++ b/console-ui/scripts/targets.mjs @@ -0,0 +1,68 @@ +/** + * targets.mjs — 部署目標的唯一讀取點(deploy.mjs 與 verify-live.mjs 共用)。 + * + * 存在的理由:宣告值(deploy.targets.json)只准被解讀一次。 + * 「部署時印在終端機的值」「寫進產物的值」「事後驗線上的值」若各自去讀、各自算, + * 三者就會漂移——2026-08-08 那場事故的形狀正是「印的是 A、推的是 B」。 + * 這支把「一個目標展開成期望的產物長相」定死成一個函式,三邊共用同一個答案。 + */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); + +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('_')); + return { raw, profiles, names }; +} + +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; + } + 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; +} diff --git a/console-ui/scripts/verify-live.mjs b/console-ui/scripts/verify-live.mjs new file mode 100644 index 0000000..802c268 --- /dev/null +++ b/console-ui/scripts/verify-live.mjs @@ -0,0 +1,111 @@ +/** + * 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✅ 所有目標:線上組態=宣告值。'); +}