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

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

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

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

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

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

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

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

驗證:cli 58/58 綠(含新增的兩條路一致性 fixture + 三種情境),tsc --noEmit 乾淨。
This commit is contained in:
uncle6me-web
2026-08-12 23:37:01 +08:00
parent e05518a2b4
commit bb548b6fdf
16 changed files with 2614 additions and 582 deletions
+49 -156
View File
@@ -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<string, string>;
/**
* `ResourceApi` 的七個方法**全部委派**給共用規則附的那支 client
* `shared/resource-rule/cf-resource-api.mjs`)。
*
* 🔴 為什麼不是在這裡自己實作一份:判斷一致還不夠,**看到的東西**也要一致。
* 兩條路各自寫一份 CF client,只要有一邊把 404 當錯誤、漏了 per_page、少認一種
* 欄位名,那一邊就會「看不到既有綁定」——而看不到既有綁定的下一步,依規則就是新建。
* Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。
*/
private readonly rule: ReturnType<typeof createCloudflareResourceApi>;
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<T>(path: string, init?: RequestInit): Promise<T> {
const { ok, status, result, error } = await this.cfRaw<T>(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<T>(
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<void> {
// GET /accounts/{id} 能通 = token 有此 account 的基本讀權限
await this.cf<{ id: string; name: string }>('');
}
/** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/
async listKvNamespaces(): Promise<Map<string, string>> {
const result = await this.cf<Array<{ id: string; title: string }>>(
'/storage/kv/namespaces?per_page=100',
);
const map = new Map<string, string>();
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<string> {
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<ScriptBindings> {
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 subdomaincypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
async getWorkersSubdomain(): Promise<string> {
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<Map<string, string>> {
const result = await this.cf<Array<{ uuid: string; name: string }>>('/d1/database?per_page=100');
const map = new Map<string, string>();
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<ScriptBindings> {
return this.rule.getScriptBindings(script);
}
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97。 */
async createD1Database(name: string): Promise<string> {
const result = await this.cf<{ uuid: string; name: string }>(
'/d1/database',
{ method: 'POST', body: JSON.stringify({ name }) },
);
return result.uuid;
/** 帳號上現有的 KV namespacetitle → id)。判斷「綁著的那顆還在不在」用。 */
listKvNamespaces(): Promise<Map<string, string>> {
return this.rule.listKvNamespaces();
}
/** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */
async listVectorizeIndexes(): Promise<string[]> {
const result = await this.cf<Array<{ name: string }>>('/vectorize/v2/indexes');
return (result ?? []).map(i => i.name);
/** 帳號上現有的 D1name → uuid)。 */
listD1Databases(): Promise<Map<string, string>> {
return this.rule.listD1Databases();
}
/** 帳號上現有的 Vectorize index 名單。 */
listVectorizeIndexes(): Promise<string[]> {
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<string> {
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<string> {
return this.rule.createKvNamespace(title);
}
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97)。 */
createD1Database(name: string): Promise<string> {
return this.rule.createD1Database(name);
}
/** 新建 KBDB embed 用的 Vectorize index。沒有 ensure 版本,理由同上(Arcrun#97)。 */
createVectorizeIndex(name: string): Promise<string> {
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` 綁定的值(#106secret_text 不會回值,本來就讀不到,也不該讀)。 */
text?: string;
}
/**
* 抽出已部署 worker 上的 `plain_text` var#106)。
*
* 只收 `plain_text`——**`secret_text` 一律不碰**CF 本來就不回值,也不該被 CLI 搬來搬去;
* wrangler deploy 不會動 secret,它們自己會留著)。
*/
function normalizeVars(raw: RawWorkerBinding[]): Record<string, string> {
const out: Record<string, string> = {};
for (const b of raw) {
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
}
return out;
}
/** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */
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;
}