fix(cli): 更新完還看得到版本號——CLI 重部署不再把版本標籤(和你的設定)洗掉
leo 08-12 實撞:更新完 leo21c,Portal 設定頁的「版本」變成 「無法讀取目前版本(知識庫服務可能正在啟動)」。版本號是 leo 唯一的驗收介面, 看不到就等於他無法自己確認任何一次更新有沒有生效。 根因(Arcrun#106):`bundle_version` 來自部署時注入的 plain_text var `ARCRUN_BUNDLE_VERSION`,而**只有安裝器會注入**。wrangler deploy 是整份覆蓋, toml 沒寫的 var 直接消失 ⇒ CLI 更新那條路每跑一次就把標籤洗掉一次。 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),沒修「櫃子上的標籤」。 修法(兩種 var 走相反的規則,這是本次的判斷): · 設定類 var = 使用者實例的事實 → **沿用**(讀綁定時同一份回應就帶回來,不多打 API) ——把 #97「已部署的 worker 上綁著什麼就是事實」原封不動套用到 plain_text var。 · 版本標籤 = 這份成品的屬性 → **每趟重烙,絕不沿用舊值**。 沿用舊值會得到一個永遠停在安裝當天的假標籤——比沒有標籤更糟, 因為它會讓人以為驗收過了。 版號取部署當下發行頻道公告的 release(Portal/daemon 就是拿它當「最新版」比), 另外把**真正部署的 commit** 一起烙上去(/health 多吐 `bundle_commit`)→ 漂掉查得出來。 查不到 release 就誠實退成 `YYYY-MM-DD+<commit7>`,不掰一個 semver 假裝已是最新。 順帶(都是同一條路上的東西): · ref 先解析成 commit sha 再用 sha 下載 archive——不可變,順手解掉 branch tarball 被快取的老病 · Portal 版本行接受帶 build metadata 的 semver(`1.4.41+d61` 這種先前一律被當成「較舊版本」) · cli 測試在 node 22 上本來一支都跑不起來(.js→.ts 解析 + parameter property),補上 resolve hook ——#97 那份「使用者的東西還在不在」的迴歸守衛也在其中,跑不起來的守衛等於沒有守衛 · types.ts 的 ARCRUN_BUNDLE_VERSION 重複宣告(TS2300)併回一處 驗證見 PR:cli 49/49 綠、cypher health 4/4 綠、Portal 版本行原始碼實跑五種情境、 對真實已部署 worker 的唯讀 dry-run。**未做**:真實實例上的 acr update 端到端 (本機唯一有憑證的帳號是 leo21c=紅線禁碰,youlin 無憑證)。 Refs: Leo/Arcrun#106, #97, #95 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -12,7 +12,7 @@
|
||||
"build:harness": "node scripts/build-harness-skill.mjs",
|
||||
"check:harness": "node scripts/check-harness-generation.mjs",
|
||||
"dev": "tsc --watch",
|
||||
"test": "node --test \"tests/**/*.test.ts\"",
|
||||
"test": "node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"",
|
||||
"prepublishOnly": "npm run build && chmod +x dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+21
-2
@@ -170,10 +170,13 @@ export class CfAccountClient implements ResourceApi {
|
||||
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
|
||||
const res = await this.cfRaw<{ bindings?: RawWorkerBinding[] }>(path);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return { deployed: false, bindings: [] };
|
||||
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
|
||||
throw new Error(`讀 ${script} 綁定失敗:${res.error}`);
|
||||
}
|
||||
return { deployed: true, bindings: normalizeBindings(res.result?.bindings ?? []) };
|
||||
const raw = res.result?.bindings ?? [];
|
||||
// #106:同一份回應裡也帶著 plain_text var(實測 CF `/settings` 會回 `text` 值)。
|
||||
// 舊版只挑資源類、把 var 整批丟掉 → 重部署等於把它們洗掉。
|
||||
return { deployed: true, bindings: normalizeBindings(raw), vars: normalizeVars(raw) };
|
||||
}
|
||||
|
||||
/** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
|
||||
@@ -234,6 +237,22 @@ interface RawWorkerBinding {
|
||||
id?: string;
|
||||
database_id?: string;
|
||||
index_name?: string;
|
||||
/** `plain_text` 綁定的值(#106;secret_text 不會回值,本來就讀不到,也不該讀)。 */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 抽出已部署 worker 上的 `plain_text` var(#106)。
|
||||
*
|
||||
* 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被 CLI 搬來搬去;
|
||||
* wrangler deploy 不會動 secret,它們自己會留著)。
|
||||
*/
|
||||
function normalizeVars(raw: RawWorkerBinding[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const b of raw) {
|
||||
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */
|
||||
|
||||
+258
-5
@@ -98,6 +98,119 @@ function giteaToken(): string | undefined {
|
||||
return process.env.ARCRUN_GITEA_TOKEN || process.env.GITEA_TOKEN || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本標籤的「發行頻道」來源(Arcrun#106)。
|
||||
*
|
||||
* Portal 設定頁與 daemon `cloudVersionStale()` 都是拿**這支**回的 `release` 當「最新版」,
|
||||
* 再跟實例 `/health` 的 `bundle_version` 比。CLI 更新完若不烙一個同一把尺量得出來的版號,
|
||||
* 使用者就只會看到「無法讀取目前版本」或永遠「落後」。
|
||||
* fork/自架另有發行頻道者用 ARCRUN_RELEASE_API 覆蓋,不寫死。
|
||||
*/
|
||||
const ARCRUN_RELEASE_API = process.env.ARCRUN_RELEASE_API ?? 'https://install.arcrun.dev/api/latest';
|
||||
|
||||
/** CLI 自己負責注入 / 自己烙的 var——**不從已部署的 worker 沿用**(沿用會蓋掉這趟算出來的正解)。 */
|
||||
export const CLI_MANAGED_VARS = [
|
||||
'WORKER_SUBDOMAIN', // 由 ctx.workerSubdomain 注入
|
||||
'CF_ACCOUNT_ID', // 由 ctx.accountId 注入
|
||||
'MULTI_TENANT', // 由 selfHosted 注入
|
||||
'KBDB_BASE_URL', // 由 workerSubdomain 組
|
||||
'ARCRUN_BUNDLE_VERSION', // 版本標籤:每趟重烙,**絕不沿用舊值**(見 resolveBundleStamp)
|
||||
'ARCRUN_BUNDLE_COMMIT',
|
||||
] as const;
|
||||
|
||||
/** 烙版本標籤的那顆 worker(`/health` 就是它吐的)。其餘 worker 不需要版本標籤。 */
|
||||
export const VERSION_STAMP_WORKER = 'arcrun-cypher-executor';
|
||||
|
||||
/** 這趟部署要烙上去的版本標籤。 */
|
||||
export interface BundleStamp {
|
||||
/** 寫進 `ARCRUN_BUNDLE_VERSION`。 */
|
||||
version: string;
|
||||
/** 寫進 `ARCRUN_BUNDLE_COMMIT`(查得到才有)。 */
|
||||
commit?: string;
|
||||
/** 給人看的一句話(CLI 會印出來),說明這個版號是怎麼來的。 */
|
||||
note: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 算「這趟部署上去的東西,該叫幾版」(Arcrun#106)。
|
||||
*
|
||||
* 🔴 為什麼**不是沿用實例上原本那個值**:那個值描述的是**當時裝上去的那份程式碼**。
|
||||
* 更新完程式碼換了,標籤沒換 = 一個永遠停在安裝當天的假標籤——比沒有標籤更糟,
|
||||
* 因為 leo 會拿它當「我驗收過了」。版本標籤是**成品的屬性**,不是使用者的設定,
|
||||
* 所以它是唯一一個「不沿用、每趟重烙」的 var(其餘 plain_text var 一律沿用,見 preservedVars)。
|
||||
*
|
||||
* 誠實邊界(mindset §7,這段要留著):
|
||||
* - CLI 部的是 `ARCRUN_REPO@ref` 的**原始碼**,發行版號(semver)是**安裝器頻道**在發的,
|
||||
* 兩者不是同一套編號。這裡取的是「部署當下該頻道公告的 release」,
|
||||
* 語義=「我跟這個頻道的最新發行同源」,並**另外把真正的 commit 一起烙上去**
|
||||
* (`ARCRUN_BUNDLE_COMMIT`/`/health` 的 `bundle_commit`)→ 有沒有漂掉,看 commit 就查得出來。
|
||||
* - 查不到 release(離線/頻道掛了)→ **不猜、不掰**,退成 `YYYY-MM-DD+<commit7>` 這個
|
||||
* 舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」——
|
||||
* 那正是我們想要的:**寧可說不準,也不要假裝已是最新**。
|
||||
*/
|
||||
export async function resolveBundleStamp(
|
||||
ref: string,
|
||||
commit?: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<BundleStamp> {
|
||||
const short = commit ? commit.slice(0, 7) : ref;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const res = await fetchImpl(ARCRUN_RELEASE_API, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { release?: string } | null;
|
||||
const release = String(body?.release ?? '').trim();
|
||||
if (!/^\d+\.\d+\.\d+$/.test(release)) throw new Error(`發行頻道回的版號不是 semver(${release || '空'})`);
|
||||
return {
|
||||
version: release,
|
||||
commit,
|
||||
note: `${release}(發行頻道 ${ARCRUN_RELEASE_API}${commit ? `;實際部署 commit ${short}` : ''})`,
|
||||
};
|
||||
} catch (e) {
|
||||
const version = `${today}+${short}`;
|
||||
return {
|
||||
version,
|
||||
commit,
|
||||
note:
|
||||
`${version}(查不到發行版號:${e instanceof Error ? e.message : String(e)})` +
|
||||
`\n → 誠實標成 commit 版;Portal 會顯示成「較舊版本」而不是假裝已是最新。`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `ref`(branch / tag / sha)解析成確切的 commit sha(Arcrun#106)。
|
||||
*
|
||||
* 兩個用途:① 版本標籤要烙「真的部了哪個 commit」;② 解出來之後**直接用 sha 下載 archive**——
|
||||
* sha 是不可變的,順帶把 #13 P2 的「branch tarball 被中間層快取成舊的」整個病根拿掉。
|
||||
* 查不到就回 undefined(呼叫端退回原本的用 ref 下載,行為不變)——這條路徑不該讓更新失敗。
|
||||
*/
|
||||
export async function resolveGiteaCommit(
|
||||
ref: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<string | undefined> {
|
||||
const headers = buildDownloadHeaders();
|
||||
const tryUrls = [
|
||||
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/branches/${encodeURIComponent(ref)}`,
|
||||
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/commits?sha=${encodeURIComponent(ref)}&limit=1&stat=false`,
|
||||
];
|
||||
for (const url of tryUrls) {
|
||||
try {
|
||||
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(20_000) });
|
||||
if (!res.ok) continue;
|
||||
const body = (await res.json()) as
|
||||
| { commit?: { id?: string } }
|
||||
| Array<{ sha?: string }>
|
||||
| null;
|
||||
const sha = Array.isArray(body) ? body[0]?.sha : body?.commit?.id;
|
||||
if (typeof sha === 'string' && /^[0-9a-f]{7,64}$/i.test(sha)) return sha;
|
||||
} catch {
|
||||
/* 換下一種問法;全都問不到就回 undefined */
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 組 Gitea archive 下載 URL(純函式,好離線測 URL 組裝)。
|
||||
* Gitea archive API:`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。
|
||||
@@ -253,9 +366,12 @@ export async function downloadAndDeploy(
|
||||
const mode = opts.mode ?? 'update';
|
||||
const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
|
||||
// 1. 下載 + 解壓 Gitea archive tarball
|
||||
// #106:先把 ref 解析成確切 commit,**用 sha 下載**(不可變 → 順帶解掉 branch tarball 被快取的老問題),
|
||||
// 同一個 sha 稍後也會被烙成版本標籤。解不出來就照舊用 ref 下載(行為不變)。
|
||||
const commit = await resolveGiteaCommit(ref);
|
||||
let root: string;
|
||||
try {
|
||||
root = await downloadRepoTarball(ref);
|
||||
root = await downloadRepoTarball(commit ?? ref, commit ? ref : undefined);
|
||||
} catch (e) {
|
||||
return {
|
||||
implemented: true,
|
||||
@@ -310,6 +426,7 @@ export async function downloadAndDeploy(
|
||||
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
|
||||
const requirements: BindingRequirement[] = [];
|
||||
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
|
||||
const dirScript = new Map<string, string>(); // dir → worker script 名(#106:var 沿用要逐顆對號)
|
||||
for (const dir of allDirs) {
|
||||
const tomlPath = join(dir, 'wrangler.toml');
|
||||
if (!existsSync(tomlPath)) continue;
|
||||
@@ -318,12 +435,14 @@ export async function downloadAndDeploy(
|
||||
const preview = renderWranglerToml(raw, ctx, new Map());
|
||||
const parsed = parseWranglerRequirements(preview);
|
||||
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
|
||||
dirScript.set(dir, parsed.script);
|
||||
for (const b of parsed.bindings) {
|
||||
requirements.push({ ...b, worker: parsed.script });
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = new Map<string, ResolvedResource>();
|
||||
let liveVars = new Map<string, Record<string, string>>();
|
||||
if (requirements.length > 0) {
|
||||
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
|
||||
let plan;
|
||||
@@ -369,6 +488,7 @@ export async function downloadAndDeploy(
|
||||
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
|
||||
};
|
||||
}
|
||||
liveVars = plan.liveVars;
|
||||
console.log(chalk.green(' ✓'));
|
||||
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
|
||||
const created = [...resolved.values()].filter((r) => r.origin === 'created');
|
||||
@@ -407,6 +527,48 @@ export async function downloadAndDeploy(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2.8 var(plain_text):既有的沿用、版本標籤重烙(Arcrun#106)─────────────────
|
||||
//
|
||||
// 🔴 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),但 **var 這批「櫃子上的標籤」沒人管**:
|
||||
// wrangler deploy 是整份覆蓋,toml 沒寫的 var 直接消失。leo 2026-08-12 實撞的畫面
|
||||
// 「無法讀取目前版本(知識庫服務可能正在啟動)」就是 `ARCRUN_BUNDLE_VERSION` 被這樣洗掉的。
|
||||
//
|
||||
// 兩種 var 走**相反**的規則,這是本次的核心判斷:
|
||||
// · 設定類(PORTAL_MAIL_RELAY_BASE / CONSOLE_TENANT / …)=**使用者實例的事實** → 沿用
|
||||
// · 版本標籤(ARCRUN_BUNDLE_VERSION)=**這份成品的屬性** → 每趟重烙,沿用舊值就是假標籤
|
||||
//
|
||||
// 範圍註記:`liveVars` 來自資源解析那一趟讀到的 worker(=有資源綁定的那些:cypher/kbdb/mcp/registry)。
|
||||
// 純零件 worker 沒有資源綁定、不在那份名單裡 → 這裡不會沿用它們的 var。目前它們的 var 只有
|
||||
// toml 自己帶的 `COMPONENT_ID`,沒有東西可丟;若哪天有人往零件 worker 注入設定,要在這裡補讀。
|
||||
const extraVarsByDir = new Map<string, Record<string, string>>();
|
||||
let stamp: BundleStamp | undefined;
|
||||
if (dirScript.size > 0) {
|
||||
const needStamp = [...dirScript.values()].includes(VERSION_STAMP_WORKER);
|
||||
if (needStamp) {
|
||||
process.stdout.write(chalk.gray(' → 算這趟要烙上去的版本標籤...'));
|
||||
stamp = await resolveBundleStamp(ref, commit);
|
||||
console.log(chalk.green(' ✓'));
|
||||
console.log(chalk.gray(` ARCRUN_BUNDLE_VERSION = ${stamp.note}`));
|
||||
}
|
||||
const preservedTotal: string[] = [];
|
||||
for (const [dir, script] of dirScript) {
|
||||
const raw = tomlPreviews.get(dir);
|
||||
if (!raw) continue;
|
||||
const keep = preservedVars(liveVars.get(script), raw);
|
||||
for (const k of Object.keys(keep)) preservedTotal.push(`${script}:${k}`);
|
||||
const vars: Record<string, string> = { ...keep };
|
||||
if (stamp && script === VERSION_STAMP_WORKER) {
|
||||
vars.ARCRUN_BUNDLE_VERSION = stamp.version;
|
||||
if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit;
|
||||
}
|
||||
if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars);
|
||||
}
|
||||
if (preservedTotal.length > 0) {
|
||||
console.log(chalk.gray(` 沿用你實例上既有的 ${preservedTotal.length} 個設定值(var):`));
|
||||
for (const item of preservedTotal) console.log(chalk.gray(` = ${item}`));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
|
||||
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
|
||||
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
|
||||
@@ -422,7 +584,7 @@ export async function downloadAndDeploy(
|
||||
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
|
||||
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
|
||||
try {
|
||||
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir));
|
||||
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir), extraVarsByDir.get(dir));
|
||||
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
|
||||
const hash = dirContentHash(dir, ctx.accountId);
|
||||
if (manifest[label] === hash) {
|
||||
@@ -599,11 +761,13 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: str
|
||||
* 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過快取,每次抓到 ref 的最新內容。
|
||||
*
|
||||
* Arcrun#4:來源由 GitHub codeload 改為 Gitea archive API(走 GITEA_TOKEN,不寫死)。*/
|
||||
async function downloadRepoTarball(ref: string): Promise<string> {
|
||||
async function downloadRepoTarball(ref: string, fromRef?: string): Promise<string> {
|
||||
// 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。
|
||||
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const url = buildArchiveUrl(ref, bust);
|
||||
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${ref},約 10–30 秒,視網速)...`));
|
||||
// fromRef 有值 = ref 已被解析成 commit sha(#106),印出來讓人看得到「這趟到底部了哪個 commit」。
|
||||
const label = fromRef ? `${fromRef} → ${ref.slice(0, 7)}` : ref;
|
||||
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${label},約 10–30 秒,視網速)...`));
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
// 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball(#13 P2 假綠根因)。
|
||||
@@ -701,11 +865,91 @@ function injectWranglerConfig(
|
||||
ctx: DeployContext,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
original?: string,
|
||||
extraVars: Record<string, string> = {},
|
||||
): void {
|
||||
if (!existsSync(tomlPath)) return;
|
||||
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
|
||||
const toml = original ?? readFileSync(tomlPath, 'utf8');
|
||||
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved), 'utf8');
|
||||
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved, extraVars), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 挑出「這顆已部署的 worker 上有、但這版 toml 不會自己帶的」plain_text var(Arcrun#106)。
|
||||
*
|
||||
* 規則就一句:**已部署 worker 上掛著什麼 var,那就是事實**(#97 對資源講的那句話,
|
||||
* 原封不動套用在標籤上)。所以預設全部沿用,只有兩種例外:
|
||||
* ① `CLI_MANAGED_VARS`——這趟由 CLI 自己算(帳號 id/subdomain/單租戶旗標/版本標籤),
|
||||
* 沿用等於拿舊值蓋掉正解。
|
||||
* ② 值一模一樣的(toml 已經寫了同樣的值)——寫進去只是雜訊,略過。
|
||||
*
|
||||
* ⚠️ 這裡刻意**不**做「toml 有宣告就以 toml 為準」:那正是這次的病
|
||||
* ——repo toml 裡的 `CONSOLE_TENANT = "leo"`/`WORKER_SUBDOMAIN` 之類是**官方 prod 的值**,
|
||||
* 拿它蓋掉使用者實例上的值,就是「更新一次把人家的設定洗成官方預設」。
|
||||
*/
|
||||
export function preservedVars(
|
||||
live: Record<string, string> | undefined,
|
||||
toml: string,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!live) return out;
|
||||
const managed = new Set<string>(CLI_MANAGED_VARS);
|
||||
for (const key of Object.keys(live).sort()) {
|
||||
if (managed.has(key)) continue;
|
||||
if (!/^[A-Za-z0-9_]+$/.test(key)) continue; // 怪名字不碰(applyVars 也會擋,這裡先濾掉不誤報)
|
||||
if (readVar(toml, key) === live[key]) continue; // toml 已經是同一個值 → 不必動
|
||||
out[key] = live[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 讀 toml 裡某個 var 目前的值(只看未註解的行)。找不到回 undefined。 */
|
||||
function readVar(toml: string, key: string): string | undefined {
|
||||
const m = toml.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm'));
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
/** TOML basic string 轉義(值裡可能有引號/反斜線,例如網址或 JSON 片段)。 */
|
||||
function tomlEscape(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一組 var 寫進 toml 的 `[vars]`(Arcrun#106)。純函式。
|
||||
*
|
||||
* 三種既有狀態各自處理(比照 injectMultiTenant,同一種文字操作層級):
|
||||
* 1. 已有未註解的同名行 → 換值
|
||||
* 2. 只有被註解掉的同名行 → 取消註解並填值
|
||||
* 3. 都沒有 → 插在 `[vars]` header 下一行;連 `[vars]` 都沒有就在檔尾新開一段
|
||||
*/
|
||||
export function applyVars(toml: string, vars: Record<string, string>): string {
|
||||
let out = toml;
|
||||
for (const key of Object.keys(vars).sort()) {
|
||||
// 只接受合法的 var 名(CF 那側本來就是這個字集)。怪名字寧可不寫,也不要拿它去組正規式。
|
||||
if (!/^[A-Za-z0-9_]+$/.test(key)) continue;
|
||||
const value = tomlEscape(vars[key]);
|
||||
// 🔴 一律用「函式版 replace」:值裡若有 `$&`/`$1` 這種字元,字串版 replace 會把它當成
|
||||
// 反向參照展開,寫出來的就不是使用者那個值了。
|
||||
if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(out)) {
|
||||
out = out.replace(
|
||||
new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(".*)$`, 'm'),
|
||||
(_m, head: string, tail: string) => `${head}${value}${tail}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (new RegExp(`^\\s*#\\s*${key}\\s*=`, 'm').test(out)) {
|
||||
out = out.replace(
|
||||
new RegExp(`^(\\s*)#\\s*${key}\\s*=\\s*"[^"]*"(.*)$`, 'm'),
|
||||
(_m, indent: string, tail: string) => `${indent}${key} = "${value}"${tail}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (/^\s*\[vars\]\s*$/m.test(out)) {
|
||||
out = out.replace(/^(\s*\[vars\]\s*)$/m, (_m, header: string) => `${header}\n${key} = "${value}"`);
|
||||
continue;
|
||||
}
|
||||
out = `${out.replace(/\s*$/, '')}\n\n[vars]\n${key} = "${value}"\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -715,11 +959,15 @@ function injectWranglerConfig(
|
||||
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
|
||||
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
|
||||
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
|
||||
*
|
||||
* `extraVars`(Arcrun#106):這顆 worker 要**沿用的既有 var** + 這趟要**重烙的版本標籤**。
|
||||
* 預覽時不傳(vars 不影響資源需求解析,傳不傳都是同一份需求清單)。
|
||||
*/
|
||||
export function renderWranglerToml(
|
||||
toml: string,
|
||||
ctx: DeployContext,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
extraVars: Record<string, string> = {},
|
||||
): string {
|
||||
// cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain
|
||||
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
|
||||
@@ -770,6 +1018,11 @@ export function renderWranglerToml(
|
||||
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
|
||||
}
|
||||
|
||||
// 沿用的既有 var + 這趟的版本標籤(#106)。**放在所有 CLI 注入之後**:
|
||||
// CLI_MANAGED_VARS 已經在 preservedVars 排除掉,故這裡不會蓋掉上面剛算好的
|
||||
// WORKER_SUBDOMAIN / CF_ACCOUNT_ID / MULTI_TENANT / KBDB_BASE_URL。
|
||||
toml = applyVars(toml, extraVars);
|
||||
|
||||
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
|
||||
// 空 map = 預覽模式,這步什麼也不做。
|
||||
return applyResolvedBindings(toml, resolved);
|
||||
|
||||
@@ -41,6 +41,16 @@ export interface ScriptBindings {
|
||||
/** false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 */
|
||||
deployed: boolean;
|
||||
bindings: LiveBinding[];
|
||||
/**
|
||||
* 這顆 worker 現在掛著的 `plain_text` var(名 → 值)。
|
||||
*
|
||||
* 🔴 Arcrun#106:#97 只把「資源類」綁定當成事實沿用(KV/D1/Vectorize),
|
||||
* plain_text var 整批沒人管 ⇒ 重部署把它們洗成 repo toml 的預設值。
|
||||
* 最痛的一個是 `ARCRUN_BUNDLE_VERSION`(安裝器注入的版本標籤)——
|
||||
* 更新完就消失,Portal 設定頁變成「無法讀取目前版本」。
|
||||
* **保留了櫃子,沒保留櫃子上的標籤**。這個欄位就是那些標籤。
|
||||
*/
|
||||
vars?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */
|
||||
@@ -87,6 +97,14 @@ export interface ResourcePlan {
|
||||
create: PlannedCreate[];
|
||||
/** 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 */
|
||||
blockers: string[];
|
||||
/**
|
||||
* 每顆**已部署** worker 現在掛著的 plain_text var(script → 名/值)。未部署的不在裡面。
|
||||
*
|
||||
* Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡——
|
||||
* 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式
|
||||
* (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。
|
||||
*/
|
||||
liveVars: Map<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface ResolvedResource {
|
||||
@@ -137,11 +155,16 @@ export async function planResources(
|
||||
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
|
||||
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
|
||||
const live = new Map<string, LiveBinding[]>();
|
||||
const liveVars = new Map<string, Record<string, string>>();
|
||||
let readFailed = false;
|
||||
for (const script of scripts) {
|
||||
try {
|
||||
const res = await api.getScriptBindings(script);
|
||||
if (res.deployed) live.set(script, res.bindings);
|
||||
if (res.deployed) {
|
||||
live.set(script, res.bindings);
|
||||
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
|
||||
liveVars.set(script, res.vars ?? {});
|
||||
}
|
||||
} catch (e) {
|
||||
readFailed = true;
|
||||
blockers.push(
|
||||
@@ -242,7 +265,7 @@ export async function planResources(
|
||||
});
|
||||
}
|
||||
|
||||
return { adopt, create: shareSameResource(adopt, create, byKey), blockers };
|
||||
return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** `node --import ./tests/register-ts-hooks.mjs --test ...` 的進入點:註冊 ts-hooks.mjs。 */
|
||||
import { register } from 'node:module';
|
||||
|
||||
register('./ts-hooks.mjs', import.meta.url);
|
||||
@@ -493,7 +493,7 @@ test('CfAccountClient.getScriptBindings:404 = 還沒部署;其他錯誤要 t
|
||||
new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 })
|
||||
) as typeof fetch;
|
||||
const cf = new CfAccountClient('a', 't');
|
||||
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [] });
|
||||
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [], vars: {} });
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 })
|
||||
@@ -526,6 +526,8 @@ test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize
|
||||
{ kind: 'd1', binding: 'DB', value: 'db1' },
|
||||
{ kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' },
|
||||
]);
|
||||
// #106:plain_text 也要收下來(service 這種不認得的仍略過)。
|
||||
assert.deepEqual(res.vars, { ENVIRONMENT: 'production' });
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 測試用 resolve hook:把 `./x.js` 這種 import 指回同名的 `./x.ts`(Arcrun#106 附帶修復)。
|
||||
*
|
||||
* 為什麼需要:`src/` 內部的 import 一律寫成 `.js`(NodeNext 慣例,編譯後才會有那個檔),
|
||||
* 但測試是**直接載入 `src/**\/*.ts`**、不經過 tsc(`outDir: dist`,所以 `src/` 底下永遠不會有 .js)。
|
||||
* Node 的型別剝離不會自己把 `.js` 對回 `.ts` ⇒ 三份測試在 node 22 上**一支都跑不起來**
|
||||
* (`ERR_MODULE_NOT_FOUND: .../src/lib/cf-api.js`)——包含 #97 那份「使用者的東西還在不在」的迴歸守衛。
|
||||
* 跑不起來的守衛等於沒有守衛,所以這裡補上。
|
||||
*
|
||||
* 只在「預設解析失敗」時才動作,且只換副檔名 → 對本來就解析得到的環境(新版 node / 已編譯)零影響。
|
||||
*/
|
||||
export async function resolve(specifier, context, next) {
|
||||
try {
|
||||
return await next(specifier, context);
|
||||
} catch (err) {
|
||||
if (typeof specifier === 'string' && specifier.endsWith('.js')) {
|
||||
return next(specifier.slice(0, -3) + '.ts', context);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Arcrun#106 迴歸守衛 —— 「更新完,設定頁還看得到版本號,而且是**這次**的版本號」
|
||||
*
|
||||
* 2026-08-12 實害:leo 更新完 leo21c,Portal 設定頁的版本欄變成
|
||||
* 「無法讀取目前版本(知識庫服務可能正在啟動)」。
|
||||
* 根因:`ARCRUN_BUNDLE_VERSION` 是部署時注入的 plain_text var,**只有安裝器會注入**;
|
||||
* CLI 這條路重部署時 wrangler 整份覆蓋 toml,沒寫的 var 直接消失 ⇒ 標籤被洗掉。
|
||||
* #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),**沒修「櫃子上的標籤」**。
|
||||
*
|
||||
* 這份測試守兩件相反的事(本次的核心判斷):
|
||||
* · 設定類 var(安裝器注入的 PORTAL_MAIL_RELAY_BASE 之類)=使用者實例的事實 → **沿用**
|
||||
* · 版本標籤 ARCRUN_BUNDLE_VERSION =這份成品的屬性 → **每趟重烙,絕不沿用舊值**
|
||||
* (沿用舊值 = 一個永遠停在安裝當天的假標籤,比沒有標籤更糟)
|
||||
*
|
||||
* 全部離線跑:真的 wrangler.toml + 真的 render/inject 程式碼,fetch 用假的,不碰任何實例。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
renderWranglerToml,
|
||||
preservedVars,
|
||||
applyVars,
|
||||
resolveBundleStamp,
|
||||
CLI_MANAGED_VARS,
|
||||
VERSION_STAMP_WORKER,
|
||||
type DeployContext,
|
||||
} from '../src/lib/deploy.ts';
|
||||
import { planResources, type ResourceApi, type ScriptBindings } from '../src/lib/resource-resolver.ts';
|
||||
|
||||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
|
||||
|
||||
const CTX: DeployContext = {
|
||||
accountId: 'acc-user-123',
|
||||
apiToken: 'token',
|
||||
workerSubdomain: 'user-sub',
|
||||
selfHosted: true,
|
||||
kbdbEmbed: true,
|
||||
};
|
||||
|
||||
/** 一台「安裝器裝出來、已經跑過的」實例上,cypher worker 現在掛著的 plain_text var。 */
|
||||
const LIVE_VARS: Record<string, string> = {
|
||||
ARCRUN_BUNDLE_VERSION: '1.4.29', // 安裝當時的舊標籤
|
||||
PORTAL_MAIL_RELAY_BASE: 'https://mail.example.com', // 安裝器注入、repo toml 沒有 → 洗掉就寄不出信
|
||||
CONSOLE_TENANT: 'someone-else', // repo toml 寫死 "leo",不能拿官方值蓋掉人家的
|
||||
WORKER_SUBDOMAIN: 'user-sub', // CLI 自己算
|
||||
CF_ACCOUNT_ID: 'acc-user-123', // CLI 自己算
|
||||
MULTI_TENANT: 'false', // CLI 自己算
|
||||
ENVIRONMENT: 'production', // 與 toml 同值 → 不必重寫
|
||||
};
|
||||
|
||||
/** 從 render 過的 toml 讀 [vars] 區塊(只看未註解的行)。 */
|
||||
function readVars(toml: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
let inVars = false;
|
||||
for (const raw of toml.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (/^\[\[?[A-Za-z0-9_]+\]?\]$/.test(line)) { inVars = line === '[vars]'; continue; }
|
||||
if (!inVars || line.startsWith('#')) continue;
|
||||
const m = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||||
if (m) out[m[1]] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ① 病灶本身:舊行為會把標籤洗掉
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ①:repo 的 cypher toml 本來就沒有 ARCRUN_BUNDLE_VERSION——不補就是洗掉(病灶重現)', () => {
|
||||
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map());
|
||||
assert.equal(
|
||||
readVars(rendered).ARCRUN_BUNDLE_VERSION,
|
||||
undefined,
|
||||
'若這行開始有值,表示 toml 自己帶了版本標籤,本測試的前提要重寫',
|
||||
);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ② 設定類 var:沿用實例上的事實
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ②:安裝器注入、repo toml 沒有的 var 會被沿用(不再被重部署洗掉)', () => {
|
||||
const keep = preservedVars(LIVE_VARS, CYPHER_TOML);
|
||||
assert.equal(keep.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||||
// repo toml 寫死的是官方值,使用者實例上的值才是事實
|
||||
assert.equal(keep.CONSOLE_TENANT, 'someone-else');
|
||||
// 與 toml 同值 → 不需要重寫進去(雜訊)
|
||||
assert.equal(keep.ENVIRONMENT, undefined);
|
||||
});
|
||||
|
||||
test('#106 ③:CLI 自己算的 var 一律不沿用(沿用等於拿舊值蓋掉這趟的正解)', () => {
|
||||
const keep = preservedVars({ ...LIVE_VARS, WORKER_SUBDOMAIN: 'OLD-sub', CF_ACCOUNT_ID: 'OLD-acc' }, CYPHER_TOML);
|
||||
for (const managed of CLI_MANAGED_VARS) {
|
||||
assert.equal(keep[managed], undefined, `${managed} 不該被沿用`);
|
||||
}
|
||||
// 而且注入完的 toml 裡,這些值仍是這趟算出來的那個
|
||||
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map(), keep);
|
||||
const vars = readVars(rendered);
|
||||
assert.equal(vars.WORKER_SUBDOMAIN, 'user-sub');
|
||||
assert.equal(vars.CF_ACCOUNT_ID, 'acc-user-123');
|
||||
assert.equal(vars.MULTI_TENANT, 'false');
|
||||
assert.equal(vars.KBDB_BASE_URL, 'https://arcrun-kbdb.user-sub.workers.dev');
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ③ 版本標籤:重烙,不沿用
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ④:版本標籤取「發行頻道公告的 release」+ 實際 commit,不是沿用舊值', async () => {
|
||||
const fakeFetch = (async () =>
|
||||
new Response(JSON.stringify({ release: '1.4.41', pin: 'ba81439' }), { status: 200 })) as typeof fetch;
|
||||
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||||
assert.equal(stamp.version, '1.4.41');
|
||||
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION); // ← 這就是本 issue
|
||||
assert.equal(stamp.commit, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||||
assert.match(stamp.version, /^\d+\.\d+\.\d+$/, 'Portal 拿它跟 /api/latest 比 semver,必須是純 semver');
|
||||
});
|
||||
|
||||
test('#106 ⑤:查不到發行版號時誠實標成 commit 版,**不**沿用舊值、也不掰一個 semver', async () => {
|
||||
const fakeFetch = (async () => { throw new Error('offline'); }) as typeof fetch;
|
||||
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||||
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+f87d0e9$/);
|
||||
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION);
|
||||
assert.doesNotMatch(stamp.version, /^\d+\.\d+\.\d+$/, '掰一個 semver 會讓 Portal 假裝「已是最新版」');
|
||||
});
|
||||
|
||||
test('#106 ⑥:發行頻道回了不是 semver 的東西 → 當成查不到(不把垃圾當版號烙上去)', async () => {
|
||||
const fakeFetch = (async () =>
|
||||
new Response(JSON.stringify({ release: 'latest' }), { status: 200 })) as typeof fetch;
|
||||
const stamp = await resolveBundleStamp('main', 'abc1234def', fakeFetch);
|
||||
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+abc1234$/);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ④ 端到端(離線):一台已安裝的實例跑一次更新,Portal 讀得到的那個欄位長什麼樣
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ⑦:模擬更新——版本標籤變新、設定 var 一個不少、資源沿用不受影響', async () => {
|
||||
const api: ResourceApi = {
|
||||
async getScriptBindings(script: string): Promise<ScriptBindings> {
|
||||
if (script !== VERSION_STAMP_WORKER) return { deployed: false, bindings: [], vars: {} };
|
||||
return {
|
||||
deployed: true,
|
||||
bindings: [
|
||||
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv-webhooks' },
|
||||
{ kind: 'kv_namespace', binding: 'CREDENTIALS_KV', value: 'kv-creds' },
|
||||
{ kind: 'kv_namespace', binding: 'RECIPES', value: 'kv-recipes' },
|
||||
{ kind: 'kv_namespace', binding: 'USERS_KV', value: 'kv-users' },
|
||||
{ kind: 'kv_namespace', binding: 'SESSIONS_KV', value: 'kv-sessions' },
|
||||
{ kind: 'kv_namespace', binding: 'ANALYTICS_KV', value: 'kv-analytics' },
|
||||
{ kind: 'kv_namespace', binding: 'EXEC_CONTEXT', value: 'kv-exec' },
|
||||
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1-kbdb' },
|
||||
],
|
||||
vars: LIVE_VARS,
|
||||
};
|
||||
},
|
||||
async listKvNamespaces() {
|
||||
return new Map([
|
||||
['a', 'kv-webhooks'], ['b', 'kv-creds'], ['c', 'kv-recipes'], ['d', 'kv-users'],
|
||||
['e', 'kv-sessions'], ['f', 'kv-analytics'], ['g', 'kv-exec'],
|
||||
]);
|
||||
},
|
||||
async listD1Databases() { return new Map([['arcrun-kbdb', 'd1-kbdb']]); },
|
||||
async listVectorizeIndexes() { return []; },
|
||||
async createKvNamespace() { throw new Error('這趟不該新建任何 KV'); },
|
||||
async createD1Database() { throw new Error('這趟不該新建 D1'); },
|
||||
async createVectorizeIndex() { throw new Error('這趟不該新建 Vectorize'); },
|
||||
};
|
||||
|
||||
const preview = renderWranglerToml(CYPHER_TOML, CTX, new Map());
|
||||
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
|
||||
const parsed = parseWranglerRequirements(preview);
|
||||
const plan = await planResources(
|
||||
api,
|
||||
parsed.bindings.map((b) => ({ ...b, worker: parsed.script })),
|
||||
'update',
|
||||
);
|
||||
assert.deepEqual(plan.blockers, []);
|
||||
// 讀綁定時順手把 var 帶回來——不另外打一次 API
|
||||
assert.equal(plan.liveVars.get(VERSION_STAMP_WORKER)?.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||||
|
||||
const fakeFetch = (async () =>
|
||||
new Response(JSON.stringify({ release: '1.4.41' }), { status: 200 })) as typeof fetch;
|
||||
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||||
const extra = {
|
||||
...preservedVars(plan.liveVars.get(parsed.script), CYPHER_TOML),
|
||||
ARCRUN_BUNDLE_VERSION: stamp.version,
|
||||
ARCRUN_BUNDLE_COMMIT: stamp.commit!,
|
||||
};
|
||||
|
||||
const deployed = readVars(renderWranglerToml(CYPHER_TOML, CTX, new Map(), extra));
|
||||
|
||||
// ① Portal 設定頁讀的就是這個欄位——更新完必須有值,且是**這趟**的版本
|
||||
assert.equal(deployed.ARCRUN_BUNDLE_VERSION, '1.4.41');
|
||||
assert.equal(deployed.ARCRUN_BUNDLE_COMMIT, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||||
// ② 安裝器注入的設定沒有在更新中消失
|
||||
assert.equal(deployed.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||||
assert.equal(deployed.CONSOLE_TENANT, 'someone-else');
|
||||
// ③ CLI 自己算的仍然是這趟算出來的
|
||||
assert.equal(deployed.WORKER_SUBDOMAIN, 'user-sub');
|
||||
assert.equal(deployed.MULTI_TENANT, 'false');
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ⑤ applyVars 的三種既有狀態 + 不弄壞別的區塊
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ⑧:applyVars——改既有行/取消註解/插進 [vars]/連 [vars] 都沒有時新開一段', () => {
|
||||
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'new' }), /^\[vars\]\nA = "new"\n$/);
|
||||
assert.match(applyVars('[vars]\n# A = "old"\n', { A: 'new' }), /A = "new"/);
|
||||
assert.match(applyVars('[vars]\nB = "b"\n', { A: 'a' }), /\[vars\]\nA = "a"\nB = "b"/);
|
||||
const noVars = applyVars('name = "w"\n', { A: 'a' });
|
||||
assert.match(noVars, /\[vars\]\nA = "a"/);
|
||||
assert.match(noVars, /^name = "w"/);
|
||||
});
|
||||
|
||||
test('#106 ⑨:var 值裡的引號/反斜線會被轉義(不會產生壞掉的 toml)', () => {
|
||||
const out = applyVars('[vars]\n', { A: 'say "hi"\\path' });
|
||||
assert.match(out, /A = "say \\"hi\\"\\\\path"/);
|
||||
});
|
||||
|
||||
test('#106 ⑨b:值裡有 $& / $1 也照原樣寫出(replace 反向參照陷阱)', () => {
|
||||
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'x$&y$1z' }), /A = "x\$&y\$1z"/);
|
||||
assert.match(applyVars('[vars]\n', { A: 'x$&y' }), /A = "x\$&y"/);
|
||||
// 怪名字不寫進去(不拿它組正規式)
|
||||
assert.equal(applyVars('[vars]\n', { 'BAD NAME': 'v' }), '[vars]\n');
|
||||
});
|
||||
|
||||
test('#106 ⑩:注入 var 不影響資源綁定解析(預覽與實際寫入看到的是同一份需求)', async () => {
|
||||
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
|
||||
const withoutVars = parseWranglerRequirements(renderWranglerToml(CYPHER_TOML, CTX, new Map()));
|
||||
const withVars = parseWranglerRequirements(
|
||||
renderWranglerToml(CYPHER_TOML, CTX, new Map(), { ARCRUN_BUNDLE_VERSION: '1.4.41', X: 'y' }),
|
||||
);
|
||||
assert.equal(withVars.script, withoutVars.script);
|
||||
assert.deepEqual(withVars.bindings, withoutVars.bindings);
|
||||
});
|
||||
@@ -2039,6 +2039,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
// 兩邊都是 semver(例 1.4.2),用數字逐段比,不用字串比('1.4.10' < '1.4.9' 會出錯)。
|
||||
var INSTALLER_ORIGIN = 'https://install.arcrun.dev';
|
||||
|
||||
// Arcrun#106:版號後面可以帶 build metadata(`1.4.41+d61`、`1.4.41+a1b2c3d`)——
|
||||
// 那是 semver 規格裡「比大小時要忽略」的那一段。舊寫法拿整串去比對正規式,
|
||||
// 一律落到「較舊版本」(youlin 實例就是這樣,明明有版號卻顯示不出來)。
|
||||
// 這裡只取前面的 `x.y.z` 當比較用的核心,顯示仍顯示完整原字串。
|
||||
function semverCore(v) {
|
||||
var m = String(v || '').match(/^(\d+\.\d+\.\d+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function cmpSemver(a, b) {
|
||||
var x = String(a || '').split('.').map(Number);
|
||||
var y = String(b || '').split('.').map(Number);
|
||||
@@ -2055,9 +2064,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var btn = $('st-ver-update');
|
||||
if (!line) return;
|
||||
|
||||
// #106:順便把 bundle_commit 帶回來(有注入才有)——版號是頻道編號,commit 才是「真的部了哪份碼」。
|
||||
var mineCommit = '';
|
||||
var mineP = fetch(window.ARCRUN_API_BASE + '/health', { cache: 'no-store' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) { return (j && j.bundle_version) || ''; })
|
||||
.then(function (j) {
|
||||
mineCommit = (j && j.bundle_commit) || '';
|
||||
return (j && j.bundle_version) || '';
|
||||
})
|
||||
.catch(function () { return ''; });
|
||||
var latestP = fetch(INSTALLER_ORIGIN + '/api/latest')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
@@ -2070,15 +2084,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
if (!mine) { line.textContent = '無法讀取目前版本(知識庫服務可能正在啟動)'; return; }
|
||||
// 舊實例的 bundle_version 是舊格式(2026-07-31+8e83589),比不了 semver。
|
||||
// 這種情況一律當成「落後」——因為新版才會寫 semver 進來。
|
||||
var mineIsSemver = /^\d+\.\d+\.\d+$/.test(mine);
|
||||
// #106:`1.4.41+<commit>` 這種帶 build metadata 的**是** semver,取核心比即可。
|
||||
var mineCore = semverCore(mine);
|
||||
var mineIsSemver = !!mineCore;
|
||||
// commit 是輔助資訊(有才顯示):版號說「哪一版」,commit 說「真的是哪份碼」。
|
||||
var commitNote = mineCommit ? ' <span class="muted">commit ' + esc(String(mineCommit).slice(0, 7)) + '</span>' : '';
|
||||
|
||||
if (!latest) {
|
||||
line.textContent = '目前版本 ' + mine + '(暫時查不到最新版,稍後再試)';
|
||||
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong>(暫時查不到最新版,稍後再試)' + commitNote;
|
||||
return;
|
||||
}
|
||||
var behind = !mineIsSemver || cmpSemver(mine, latest) < 0;
|
||||
var behind = !mineIsSemver || cmpSemver(mineCore, latest) < 0;
|
||||
if (!behind) {
|
||||
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong> 已是最新版';
|
||||
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong> 已是最新版' + commitNote;
|
||||
dot.style.display = 'none';
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
|
||||
@@ -15,11 +15,19 @@ export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
// 要在實例自己這一側就看得出來,不是等用戶登不進去才發現(#10「寧可明顯失敗」)。
|
||||
// 只回統計不回內容(帳號數/有沒有 console 帳密/分片數),不洩漏任何 email 或雜湊。
|
||||
// bundle_version 的既有行為不動(未注入就省略該欄——daemon 對空字串判 stale 是正確的)。
|
||||
// Arcrun#106(leo 08-12 實撞:更新完設定頁變成「無法讀取目前版本」):
|
||||
// `bundle_version` 只在部署時被注入,而**只有安裝器會注入**——CLI 更新那條路重部署
|
||||
// 等於把這個標籤洗掉(wrangler deploy 整份覆蓋,toml 沒寫的 var 直接消失)。
|
||||
// 修在 CLI 那側(cli/src/lib/deploy.ts:既有 var 沿用 + 版本標籤每趟重烙)。
|
||||
// 這裡只多吐一個 `bundle_commit`:版號是「發行頻道的編號」,commit 才是「真的部了哪份碼」——
|
||||
// 兩個一起看才有辦法查「標籤有沒有跟成品漂掉」。沒注入就省略該欄(同 bundle_version 的既有行為)。
|
||||
healthRouter.get('/health', (c) => {
|
||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
|
||||
return c.json({
|
||||
ok: true,
|
||||
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
|
||||
...(bundleCommit ? { bundle_commit: bundleCommit } : {}),
|
||||
auth_store: authStoreStatus(c.env),
|
||||
// arcrun-rag#38/#69/#25(2026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
|
||||
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
|
||||
|
||||
@@ -75,6 +75,13 @@ export type Bindings = {
|
||||
* 未注入(本地 dev/舊實例)= undefined,/health 省略該欄。
|
||||
*/
|
||||
ARCRUN_BUNDLE_VERSION?: string;
|
||||
/**
|
||||
* Arcrun#106:這份成品實際來自哪個 commit(40 碼 sha)。
|
||||
* `ARCRUN_BUNDLE_VERSION` 是**發行頻道的編號**(semver,Portal/daemon 拿它比新舊),
|
||||
* 這個是**真的部了哪份碼**——兩個一起吐,標籤跟成品漂掉時查得出來。
|
||||
* 由 `acr init/update`(cli/src/lib/deploy.ts)注入;安裝器那條路沒有此 var → /health 省略該欄。
|
||||
*/
|
||||
ARCRUN_BUNDLE_COMMIT?: string;
|
||||
// Platform telemetry api_key(可選,wrangler secret)
|
||||
// 對應 SDD .agents/specs/llm-interface/ M1.2
|
||||
// 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下
|
||||
@@ -103,9 +110,8 @@ export type Bindings = {
|
||||
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token)
|
||||
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
|
||||
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
|
||||
// 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。
|
||||
// daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。
|
||||
ARCRUN_BUNDLE_VERSION?: string;
|
||||
// (ARCRUN_BUNDLE_VERSION 原本在這裡重複宣告了一次——TS2300 重複識別字,
|
||||
// #106 順手併回上面那一處,說明同源,行為零變化。)
|
||||
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
|
||||
// 真相住在 mcp worker 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份
|
||||
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
|
||||
|
||||
@@ -26,4 +26,33 @@ describe('GET /health — bundle_version 欄位', () => {
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.bundle_version).toBe('2026-07-28/6d06162');
|
||||
});
|
||||
|
||||
// Arcrun#106:CLI 更新那條路會多烙一個 commit(版號=發行頻道編號,commit=真的部了哪份碼)。
|
||||
it('有 ARCRUN_BUNDLE_COMMIT 時一起回(acr update 注入情境)', async () => {
|
||||
const fakeEnv = {
|
||||
ARCRUN_BUNDLE_VERSION: '1.4.41',
|
||||
ARCRUN_BUNDLE_COMMIT: 'f87d0e92f49690253e7c89c5badc82a08eb5d21b',
|
||||
} as unknown as Bindings;
|
||||
const res = await healthRouter.fetch(
|
||||
new Request('http://localhost/health'),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
const data = await res.json() as { bundle_version: string; bundle_commit: string };
|
||||
expect(data.bundle_version).toBe('1.4.41');
|
||||
expect(data.bundle_commit).toBe('f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||||
});
|
||||
|
||||
// 安裝器那條路沒有這個 var(回歸:不能因為多了新欄位就讓舊路徑多吐一個空字串出來)。
|
||||
it('沒 ARCRUN_BUNDLE_COMMIT 就省略該欄(安裝器路徑不受影響)', async () => {
|
||||
const fakeEnv = { ARCRUN_BUNDLE_VERSION: '1.4.41' } as unknown as Bindings;
|
||||
const res = await healthRouter.fetch(
|
||||
new Request('http://localhost/health'),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
const data = await res.json() as { bundle_version: string; bundle_commit?: string };
|
||||
expect(data.bundle_version).toBe('1.4.41');
|
||||
expect(data.bundle_commit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user