refactor(shared): 「該用哪些資源」搬出 CLI——一份實作,acr 與安裝器吃同一條規則

leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」

「這個實例該用哪些資源」換到安裝器就要重寫一次 ⇒ 依 rules/07-thin-shell.md 的判準
它是**能力**,而它原本住在 cli/src/lib/resource-resolver.ts ⇒ 那本身就是違規。
後果已經真的發生:acr 那條有 Arcrun#97 的修法、安裝器那條沒有,於是安裝器照名字
找、找不到就建一顆空的綁上去 ⇒「我按了更新,工作流和登入全不見了」。

規則搬到 shared/resource-rule/(零依賴 ESM,Node 與 Workers runtime 都直接跑):

  · rule.mjs           規則本體+把 CF 回應讀成事實的 normalizeLive*
  · cf-resource-api.mjs ResourceApi 的 CF REST 實作——**眼睛也共用**:
                        兩條路各自解讀 CF 回應,只要一邊看不到既有綁定就會去新建,
                        #97 不需要規則寫錯就能重演
  · installer-entry.mjs 安裝器唯一該碰的入口 resolveInstanceResources()

不是做成 cypher 端點的理由(自舉):這條規則要在「決定怎麼裝」的當下就用得到,
而那時 cypher 可能還不存在(安裝器的工作正是把它生出來);且輸入是使用者自己帳號的
綁定狀態,不該送去平台換答案。它是純函式,用不著變成服務。

只有一份,機械看守:
  · 安裝器直接 import repo archive 裡的原稿,**不需要副本**
  · acr 因為 npm pack 打不進套件目錄外的檔案,帶一份逐位元組鏡射
    (scripts/sync-resource-rule.mjs 產生;build/test 先跑 --check,差一位元組就紅)
    ——同 cli/harness/ 產生物+世代閘的既有慣例
  · cli/tests/single-implementation.test.ts 掃全 repo:7 支規則函式的實作只有一處

CLI 淨 -496 行(邏輯是搬走,不是複製)。cf-api.ts 的 CfAccountClient 保留公開介面,
ResourceApi 那七個方法全部委派共用 client。

驗證:cli 58/58 綠(含新增的兩條路一致性 fixture + 三種情境),tsc --noEmit 乾淨。
This commit is contained in:
uncle6me-web
2026-08-12 23:37:01 +08:00
parent e05518a2b4
commit bb548b6fdf
16 changed files with 2614 additions and 582 deletions
+35 -424
View File
@@ -1,431 +1,42 @@
/**
* resource-resolver.ts — 資源解析:「已部署的 worker 現在綁著什麼,那就是事實」
* resource-resolver.ts — **這裡沒有邏輯**,只是把共用規則接到 CLI 的既有 import 路徑上。
*
* 🔴 Arcrun#972026-08-12 實害,leo 的實例中了):
* 舊做法叫「照名字 ensure」——`acr update` 拿 **binding 名**`WEBHOOKS`)當成 Cloudflare 上的
* **資源標題**去找,找不到就**新建一顆空的、然後綁到 worker 上**
* 安裝器建的資源不叫那個名字(它叫 `arcrun-rag-<instance>-kv-webhooks`)⇒ 一次例行更新
* 新建了 9 顆 KV、1 顆 D1,使用者的工作流/登入狀態/子庫**在畫面上全部消失**。
* 資料沒有被刪,但 worker 被綁去空的那幾顆——從使用者的角度,他的東西就是不見了。
* 「這個實例該用哪些資源」的規則住在 `shared/resource-rule/`repo 根目錄),
* 那是**唯一一份人手維護的實作**`./resource-rule/` 是該目錄的逐位元組鏡射
* `scripts/sync-resource-rule.mjs` 產生,`npm run build` / `npm test` 會跑 `--check` 擋漂移)
* 之所以要有這份鏡射:`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案。
*
* 根因不是「KV 那段寫錯」,是**「用名字猜使用者的資源」這個做法本身**
* 名字是**使用者那側的事實**(安裝器要怎麼取名由它決定,而且它有權改),
* 我們不能拿自己的命名慣例去對號入座,更不能在對不上的時候自作主張生一顆新的。
* ——所以修法不是「多比對幾種名字」,是**不再用名字當識別**
* 為什麼規則不在 CLIleo 2026-08-12
* 「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
* ——`acr` 有這條規則、安裝器沒有,結果就是 Arcrun#97:
* 安裝器照名字找、找不到就建一顆空的綁上去,使用者的工作流與登入狀態整片消失
* 規則搬到共用層之後,安裝器直接 import 同一份原稿,**不再有第二種答案**。
*
* ── 新規則(三句話)────────────────────────────────────────────────
* 1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
* 2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
* 3. **只要有一點說不準就整趟停手**(讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
* 該更新的 worker 一顆都不在),**什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
*
* ── 為什麼拆成 plan / apply 兩段 ─────────────────────────────────────
* `planResources()` **完全不寫入**,只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。
* ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,
* 不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。
* 🔴 不要把任何判斷寫回這個檔案。要改規則 → 改 `shared/resource-rule/rule.mjs`。
*/
/** 這支負責的資源種類。要加新種類(R2/Queue/Hyperdrive…)就加在這裡,
* 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。 */
export type ResourceKind = 'kv_namespace' | 'd1' | 'vectorize';
export {
planResources,
applyResourcePlan,
parseWranglerRequirements,
normalizeLiveBindings,
normalizeLiveVars,
bindingKey,
ResourcePlanBlocked,
KIND_LABEL,
TABLE_KIND,
} from './resource-rule/rule.mjs';
/** 從已部署 worker 上讀回來的一條綁定。`value`KV/D1 是資源 idVectorize 是 index 名。 */
export interface LiveBinding {
kind: ResourceKind;
binding: string;
value: string;
}
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 能力(收窄成介面,方便離線測試餵假帳號)。 */
export interface ResourceApi {
getScriptBindings(script: string): Promise<ScriptBindings>;
/** title → id */
listKvNamespaces(): Promise<Map<string, string>>;
/** name → uuid */
listD1Databases(): Promise<Map<string, string>>;
listVectorizeIndexes(): Promise<string[]>;
createKvNamespace(title: string): Promise<string>;
createD1Database(name: string): Promise<string>;
createVectorizeIndex(name: string): Promise<string>;
}
/** 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。 */
export interface BindingRequirement {
kind: ResourceKind;
binding: string;
/** 需要它的 worker script 名(= wrangler.toml 的 `name`)。 */
worker: string;
createName: string;
}
export interface PlannedAdopt {
kind: ResourceKind;
binding: string;
value: string;
/** 從哪顆已部署的 worker 上讀到的 */
from: string;
}
export interface PlannedCreate {
kind: ResourceKind;
binding: string;
createName: string;
wantedBy: string[];
/** 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。 */
alsoBind: string[];
}
export interface ResourcePlan {
adopt: PlannedAdopt[];
create: PlannedCreate[];
/** 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 */
blockers: string[];
/**
* 每顆**已部署** worker 現在掛著的 plain_text varscript → 名/值)。未部署的不在裡面。
*
* Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡——
* 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式
* (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。
*/
liveVars: Map<string, Record<string, string>>;
}
export interface ResolvedResource {
kind: ResourceKind;
binding: string;
value: string;
origin: 'adopted' | 'created';
from?: string;
}
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
constructor(readonly blockers: string[]) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
}
}
export function bindingKey(kind: ResourceKind, binding: string): string {
return `${kind}:${binding}`;
}
const KIND_LABEL: Record<ResourceKind, string> = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
function msg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/**
* 決定每個 binding 要沿用哪顆資源/要不要新建,**不寫入任何東西**。
*
* @param mode 'update' = 這台照定義已經裝過了(見下方「一顆都不在」規則);'init' = 全新安裝,允許從零建。
*/
export async function planResources(
api: ResourceApi,
requirements: readonly BindingRequirement[],
mode: 'update' | 'init',
): Promise<ResourcePlan> {
const blockers: string[] = [];
const adopt: PlannedAdopt[] = [];
const create: PlannedCreate[] = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#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);
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
liveVars.set(script, res.vars ?? {});
}
} catch (e) {
readFailed = true;
blockers.push(
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
);
}
}
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
blockers.push(
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
`已停手,沒有新建任何資源。`,
);
}
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
const byKey = new Map<string, BindingRequirement[]>();
for (const req of requirements) {
const key = bindingKey(req.kind, req.binding);
const list = byKey.get(key);
if (list) list.push(req);
else byKey.set(key, [req]);
}
const existingCache = new Map<ResourceKind, Set<string>>();
const listExisting = async (kind: ResourceKind): Promise<Set<string>> => {
const hit = existingCache.get(kind);
if (hit) return hit;
let set: Set<string>;
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
else set = new Set(await api.listVectorizeIndexes());
existingCache.set(kind, set);
return set;
};
for (const [, reqs] of byKey) {
const { kind, binding } = reqs[0];
const found: Array<{ value: string; script: string }> = [];
for (const [script, bindings] of live) {
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
if (hit) found.push({ value: hit.value, script });
}
const distinct = [...new Set(found.map((f) => f.value))];
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
if (distinct.length > 1) {
blockers.push(
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
`${found.map((f) => `${f.script}${f.value}`).join('、')})。` +
`分不出哪一顆才是你在用的,不猜——停手。`,
);
continue;
}
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
let existing: Set<string>;
try {
existing = await listExisting(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
`${msg(e)})。不確定就不動——停手。`,
);
continue;
}
if (!existing.has(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
`但這顆在你的 Cloudflare 帳號上找不到了。` +
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
);
continue;
}
adopt.push({ kind, binding, value, from: found[0].script });
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
create.push({
kind,
binding,
createName: reqs[0].createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
}
return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars };
}
/**
* 收斂「不同 binding 其實是同一顆資源」的情況。
*
* 判準是 **toml 自己宣告的名字**`database_name` / `index_name`),不是使用者那側的資源名——
* cypher 的 `CREDENTIALS_DB` 與 kbdb 的 `DB` 都寫 `database_name = "arcrun-kbdb"`
* 那是**我們**在宣告「這兩個綁定指向同一顆庫」,跟 #97 那種「拿名字去猜使用者的資源」是兩回事。
*
* 沒有這一步會出兩種錯:
* ① 全新安裝時建出兩顆同名 D1,KBDB 的資料與 credential 目錄從此分家。
* ② 一邊已部署(沿用既有)、另一邊沒有(新建一顆空的)→ 半套資料,比全壞更難查。
*/
function shareSameResource(
adopt: PlannedAdopt[],
create: PlannedCreate[],
byKey: Map<string, BindingRequirement[]>,
): PlannedCreate[] {
const declaredName = (kind: ResourceKind, binding: string): string | undefined =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
const out: PlannedCreate[] = [];
const groups = new Map<string, PlannedCreate>();
for (const c of create) {
const groupKey = `${c.kind}${c.createName}`;
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
const twin = adopt.find(
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
);
if (twin) {
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
continue;
}
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
const head = groups.get(groupKey);
if (head) {
head.alsoBind.push(c.binding);
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
continue;
}
groups.set(groupKey, c);
out.push(c);
}
return out;
}
/**
* 照 plan 動手:沿用的原樣帶出來,該建的才建。
* 有任何 blocker 直接丟 ResourcePlanBlocked**一顆都不建**。
*/
export async function applyResourcePlan(
api: ResourceApi,
plan: ResourcePlan,
): Promise<Map<string, ResolvedResource>> {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
const out = new Map<string, ResolvedResource>();
for (const a of plan.adopt) {
out.set(bindingKey(a.kind, a.binding), {
kind: a.kind,
binding: a.binding,
value: a.value,
origin: 'adopted',
from: a.from,
});
}
const madeSoFar: string[] = [];
for (const c of plan.create) {
let value: string;
try {
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
else value = await api.createVectorizeIndex(c.createName);
} catch (e) {
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
const orphans = madeSoFar.length > 0
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
: '';
throw new Error(`${KIND_LABEL[c.kind]}${c.createName}」失敗:${msg(e)}${orphans}`);
}
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
for (const binding of [c.binding, ...c.alsoBind]) {
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// wrangler.toml → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
export interface WranglerRequirements {
/** worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。 */
script: string;
bindings: Array<{ kind: ResourceKind; binding: string; createName: string }>;
}
/** wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。 */
export const TABLE_KIND: Record<string, ResourceKind> = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* 從 wrangler.toml 抽出「這顆 worker 需要哪些資源綁定」。
*
* 刻意寫成行掃描而不引 TOML parser:注入端(injectWranglerConfig)本來就是純文字操作,
* 兩邊用同一種視角看這份檔案才不會對不上。註解掉的區塊**不算需求**
* kbdb 的 `[[vectorize]]` 預設是註解狀態,要開語義查詢時才會被取消註解 → 那時才成為需求)。
*/
export function parseWranglerRequirements(toml: string): WranglerRequirements {
let script = '';
let seenTable = false;
const bindings: WranglerRequirements['bindings'] = [];
let kind: ResourceKind | null = null;
let binding = '';
let createName = '';
const flush = (): void => {
if (kind && binding) {
bindings.push({ kind, binding, createName: createName || binding });
}
kind = null;
binding = '';
createName = '';
};
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
seenTable = true;
kind = TABLE_KIND[table[1]] ?? null;
continue;
}
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!kv) continue;
const [, key, value] = kv;
if (!seenTable && key === 'name') {
script = value;
continue;
}
if (!kind) continue;
if (key === 'binding') binding = value;
// 只有 D1Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
else if (key === 'database_name' || key === 'index_name') createName = value;
}
flush();
return { script, bindings };
}
export type {
ResourceKind,
LiveBinding,
ScriptBindings,
ResourceApi,
BindingRequirement,
PlannedAdopt,
PlannedCreate,
ResourcePlan,
ResolvedResource,
WranglerRequirements,
RawWorkerBinding,
} from './resource-rule/rule.mjs';