Files
Arcrun/cli/src/lib/cf-api.ts
T
uncle6me-web bb548b6fdf 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 乾淨。
2026-08-12 23:37:01 +08:00

168 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Cloudflare KV REST API wrapper
* 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI
*/
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';
export interface CfKvClientOptions {
accountId: string;
namespaceId: string;
apiToken: string;
}
export class CfKvClient {
private base: string;
private headers: Record<string, string>;
constructor({ accountId, namespaceId, apiToken }: CfKvClientOptions) {
this.base = `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}`;
this.headers = {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
};
}
async put(key: string, value: string): Promise<void> {
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: { ...this.headers, 'Content-Type': 'text/plain' },
body: value,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`KV PUT 失敗(${res.status}):${err.slice(0, 200)}`);
}
}
async get(key: string): Promise<string | null> {
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
headers: this.headers,
});
if (res.status === 404) return null;
if (!res.ok) {
const err = await res.text();
throw new Error(`KV GET 失敗(${res.status}):${err.slice(0, 200)}`);
}
return res.text();
}
async list(prefix?: string): Promise<Array<{ name: string; expiration?: number; metadata?: unknown }>> {
const url = new URL(`${this.base}/keys`);
if (prefix) url.searchParams.set('prefix', prefix);
url.searchParams.set('limit', '1000');
const res = await fetch(url.toString(), { headers: this.headers });
if (!res.ok) {
const err = await res.text();
throw new Error(`KV LIST 失敗(${res.status}):${err.slice(0, 200)}`);
}
const data = await res.json() as {
result: Array<{ name: string; expiration?: number; metadata?: unknown }>;
};
return data.result ?? [];
}
async delete(key: string): Promise<void> {
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
method: 'DELETE',
headers: this.headers,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`KV DELETE 失敗(${res.status}):${err.slice(0, 200)}`);
}
}
}
/**
* Cloudflare Account-level API wrapperself-hosted installer 用)。
*
* 負責 acr init --self-hosted 的資源建立:驗 token、建/列 KV namespace、查 workers.dev subdomain。
* (不建 R2R2 是 dead storage 且綁卡違背開源免費;見 init.ts step 2 + registry-canon Phase 1.5。)
* 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。
* 對應 SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
*/
export class CfAccountClient implements ResourceApi {
/**
* `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.rule = createCloudflareResourceApi({ accountId, apiToken });
}
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
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;
}
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
async verifyAccess(): Promise<void> {
// GET /accounts/{id} 能通 = token 有此 account 的基本讀權限
await this.cf<{ id: string; name: string }>('');
}
/** 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
async getWorkersSubdomain(): Promise<string> {
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
return result.subdomain;
}
// ── 以下七支=`ResourceApi`,一律委派共用規則,**這個檔案不得自己實作** ────────────
// `shared/resource-rule/cf-resource-api.mjs`;委派而非複製的理由見本 class 開頭)
/** 讀一顆已部署 worker 現在綁著哪些資源——使用者那側的事實(Arcrun#97 的唯一真相源)。 */
getScriptBindings(script: string): Promise<ScriptBindings> {
return this.rule.getScriptBindings(script);
}
/** 帳號上現有的 KV namespacetitle → id)。判斷「綁著的那顆還在不在」用。 */
listKvNamespaces(): Promise<Map<string, string>> {
return this.rule.listKvNamespaces();
}
/** 帳號上現有的 D1name → uuid)。 */
listD1Databases(): Promise<Map<string, string>> {
return this.rule.listD1Databases();
}
/** 帳號上現有的 Vectorize index 名單。 */
listVectorizeIndexes(): Promise<string[]> {
return this.rule.listVectorizeIndexes();
}
/**
* 無條件新建一顆 KV namespace。
*
* 🔴 Arcrun#97:**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路。
* 要不要建,一律先經過 planResources;那裡只有在「確定沒有任何已部署的 worker
* 綁過這個 binding」時才會排進 create。
*/
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);
}
}