diff --git a/cli/package.json b/cli/package.json index 2a6baf9..3f00171 100644 --- a/cli/package.json +++ b/cli/package.json @@ -8,11 +8,12 @@ "main": "./dist/index.js", "type": "module", "scripts": { - "build": "npm run build:harness && npm run check:harness && tsc", + "build": "npm run build:harness && npm run check:harness && npm run check:rule && tsc", "build:harness": "node scripts/build-harness-skill.mjs", "check:harness": "node scripts/check-harness-generation.mjs", + "check:rule": "node ../scripts/sync-resource-rule.mjs --check", "dev": "tsc --watch", - "test": "node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"", + "test": "npm run check:rule && node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"", "prepublishOnly": "npm run build && chmod +x dist/index.js" }, "dependencies": { diff --git a/cli/src/lib/cf-api.ts b/cli/src/lib/cf-api.ts index 378611e..be38f4b 100644 --- a/cli/src/lib/cf-api.ts +++ b/cli/src/lib/cf-api.ts @@ -3,7 +3,8 @@ * 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI */ -import type { LiveBinding, ResourceApi, ScriptBindings } from './resource-resolver.js'; +import { createCloudflareResourceApi } from './resource-rule/cf-resource-api.mjs'; +import type { ResourceApi, ScriptBindings } from './resource-resolver.js'; const CF_API_BASE = 'https://api.cloudflare.com/client/v4'; @@ -86,189 +87,81 @@ export class CfKvClient { * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2 */ export class CfAccountClient implements ResourceApi { - private accountBase: string; - private headers: Record; + /** + * `ResourceApi` 的七個方法**全部委派**給共用規則附的那支 client + * (`shared/resource-rule/cf-resource-api.mjs`)。 + * + * 🔴 為什麼不是在這裡自己實作一份:判斷一致還不夠,**看到的東西**也要一致。 + * 兩條路各自寫一份 CF client,只要有一邊把 404 當錯誤、漏了 per_page、少認一種 + * 欄位名,那一邊就會「看不到既有綁定」——而看不到既有綁定的下一步,依規則就是新建。 + * Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。 + */ + private readonly rule: ReturnType; constructor(accountId: string, apiToken: string) { - this.accountBase = `${CF_API_BASE}/accounts/${accountId}`; - this.headers = { - 'Authorization': `Bearer ${apiToken}`, - 'Content-Type': 'application/json', - }; + this.rule = createCloudflareResourceApi({ accountId, apiToken }); } private async cf(path: string, init?: RequestInit): Promise { - const { ok, status, result, error } = await this.cfRaw(path, init); + const { ok, status, result, error } = await this.rule.cfRaw(path, init); if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`); return result as T; } - /** 同 cf(),但把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 */ - private async cfRaw( - path: string, - init?: RequestInit, - ): Promise<{ ok: boolean; status: number; result?: T; error?: string }> { - const res = await fetch(`${this.accountBase}${path}`, { - ...init, - headers: { ...this.headers, ...(init?.headers ?? {}) }, - }); - const data = await res.json().catch(() => null) as - | { success: boolean; result: T; errors?: Array<{ message: string }> } - | null; - if (!res.ok || !data?.success) { - return { - ok: false, - status: res.status, - error: data?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`, - }; - } - return { ok: true, status: res.status, result: data.result }; - } - /** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/ async verifyAccess(): Promise { // GET /accounts/{id} 能通 = token 有此 account 的基本讀權限 await this.cf<{ id: string; name: string }>(''); } - /** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/ - async listKvNamespaces(): Promise> { - const result = await this.cf>( - '/storage/kv/namespaces?per_page=100', - ); - const map = new Map(); - for (const ns of result) map.set(ns.title, ns.id); - return map; - } - - /** - * 無條件新建一顆 KV namespace。 - * - * 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。 - * 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路 - * (安裝器取的名字跟我們的 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。 - * 要不要建,一律先經過 resource-resolver 的 planResources 判斷;那裡只有在 - * 「確定沒有任何已部署的 worker 綁過這個 binding」時才會排進 create。 - */ - async createKvNamespace(title: string): Promise { - const result = await this.cf<{ id: string; title: string }>( - '/storage/kv/namespaces', - { method: 'POST', body: JSON.stringify({ title }) }, - ); - return result.id; - } - - /** - * 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。 - * CF:`GET /accounts/{id}/workers/scripts/{script}/settings` → `result.bindings[]`。 - * - * - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。 - * - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」—— - * 把查不到當成不存在,就是 #97 的根因。 - */ - async getScriptBindings(script: string): Promise { - 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: [], vars: {} }; - throw new Error(`讀 ${script} 綁定失敗:${res.error}`); - } - 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)。*/ async getWorkersSubdomain(): Promise { const result = await this.cf<{ subdomain: string }>('/workers/subdomain'); return result.subdomain; } - // D1 (KBDB Base). Free on Workers Free plan, no credit card (kbdb-base Q4 verified). - async listD1Databases(): Promise> { - const result = await this.cf>('/d1/database?per_page=100'); - const map = new Map(); - for (const db of result) map.set(db.name, db.uuid); - return map; + // ── 以下七支=`ResourceApi`,一律委派共用規則,**這個檔案不得自己實作** ──────────── + // (`shared/resource-rule/cf-resource-api.mjs`;委派而非複製的理由見本 class 開頭) + + /** 讀一顆已部署 worker 現在綁著哪些資源——使用者那側的事實(Arcrun#97 的唯一真相源)。 */ + getScriptBindings(script: string): Promise { + return this.rule.getScriptBindings(script); } - /** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。 */ - async createD1Database(name: string): Promise { - const result = await this.cf<{ uuid: string; name: string }>( - '/d1/database', - { method: 'POST', body: JSON.stringify({ name }) }, - ); - return result.uuid; + /** 帳號上現有的 KV namespace(title → id)。判斷「綁著的那顆還在不在」用。 */ + listKvNamespaces(): Promise> { + return this.rule.listKvNamespaces(); } - /** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */ - async listVectorizeIndexes(): Promise { - const result = await this.cf>('/vectorize/v2/indexes'); - return (result ?? []).map(i => i.name); + /** 帳號上現有的 D1(name → uuid)。 */ + listD1Databases(): Promise> { + return this.rule.listD1Databases(); + } + + /** 帳號上現有的 Vectorize index 名單。 */ + listVectorizeIndexes(): Promise { + return this.rule.listVectorizeIndexes(); } /** - * 新建 KBDB embed 用的 Vectorize index(**bge-m3 = 1024 維 / cosine**,見 deploy.ts 常數說明)。 - * 已存在(409 / already exists)視為成功——並行或重跑不該炸。沒有 ensure 版本: - * 「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。 + * 無條件新建一顆 KV namespace。 + * + * 🔴 Arcrun#97:**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。 + * 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路。 + * 要不要建,一律先經過 planResources;那裡只有在「確定沒有任何已部署的 worker + * 綁過這個 binding」時才會排進 create。 */ - async createVectorizeIndex(name: string): Promise { - const res = await this.cfRaw<{ name: string }>('/vectorize/v2/indexes', { - method: 'POST', - body: JSON.stringify({ - name, - config: { dimensions: 1024, metric: 'cosine' }, - description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)', - }), - }); - if (res.ok) return name; - const detail = (res.error ?? '').toLowerCase(); - if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name; - throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`); + createKvNamespace(title: string): Promise { + return this.rule.createKvNamespace(title); + } + + /** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。 */ + createD1Database(name: string): Promise { + return this.rule.createD1Database(name); + } + + /** 新建 KBDB embed 用的 Vectorize index。沒有 ensure 版本,理由同上(Arcrun#97)。 */ + createVectorizeIndex(name: string): Promise { + return this.rule.createVectorizeIndex(name); } } - -/** CF `/settings` 回的 binding 原始形狀(同一種資源在不同 API 版本欄位名不一,故全都收)。 */ -interface RawWorkerBinding { - type?: string; - name?: string; - namespace_id?: string; - 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 { - const out: Record = {}; - 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 認得的三種資源。不認得的型別直接略過。 */ -function normalizeBindings(raw: RawWorkerBinding[]): LiveBinding[] { - const out: LiveBinding[] = []; - for (const b of raw) { - if (!b?.name) continue; - if (b.type === 'kv_namespace') { - const value = b.namespace_id ?? b.id; - if (value) out.push({ kind: 'kv_namespace', binding: b.name, value }); - } else if (b.type === 'd1' || b.type === 'd1_database') { - const value = b.id ?? b.database_id; - if (value) out.push({ kind: 'd1', binding: b.name, value }); - } else if (b.type === 'vectorize') { - if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name }); - } - } - return out; -} diff --git a/cli/src/lib/resource-resolver.ts b/cli/src/lib/resource-resolver.ts index 9d41384..3a9d0bf 100644 --- a/cli/src/lib/resource-resolver.ts +++ b/cli/src/lib/resource-resolver.ts @@ -1,431 +1,42 @@ /** - * resource-resolver.ts — 資源解析:「已部署的 worker 現在綁著什麼,那就是事實」 + * resource-resolver.ts — **這裡沒有邏輯**,只是把共用規則接到 CLI 的既有 import 路徑上。 * - * 🔴 Arcrun#97(2026-08-12 實害,leo 的實例中了): - * 舊做法叫「照名字 ensure」——`acr update` 拿 **binding 名**(`WEBHOOKS`)當成 Cloudflare 上的 - * **資源標題**去找,找不到就**新建一顆空的、然後綁到 worker 上**。 - * 安裝器建的資源不叫那個名字(它叫 `arcrun-rag--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 那段寫錯」,是**「用名字猜使用者的資源」這個做法本身**: - * 名字是**使用者那側的事實**(安裝器要怎麼取名由它決定,而且它有權改), - * 我們不能拿自己的命名慣例去對號入座,更不能在對不上的時候自作主張生一顆新的。 - * ——所以修法不是「多比對幾種名字」,是**不再用名字當識別**。 + * 為什麼規則不在 CLI(leo 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 是資源 id,Vectorize 是 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; -} - -/** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */ -export interface ResourceApi { - getScriptBindings(script: string): Promise; - /** title → id */ - listKvNamespaces(): Promise>; - /** name → uuid */ - listD1Databases(): Promise>; - listVectorizeIndexes(): Promise; - createKvNamespace(title: string): Promise; - createD1Database(name: string): Promise; - createVectorizeIndex(name: string): Promise; -} - -/** 「這顆 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 var(script → 名/值)。未部署的不在裡面。 - * - * Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡—— - * 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式 - * (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。 - */ - liveVars: Map>; -} - -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 = { - 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 { - 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(); - const liveVars = new Map>(); - 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(); - 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>(); - const listExisting = async (kind: ResourceKind): Promise> => { - const hit = existingCache.get(kind); - if (hit) return hit; - let set: Set; - 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; - 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, -): PlannedCreate[] { - const declaredName = (kind: ResourceKind, binding: string): string | undefined => - byKey.get(bindingKey(kind, binding))?.[0]?.createName; - - const out: PlannedCreate[] = []; - const groups = new Map(); - - 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> { - if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers); - - const out = new Map(); - 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 = { - 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; - // 只有 D1/Vectorize 在 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'; diff --git a/cli/src/lib/resource-rule/cf-resource-api.mjs b/cli/src/lib/resource-rule/cf-resource-api.mjs new file mode 100644 index 0000000..647857f --- /dev/null +++ b/cli/src/lib/resource-rule/cf-resource-api.mjs @@ -0,0 +1,202 @@ +// @ts-check +/** + * cf-resource-api.mjs — 規則的**眼睛與手**:對 Cloudflare 帳號的那七個動作,也只有一份。 + * + * `rule.mjs` 是純判斷,IO 由呼叫端注入(`ResourceApi`)。本檔就是那個注入物的正貨: + * 用 CF REST API 實作 `ResourceApi`,零依賴、只用 global `fetch` + * ⇒ Node 18+ 與 Cloudflare Workers runtime 都能直接跑。 + * + * 【為什麼連這層也要共用】 + * 判斷一致還不夠——**看到的東西**也要一致。 + * 「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的; + * 如果兩條路各自寫一份 client,隨便一個差異(打錯端點、把 404 當錯誤、漏了 per_page、 + * 少認一種欄位名)都會讓其中一條路「看不到既有綁定」——而看不到既有綁定的下一步, + * 依規則就是**新建**。Arcrun#97 的災情不需要規則寫錯,只要眼睛不一樣就會重演。 + * + * 這裡**故意只有 `ResourceApi` 那七個方法**。verifyAccess / 查 subdomain / KV 讀寫 + * 這些跟「該用哪些資源」無關的帳號操作留在各自的呼叫端,不往共用層堆。 + * + * 🔴 除了同目錄的 `./rule.mjs`,這支不准 import 任何東西——共用層的價值在於 + * 「整個目錄複製到哪個 runtime 都能直接跑」,多一個外部依賴就少一條路吃得到。 + */ + +import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs'; + +const CF_API_BASE = 'https://api.cloudflare.com/client/v4'; + +/** + * @typedef {import('./rule.mjs').ResourceApi} ResourceApi + * @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings + * @typedef {import('./rule.mjs').RawWorkerBinding} RawWorkerBinding + */ + +/** + * @typedef {object} CfResourceApiOptions + * @property {string} accountId + * @property {string} apiToken + * @property {typeof globalThis.fetch} [fetch] + * 注入用(離線測試餵假帳號、或宿主要用自己的 fetch)。預設 global fetch。 + */ + +/** + * 建一個打真實 Cloudflare 的 `ResourceApi`。 + * + * @param {CfResourceApiOptions} options + * @returns {ResourceApi & { cfRaw: (path: string, init?: RequestInit) => Promise<{ok: boolean, status: number, result?: any, error?: string}> }} + */ +export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchImpl }) { + const doFetch = fetchImpl ?? globalThis.fetch; + if (typeof doFetch !== 'function') { + throw new Error('createCloudflareResourceApi:這個執行環境沒有 fetch,請用 options.fetch 注入。'); + } + const accountBase = `${CF_API_BASE}/accounts/${accountId}`; + const headers = { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }; + + /** + * 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 + * @param {string} path + * @param {RequestInit} [init] + * @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>} + */ + async function cfRaw(path, init) { + const res = await doFetch(`${accountBase}${path}`, { + ...init, + headers: { ...headers, ...(init?.headers ?? {}) }, + }); + const data = await res.json().catch(() => null); + if (!res.ok || !data?.success) { + return { + ok: false, + status: res.status, + error: + (data?.errors ?? []).map((/** @type {{message?: string}} */ e) => e.message).filter(Boolean).join('; ') || + `HTTP ${res.status}`, + }; + } + return { ok: true, status: res.status, result: data.result }; + } + + /** + * @param {string} path + * @param {RequestInit} [init] + * @returns {Promise} + */ + async function cf(path, init) { + const { ok, status, result, error } = await cfRaw(path, init); + if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`); + return result; + } + + return { + cfRaw, + + /** + * 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。 + * + * - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。 + * - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」—— + * 把查不到當成不存在,就是 #97 的根因。 + * + * @param {string} script + * @returns {Promise} + */ + async getScriptBindings(script) { + const path = `/workers/scripts/${encodeURIComponent(script)}/settings`; + const res = await cfRaw(path); + if (!res.ok) { + if (res.status === 404) return { deployed: false, bindings: [], vars: {} }; + throw new Error(`讀 ${script} 綁定失敗:${res.error}`); + } + /** @type {RawWorkerBinding[]} */ + const raw = res.result?.bindings ?? []; + return { + deployed: true, + bindings: normalizeLiveBindings(raw), + vars: normalizeLiveVars(raw), + }; + }, + + /** @returns {Promise>} title → id */ + async listKvNamespaces() { + /** @type {Array<{id: string, title: string}>} */ + const result = await cf('/storage/kv/namespaces?per_page=100'); + const map = new Map(); + for (const ns of result) map.set(ns.title, ns.id); + return map; + }, + + /** @returns {Promise>} name → uuid */ + async listD1Databases() { + /** @type {Array<{uuid: string, name: string}>} */ + const result = await cf('/d1/database?per_page=100'); + const map = new Map(); + for (const db of result) map.set(db.name, db.uuid); + return map; + }, + + /** @returns {Promise} */ + async listVectorizeIndexes() { + /** @type {Array<{name: string}>} */ + const result = await cf('/vectorize/v2/indexes'); + return (result ?? []).map((i) => i.name); + }, + + /** + * 無條件新建一顆 KV namespace。 + * + * 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。 + * 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路 + * (安裝器取的名字跟 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。 + * 要不要建一律先過 `planResources`。 + * + * @param {string} title + * @returns {Promise} + */ + async createKvNamespace(title) { + const result = await cf('/storage/kv/namespaces', { + method: 'POST', + body: JSON.stringify({ title }), + }); + return result.id; + }, + + /** + * 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。 + * @param {string} name + * @returns {Promise} + */ + async createD1Database(name) { + const result = await cf('/d1/database', { + method: 'POST', + body: JSON.stringify({ name }), + }); + return result.uuid; + }, + + /** + * 新建 KBDB embed 用的 Vectorize index(**bge-m3 = 1024 維 / cosine**)。 + * 已存在(409 / already exists)視為成功——並行或重跑不該炸。 + * 沒有 ensure 版本:「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。 + * + * @param {string} name + * @returns {Promise} + */ + async createVectorizeIndex(name) { + const res = await cfRaw('/vectorize/v2/indexes', { + method: 'POST', + body: JSON.stringify({ + name, + config: { dimensions: 1024, metric: 'cosine' }, + description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)', + }), + }); + if (res.ok) return name; + const detail = (res.error ?? '').toLowerCase(); + if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name; + throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`); + }, + }; +} diff --git a/cli/src/lib/resource-rule/installer-entry.mjs b/cli/src/lib/resource-rule/installer-entry.mjs new file mode 100644 index 0000000..e31e464 --- /dev/null +++ b/cli/src/lib/resource-rule/installer-entry.mjs @@ -0,0 +1,100 @@ +// @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} bindings `${kind}:${binding}` → 資源 id/index 名。 + * @property {Record} origin 同上 key → 這顆是沿用還是新建。 + * @property {Record>} liveVars script → 現有 plain_text var(#106)。 + */ + +/** + * 決定這台實例每個 binding 該用哪顆資源;照規則沿用既有、只在確定沒人綁過時才新建。 + * + * @param {ResolveOptions} options + * @returns {Promise} + */ +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} */ + 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} */ + const bindings = {}; + /** @type {Record} */ + 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) }; +} diff --git a/cli/src/lib/resource-rule/rule.mjs b/cli/src/lib/resource-rule/rule.mjs new file mode 100644 index 0000000..3acfcd1 --- /dev/null +++ b/cli/src/lib/resource-rule/rule.mjs @@ -0,0 +1,570 @@ +// @ts-check +/** + * rule.mjs — 「這個實例該用哪些資源」的**唯一一份**規則。 + * + * ───────────────────────────────────────────────────────────────────────────── + * 這份檔案為什麼在這裡(`shared/`),不在 `cli/` + * ───────────────────────────────────────────────────────────────────────────── + * leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」 + * + * `.claude/rules/07-thin-shell.md` 的判準口訣: + * 「這段邏輯換一個介面要不要重寫?」要重寫 → 它是能力,該在共用層。 + * + * 「該沿用哪幾顆資源」換到安裝器就得重寫一次 ⇒ 它是**能力**,不是薄殼的事。 + * 而它原本住在 `cli/src/lib/resource-resolver.ts` ⇒ 那本身就是違規, + * 後果也真的發生了:`acr` 那條有這條規則、安裝器那條沒有,於是安裝器照名字找、 + * 找不到就建新的空的 ⇒ Arcrun#97「我按了更新,工作流和登入全不見了」。 + * + * ── 為什麼不是 cypher-executor 的 API 端點(薄殼原則的標準答案)──────────── + * **自舉**:這條規則要在「決定怎麼裝/怎麼更新」的當下就用得到,而那個當下 + * cypher 可能還不存在(安裝器的工作正是把它生出來),或正要被覆蓋。 + * 而且判斷的輸入是**使用者自己 Cloudflare 帳號上的綁定狀態**—— + * 把它送去一顆平台託管的 worker 換一個答案,等於①讓「能不能安裝」綁在平台是否活著, + * ②把使用者的帳號拓撲交給第三方。兩件都不該為了形式上的漂亮而做。 + * + * 薄殼原則要求的是「能力只實作一次」,不是「能力一定要是 HTTP」。 + * 這條規則是**純函式**(唯一的 IO 由呼叫端注入 `ResourceApi`), + * 所以它用不著變成服務——一份零依賴的 ESM 就能讓每條路吃到同一份判斷。 + * + * ── 怎麼讓兩條路吃到「同一份」而不是各留一份 ─────────────────────────────── + * 本檔是**唯一被人手維護的實作**,零依賴、不吃任何 node 內建、Workers runtime 可直接跑。 + * · `acr`:`cli/src/lib/resource-rule.mjs` 是本檔的**逐位元組副本**, + * 由 `scripts/sync-resource-rule.mjs` 產生(CLI 要能單獨 npm publish, + * 套件目錄外的檔案打不進 tarball,故必須有這一份)。 + * `npm run build` / `npm test` 都會跑 `--check`,內容一漂就紅。 + * ——同 `cli/harness/`(產生物+世代閘)的既有慣例。 + * · 安裝器 / 任何 Worker:安裝器本來就會下載本 repo 的 archive(部署來源, + * 見 `.claude/rules/05-deploy-convention.md`「WASM 來源」), + * 直接 import 這一份 `shared/resource-rule/rule.mjs` 即可,**不需要再編一次、也不留副本**。 + * 用法見同目錄 README.md。 + * + * ───────────────────────────────────────────────────────────────────────────── + * 規則本身(leo 的兩句話) + * ───────────────────────────────────────────────────────────────────────────── + * 「如果你沒有裝,就是新的;如果你已經有,原來叫什麼名字就繼續用下去。」 + * + * 判準是「**這顆 worker 現在綁著誰**」,不是「有沒有叫這個名字的資源」: + * 1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。 + * 2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。 + * 3. **只要有一點說不準就整趟停手**(讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/ + * 該更新的 worker 一顆都不在),**什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。 + * + * ── 為什麼拆成 plan / apply 兩段 ───────────────────────────────────── + * `planResources()` **完全不寫入**,只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。 + * `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。 + * ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**, + * 不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。 + * + * 🔴 這份檔案沒有 import、也不准有。任何依賴都會讓某一條路吃不到它。 + */ + +/** + * 這支負責的資源種類。要加新種類(R2/Queue/Hyperdrive…)就加在這裡, + * 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。 + * @typedef {'kv_namespace' | 'd1' | 'vectorize'} ResourceKind + */ + +/** + * 從已部署 worker 上讀回來的一條綁定。`value`:KV/D1 是資源 id,Vectorize 是 index 名。 + * @typedef {object} LiveBinding + * @property {ResourceKind} kind + * @property {string} binding + * @property {string} value + */ + +/** + * @typedef {object} ScriptBindings + * @property {boolean} deployed + * false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 + * @property {LiveBinding[]} bindings + * @property {Record} [vars] + * 這顆 worker 現在掛著的 `plain_text` var(名 → 值)。 + * + * 🔴 Arcrun#106:#97 只把「資源類」綁定當成事實沿用(KV/D1/Vectorize), + * plain_text var 整批沒人管 ⇒ 重部署把它們洗成 repo toml 的預設值。 + * 最痛的一個是 `ARCRUN_BUNDLE_VERSION`(安裝器注入的版本標籤)—— + * 更新完就消失,Portal 設定頁變成「無法讀取目前版本」。 + * **保留了櫃子,沒保留櫃子上的標籤**。這個欄位就是那些標籤。 + */ + +/** + * 規則需要的 CF 能力(收窄成介面,方便離線測試餵假帳號,也讓安裝器用自己的 fetch 實作)。 + * @typedef {object} ResourceApi + * @property {(script: string) => Promise} getScriptBindings + * @property {() => Promise>} listKvNamespaces title → id + * @property {() => Promise>} listD1Databases name → uuid + * @property {() => Promise} listVectorizeIndexes + * @property {(title: string) => Promise} createKvNamespace + * @property {(name: string) => Promise} createD1Database + * @property {(name: string) => Promise} createVectorizeIndex + */ + +/** + * 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。 + * @typedef {object} BindingRequirement + * @property {ResourceKind} kind + * @property {string} binding + * @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。 + * @property {string} createName + */ + +/** + * @typedef {object} PlannedAdopt + * @property {ResourceKind} kind + * @property {string} binding + * @property {string} value + * @property {string} from 從哪顆已部署的 worker 上讀到的 + */ + +/** + * @typedef {object} PlannedCreate + * @property {ResourceKind} kind + * @property {string} binding + * @property {string} createName + * @property {string[]} wantedBy + * @property {string[]} alsoBind 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。 + */ + +/** + * @typedef {object} ResourcePlan + * @property {PlannedAdopt[]} adopt + * @property {PlannedCreate[]} create + * @property {string[]} blockers 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 + * @property {Map>} liveVars + * 每顆**已部署** worker 現在掛著的 plain_text var(script → 名/值)。未部署的不在裡面。 + * + * Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡—— + * 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式 + * (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。 + */ + +/** + * @typedef {object} ResolvedResource + * @property {ResourceKind} kind + * @property {string} binding + * @property {string} value + * @property {'adopted' | 'created'} origin + * @property {string} [from] + */ + +/** + * @typedef {object} WranglerRequirements + * @property {string} script worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。 + * @property {Array<{kind: ResourceKind, binding: string, createName: string}>} bindings + */ + +/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */ +export class ResourcePlanBlocked extends Error { + /** @param {string[]} blockers */ + constructor(blockers) { + super(`資源解析被擋下(${blockers.length} 項)`); + this.name = 'ResourcePlanBlocked'; + /** @type {string[]} */ + this.blockers = blockers; + } +} + +/** + * @param {ResourceKind} kind + * @param {string} binding + * @returns {string} + */ +export function bindingKey(kind, binding) { + return `${kind}:${binding}`; +} + +/** @type {Record} */ +export const KIND_LABEL = { + kv_namespace: 'KV namespace', + d1: 'D1 資料庫', + vectorize: 'Vectorize index', +}; + +/** + * @param {unknown} e + * @returns {string} + */ +function msg(e) { + return e instanceof Error ? e.message : String(e); +} + +/** + * 決定每個 binding 要沿用哪顆資源/要不要新建,**不寫入任何東西**。 + * + * @param {ResourceApi} api + * @param {readonly BindingRequirement[]} requirements + * @param {'update' | 'init'} mode + * 'update' = 這台照定義已經裝過了(見下方「一顆都不在」規則);'init' = 全新安裝,允許從零建。 + * @returns {Promise} + */ +export async function planResources(api, requirements, mode) { + /** @type {string[]} */ + const blockers = []; + /** @type {PlannedAdopt[]} */ + const adopt = []; + /** @type {PlannedCreate[]} */ + const create = []; + + // ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ────────────────── + // 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。 + const scripts = [...new Set(requirements.map((r) => r.worker))].sort(); + /** @type {Map} */ + const live = new Map(); + /** @type {Map>} */ + const liveVars = new Map(); + 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 決定:沿用 / 新建 / 停手 ───────────────────────── + /** @type {Map} */ + const byKey = new Map(); + 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]); + } + + /** @type {Map>} */ + const existingCache = new Map(); + /** @param {ResourceKind} kind @returns {Promise>} */ + const listExisting = async (kind) => { + const hit = existingCache.get(kind); + if (hit) return hit; + /** @type {Set} */ + let set; + 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]; + + /** @type {Array<{value: string, script: string}>} */ + const found = []; + 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]; + /** @type {Set} */ + let existing; + 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 目錄從此分家。 + * ② 一邊已部署(沿用既有)、另一邊沒有(新建一顆空的)→ 半套資料,比全壞更難查。 + * + * @param {PlannedAdopt[]} adopt + * @param {PlannedCreate[]} create + * @param {Map} byKey + * @returns {PlannedCreate[]} + */ +function shareSameResource(adopt, create, byKey) { + /** @param {ResourceKind} kind @param {string} binding @returns {string | undefined} */ + const declaredName = (kind, binding) => + byKey.get(bindingKey(kind, binding))?.[0]?.createName; + + /** @type {PlannedCreate[]} */ + const out = []; + /** @type {Map} */ + const groups = new Map(); + + 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,**一顆都不建**。 + * + * @param {ResourceApi} api + * @param {ResourcePlan} plan + * @returns {Promise>} + */ +export async function applyResourcePlan(api, plan) { + if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers); + + /** @type {Map} */ + const out = new Map(); + 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, + }); + } + /** @type {string[]} */ + const madeSoFar = []; + for (const c of plan.create) { + /** @type {string} */ + let value; + 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 → 需求清單 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。 + * @type {Record} + */ +export const TABLE_KIND = { + kv_namespaces: 'kv_namespace', + d1_databases: 'd1', + vectorize: 'vectorize', +}; + +/** + * 從 wrangler.toml 抽出「這顆 worker 需要哪些資源綁定」。 + * + * 刻意寫成行掃描而不引 TOML parser:注入端(injectWranglerConfig)本來就是純文字操作, + * 兩邊用同一種視角看這份檔案才不會對不上。註解掉的區塊**不算需求** + * (kbdb 的 `[[vectorize]]` 預設是註解狀態,要開語義查詢時才會被取消註解 → 那時才成為需求)。 + * + * 也是「零依賴」的一部分:不引 TOML parser ⇒ 安裝器 import 這支不必多裝任何東西。 + * + * @param {string} toml + * @returns {WranglerRequirements} + */ +export function parseWranglerRequirements(toml) { + let script = ''; + let seenTable = false; + /** @type {WranglerRequirements['bindings']} */ + const bindings = []; + + /** @type {ResourceKind | null} */ + let kind = null; + let binding = ''; + let createName = ''; + + const flush = () => { + 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; + // 只有 D1/Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。 + else if (key === 'database_name' || key === 'index_name') createName = value; + } + flush(); + + return { script, bindings }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Cloudflare `/settings` 回應 → 事實(兩條路都要用同一種眼睛看) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * CF `GET /accounts/{id}/workers/scripts/{script}/settings` 回的 binding 原始形狀 + * (同一種資源在不同 API 版本欄位名不一,故全都收)。 + * + * @typedef {object} RawWorkerBinding + * @property {string} [type] + * @property {string} [name] + * @property {string} [namespace_id] + * @property {string} [id] + * @property {string} [database_id] + * @property {string} [index_name] + * @property {string} [text] `plain_text` 綁定的值(#106;secret_text 不會回值,本來就讀不到,也不該讀)。 + */ + +/** + * 把 CF 的 binding 陣列收斂成規則認得的三種資源。不認得的型別直接略過。 + * + * 🔴 這支**刻意放在規則裡**,不留在各自的 CF client: + * 「什麼才算『這顆 worker 綁著某顆資源』」是規則的一部分。 + * 兩條路各自解讀 CF 回應 = 漂移會從這裡長回來(例如一邊認 `namespace_id`、 + * 另一邊只認 `id`,於是一邊看得到綁定、另一邊看不到 → 後者又去新建了)。 + * + * @param {RawWorkerBinding[]} raw + * @returns {LiveBinding[]} + */ +export function normalizeLiveBindings(raw) { + /** @type {LiveBinding[]} */ + const out = []; + for (const b of raw) { + if (!b?.name) continue; + if (b.type === 'kv_namespace') { + const value = b.namespace_id ?? b.id; + if (value) out.push({ kind: 'kv_namespace', binding: b.name, value }); + } else if (b.type === 'd1' || b.type === 'd1_database') { + const value = b.id ?? b.database_id; + if (value) out.push({ kind: 'd1', binding: b.name, value }); + } else if (b.type === 'vectorize') { + if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name }); + } + } + return out; +} + +/** + * 抽出已部署 worker 上的 `plain_text` var(#106)。 + * + * 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被搬來搬去; + * wrangler deploy 不會動 secret,它們自己會留著)。 + * + * @param {RawWorkerBinding[]} raw + * @returns {Record} + */ +export function normalizeLiveVars(raw) { + /** @type {Record} */ + const out = {}; + for (const b of raw) { + if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text; + } + return out; +} diff --git a/cli/tests/single-implementation.test.ts b/cli/tests/single-implementation.test.ts new file mode 100644 index 0000000..4771104 --- /dev/null +++ b/cli/tests/single-implementation.test.ts @@ -0,0 +1,111 @@ +/** + * 「只有一份」的機械證明。 + * + * leo 的驗收條件:「改完之後,`grep` 得出『決定用哪些資源』的邏輯**只有一個地方**。 + * 兩個以上呼叫端各自有一份 ⇒ 不算完成。」 + * + * 這份測試就是把那個 grep 寫成會紅的東西: + * ① 規則的每一支函式,全 repo 只有 `shared/resource-rule/` 有實作 + * (`cli/src/lib/resource-rule/` 是它的逐位元組鏡射,由 sync 腳本產生並看守,不算第二份) + * ② 鏡射與原稿逐位元組相同(sync --check 的同一道閘,這裡再測一次讓 `npm test` 也擋得住) + * ③ 共用層不准長出依賴——有依賴就會有某條路吃不到它 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; + +const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..'); +const SOURCE_DIR = join(REPO, 'shared/resource-rule'); +const MIRROR_DIR = join(REPO, 'cli/src/lib/resource-rule'); + +/** 規則的實作特徵:這些**宣告**只准出現在原稿目錄(與它的鏡射)裡。 */ +const RULE_DECLARATIONS = [ + 'function planResources', + 'function applyResourcePlan', + 'function shareSameResource', + 'function parseWranglerRequirements', + 'function normalizeLiveBindings', + 'function normalizeLiveVars', + 'function createCloudflareResourceApi', +]; + +const SKIP_DIRS = new Set([ + 'node_modules', '.git', 'dist', '.wrangler', '.worker-builds', '.component-builds', + '.github-public', 'coverage', +]); + +/** 只掃「人會寫程式的地方」;產生物與二進位不掃。 */ +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const abs = join(dir, name); + const st = statSync(abs); + if (st.isDirectory()) walk(abs, out); + else if (/\.(ts|tsx|js|mjs|cjs)$/.test(name)) out.push(abs); + } + return out; +} + +const sha256 = (b: Buffer): string => createHash('sha256').update(b).digest('hex'); + +test('① 規則的實作全 repo 只有一份(原稿目錄 + 它的鏡射,沒有第三處)', () => { + const files = walk(REPO); + const offenders: string[] = []; + + for (const abs of files) { + const rel = relative(REPO, abs); + // 原稿與鏡射本來就該有;測試檔在講規則、不是實作規則 + if (rel.startsWith('shared/resource-rule/')) continue; + if (rel.startsWith('cli/src/lib/resource-rule/')) continue; + if (rel.startsWith('cli/tests/')) continue; + if (rel === 'scripts/sync-resource-rule.mjs') continue; + + const src = readFileSync(abs, 'utf8'); + for (const decl of RULE_DECLARATIONS) { + if (src.includes(decl)) offenders.push(`${rel} → ${decl}`); + } + } + + assert.deepEqual(offenders, [], + '「決定用哪些資源」的實作出現在共用層之外——這正是本票要消滅的東西:\n' + + offenders.map((o) => ` • ${o}`).join('\n') + + '\n要改規則就改 shared/resource-rule/,呼叫端只准 import。'); + + console.log(`\n ① 掃過 ${files.length} 個原始碼檔,${RULE_DECLARATIONS.length} 支規則函式的實作` + + ' 全部只出現在 shared/resource-rule/(+機械鏡射)'); +}); + +test('② CLI 帶的那份與原稿逐位元組相同(漂移=第二份實作偷偷長出來)', () => { + const files = readdirSync(SOURCE_DIR).filter((f) => f.endsWith('.mjs')).sort(); + assert.ok(files.length > 0, 'shared/resource-rule/ 裡沒有任何 .mjs 原稿'); + + const mirrored = readdirSync(MIRROR_DIR).filter((f) => f.endsWith('.mjs')).sort(); + assert.deepEqual(mirrored, files, '鏡射目錄的檔案清單與原稿不一致'); + + for (const f of files) { + const a = sha256(readFileSync(join(SOURCE_DIR, f))); + const b = sha256(readFileSync(join(MIRROR_DIR, f))); + assert.equal(b, a, `cli/src/lib/resource-rule/${f} 與原稿不一致——不要手改產生物,` + + '改 shared/resource-rule/ 後跑 node scripts/sync-resource-rule.mjs'); + console.log(` ② ${f.padEnd(24)} sha256 ${a.slice(0, 16)} 原稿 = 鏡射`); + } +}); + +test('③ 共用層零外部依賴(只准 import 同目錄的兄弟檔)', () => { + for (const f of readdirSync(SOURCE_DIR).filter((x) => x.endsWith('.mjs'))) { + const src = readFileSync(join(SOURCE_DIR, f), 'utf8'); + const imports = [...src.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)].map((m) => m[1]); + for (const spec of imports) { + assert.ok(spec.startsWith('./'), + `shared/resource-rule/${f} import 了 "${spec}"——共用層一旦有外部依賴,` + + '就會有某條路(Workers runtime/安裝器)吃不到它。'); + } + assert.doesNotMatch(src, /require\(|from\s+['"]node:/, + `shared/resource-rule/${f} 用到 node 專屬 API——Cloudflare Workers 上跑不起來。`); + console.log(` ③ ${f.padEnd(24)} import: ${imports.length ? imports.join(', ') : '(無)'}`); + } +}); diff --git a/cli/tests/two-paths-agree.test.ts b/cli/tests/two-paths-agree.test.ts new file mode 100644 index 0000000..fe780a7 --- /dev/null +++ b/cli/tests/two-paths-agree.test.ts @@ -0,0 +1,159 @@ +/** + * 兩條路必須得出同一個答案 —— 本票的核心驗收。 + * + * leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」 + * + * 後果已經真的發生過:`acr` 那條有 Arcrun#97 的修法、安裝器那條沒有, + * 於是安裝器照名字找、找不到就建一顆空的綁上去 ⇒ 使用者的工作流與登入狀態整片消失。 + * + * 這份測試把**同一個帳號狀態**餵給兩條路: + * A. `acr` 那條:`CfAccountClient` + `resource-resolver`(CLI 真正跑的 import 鏈) + * B. 安裝器那條:只 import `shared/resource-rule/`(安裝器唯一該碰的入口) + * 然後比對它們選出的 **resource id 必須相同**。 + * + * 假的是 `fetch`,不是 `ResourceApi`——所以兩條路都真的走完 HTTP → 解析 → 判斷整條鏈。 + * 只測判斷會漏掉「怎麼把 CF 回應讀成事實」,而 #97 的重演只要眼睛不一樣就夠了。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── A:acr 那條(CLI 真正用的東西) +import { CfAccountClient } from '../src/lib/cf-api.ts'; +import { planResources, applyResourcePlan, bindingKey } from '../src/lib/resource-resolver.ts'; +import type { BindingRequirement } from '../src/lib/resource-resolver.ts'; + +// ── B:安裝器那條(只碰 shared/) +import { resolveInstanceResources } from '../../shared/resource-rule/installer-entry.mjs'; + +// ── 共用 fixture +import { + makeAccount, + requirements, + SCENARIOS, + WORKER_NEEDS, + type Scenario, +} from '../../shared/resource-rule/tests/fixture-account.mjs'; + +const ACCOUNT = 'acct-fixture'; +const TOKEN = 'tok-fixture'; + +/** 把 fixture 的需求組成安裝器吃的 wrangler.toml 文字(它的入口是從 toml 讀需求的)。 */ +function tomlsFor(): string[] { + return Object.entries(WORKER_NEEDS).map(([script, need]) => { + let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`; + for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`; + for (const d of need.d1) { + t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`; + } + return t; + }); +} + +/** A:跑 acr 那條。CfAccountClient 走 global fetch,所以這裡把它換成 fixture。 */ +async function runAcrPath(scenario: Scenario, mode: 'update' | 'init') { + const account = makeAccount(scenario); + const realFetch = globalThis.fetch; + globalThis.fetch = account.fetch; + try { + const api = new CfAccountClient(ACCOUNT, TOKEN); + const plan = await planResources(api, requirements() as BindingRequirement[], mode); + if (plan.blockers.length > 0) { + return { blocked: true, blockers: plan.blockers, bindings: {} as Record, account }; + } + const resolved = await applyResourcePlan(api, plan); + const bindings: Record = {}; + for (const [k, r] of resolved) bindings[k] = r.value; + return { blocked: false, blockers: [] as string[], bindings, account }; + } finally { + globalThis.fetch = realFetch; + } +} + +/** B:跑安裝器那條。只用 shared/ 的入口,fetch 直接注入。 */ +async function runInstallerPath(scenario: Scenario, mode: 'update' | 'init') { + const account = makeAccount(scenario); + const r = await resolveInstanceResources({ + accountId: ACCOUNT, + apiToken: TOKEN, + wranglerTomls: tomlsFor(), + mode, + fetch: account.fetch, + }); + return { blocked: r.blocked, blockers: r.blockers, bindings: r.bindings, account }; +} + +/** 把兩邊的決定印出來——PR 要貼的就是這張對照表。 */ +function report(scenario: Scenario, a: Record, b: Record): void { + const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort(); + console.log(`\n ── ${scenario}:${SCENARIOS[scenario].label}`); + console.log(` ${'binding'.padEnd(34)} ${'acr 選的'.padEnd(24)} 安裝器選的 一致?`); + for (const k of keys) { + const same = a[k] === b[k] ? '✓' : '✗'; + console.log(` ${k.padEnd(34)} ${(a[k] ?? '—').padEnd(24)} ${(b[k] ?? '—').padEnd(18)} ${same}`); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +for (const scenario of ['fresh', 'installed', 'renamed'] as const) { + const mode = scenario === 'fresh' ? 'init' : 'update'; + + test(`兩條路一致 — ${scenario}:${SCENARIOS[scenario].label}`, async () => { + const a = await runAcrPath(scenario, mode); + const b = await runInstallerPath(scenario, mode); + + assert.equal(a.blocked, b.blocked, '一邊停手、一邊照做 = 最危險的分歧'); + assert.deepEqual(a.blockers, b.blockers, '停手的理由也要一樣'); + report(scenario, a.bindings, b.bindings); + assert.deepEqual( + a.bindings, + b.bindings, + `${scenario}:兩條路選出的 resource id 不同——這就是 Arcrun#97 的形狀`, + ); + + // 建立行為也要一致(一邊沿用、一邊新建 = 使用者的東西在其中一條路上會消失) + assert.deepEqual(a.account.created, b.account.created, '兩條路「建了什麼」必須一樣'); + }); +} + +// ── 三種情境各自該有的行為(不只是「兩邊一樣」,還要「一樣地對」)───────────── + +test('情境① 沒裝過 → 正常建新的(不能為了沿用而變成永遠不建)', async () => { + const { blocked, bindings, account } = await runInstallerPath('fresh', 'init'); + assert.equal(blocked, false, '全新帳號要裝得起來'); + assert.equal(account.created.kv.length, 9, `應新建 9 顆 KV,實際 ${account.created.kv.length}`); + assert.equal(account.created.d1.length, 1, `應新建 1 顆 D1,實際 ${account.created.d1.length}`); + // cypher 的 CREDENTIALS_DB 與 kbdb 的 DB 宣告同一個 database_name → 只該建一顆,兩邊共用 + assert.equal(bindings['d1:CREDENTIALS_DB'], bindings['d1:DB'], '同一顆 D1 不該被建成兩顆'); + console.log(`\n ① 新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length} 顆` + + `(D1 共用:CREDENTIALS_DB = DB = ${bindings['d1:DB']})`); +}); + +test('情境② 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在', async () => { + const { blocked, bindings, account } = await runInstallerPath('installed', 'update'); + assert.equal(blocked, false); + assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] }, '更新不該建出任何新資源'); + + // 使用者的東西掛在資源 id 上:綁定還指向原本那顆 = 東西還在 + assert.equal(bindings['kv_namespace:WEBHOOKS'], account.kvIdFor('WEBHOOKS')); + assert.equal(bindings['kv_namespace:SESSIONS_KV'], account.kvIdFor('SESSIONS_KV')); + assert.equal(bindings['d1:DB'], account.d1Id); + console.log(`\n ② 沿用:WEBHOOKS → ${bindings['kv_namespace:WEBHOOKS']}` + + `(工作流 ${account.userData.workflows.length} 支還在)|` + + `SESSIONS_KV → ${bindings['kv_namespace:SESSIONS_KV']}(登入 session 還在)|` + + `DB → ${bindings['d1:DB']}(子庫 ${account.userData.libraries.length} 個還在)|新建 0 顆`); +}); + +test('情境③ 資源在但名字與預期完全不同 → 仍然沿用(#97 的病根,專門驗)', async () => { + const { blocked, bindings, account } = await runInstallerPath('renamed', 'update'); + assert.equal(blocked, false); + assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] }, + '名字對不上就新建 = 正是 #97:一次更新生出 9 顆空 KV,使用者的東西從畫面上消失'); + for (const b of ['WEBHOOKS', 'SESSIONS_KV', 'RECIPES', 'USERS_KV']) { + assert.equal(bindings[bindingKey('kv_namespace', b)], account.kvIdFor(b), + `${b} 沒有沿用到原本那顆`); + } + console.log(`\n ③ 名字全不同(例:WEBHOOKS 那顆實際叫 "${SCENARIOS.renamed.titleFor('WEBHOOKS')}")` + + ` → 仍沿用 ${bindings['kv_namespace:WEBHOOKS']},新建 0 顆`); +}); diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 79e4afb..1519e0a 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -6,6 +6,11 @@ "outDir": "./dist", "rootDir": "./src", "strict": true, + // resource-rule.mjs 是共用規則的副本(純 JS + JSDoc,零依賴,見該檔開頭)。 + // allowJs 讓 tsc 把它一起編進 dist(否則 npm 套件裡會缺這支 → 執行期 MODULE_NOT_FOUND); + // checkJs 讓它的 JSDoc 型別真的被檢查,而不是靜靜地當 any。 + "allowJs": true, + "checkJs": true, "esModuleInterop": true, "skipLibCheck": true, "declaration": true, diff --git a/scripts/sync-resource-rule.mjs b/scripts/sync-resource-rule.mjs new file mode 100644 index 0000000..87b5c46 --- /dev/null +++ b/scripts/sync-resource-rule.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * sync-resource-rule.mjs — 把「該用哪些資源」這條規則的**唯一原稿**同步給需要打包的呼叫端。 + * + * 【為什麼需要這支】 + * 規則的原稿在 `shared/resource-rule/rule.mjs`(理由見該檔開頭)。 + * 兩條路取用它的方式不同: + * + * · **安裝器 / 任何 Worker**:本來就會下載這個 repo 的 archive 當部署來源, + * 直接 import `shared/resource-rule/rule.mjs`。**不需要副本,本支不管它。** + * + * · **`acr` CLI**:`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 + * ⇒ 套件裡必須有一份。這支就是產生那一份的地方。 + * + * 【這算不算「第二份實作」】 + * 不算,而且是機械保證的:產生物是**逐位元組副本**,`--check` 一有差就 exit 1, + * 而 `npm run build` 與 `npm test` 都會先跑 `--check`。 + * 也就是說「有人手改了 CLI 那一份」= build 紅、publish 擋下。 + * ——同 `cli/harness/`(產生物進 repo + `check:harness` 世代閘)的既有慣例, + * 不是為本票新發明的做法。 + * + * 用法: + * node scripts/sync-resource-rule.mjs 產生/更新副本 + * node scripts/sync-resource-rule.mjs --check 只檢查,有漂移就 exit 1(不寫檔) + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; + +// REPO 一律由本檔位置推導,不吃 cwd(比照 scripts/build-worker-artifacts.mjs)。 +const REPO = resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); +const SOURCE_DIR = join(REPO, 'shared/resource-rule'); + +/** + * 需要「套件內自帶一份」的呼叫端。**整個目錄原樣鏡射**(不是挑檔案)—— + * 檔名與相對位置保持一致,`cf-resource-api.mjs` 裡的 `./rule.mjs` 才不用改寫。 + * 安裝器不在此列:它直接讀 repo archive 裡的原稿,連副本都不需要。 + */ +const MIRRORS = ['cli/src/lib/resource-rule']; + +const CHECK_ONLY = process.argv.includes('--check'); + +/** @param {string|Buffer} b */ +const sha256 = (b) => createHash('sha256').update(b).digest('hex'); + +if (!existsSync(SOURCE_DIR)) { + console.error(`❌ 找不到規則原稿目錄:${SOURCE_DIR}`); + process.exit(1); +} + +/** 原稿目錄裡所有 .mjs(README / 測試不進副本)。 */ +const FILES = readdirSync(SOURCE_DIR).filter((f) => f.endsWith('.mjs')).sort(); +if (FILES.length === 0) { + console.error(`❌ ${SOURCE_DIR} 裡沒有任何 .mjs 原稿`); + process.exit(1); +} + +let drifted = 0; +for (const mirror of MIRRORS) { + for (const file of FILES) { + const src = readFileSync(join(SOURCE_DIR, file)); + const srcHash = sha256(src); + const rel = `${mirror}/${file}`; + const abs = join(REPO, mirror, file); + const had = existsSync(abs) ? readFileSync(abs) : null; + + if (had !== null && sha256(had) === srcHash) { + console.log(`✔ ${rel} = 原稿(sha256 ${srcHash.slice(0, 12)})`); + continue; + } + + if (CHECK_ONLY) { + drifted++; + console.error( + had === null + ? `✗ ${rel} 不存在——跑 \`node scripts/sync-resource-rule.mjs\` 產生。` + : `✗ ${rel} 與原稿不一致(副本 ${sha256(had).slice(0, 12)} ≠ 原稿 ${srcHash.slice(0, 12)})。\n` + + ` 這一份是**產生物**,不要手改:規則要改就改 shared/resource-rule/${file},` + + `然後跑 \`node scripts/sync-resource-rule.mjs\`。`, + ); + continue; + } + + mkdirSync(join(REPO, mirror), { recursive: true }); + writeFileSync(abs, src); + console.log(`↻ ${rel} ← shared/resource-rule/${file}(sha256 ${srcHash.slice(0, 12)})`); + } + + // 副本目錄裡多出來的 .mjs = 有人在產生物旁邊自己加了一支(第二份實作的常見長法)。 + const mirrorAbs = join(REPO, mirror); + const extra = existsSync(mirrorAbs) + ? readdirSync(mirrorAbs).filter((f) => f.endsWith('.mjs') && !FILES.includes(f)) + : []; + for (const f of extra) { + drifted++; + console.error(`✗ ${mirror}/${f} 在原稿目錄裡不存在——副本目錄不是放自己東西的地方。`); + } +} + +if (drifted > 0) { + console.error( + `\n❌ ${drifted} 項與規則原稿脫節。` + + `\n「該用哪些資源」只能有一份實作(.claude/rules/07-thin-shell.md)——` + + `副本漂移就是第二份實作偷偷長出來的樣子。`, + ); + process.exit(1); +} diff --git a/shared/resource-rule/README.md b/shared/resource-rule/README.md new file mode 100644 index 0000000..b2f20ec --- /dev/null +++ b/shared/resource-rule/README.md @@ -0,0 +1,138 @@ +# `shared/resource-rule` — 「這個實例該用哪些資源」的唯一一份規則 + +> leo 2026-08-12: +> ①「如果你沒有裝,就是新的;**如果你已經有,原來叫什麼名字就繼續用下去**。」 +> ②「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」 + +① 是規則本身,② 是它該住哪裡。這個目錄就是 ②。 + +--- + +## 1. 規則(三句話) + +判準是「**這顆 worker 現在綁著誰**」,**不是**「有沒有叫這個名字的資源」。 + +1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。 +2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。 +3. **只要有一點說不準就整趟停手**——讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/ + 該更新的 worker 一顆都不在 ⇒ **什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。 + +`planResources()`(不寫入,只出計畫)與 `applyResourcePlan()`(有 blocker 就拒絕執行)分兩段, +所以「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,不是靠誰記得寫 early return。 + +--- + +## 2. 為什麼在這裡,不在 cypher-executor 的 API + +`.claude/rules/07-thin-shell.md` 的標準答案是「能力放 API」。這一條**不走那條路**,理由是自舉: + +| 問題 | 說明 | +|---|---| +| **cypher 可能還不存在** | 這條規則要在「決定怎麼裝」的當下就用得到,而安裝器的工作正是把 cypher 生出來。把規則放進 cypher = 要先有雞才能有蛋。 | +| **輸入是使用者自己的帳號狀態** | 判斷的依據是使用者 Cloudflare 帳號上的綁定。送去平台託管的 worker 換一個答案 ⇒ ①「能不能安裝」綁在平台是否活著,②使用者的帳號拓撲交給第三方。 | +| **它根本不需要是服務** | 這是**純函式**:唯一的 IO 由呼叫端注入(`ResourceApi`)。薄殼原則要求「能力只實作一次」,不是「能力一定要是 HTTP」。 | + +所以形態是**一份零依賴的 ESM**——Node 18+ 與 Cloudflare Workers runtime 都能直接 import, +不必編譯、不必連網、不必先有任何 arcrun 元件活著。 + +其他評估過的形態:**共用 npm 套件** → 要多發一個 package + token,且安裝器得先 `npm i` 才能判斷, +自舉問題只是換個位置;**做成一顆零件** → 得用 TinyGo/AssemblyScript 重寫一次,那正是「第二份實作」。 + +--- + +## 3. 檔案 + +| 檔案 | 內容 | +|---|---| +| `rule.mjs` | 規則本體:`planResources` / `applyResourcePlan` / `parseWranglerRequirements` + 把 CF 回應讀成事實的 `normalizeLiveBindings` / `normalizeLiveVars` | +| `cf-resource-api.mjs` | `ResourceApi` 的 CF REST 實作(只用 global `fetch`)。**眼睛也要共用**——見下 §5 | +| `installer-entry.mjs` | 安裝器唯一該碰的入口:`resolveInstanceResources()` | +| `tests/fixture-account.mjs` | 假 Cloudflare 帳號(`fetch` 替身)+三種情境 | +| `tests/demo.mjs` | `node shared/resource-rule/tests/demo.mjs`——零依賴、零建置就能跑的示範 | + +🔴 **零依賴是硬規則**:只准 import 同目錄的兄弟檔,不准碰 `node:*`。 +有外部依賴就會有某條路吃不到它。`cli/tests/single-implementation.test.ts` ③ 會擋。 + +--- + +## 4. 兩條路怎麼取用 + +### 安裝器 / 任何 Worker(不需要副本) + +安裝器本來就會下載本 repo 的 archive 當部署來源(`.claude/rules/05-deploy-convention.md` +「WASM 來源」),`shared/resource-rule/` 就在那份 archive 裡: + +```js +import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs'; + +const r = await resolveInstanceResources({ + accountId, apiToken, + wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // toml 的「內容」,不是路徑 + mode: isUpdate ? 'update' : 'init', +}); + +if (r.blocked) { + // 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。 + return showAndStop(r.blockers); +} +// r.bindings : { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … } +// r.origin : { 'kv_namespace:WEBHOOKS': 'adopted' | 'created', … } +// r.liveVars : { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } } ← #106 +``` + +**安裝器不准自己判斷要不要建資源**,也不准自己解讀 CF 的 binding 回應。只呼叫這一支。 + +### `acr` CLI(需要一份鏡射) + +`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒ 套件裡必須自帶一份。 +`cli/src/lib/resource-rule/` 就是本目錄的**逐位元組鏡射**,由 +`node scripts/sync-resource-rule.mjs` 產生。 + +**要改規則就改這個目錄,然後重跑 sync。** 手改鏡射會被擋下: +`npm run build` 與 `npm test` 都先跑 `sync-resource-rule.mjs --check`, +差一個位元組就 exit 1(同 `cli/harness/` 的產生物+世代閘慣例)。 + +--- + +## 5. 為什麼連 CF client 也共用 + +判斷一致還不夠,**看到的東西**也要一致。 + +「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的。 +兩條路各自寫一份 client,只要有一邊把 404 當錯誤、漏了 `per_page`、少認一種欄位名 +(`namespace_id` vs `id`),那一邊就會「看不到既有綁定」—— +而看不到既有綁定的下一步,依規則就是**新建**。 + +**Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。** +所以 `cli/src/lib/cf-api.ts` 的 `CfAccountClient` 把 `ResourceApi` 那七個方法**全部委派** +給 `cf-resource-api.mjs`,自己不留實作。 + +--- + +## 6. 驗收 + +```bash +cd cli && npm test # 58 項,含下列三組 +node shared/resource-rule/tests/demo.mjs # 安裝器那條路,零依賴獨立跑 +``` + +| 測試 | 證的事 | +|---|---| +| `cli/tests/two-paths-agree.test.ts` | 同一個帳號狀態餵給 `acr` 那條與安裝器那條,**選出的 resource id 相同**、建的東西相同、停手的理由相同 | +| `cli/tests/single-implementation.test.ts` | ①規則的 7 支函式全 repo 只有這裡有實作 ②鏡射逐位元組相同 ③共用層零依賴 | +| `cli/tests/resource-adoption.test.ts` | #97 本身的迴歸(沿用/不多建/四種停手情境),改共用層後照樣全過 | + +三種情境(`tests/fixture-account.mjs` 的 `SCENARIOS`): + +- `fresh` — 沒裝過 → **正常建新的**(不能為了沿用而變成永遠不建) +- `installed` — 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在 +- `renamed` — **資源在但名字與預期完全不同** → 仍然沿用(#97 的病根,專門驗) + +--- + +## 7. 相關 + +- `Arcrun#97` — 「我按了更新,工作流和登入全不見了」:CLI 那條已修,本目錄是把同一條規則交給所有路徑 +- `Arcrun#106` — 重部署把 `plain_text` var(含版本標籤)洗掉:`liveVars` 就是那些標籤 +- `Arcrun#80` / `arcrun-rag#39` — 同一個「重複做 Arcrun 的工作」家族;Arcrun 是唯一編譯點的既有慣例 +- `.claude/rules/07-thin-shell.md` — 本目錄存在的依據 diff --git a/shared/resource-rule/cf-resource-api.mjs b/shared/resource-rule/cf-resource-api.mjs new file mode 100644 index 0000000..647857f --- /dev/null +++ b/shared/resource-rule/cf-resource-api.mjs @@ -0,0 +1,202 @@ +// @ts-check +/** + * cf-resource-api.mjs — 規則的**眼睛與手**:對 Cloudflare 帳號的那七個動作,也只有一份。 + * + * `rule.mjs` 是純判斷,IO 由呼叫端注入(`ResourceApi`)。本檔就是那個注入物的正貨: + * 用 CF REST API 實作 `ResourceApi`,零依賴、只用 global `fetch` + * ⇒ Node 18+ 與 Cloudflare Workers runtime 都能直接跑。 + * + * 【為什麼連這層也要共用】 + * 判斷一致還不夠——**看到的東西**也要一致。 + * 「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的; + * 如果兩條路各自寫一份 client,隨便一個差異(打錯端點、把 404 當錯誤、漏了 per_page、 + * 少認一種欄位名)都會讓其中一條路「看不到既有綁定」——而看不到既有綁定的下一步, + * 依規則就是**新建**。Arcrun#97 的災情不需要規則寫錯,只要眼睛不一樣就會重演。 + * + * 這裡**故意只有 `ResourceApi` 那七個方法**。verifyAccess / 查 subdomain / KV 讀寫 + * 這些跟「該用哪些資源」無關的帳號操作留在各自的呼叫端,不往共用層堆。 + * + * 🔴 除了同目錄的 `./rule.mjs`,這支不准 import 任何東西——共用層的價值在於 + * 「整個目錄複製到哪個 runtime 都能直接跑」,多一個外部依賴就少一條路吃得到。 + */ + +import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs'; + +const CF_API_BASE = 'https://api.cloudflare.com/client/v4'; + +/** + * @typedef {import('./rule.mjs').ResourceApi} ResourceApi + * @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings + * @typedef {import('./rule.mjs').RawWorkerBinding} RawWorkerBinding + */ + +/** + * @typedef {object} CfResourceApiOptions + * @property {string} accountId + * @property {string} apiToken + * @property {typeof globalThis.fetch} [fetch] + * 注入用(離線測試餵假帳號、或宿主要用自己的 fetch)。預設 global fetch。 + */ + +/** + * 建一個打真實 Cloudflare 的 `ResourceApi`。 + * + * @param {CfResourceApiOptions} options + * @returns {ResourceApi & { cfRaw: (path: string, init?: RequestInit) => Promise<{ok: boolean, status: number, result?: any, error?: string}> }} + */ +export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchImpl }) { + const doFetch = fetchImpl ?? globalThis.fetch; + if (typeof doFetch !== 'function') { + throw new Error('createCloudflareResourceApi:這個執行環境沒有 fetch,請用 options.fetch 注入。'); + } + const accountBase = `${CF_API_BASE}/accounts/${accountId}`; + const headers = { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }; + + /** + * 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 + * @param {string} path + * @param {RequestInit} [init] + * @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>} + */ + async function cfRaw(path, init) { + const res = await doFetch(`${accountBase}${path}`, { + ...init, + headers: { ...headers, ...(init?.headers ?? {}) }, + }); + const data = await res.json().catch(() => null); + if (!res.ok || !data?.success) { + return { + ok: false, + status: res.status, + error: + (data?.errors ?? []).map((/** @type {{message?: string}} */ e) => e.message).filter(Boolean).join('; ') || + `HTTP ${res.status}`, + }; + } + return { ok: true, status: res.status, result: data.result }; + } + + /** + * @param {string} path + * @param {RequestInit} [init] + * @returns {Promise} + */ + async function cf(path, init) { + const { ok, status, result, error } = await cfRaw(path, init); + if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`); + return result; + } + + return { + cfRaw, + + /** + * 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。 + * + * - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。 + * - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」—— + * 把查不到當成不存在,就是 #97 的根因。 + * + * @param {string} script + * @returns {Promise} + */ + async getScriptBindings(script) { + const path = `/workers/scripts/${encodeURIComponent(script)}/settings`; + const res = await cfRaw(path); + if (!res.ok) { + if (res.status === 404) return { deployed: false, bindings: [], vars: {} }; + throw new Error(`讀 ${script} 綁定失敗:${res.error}`); + } + /** @type {RawWorkerBinding[]} */ + const raw = res.result?.bindings ?? []; + return { + deployed: true, + bindings: normalizeLiveBindings(raw), + vars: normalizeLiveVars(raw), + }; + }, + + /** @returns {Promise>} title → id */ + async listKvNamespaces() { + /** @type {Array<{id: string, title: string}>} */ + const result = await cf('/storage/kv/namespaces?per_page=100'); + const map = new Map(); + for (const ns of result) map.set(ns.title, ns.id); + return map; + }, + + /** @returns {Promise>} name → uuid */ + async listD1Databases() { + /** @type {Array<{uuid: string, name: string}>} */ + const result = await cf('/d1/database?per_page=100'); + const map = new Map(); + for (const db of result) map.set(db.name, db.uuid); + return map; + }, + + /** @returns {Promise} */ + async listVectorizeIndexes() { + /** @type {Array<{name: string}>} */ + const result = await cf('/vectorize/v2/indexes'); + return (result ?? []).map((i) => i.name); + }, + + /** + * 無條件新建一顆 KV namespace。 + * + * 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。 + * 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路 + * (安裝器取的名字跟 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。 + * 要不要建一律先過 `planResources`。 + * + * @param {string} title + * @returns {Promise} + */ + async createKvNamespace(title) { + const result = await cf('/storage/kv/namespaces', { + method: 'POST', + body: JSON.stringify({ title }), + }); + return result.id; + }, + + /** + * 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。 + * @param {string} name + * @returns {Promise} + */ + async createD1Database(name) { + const result = await cf('/d1/database', { + method: 'POST', + body: JSON.stringify({ name }), + }); + return result.uuid; + }, + + /** + * 新建 KBDB embed 用的 Vectorize index(**bge-m3 = 1024 維 / cosine**)。 + * 已存在(409 / already exists)視為成功——並行或重跑不該炸。 + * 沒有 ensure 版本:「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。 + * + * @param {string} name + * @returns {Promise} + */ + async createVectorizeIndex(name) { + const res = await cfRaw('/vectorize/v2/indexes', { + method: 'POST', + body: JSON.stringify({ + name, + config: { dimensions: 1024, metric: 'cosine' }, + description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)', + }), + }); + if (res.ok) return name; + const detail = (res.error ?? '').toLowerCase(); + if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name; + throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`); + }, + }; +} diff --git a/shared/resource-rule/installer-entry.mjs b/shared/resource-rule/installer-entry.mjs new file mode 100644 index 0000000..e31e464 --- /dev/null +++ b/shared/resource-rule/installer-entry.mjs @@ -0,0 +1,100 @@ +// @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} bindings `${kind}:${binding}` → 資源 id/index 名。 + * @property {Record} origin 同上 key → 這顆是沿用還是新建。 + * @property {Record>} liveVars script → 現有 plain_text var(#106)。 + */ + +/** + * 決定這台實例每個 binding 該用哪顆資源;照規則沿用既有、只在確定沒人綁過時才新建。 + * + * @param {ResolveOptions} options + * @returns {Promise} + */ +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} */ + 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} */ + const bindings = {}; + /** @type {Record} */ + 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) }; +} diff --git a/shared/resource-rule/rule.mjs b/shared/resource-rule/rule.mjs new file mode 100644 index 0000000..3acfcd1 --- /dev/null +++ b/shared/resource-rule/rule.mjs @@ -0,0 +1,570 @@ +// @ts-check +/** + * rule.mjs — 「這個實例該用哪些資源」的**唯一一份**規則。 + * + * ───────────────────────────────────────────────────────────────────────────── + * 這份檔案為什麼在這裡(`shared/`),不在 `cli/` + * ───────────────────────────────────────────────────────────────────────────── + * leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」 + * + * `.claude/rules/07-thin-shell.md` 的判準口訣: + * 「這段邏輯換一個介面要不要重寫?」要重寫 → 它是能力,該在共用層。 + * + * 「該沿用哪幾顆資源」換到安裝器就得重寫一次 ⇒ 它是**能力**,不是薄殼的事。 + * 而它原本住在 `cli/src/lib/resource-resolver.ts` ⇒ 那本身就是違規, + * 後果也真的發生了:`acr` 那條有這條規則、安裝器那條沒有,於是安裝器照名字找、 + * 找不到就建新的空的 ⇒ Arcrun#97「我按了更新,工作流和登入全不見了」。 + * + * ── 為什麼不是 cypher-executor 的 API 端點(薄殼原則的標準答案)──────────── + * **自舉**:這條規則要在「決定怎麼裝/怎麼更新」的當下就用得到,而那個當下 + * cypher 可能還不存在(安裝器的工作正是把它生出來),或正要被覆蓋。 + * 而且判斷的輸入是**使用者自己 Cloudflare 帳號上的綁定狀態**—— + * 把它送去一顆平台託管的 worker 換一個答案,等於①讓「能不能安裝」綁在平台是否活著, + * ②把使用者的帳號拓撲交給第三方。兩件都不該為了形式上的漂亮而做。 + * + * 薄殼原則要求的是「能力只實作一次」,不是「能力一定要是 HTTP」。 + * 這條規則是**純函式**(唯一的 IO 由呼叫端注入 `ResourceApi`), + * 所以它用不著變成服務——一份零依賴的 ESM 就能讓每條路吃到同一份判斷。 + * + * ── 怎麼讓兩條路吃到「同一份」而不是各留一份 ─────────────────────────────── + * 本檔是**唯一被人手維護的實作**,零依賴、不吃任何 node 內建、Workers runtime 可直接跑。 + * · `acr`:`cli/src/lib/resource-rule.mjs` 是本檔的**逐位元組副本**, + * 由 `scripts/sync-resource-rule.mjs` 產生(CLI 要能單獨 npm publish, + * 套件目錄外的檔案打不進 tarball,故必須有這一份)。 + * `npm run build` / `npm test` 都會跑 `--check`,內容一漂就紅。 + * ——同 `cli/harness/`(產生物+世代閘)的既有慣例。 + * · 安裝器 / 任何 Worker:安裝器本來就會下載本 repo 的 archive(部署來源, + * 見 `.claude/rules/05-deploy-convention.md`「WASM 來源」), + * 直接 import 這一份 `shared/resource-rule/rule.mjs` 即可,**不需要再編一次、也不留副本**。 + * 用法見同目錄 README.md。 + * + * ───────────────────────────────────────────────────────────────────────────── + * 規則本身(leo 的兩句話) + * ───────────────────────────────────────────────────────────────────────────── + * 「如果你沒有裝,就是新的;如果你已經有,原來叫什麼名字就繼續用下去。」 + * + * 判準是「**這顆 worker 現在綁著誰**」,不是「有沒有叫這個名字的資源」: + * 1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。 + * 2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。 + * 3. **只要有一點說不準就整趟停手**(讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/ + * 該更新的 worker 一顆都不在),**什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。 + * + * ── 為什麼拆成 plan / apply 兩段 ───────────────────────────────────── + * `planResources()` **完全不寫入**,只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。 + * `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。 + * ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**, + * 不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。 + * + * 🔴 這份檔案沒有 import、也不准有。任何依賴都會讓某一條路吃不到它。 + */ + +/** + * 這支負責的資源種類。要加新種類(R2/Queue/Hyperdrive…)就加在這裡, + * 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。 + * @typedef {'kv_namespace' | 'd1' | 'vectorize'} ResourceKind + */ + +/** + * 從已部署 worker 上讀回來的一條綁定。`value`:KV/D1 是資源 id,Vectorize 是 index 名。 + * @typedef {object} LiveBinding + * @property {ResourceKind} kind + * @property {string} binding + * @property {string} value + */ + +/** + * @typedef {object} ScriptBindings + * @property {boolean} deployed + * false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 + * @property {LiveBinding[]} bindings + * @property {Record} [vars] + * 這顆 worker 現在掛著的 `plain_text` var(名 → 值)。 + * + * 🔴 Arcrun#106:#97 只把「資源類」綁定當成事實沿用(KV/D1/Vectorize), + * plain_text var 整批沒人管 ⇒ 重部署把它們洗成 repo toml 的預設值。 + * 最痛的一個是 `ARCRUN_BUNDLE_VERSION`(安裝器注入的版本標籤)—— + * 更新完就消失,Portal 設定頁變成「無法讀取目前版本」。 + * **保留了櫃子,沒保留櫃子上的標籤**。這個欄位就是那些標籤。 + */ + +/** + * 規則需要的 CF 能力(收窄成介面,方便離線測試餵假帳號,也讓安裝器用自己的 fetch 實作)。 + * @typedef {object} ResourceApi + * @property {(script: string) => Promise} getScriptBindings + * @property {() => Promise>} listKvNamespaces title → id + * @property {() => Promise>} listD1Databases name → uuid + * @property {() => Promise} listVectorizeIndexes + * @property {(title: string) => Promise} createKvNamespace + * @property {(name: string) => Promise} createD1Database + * @property {(name: string) => Promise} createVectorizeIndex + */ + +/** + * 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。 + * @typedef {object} BindingRequirement + * @property {ResourceKind} kind + * @property {string} binding + * @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。 + * @property {string} createName + */ + +/** + * @typedef {object} PlannedAdopt + * @property {ResourceKind} kind + * @property {string} binding + * @property {string} value + * @property {string} from 從哪顆已部署的 worker 上讀到的 + */ + +/** + * @typedef {object} PlannedCreate + * @property {ResourceKind} kind + * @property {string} binding + * @property {string} createName + * @property {string[]} wantedBy + * @property {string[]} alsoBind 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。 + */ + +/** + * @typedef {object} ResourcePlan + * @property {PlannedAdopt[]} adopt + * @property {PlannedCreate[]} create + * @property {string[]} blockers 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 + * @property {Map>} liveVars + * 每顆**已部署** worker 現在掛著的 plain_text var(script → 名/值)。未部署的不在裡面。 + * + * Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡—— + * 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式 + * (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。 + */ + +/** + * @typedef {object} ResolvedResource + * @property {ResourceKind} kind + * @property {string} binding + * @property {string} value + * @property {'adopted' | 'created'} origin + * @property {string} [from] + */ + +/** + * @typedef {object} WranglerRequirements + * @property {string} script worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。 + * @property {Array<{kind: ResourceKind, binding: string, createName: string}>} bindings + */ + +/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */ +export class ResourcePlanBlocked extends Error { + /** @param {string[]} blockers */ + constructor(blockers) { + super(`資源解析被擋下(${blockers.length} 項)`); + this.name = 'ResourcePlanBlocked'; + /** @type {string[]} */ + this.blockers = blockers; + } +} + +/** + * @param {ResourceKind} kind + * @param {string} binding + * @returns {string} + */ +export function bindingKey(kind, binding) { + return `${kind}:${binding}`; +} + +/** @type {Record} */ +export const KIND_LABEL = { + kv_namespace: 'KV namespace', + d1: 'D1 資料庫', + vectorize: 'Vectorize index', +}; + +/** + * @param {unknown} e + * @returns {string} + */ +function msg(e) { + return e instanceof Error ? e.message : String(e); +} + +/** + * 決定每個 binding 要沿用哪顆資源/要不要新建,**不寫入任何東西**。 + * + * @param {ResourceApi} api + * @param {readonly BindingRequirement[]} requirements + * @param {'update' | 'init'} mode + * 'update' = 這台照定義已經裝過了(見下方「一顆都不在」規則);'init' = 全新安裝,允許從零建。 + * @returns {Promise} + */ +export async function planResources(api, requirements, mode) { + /** @type {string[]} */ + const blockers = []; + /** @type {PlannedAdopt[]} */ + const adopt = []; + /** @type {PlannedCreate[]} */ + const create = []; + + // ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ────────────────── + // 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。 + const scripts = [...new Set(requirements.map((r) => r.worker))].sort(); + /** @type {Map} */ + const live = new Map(); + /** @type {Map>} */ + const liveVars = new Map(); + 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 決定:沿用 / 新建 / 停手 ───────────────────────── + /** @type {Map} */ + const byKey = new Map(); + 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]); + } + + /** @type {Map>} */ + const existingCache = new Map(); + /** @param {ResourceKind} kind @returns {Promise>} */ + const listExisting = async (kind) => { + const hit = existingCache.get(kind); + if (hit) return hit; + /** @type {Set} */ + let set; + 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]; + + /** @type {Array<{value: string, script: string}>} */ + const found = []; + 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]; + /** @type {Set} */ + let existing; + 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 目錄從此分家。 + * ② 一邊已部署(沿用既有)、另一邊沒有(新建一顆空的)→ 半套資料,比全壞更難查。 + * + * @param {PlannedAdopt[]} adopt + * @param {PlannedCreate[]} create + * @param {Map} byKey + * @returns {PlannedCreate[]} + */ +function shareSameResource(adopt, create, byKey) { + /** @param {ResourceKind} kind @param {string} binding @returns {string | undefined} */ + const declaredName = (kind, binding) => + byKey.get(bindingKey(kind, binding))?.[0]?.createName; + + /** @type {PlannedCreate[]} */ + const out = []; + /** @type {Map} */ + const groups = new Map(); + + 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,**一顆都不建**。 + * + * @param {ResourceApi} api + * @param {ResourcePlan} plan + * @returns {Promise>} + */ +export async function applyResourcePlan(api, plan) { + if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers); + + /** @type {Map} */ + const out = new Map(); + 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, + }); + } + /** @type {string[]} */ + const madeSoFar = []; + for (const c of plan.create) { + /** @type {string} */ + let value; + 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 → 需求清單 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。 + * @type {Record} + */ +export const TABLE_KIND = { + kv_namespaces: 'kv_namespace', + d1_databases: 'd1', + vectorize: 'vectorize', +}; + +/** + * 從 wrangler.toml 抽出「這顆 worker 需要哪些資源綁定」。 + * + * 刻意寫成行掃描而不引 TOML parser:注入端(injectWranglerConfig)本來就是純文字操作, + * 兩邊用同一種視角看這份檔案才不會對不上。註解掉的區塊**不算需求** + * (kbdb 的 `[[vectorize]]` 預設是註解狀態,要開語義查詢時才會被取消註解 → 那時才成為需求)。 + * + * 也是「零依賴」的一部分:不引 TOML parser ⇒ 安裝器 import 這支不必多裝任何東西。 + * + * @param {string} toml + * @returns {WranglerRequirements} + */ +export function parseWranglerRequirements(toml) { + let script = ''; + let seenTable = false; + /** @type {WranglerRequirements['bindings']} */ + const bindings = []; + + /** @type {ResourceKind | null} */ + let kind = null; + let binding = ''; + let createName = ''; + + const flush = () => { + 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; + // 只有 D1/Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。 + else if (key === 'database_name' || key === 'index_name') createName = value; + } + flush(); + + return { script, bindings }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Cloudflare `/settings` 回應 → 事實(兩條路都要用同一種眼睛看) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * CF `GET /accounts/{id}/workers/scripts/{script}/settings` 回的 binding 原始形狀 + * (同一種資源在不同 API 版本欄位名不一,故全都收)。 + * + * @typedef {object} RawWorkerBinding + * @property {string} [type] + * @property {string} [name] + * @property {string} [namespace_id] + * @property {string} [id] + * @property {string} [database_id] + * @property {string} [index_name] + * @property {string} [text] `plain_text` 綁定的值(#106;secret_text 不會回值,本來就讀不到,也不該讀)。 + */ + +/** + * 把 CF 的 binding 陣列收斂成規則認得的三種資源。不認得的型別直接略過。 + * + * 🔴 這支**刻意放在規則裡**,不留在各自的 CF client: + * 「什麼才算『這顆 worker 綁著某顆資源』」是規則的一部分。 + * 兩條路各自解讀 CF 回應 = 漂移會從這裡長回來(例如一邊認 `namespace_id`、 + * 另一邊只認 `id`,於是一邊看得到綁定、另一邊看不到 → 後者又去新建了)。 + * + * @param {RawWorkerBinding[]} raw + * @returns {LiveBinding[]} + */ +export function normalizeLiveBindings(raw) { + /** @type {LiveBinding[]} */ + const out = []; + for (const b of raw) { + if (!b?.name) continue; + if (b.type === 'kv_namespace') { + const value = b.namespace_id ?? b.id; + if (value) out.push({ kind: 'kv_namespace', binding: b.name, value }); + } else if (b.type === 'd1' || b.type === 'd1_database') { + const value = b.id ?? b.database_id; + if (value) out.push({ kind: 'd1', binding: b.name, value }); + } else if (b.type === 'vectorize') { + if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name }); + } + } + return out; +} + +/** + * 抽出已部署 worker 上的 `plain_text` var(#106)。 + * + * 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被搬來搬去; + * wrangler deploy 不會動 secret,它們自己會留著)。 + * + * @param {RawWorkerBinding[]} raw + * @returns {Record} + */ +export function normalizeLiveVars(raw) { + /** @type {Record} */ + const out = {}; + for (const b of raw) { + if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text; + } + return out; +} diff --git a/shared/resource-rule/tests/demo.mjs b/shared/resource-rule/tests/demo.mjs new file mode 100644 index 0000000..2da2cd3 --- /dev/null +++ b/shared/resource-rule/tests/demo.mjs @@ -0,0 +1,60 @@ +// @ts-check +/** + * demo.mjs — 安裝器那條路的**可獨立執行**證明。 + * + * node shared/resource-rule/tests/demo.mjs + * + * 這支只 import `shared/resource-rule/`,**沒有 node_modules、沒有建置步驟**—— + * 跑得起來本身就是「安裝器把 repo archive 拉下來就能直接用」這句話的證據。 + * (對照組:`acr` 那條要先 npm ci + TS 轉譯才跑得動。兩條路差在外殼,判斷是同一份。) + * + * 三種情境各跑一次,印出每個 binding 選到哪顆資源、以及這一趟建了幾顆。 + */ + +import { resolveInstanceResources } from '../installer-entry.mjs'; +import { makeAccount, SCENARIOS, WORKER_NEEDS } from './fixture-account.mjs'; + +/** 用 fixture 的需求組出各 worker 的 wrangler.toml 內容。 */ +function tomls() { + return Object.entries(WORKER_NEEDS).map(([script, need]) => { + let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`; + for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`; + for (const d of need.d1) { + t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`; + } + return t; + }); +} + +const order = /** @type {const} */ (['fresh', 'installed', 'renamed']); + +console.log('安裝器那條路(只 import shared/resource-rule/,零依賴、零建置)\n'); + +for (const scenario of order) { + const mode = scenario === 'fresh' ? 'init' : 'update'; + const account = makeAccount(scenario); + const r = await resolveInstanceResources({ + accountId: 'acct-demo', + apiToken: 'tok-demo', + wranglerTomls: tomls(), + mode, + fetch: account.fetch, + }); + + console.log(`── ${scenario}(mode=${mode}):${SCENARIOS[scenario].label}`); + if (r.blocked) { + console.log(' ⛔ 停手,一顆資源都沒建:'); + for (const b of r.blockers) console.log(` • ${b}`); + console.log(''); + continue; + } + for (const key of Object.keys(r.bindings).sort()) { + console.log(` ${key.padEnd(30)} → ${r.bindings[key].padEnd(26)} ${r.origin[key]}`); + } + console.log( + ` 本趟新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length} 顆、` + + `Vectorize ${account.created.vectorize.length} 顆` + + `|沿用既有版本標籤 ARCRUN_BUNDLE_VERSION=` + + `${r.liveVars['arcrun-cypher-executor']?.ARCRUN_BUNDLE_VERSION ?? '(無,全新安裝)'}\n`, + ); +} diff --git a/shared/resource-rule/tests/fixture-account.mjs b/shared/resource-rule/tests/fixture-account.mjs new file mode 100644 index 0000000..3808d7e --- /dev/null +++ b/shared/resource-rule/tests/fixture-account.mjs @@ -0,0 +1,202 @@ +// @ts-check +/** + * fixture-account.mjs — 一個假的 Cloudflare 帳號,做成 **`fetch` 替身**。 + * + * 【為什麼是 fetch 替身,不是假的 ResourceApi 物件】 + * 本票要證的是「`acr` 那條與安裝器那條,跑出來的決定必須一致」。 + * 如果兩條路各自餵一個假的 `ResourceApi`,那就只測到了 `rule.mjs` 的判斷, + * **完全跳過了「怎麼把 CF 回應讀成事實」**——而 Arcrun#97 的重演只需要眼睛不一樣就夠了 + * (一邊把 404 當錯誤、一邊漏認 `namespace_id`…)。 + * 從 `fetch` 這一層假起,兩條路就是真的走完整條鏈:HTTP → 解析 → 判斷。 + * + * 零依賴、純 ESM,Node 與 Workers 都能跑。 + */ + +/** arcrun 各 worker 在 wrangler.toml 裡宣告的 KV binding 名(= 需求,不是資源名)。 */ +export const KV_BINDINGS = [ + 'WEBHOOKS', 'CREDENTIALS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV', + 'ANALYTICS_KV', 'EXEC_CONTEXT', 'SUBMISSIONS_KV', 'OAUTH_KV', +]; + +/** 這台實例上有資源綁定的四顆 worker,以及各自需要的綁定。 */ +export const WORKER_NEEDS = { + 'arcrun-cypher-executor': { + kv: ['EXEC_CONTEXT', 'WEBHOOKS', 'CREDENTIALS_KV', 'ANALYTICS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV'], + d1: [{ binding: 'CREDENTIALS_DB', database_name: 'arcrun-kbdb' }], + }, + 'arcrun-registry': { kv: ['SUBMISSIONS_KV', 'ANALYTICS_KV'], d1: [] }, + 'arcrun-mcp': { kv: ['OAUTH_KV'], d1: [] }, + 'arcrun-kbdb': { kv: [], d1: [{ binding: 'DB', database_name: 'arcrun-kbdb' }] }, +}; + +/** + * 把 WORKER_NEEDS 攤成 `BindingRequirement[]`——兩條路都用**同一份需求**進去, + * 才能證明差異(如果有)來自實作而不是輸入。 + * @returns {Array<{kind: 'kv_namespace'|'d1', binding: string, worker: string, createName: string}>} + */ +export function requirements() { + const out = []; + for (const [worker, need] of Object.entries(WORKER_NEEDS)) { + for (const b of need.kv) out.push({ kind: 'kv_namespace', binding: b, worker, createName: b }); + for (const d of need.d1) { + out.push({ kind: 'd1', binding: d.binding, worker, createName: d.database_name }); + } + } + return out; +} + +/** + * 三種情境。`titleFor` 決定「使用者帳號上那顆資源實際叫什麼名字」—— + * 這正是 #97 的病根所在:規則**不准**拿名字當識別。 + * + * @typedef {'fresh' | 'installed' | 'renamed'} Scenario + */ + +/** @type {Record string}>} */ +export const SCENARIOS = { + fresh: { + label: '沒裝過(全新帳號,一顆 worker 都沒有)', + deployed: false, + titleFor: (b) => b, + }, + installed: { + label: '裝過了(安裝器命名慣例 arcrun-rag--kv-)', + deployed: true, + titleFor: (b) => `arcrun-rag-yuga3bse-kv-${b.toLowerCase()}`, + }, + renamed: { + label: '資源在,但名字與預期完全不同(使用者自己改過/別的安裝器版本取的名)', + deployed: true, + // 刻意取成跟 binding 名毫無關聯的字串:只要規則有一絲「照名字對號」就會在這裡露餡。 + titleFor: (b) => `kv-${[...b].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)}`, + }, +}; + +/** + * 建一個假帳號 + 對應的 `fetch` 替身。 + * + * @param {Scenario} scenario + * @returns {{ + * fetch: typeof globalThis.fetch, + * created: {kv: string[], d1: string[], vectorize: string[]}, + * userData: {workflows: string[], sessions: string[], libraries: string[]}, + * kvIdFor: (binding: string) => string | undefined, + * d1Id: string, + * requestLog: string[], + * }} + */ +export function makeAccount(scenario) { + const spec = SCENARIOS[scenario]; + /** title → id */ + const kv = new Map(); + /** name → uuid */ + const d1 = new Map(); + /** @type {string[]} */ + const vectorize = []; + /** script → CF `/settings` 回應裡的 bindings[] 原始形狀 */ + const scripts = new Map(); + + const created = { kv: [], d1: [], vectorize: [] }; + const requestLog = []; + + // 使用者的東西——驗「更新完還在不在」用。掛在資源 id 上,不是掛在名字上。 + const userData = { + workflows: ['webhook:leo:daily-digest', 'webhook:leo:inbox-sync', 'webhook:leo:rag-ingest'], + sessions: ['session:leo-abc123'], + libraries: ['general', '課程', '客戶', '研究'], + }; + + const kvIdByBinding = new Map(); + const D1_ID = 'd1id-kbdb-REAL'; + + if (spec.deployed) { + // 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分) + for (const b of KV_BINDINGS) { + const id = `kvid-${b.toLowerCase()}-REAL`; + kv.set(spec.titleFor(b), id); + kvIdByBinding.set(b, id); + } + d1.set('arcrun-rag-yuga3bse-kbdb', D1_ID); + + // 已部署的 worker 上綁著它們——**這才是規則要看的事實** + for (const [script, need] of Object.entries(WORKER_NEEDS)) { + const bindings = []; + for (const b of need.kv) { + bindings.push({ type: 'kv_namespace', name: b, namespace_id: kvIdByBinding.get(b) }); + } + for (const d of need.d1) bindings.push({ type: 'd1', name: d.binding, id: D1_ID }); + // #106:plain_text var 也在同一份回應裡 + bindings.push({ type: 'plain_text', name: 'ARCRUN_BUNDLE_VERSION', text: '1.4.33' }); + scripts.set(script, bindings); + } + } + + /** @param {unknown} result @param {number} [status] */ + const ok = (result, status = 200) => + new Response(JSON.stringify({ success: true, result, errors: [] }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + /** @param {string} message @param {number} status */ + const fail = (message, status) => + new Response(JSON.stringify({ success: false, result: null, errors: [{ message }] }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + + /** @type {typeof globalThis.fetch} */ + // @ts-expect-error — 測試替身只實作用得到的那幾條路徑 + const fakeFetch = async (input, init) => { + const url = new URL(typeof input === 'string' ? input : String(input)); + const path = url.pathname.replace(/^\/client\/v4\/accounts\/[^/]+/, ''); + const method = (init?.method ?? 'GET').toUpperCase(); + requestLog.push(`${method} ${path}${url.search}`); + const body = init?.body ? JSON.parse(String(init.body)) : null; + + // 已部署 worker 的綁定 + const m = path.match(/^\/workers\/scripts\/([^/]+)\/settings$/); + if (m && method === 'GET') { + const script = decodeURIComponent(m[1]); + if (!scripts.has(script)) return fail('workers.api.error.script_not_found', 404); + return ok({ bindings: scripts.get(script) }); + } + + if (path === '/storage/kv/namespaces' && method === 'GET') { + return ok([...kv].map(([title, id]) => ({ id, title }))); + } + if (path === '/storage/kv/namespaces' && method === 'POST') { + const id = `kvid-NEW-${created.kv.length + 1}`; + kv.set(body.title, id); + created.kv.push(body.title); + return ok({ id, title: body.title }); + } + if (path === '/d1/database' && method === 'GET') { + return ok([...d1].map(([name, uuid]) => ({ uuid, name }))); + } + if (path === '/d1/database' && method === 'POST') { + const uuid = `d1id-NEW-${created.d1.length + 1}`; + d1.set(body.name, uuid); + created.d1.push(body.name); + return ok({ uuid, name: body.name }); + } + if (path === '/vectorize/v2/indexes' && method === 'GET') { + return ok(vectorize.map((name) => ({ name }))); + } + if (path === '/vectorize/v2/indexes' && method === 'POST') { + vectorize.push(body.name); + created.vectorize.push(body.name); + return ok({ name: body.name }); + } + + return fail(`fixture 沒有實作這條路徑:${method} ${path}`, 501); + }; + + return { + fetch: fakeFetch, + created, + userData, + kvIdFor: (binding) => kvIdByBinding.get(binding), + d1Id: D1_ID, + requestLog, + }; +}