t160 世代債清除(leo:「如果你會搞不清楚,就把錯的東西刪掉」)——repo 只留一個 UI 世代
事故鏈(07-31 深夜,leo 刷新撞到):t159 重打包 staging bundle 時,build-ui-bundle 吃了 feat/step3 分支的 console-ui/public/——該分支基底早於 maind7fab7c「拿掉登記新庫」,public 停在舊世代 ⇒ youlin 實例 UI 被打回被淘汰的 「登記新庫」人工表單典範(07-27 mistakes 已記帳的 src/public 雙世代債, 這次由「分支副本停舊代」路徑引爆——債沒還,任何一個部署路徑都會引爆它)。 刪掉的(舊世代,git rm 實體刪除): - console-ui/src/(console.ts/console-dashboard.ts/portal-ui.ts)=07-22 從 cypher-executor 搬出的 renderer 快照,自 07-27 起與 public/ 真身脫鉤、只會 重產舊 UI - console-ui/scripts/build.mjs=從上述舊 renderer 重產 public 的 build 步驟 (deploy.mjs 原自動跑它=引爆器本體) - cypher POST /portal/admin/libraries(人工建庫端點)=死端點(e28e190 起 UI 零呼叫;leo 規格「要直通 daemon,同步,沒有登記這回事」)。GET 列表/PATCH 管理/t159 daemon 自動登記照舊 新唯一源:console-ui/public/=手改演進的真身(本 commit 同步到 maine28e190世代+註解字樣改寫達成「登記新庫」全檔 0 命中);deploy.mjs 改為直接託管 public(無 build 步)+世代閘(缺「不需要人工新增」或含「登記新庫」拒部署)。 驗:tsc 全綠;deploy.mjs node --check 過;重打 ui bundle 242KB 過世代閘, 指紋「登記新庫」=0/「不需要人工新增」=1。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* console-ui build — 把 cypher-executor 的三支 UI renderer 在「建置時」跑一次,
|
||||
* 產出純靜態 HTML 到 public/,交給 Cloudflare Pages 託管。
|
||||
*
|
||||
* 為什麼這樣做(cypher-ui-split 第一刀):
|
||||
* 原本 console/portal/dashboard 的 HTML 由 cypher-executor Worker 在「每次請求時」
|
||||
* 用 template literal 組出來 → 5,240 行 UI 字串永遠躺在 Worker bundle 裡(748KB),
|
||||
* 連 /health 這種什麼都不做的請求都要付 5-7ms CPU(免費層上限 10ms)。
|
||||
* UI 是靜態的(單檔 HTML+原生 JS、零外部資源),本來就該待在 Pages。
|
||||
*
|
||||
* 保持原特性(leo 反覆強調簡化):
|
||||
* - 零打包工具、零 npm 依賴:本檔只用 node 內建 fs/path,正則抽出 renderer 的
|
||||
* template literal 後求值。不引入 esbuild/vite/rollup。
|
||||
* - 產出仍是「單檔 HTML+原生 JS hash routing、零外部資源」。
|
||||
*
|
||||
* 唯一的行為差異=API base:
|
||||
* 原本 UI 與 API 同源,fetch 全用相對路徑('/kbdb/search')。搬上 Pages 後跨網域,
|
||||
* 故注入 window.ARCRUN_API_BASE,並把 fetch 的相對路徑改成 API_BASE + path。
|
||||
* 見下方 rewriteFetchPaths()。
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..');
|
||||
const SRC = join(ROOT, '..', 'cypher-executor', 'src');
|
||||
const OUT = join(ROOT, 'public');
|
||||
|
||||
// ── 建置期組態(原本是 Worker 的 env var,現在是建置參數)────────────────
|
||||
// Pages 是靜態站,沒有 per-request env;品牌/profile 這類「一個部署一個值」的
|
||||
// 設定改在建置時決定(要換值=重跑 build 再部署,符合靜態站模型)。
|
||||
// 具名部署目標(deploy.targets.json):一個目標=帳號+profile+apiBase 綁在一起。
|
||||
// 帶 DEPLOY_TARGET=personal|enterprise 就套用該組值;個別環境變數仍可覆蓋(除錯用)。
|
||||
// 立此檔的原因見 deploy.targets.json 的 _readme——散在部署指令裡的參數帶漏過三次。
|
||||
const TARGET_NAME = process.env.DEPLOY_TARGET || '';
|
||||
let TARGET = {};
|
||||
if (TARGET_NAME) {
|
||||
const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
|
||||
TARGET = targets[TARGET_NAME];
|
||||
if (!TARGET) {
|
||||
const names = Object.keys(targets).filter((k) => !k.startsWith('_'));
|
||||
throw new Error(`未知的 DEPLOY_TARGET:"${TARGET_NAME}"。可用:${names.join(' / ')}`);
|
||||
}
|
||||
console.log(`部署目標:${TARGET_NAME} — ${TARGET.description}`);
|
||||
}
|
||||
|
||||
const CFG = {
|
||||
brand: process.env.CONSOLE_BRAND || TARGET.brand || 'Arcrun',
|
||||
profile: process.env.CONSOLE_PROFILE || TARGET.profile || 'full',
|
||||
registryBase: process.env.REGISTRY_BASE || 'https://registry.arcrun.dev',
|
||||
sourceWebBase: process.env.PORTAL_SOURCE_WEB_BASE || '',
|
||||
// API base 走 runtime 注入(見 public/config.js),這裡只放預設值
|
||||
apiBase: process.env.ARCRUN_API_BASE || TARGET.apiBase || '',
|
||||
};
|
||||
|
||||
/**
|
||||
* 讀 TS 原始碼並取出整個 renderer 函式的**函式主體**(不只 template literal)。
|
||||
*
|
||||
* 取整個 body 而非只取反引號區塊,是因為 renderer 在 return 之前會先算區域變數
|
||||
* (如 console.ts 的 rag/views/home 由 profile 推導)。只搬模板=把那段推導邏輯
|
||||
* 複製一份到本檔=雙份真相會漂移。連 body 一起求值 → 推導邏輯永遠只有一份。
|
||||
*/
|
||||
/**
|
||||
* renderer 原始檔的位置:本專案 `console-ui/src/` 優先,找不到才回退 cypher-executor。
|
||||
*
|
||||
* 為什麼要這層(2026-07-22 修):`5a16484` 把 UI 搬出 cypher-executor 時,
|
||||
* **刪了 console.ts / portal-ui.ts 卻只搬走 build 產物(HTML),原始檔沒跟著搬**
|
||||
* → build.mjs 讀不到來源,`npm run build` 從那天起就 ENOENT 死掉,
|
||||
* 線上 HTML 是刪檔前烤好的、之後再也無法重建(profile 改了也不會生效)。
|
||||
* 現已從 git 撈回放進 console-ui/src/——UI 原始碼跟著 UI 專案走,才是那一刀的原意。
|
||||
* console-dashboard.ts 仍在 cypher-executor(它同時含 API),故保留回退路徑。
|
||||
*/
|
||||
function resolveSource(file) {
|
||||
const local = join(ROOT, 'src', file.replace(/^routes\//, ''));
|
||||
if (existsSync(local)) return local;
|
||||
return join(SRC, file);
|
||||
}
|
||||
|
||||
function extractRendererBody(file, fnName) {
|
||||
const code = readFileSync(resolveSource(file), 'utf8');
|
||||
const start = code.indexOf(`function ${fnName}(`);
|
||||
if (start < 0) throw new Error(`找不到 ${fnName} in ${file}`);
|
||||
const braceStart = code.indexOf('{', code.indexOf(')', start));
|
||||
if (braceStart < 0) throw new Error(`${fnName} 找不到函式主體`);
|
||||
// 掃到配對的收尾大括號;需略過字串/template literal/註解裡的括號
|
||||
let i = braceStart + 1;
|
||||
let depth = 1;
|
||||
let mode = null; // null | "'" | '"' | '`' | 'line' | 'block'
|
||||
let tplDepth = 0;
|
||||
while (i < code.length && depth > 0) {
|
||||
const ch = code[i];
|
||||
const nx = code[i + 1];
|
||||
if (mode === null) {
|
||||
if (ch === '\\') { i += 2; continue; }
|
||||
if (ch === '/' && nx === '/') { mode = 'line'; i += 2; continue; }
|
||||
if (ch === '/' && nx === '*') { mode = 'block'; i += 2; continue; }
|
||||
if (ch === "'" || ch === '"') { mode = ch; i++; continue; }
|
||||
if (ch === '`') { mode = '`'; tplDepth = 0; i++; continue; }
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (mode === 'line') { if (ch === '\n') mode = null; i++; continue; }
|
||||
if (mode === 'block') { if (ch === '*' && nx === '/') { mode = null; i += 2; continue; } i++; continue; }
|
||||
if (ch === '\\') { i += 2; continue; }
|
||||
if (mode === '`') {
|
||||
// template literal 內的 ${ … } 是真程式碼,其中的引號/括號要照常計數才不會誤判收尾
|
||||
if (ch === '$' && nx === '{') { tplDepth++; i += 2; continue; }
|
||||
if (ch === '}' && tplDepth > 0) { tplDepth--; i++; continue; }
|
||||
if (ch === '`' && tplDepth === 0) { mode = null; i++; continue; }
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === mode) mode = null;
|
||||
i++;
|
||||
}
|
||||
// 去掉 TS 的型別註記(本 body 只有 `const x: T =` 這種簡單形態)
|
||||
return code.slice(braceStart + 1, i - 1).replace(/\bconst\s+(\w+):\s*[\w<>[\]|]+\s*=/g, 'const $1 =');
|
||||
}
|
||||
|
||||
/** 取出 lib/taipei-time.ts 匯出的 TAIPEI_CLIENT_JS 字串常數(UI 內嵌的客戶端時間工具)。 */
|
||||
function extractTaipeiClientJs() {
|
||||
const code = readFileSync(join(SRC, 'lib', 'taipei-time.ts'), 'utf8');
|
||||
// 形態=字串陣列 .join('\n')(見 lib/taipei-time.ts),直接求值該陣列表達式
|
||||
const m = code.match(/export const TAIPEI_CLIENT_JS\s*=\s*(\[[\s\S]*?\]\.join\('\\n'\));/);
|
||||
if (!m) throw new Error('找不到 TAIPEI_CLIENT_JS');
|
||||
return new Function(`return ${m[1]};`)();
|
||||
}
|
||||
|
||||
/**
|
||||
* 求值 renderer 函式主體。用 new Function 而非 eval——只餵建置期組態,
|
||||
* 輸入是本 repo 自己的原始碼(非使用者輸入),無注入面。
|
||||
*/
|
||||
function render(body, vars) {
|
||||
const names = Object.keys(vars);
|
||||
const fn = new Function(...names, body);
|
||||
return fn(...names.map((n) => vars[n]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 UI 內原生 JS 的相對路徑 fetch 改成打 API base。
|
||||
*
|
||||
* 只改 `fetch('/...` 與 `fetch("/...`(開頭是單斜線=同源絕對路徑)這一種形態,
|
||||
* 其餘(fetch(url, …) 這類變數形式)另由各檔的 url 組法在下面單獨處理。
|
||||
*/
|
||||
function rewriteFetchPaths(html, file) {
|
||||
// ① fetch('/xxx → fetch(API_BASE + '/xxx
|
||||
let out = html.replace(/fetch\((['"])\/(?!\/)/g, 'fetch(API_BASE + $1/');
|
||||
// ② 變數式 fetch(url, ...):url 由上方 var url = '/kbdb/search?...' 組成 →
|
||||
// 把這類「以單斜線開頭的路徑字面值指派」也補上 API_BASE
|
||||
out = out.replace(/(\bvar\s+url\s*=\s*)(['"])\/(?!\/)/g, '$1API_BASE + $2/');
|
||||
// ③ portal 的 adminApi(method, path, body):path 由呼叫端傳字面值進來,①②
|
||||
// 都掃不到(8 個呼叫點)。在 helper 內部補前綴=一處修好全部,不必改 8 個呼叫點。
|
||||
out = out.replace(
|
||||
/(function adminApi\(method, path, body\) \{)/,
|
||||
'$1\n path = API_BASE + path;'
|
||||
);
|
||||
|
||||
// 防呆:搬完後不該再有「直接 fetch 同源相對路徑」的殘留。掃到就讓建置失敗,
|
||||
// 免得漏網的呼叫點在 Pages 上打到 Pages 自己(404)才被發現。
|
||||
// 註:adminApi 的呼叫端仍是相對路徑字面值——那是對的,前綴由 helper 內部(③)加。
|
||||
const unprefixed = [...out.matchAll(/fetch\((['"])\/(?!\/)[^'"]*/g)].map((m) => m[0]);
|
||||
if (unprefixed.length) {
|
||||
throw new Error(
|
||||
`${file}:有 ${unprefixed.length} 個相對路徑 fetch 沒被改寫成 API_BASE:\n ` +
|
||||
[...new Set(unprefixed)].join('\n ')
|
||||
);
|
||||
}
|
||||
// adminApi 形態存在時,必須確認 helper 已被加上前綴(否則 8 個呼叫點全會打錯家)
|
||||
if (/function adminApi\(method, path, body\)/.test(out) && !/path = API_BASE \+ path;/.test(out)) {
|
||||
throw new Error(`${file}:偵測到 adminApi helper 但前綴注入失敗`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 在頁面 <head> 注入 config.js(runtime 決定 API base),並定義 API_BASE 供內嵌 JS 用。 */
|
||||
function injectApiBase(html) {
|
||||
const snippet = `<script src="/config.js"></script>
|
||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || ${JSON.stringify(CFG.apiBase)};</script>`;
|
||||
const withCfg = html.replace('</head>', `${snippet}\n</head>`);
|
||||
// 內嵌的 IIFE 裡宣告 API_BASE(各頁的主 <script> 都是 (function(){ … })() 形態)
|
||||
return withCfg.replace(
|
||||
/<script>\s*\(function\s*\(\)\s*\{/,
|
||||
'<script>\n(function () {\n var API_BASE = window.ARCRUN_API_BASE || \'\';'
|
||||
);
|
||||
}
|
||||
|
||||
function build(name, file, fnName, vars) {
|
||||
const body = extractRendererBody(file, fnName);
|
||||
let html = render(body, vars);
|
||||
html = rewriteFetchPaths(html, name);
|
||||
html = injectApiBase(html);
|
||||
const dest = join(OUT, name);
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
writeFileSync(dest, html, 'utf8');
|
||||
console.log(` ${name.padEnd(24)} ${(Buffer.byteLength(html) / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
|
||||
const TAIPEI_CLIENT_JS = extractTaipeiClientJs();
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
console.log('console-ui build →', OUT);
|
||||
|
||||
// /console — Admin Console 完整版(console.ts renderConsoleHtml)
|
||||
build('console/index.html', 'routes/console.ts', 'renderConsoleHtml', {
|
||||
registryBase: CFG.registryBase,
|
||||
brand: CFG.brand,
|
||||
profile: CFG.profile,
|
||||
TAIPEI_CLIENT_JS,
|
||||
});
|
||||
|
||||
// /portal — RAG Portal(portal-ui.ts renderPortalHtml)
|
||||
build('portal/index.html', 'routes/portal-ui.ts', 'renderPortalHtml', {
|
||||
brand: CFG.brand,
|
||||
sourceWebBase: CFG.sourceWebBase,
|
||||
TAIPEI_CLIENT_JS,
|
||||
});
|
||||
|
||||
// /console/dashboard — 駕駛艙(console-dashboard.ts renderDashboardHtml)
|
||||
build('console/dashboard/index.html', 'routes/console-dashboard.ts', 'renderDashboardHtml', {
|
||||
brand: CFG.brand,
|
||||
TAIPEI_CLIENT_JS,
|
||||
});
|
||||
|
||||
// config.js:部署後可直接改這一檔切 API 目標,不必重 build
|
||||
writeFileSync(
|
||||
join(OUT, 'config.js'),
|
||||
`// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
|
||||
window.ARCRUN_CONFIG = { apiBase: ${JSON.stringify(CFG.apiBase)} };
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
console.log(' config.js');
|
||||
console.log('done.');
|
||||
@@ -39,8 +39,17 @@ console.log(` apiBase :${t.apiBase}\n`);
|
||||
|
||||
const env = { ...process.env, DEPLOY_TARGET: name, CLOUDFLARE_ACCOUNT_ID: t.accountId };
|
||||
|
||||
const build = spawnSync('node', [join(ROOT, 'scripts', 'build.mjs')], { stdio: 'inherit', env });
|
||||
if (build.status !== 0) process.exit(build.status ?? 1);
|
||||
// t160(leo 07-31:「如果你會搞不清楚,就把錯的東西刪掉」):build 步驟已隨舊世代
|
||||
// src/ 一起 git rm——public/ 是唯一世代真身(手改演進),deploy=直接託管它。
|
||||
// 病史:src/(舊代 renderer 快照)與 public/(新代真身)並存,deploy 自動跑 build
|
||||
// 從舊 src 重產 public ⇒ 任何一次部署都可能把 UI 打回舊世代(07-27 記帳、07-31 引爆:
|
||||
// t159 重打包用了舊 public 的分支副本,leo 刷新看到被淘汰的「登記新庫」表單)。
|
||||
// 世代閘:部署前驗 public 指紋,舊世代(缺新文案/含人工建庫表單)直接拒部。
|
||||
const portalHtml = readFileSync(join(ROOT, 'public', 'portal', 'index.html'), 'utf8');
|
||||
if (!portalHtml.includes('不需要人工新增') || portalHtml.includes('登記新庫')) {
|
||||
console.error('✘ 世代閘:public/portal/index.html 不是現行世代(缺「不需要人工新增」或含「登記新庫」)——拒絕部署舊 UI。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --commit-dirty:本地部署常有未提交變更,不因此中斷
|
||||
const deploy = spawnSync(
|
||||
|
||||
Reference in New Issue
Block a user