e730b3f831
同源於 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 <noreply@anthropic.com>
69 lines
3.0 KiB
JavaScript
69 lines
3.0 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|