Files
Arcrun/cli/src/lib/cf-api.ts
T
uncle6me-web 53b05c6d3d fix(cli): 更新完還看得到版本號——CLI 重部署不再把版本標籤(和你的設定)洗掉
leo 08-12 實撞:更新完 leo21c,Portal 設定頁的「版本」變成
「無法讀取目前版本(知識庫服務可能正在啟動)」。版本號是 leo 唯一的驗收介面,
看不到就等於他無法自己確認任何一次更新有沒有生效。

根因(Arcrun#106):`bundle_version` 來自部署時注入的 plain_text var
`ARCRUN_BUNDLE_VERSION`,而**只有安裝器會注入**。wrangler deploy 是整份覆蓋,
toml 沒寫的 var 直接消失 ⇒ CLI 更新那條路每跑一次就把標籤洗掉一次。
#97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),沒修「櫃子上的標籤」。

修法(兩種 var 走相反的規則,這是本次的判斷):
· 設定類 var = 使用者實例的事實 → **沿用**(讀綁定時同一份回應就帶回來,不多打 API)
  ——把 #97「已部署的 worker 上綁著什麼就是事實」原封不動套用到 plain_text var。
· 版本標籤 = 這份成品的屬性 → **每趟重烙,絕不沿用舊值**。
  沿用舊值會得到一個永遠停在安裝當天的假標籤——比沒有標籤更糟,
  因為它會讓人以為驗收過了。
  版號取部署當下發行頻道公告的 release(Portal/daemon 就是拿它當「最新版」比),
  另外把**真正部署的 commit** 一起烙上去(/health 多吐 `bundle_commit`)→ 漂掉查得出來。
  查不到 release 就誠實退成 `YYYY-MM-DD+<commit7>`,不掰一個 semver 假裝已是最新。

順帶(都是同一條路上的東西):
· ref 先解析成 commit sha 再用 sha 下載 archive——不可變,順手解掉 branch tarball 被快取的老病
· Portal 版本行接受帶 build metadata 的 semver(`1.4.41+d61` 這種先前一律被當成「較舊版本」)
· cli 測試在 node 22 上本來一支都跑不起來(.js→.ts 解析 + parameter property),補上 resolve hook
  ——#97 那份「使用者的東西還在不在」的迴歸守衛也在其中,跑不起來的守衛等於沒有守衛
· types.ts 的 ARCRUN_BUNDLE_VERSION 重複宣告(TS2300)併回一處

驗證見 PR:cli 49/49 綠、cypher health 4/4 綠、Portal 版本行原始碼實跑五種情境、
對真實已部署 worker 的唯讀 dry-run。**未做**:真實實例上的 acr update 端到端
(本機唯一有憑證的帳號是 leo21c=紅線禁碰,youlin 無憑證)。

Refs: Leo/Arcrun#106, #97, #95

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:58:43 +08:00

275 lines
11 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 type { LiveBinding, 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 {
private accountBase: string;
private headers: Record<string, string>;
constructor(accountId: string, apiToken: string) {
this.accountBase = `${CF_API_BASE}/accounts/${accountId}`;
this.headers = {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
};
}
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
const { ok, status, result, error } = await this.cfRaw<T>(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;
}
/** 無條件新建 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;
}
/** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */
async listVectorizeIndexes(): Promise<string[]> {
const result = await this.cf<Array<{ name: string }>>('/vectorize/v2/indexes');
return (result ?? []).map(i => i.name);
}
/**
* 新建 KBDB embed 用的 Vectorize index**bge-m3 = 1024 維 / cosine**,見 deploy.ts 常數說明)。
* 已存在(409 / already exists)視為成功——並行或重跑不該炸。沒有 ensure 版本:
* 「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。
*/
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}`);
}
}
/** 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;
}