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:
uncle6me-web
2026-08-12 21:58:43 +08:00
parent f87d0e92f4
commit 53b05c6d3d
12 changed files with 645 additions and 19 deletions
+4
View File
@@ -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);
+3 -1
View File
@@ -493,7 +493,7 @@ test('CfAccountClient.getScriptBindings404 = 還沒部署;其他錯誤要 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' },
]);
// #106plain_text 也要收下來(service 這種不認得的仍略過)。
assert.deepEqual(res.vars, { ENVIRONMENT: 'production' });
} finally {
globalThis.fetch = orig;
}
+21
View File
@@ -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;
}
}
+243
View File
@@ -0,0 +1,243 @@
/**
* Arcrun#106 迴歸守衛 —— 「更新完,設定頁還看得到版本號,而且是**這次**的版本號」
*
* 2026-08-12 實害:leo 更新完 leo21cPortal 設定頁的版本欄變成
* 「無法讀取目前版本(知識庫服務可能正在啟動)」。
* 根因:`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);
});