bb548b6fdf
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 乾淨。
101 lines
4.3 KiB
JavaScript
101 lines
4.3 KiB
JavaScript
// @ts-check
|
||
/**
|
||
* installer-entry.mjs — 安裝器那條路的**唯一入口**。
|
||
*
|
||
* 安裝器(arcrun-rag `installer/oauth-prototype/worker.js`)不必、也不准自己判斷
|
||
* 「該建哪些資源」——它只要呼叫這一支,拿回「每個 binding 該用哪顆資源」。
|
||
*
|
||
* ```js
|
||
* import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
|
||
*
|
||
* const r = await resolveInstanceResources({
|
||
* accountId, apiToken,
|
||
* wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // 字串陣列
|
||
* mode: isUpdate ? 'update' : 'init',
|
||
* });
|
||
* if (r.blocked) {
|
||
* // 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
|
||
* return showAndStop(r.blockers);
|
||
* }
|
||
* // r.bindings: { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
|
||
* // r.liveVars: { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } }
|
||
* ```
|
||
*
|
||
* 為什麼安裝器不需要副本:安裝器本來就會下載本 repo 的 archive 當部署來源
|
||
* (見 `.claude/rules/05-deploy-convention.md`「WASM 來源」),
|
||
* `shared/resource-rule/` 就在那份 archive 裡,直接 import 即可——
|
||
* **不必再編一次、不必貼一份、也就不會有第二種答案。**
|
||
*/
|
||
|
||
import { planResources, applyResourcePlan, parseWranglerRequirements, ResourcePlanBlocked } from './rule.mjs';
|
||
import { createCloudflareResourceApi } from './cf-resource-api.mjs';
|
||
|
||
/**
|
||
* @typedef {object} ResolveOptions
|
||
* @property {string} accountId
|
||
* @property {string} apiToken
|
||
* @property {string[]} wranglerTomls 各 worker 的 wrangler.toml **內容**(不是路徑)。
|
||
* @property {'update' | 'init'} mode 這台照定義裝過了沒。
|
||
* @property {typeof globalThis.fetch} [fetch] 注入用(測試/宿主自帶 fetch)。
|
||
*/
|
||
|
||
/**
|
||
* @typedef {object} ResolveResult
|
||
* @property {boolean} blocked true = 什麼都沒建、什麼都不該部署。
|
||
* @property {string[]} blockers blocked 時的原因原文(要原樣轉給使用者)。
|
||
* @property {Record<string, string>} bindings `${kind}:${binding}` → 資源 id/index 名。
|
||
* @property {Record<string, 'adopted'|'created'>} origin 同上 key → 這顆是沿用還是新建。
|
||
* @property {Record<string, Record<string, string>>} liveVars script → 現有 plain_text var(#106)。
|
||
*/
|
||
|
||
/**
|
||
* 決定這台實例每個 binding 該用哪顆資源;照規則沿用既有、只在確定沒人綁過時才新建。
|
||
*
|
||
* @param {ResolveOptions} options
|
||
* @returns {Promise<ResolveResult>}
|
||
*/
|
||
export async function resolveInstanceResources({ accountId, apiToken, wranglerTomls, mode, fetch }) {
|
||
const api = createCloudflareResourceApi({ accountId, apiToken, fetch });
|
||
|
||
/** @type {import('./rule.mjs').BindingRequirement[]} */
|
||
const requirements = [];
|
||
for (const toml of wranglerTomls) {
|
||
const parsed = parseWranglerRequirements(toml);
|
||
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
|
||
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
|
||
}
|
||
|
||
/** @param {string[]} blockers @returns {ResolveResult} */
|
||
const stop = (blockers) => ({ blocked: true, blockers, bindings: {}, origin: {}, liveVars: {} });
|
||
|
||
if (requirements.length === 0) {
|
||
return stop(['這批 wrangler.toml 裡讀不到任何資源綁定需求——不確定要裝什麼,停手。']);
|
||
}
|
||
|
||
let plan;
|
||
try {
|
||
plan = await planResources(api, requirements, mode);
|
||
} catch (e) {
|
||
return stop([`資源解析失敗(${e instanceof Error ? e.message : String(e)})。沒有建立任何資源。`]);
|
||
}
|
||
if (plan.blockers.length > 0) return stop(plan.blockers);
|
||
|
||
/** @type {Map<string, import('./rule.mjs').ResolvedResource>} */
|
||
let resolved;
|
||
try {
|
||
resolved = await applyResourcePlan(api, plan);
|
||
} catch (e) {
|
||
return stop(e instanceof ResourcePlanBlocked ? e.blockers : [e instanceof Error ? e.message : String(e)]);
|
||
}
|
||
|
||
/** @type {Record<string, string>} */
|
||
const bindings = {};
|
||
/** @type {Record<string, 'adopted'|'created'>} */
|
||
const origin = {};
|
||
for (const [key, r] of resolved) {
|
||
bindings[key] = r.value;
|
||
origin[key] = r.origin;
|
||
}
|
||
return { blocked: false, blockers: [], bindings, origin, liveVars: Object.fromEntries(plan.liveVars) };
|
||
}
|