Files
Arcrun/cli/src/lib/deploy.ts
T
uncle6me-web ceb7638d74 feat(kbdb): 樹狀 record 模型第一刀——record 有身分、關係是唯一機制、entry_values 拆表(v7 定稿實作)
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。
模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」

- 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數
  (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+
  每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列
  (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values
  (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。
- record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留,
  驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。
- entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry
  接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。
- 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列,
  LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀),
  GET /maintenance/relation-orphans 唯讀巡檢。
- cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS,
  整檔送 /query 會在重跑時假紅)。
- 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描;
  釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。

遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型
與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:34:48 +08:00

1286 lines
65 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.
/**
* deploy.ts — self-hosted Worker 部署(installer 的「下載 repo tarball + wrangler deploy」段)
*
* 對應 SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6commit wasm + archive
*
* 策略(richblack 2026-06-02):repo 自帶預編譯 wasm.component-builds 下各 component.wasm
* 見 rule 05 慣例變更)→ CLI 從 repo archive tarball 拿完整部署物 → 注入用戶的 KV id
* → 用用戶自己的 CF token wrangler deploy。用戶不需 git / tinygo,只需 wrangler。
*
* 下載源(Arcrun#42026-07-07):從 GitHub codeload 改指 Giteagit.uncle6.mearchive API。
* 動機:D20 防 flag 鐵律下 self-hosted 用戶(如 Mira)不能走 GitHub;且「init 之後才新增的
* 零件」(如 code 零件)唯一重裝管道就是 acr update,綁死 GitHub codeload = 壞。改指 Gitea 後,
* 用戶跑 acr update 就會把新零件補上、已裝的(內容指紋未變)略過(downloadAndDeploy 冪等)。
* fork/自架者可用 ARCRUN_REPO / ARCRUN_GITEA_BASE / GITEA_TOKEN env 覆蓋(見下方常數)。
*/
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import chalk from 'chalk';
import { CfAccountClient } from './cf-api.js';
import {
applyResourcePlan,
bindingKey,
parseWranglerRequirements,
planResources,
ResourcePlanBlocked,
TABLE_KIND,
type BindingRequirement,
type ResourceApi,
type ResourceKind,
type ResolvedResource,
} from './resource-resolver.js';
/** 部署狀態 manifest:記錄上次成功部署每個 worker 的內容指紋(content hash),
* 讓 acr update 跳過未變動的 worker(壓測 2026-06-1222/23 成功後重跑仍全部
* pnpm install + wrangler deploy22 個沒變的白跑)。存 ~/.arcrun/。
* 指紋含 wrangler.toml 注入後的內容 → 換帳號/KV 會變更指紋 → 自動重部,不會誤跳。*/
const MANIFEST_PATH = join(homedir(), '.arcrun', 'deploy-manifest.json');
function loadManifest(): Record<string, string> {
try {
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Record<string, string>;
} catch {
return {};
}
}
function saveManifest(m: Record<string, string>): void {
try {
writeFileSync(MANIFEST_PATH, JSON.stringify(m, null, 2));
} catch {
/* manifest 寫失敗不致命:下次全部重部(退化成舊行為,不會錯,只是慢) */
}
}
/** 算一個 worker 目錄的內容指紋:遞迴 hash 所有檔案(排除 node_modules),
* 加上 accountId(換帳號要重部)。檔案路徑相對化後排序 → 跨機器/temp 目錄穩定。*/
function dirContentHash(dir: string, accountId: string): string {
const h = createHash('sha256');
h.update(accountId);
const walk = (d: string, rel: string): void => {
let entries: string[];
try { entries = readdirSync(d).sort(); } catch { return; }
for (const name of entries) {
if (name === 'node_modules' || name === '.git') continue;
const full = join(d, name);
const relPath = rel ? `${rel}/${name}` : name;
let st;
try { st = statSync(full); } catch { continue; }
if (st.isDirectory()) {
walk(full, relPath);
} else {
h.update(relPath);
try { h.update(readFileSync(full)); } catch { /* skip unreadable */ }
}
}
};
walk(dir, '');
return h.digest('hex');
}
/** repo 路徑 owner/namearchive tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。
* 注意:repo 名大小寫敏感(archive 路徑需完全一致)。
* Arcrun#4:真身在 GiteaLeo/Arcrun),故預設從 GitHub 的 uncle6me-web/Arcrun 改為 Gitea 路徑。*/
const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'Leo/Arcrun';
/** Gitea 站台 base URLarchive API host)。fork/自架不同站台用 ARCRUN_GITEA_BASE env 覆蓋。
* 末尾斜線會被正規化掉,避免組出 `//api`。*/
const ARCRUN_GITEA_BASE = (process.env.ARCRUN_GITEA_BASE ?? 'https://git.uncle6.me').replace(/\/+$/, '');
/** Gitea access tokenprivate repo 下載用)。走既有 env 機制,不寫死。
* ARCRUN_GITEA_TOKEN 優先(專用),否則沿用 bootstrap 既有的 GITEA_TOKEN。
* public repo 可不設(回傳 undefined → 不帶 Authorization header 也能下載)。*/
function giteaToken(): string | undefined {
return process.env.ARCRUN_GITEA_TOKEN || process.env.GITEA_TOKEN || undefined;
}
/**
* 版本標籤的「發行頻道」來源(Arcrun#106)。
*
* Portal 設定頁與 daemon `cloudVersionStale()` 都是拿**這支**回的 `release` 當「最新版」,
* 再跟實例 `/health` 的 `bundle_version` 比。CLI 更新完若不烙一個同一把尺量得出來的版號,
* 使用者就只會看到「無法讀取目前版本」或永遠「落後」。
* fork/自架另有發行頻道者用 ARCRUN_RELEASE_API 覆蓋,不寫死。
*/
const ARCRUN_RELEASE_API = process.env.ARCRUN_RELEASE_API ?? 'https://install.arcrun.dev/api/latest';
/** CLI 自己負責注入 / 自己烙的 var——**不從已部署的 worker 沿用**(沿用會蓋掉這趟算出來的正解)。 */
export const CLI_MANAGED_VARS = [
'WORKER_SUBDOMAIN', // 由 ctx.workerSubdomain 注入
'CF_ACCOUNT_ID', // 由 ctx.accountId 注入
'MULTI_TENANT', // 由 selfHosted 注入
'KBDB_BASE_URL', // 由 workerSubdomain 組
'ARCRUN_BUNDLE_VERSION', // 版本標籤:每趟重烙,**絕不沿用舊值**(見 resolveBundleStamp
'ARCRUN_BUNDLE_COMMIT',
] as const;
/** 烙版本標籤的那顆 worker(`/health` 就是它吐的)。其餘 worker 不需要版本標籤。 */
export const VERSION_STAMP_WORKER = 'arcrun-cypher-executor';
/** 這趟部署要烙上去的版本標籤。 */
export interface BundleStamp {
/** 寫進 `ARCRUN_BUNDLE_VERSION`。 */
version: string;
/** 寫進 `ARCRUN_BUNDLE_COMMIT`(查得到才有)。 */
commit?: string;
/** 給人看的一句話(CLI 會印出來),說明這個版號是怎麼來的。 */
note: string;
}
/**
* 算「這趟部署上去的東西,該叫幾版」(Arcrun#106)。
*
* 🔴 為什麼**不是沿用實例上原本那個值**:那個值描述的是**當時裝上去的那份程式碼**。
* 更新完程式碼換了,標籤沒換 = 一個永遠停在安裝當天的假標籤——比沒有標籤更糟,
* 因為 leo 會拿它當「我驗收過了」。版本標籤是**成品的屬性**,不是使用者的設定,
* 所以它是唯一一個「不沿用、每趟重烙」的 var(其餘 plain_text var 一律沿用,見 preservedVars)。
*
* 誠實邊界(mindset §7,這段要留著):
* - CLI 部的是 `ARCRUN_REPO@ref` 的**原始碼**,發行版號(semver)是**安裝器頻道**在發的,
* 兩者不是同一套編號。這裡取的是「部署當下該頻道公告的 release」,
* 語義=「我跟這個頻道的最新發行同源」,並**另外把真正的 commit 一起烙上去**
* `ARCRUN_BUNDLE_COMMIT``/health` 的 `bundle_commit`)→ 有沒有漂掉,看 commit 就查得出來。
* - 查不到 release(離線/頻道掛了)→ **不猜、不掰**,退成 `YYYY-MM-DD+<commit7>` 這個
* 舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」——
* 那正是我們想要的:**寧可說不準,也不要假裝已是最新**。
*/
export async function resolveBundleStamp(
ref: string,
commit?: string,
fetchImpl: typeof fetch = fetch,
): Promise<BundleStamp> {
const short = commit ? commit.slice(0, 7) : ref;
const today = new Date().toISOString().slice(0, 10);
try {
const res = await fetchImpl(ARCRUN_RELEASE_API, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as { release?: string } | null;
const release = String(body?.release ?? '').trim();
if (!/^\d+\.\d+\.\d+$/.test(release)) throw new Error(`發行頻道回的版號不是 semver${release || '空'}`);
return {
version: release,
commit,
note: `${release}(發行頻道 ${ARCRUN_RELEASE_API}${commit ? `;實際部署 commit ${short}` : ''}`,
};
} catch (e) {
const version = `${today}+${short}`;
return {
version,
commit,
note:
`${version}(查不到發行版號:${e instanceof Error ? e.message : String(e)}` +
`\n → 誠實標成 commit 版;Portal 會顯示成「較舊版本」而不是假裝已是最新。`,
};
}
}
/**
* 把 `ref`branch / tag / sha)解析成確切的 commit shaArcrun#106)。
*
* 兩個用途:① 版本標籤要烙「真的部了哪個 commit」;② 解出來之後**直接用 sha 下載 archive**——
* sha 是不可變的,順帶把 #13 P2 的「branch tarball 被中間層快取成舊的」整個病根拿掉。
* 查不到就回 undefined(呼叫端退回原本的用 ref 下載,行為不變)——這條路徑不該讓更新失敗。
*/
export async function resolveGiteaCommit(
ref: string,
fetchImpl: typeof fetch = fetch,
): Promise<string | undefined> {
const headers = buildDownloadHeaders();
const tryUrls = [
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/branches/${encodeURIComponent(ref)}`,
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/commits?sha=${encodeURIComponent(ref)}&limit=1&stat=false`,
];
for (const url of tryUrls) {
try {
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(20_000) });
if (!res.ok) continue;
const body = (await res.json()) as
| { commit?: { id?: string } }
| Array<{ sha?: string }>
| null;
const sha = Array.isArray(body) ? body[0]?.sha : body?.commit?.id;
if (typeof sha === 'string' && /^[0-9a-f]{7,64}$/i.test(sha)) return sha;
} catch {
/* 換下一種問法;全都問不到就回 undefined */
}
}
return undefined;
}
/**
* 組 Gitea archive 下載 URL(純函式,好離線測 URL 組裝)。
* Gitea archive API`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。
* bust:唯一 cache-buster query paramGitea 對不同 query 視為不同請求 → 繞過任何中間快取;
* 對齊原 codeload #13 P2 假綠防護,行為保留)。
*/
export function buildArchiveUrl(ref: string, bust: string, repo = ARCRUN_REPO, base = ARCRUN_GITEA_BASE): string {
return `${base}/api/v1/repos/${repo}/archive/${ref}.tar.gz?_cb=${encodeURIComponent(bust)}`;
}
/**
* 組下載用的 request headers(純函式,好離線測 token → header 對映)。
* 帶 no-cache(保留 #13 P2 stale 防護);有 token 才加 Gitea 慣用的 `Authorization: token <TOKEN>`
*private repo 需要;public repo 省略也可下載)。token 不寫死,來自 giteaToken()。
*/
export function buildDownloadHeaders(token = giteaToken()): Record<string, string> {
const headers: Record<string, string> = { 'Cache-Control': 'no-cache', Pragma: 'no-cache' };
if (token) headers.Authorization = `token ${token}`;
return headers;
}
/**
* arcrun 各 worker 會用到的 KV **binding 名**清單。
*
* 🔴 Arcrun#97 之後,這份清單**不再是「要去 CF 上建的資源標題」**——
* 真正要哪些綁定,是部署當下從每份 wrangler.toml 讀出來的(parseWranglerRequirements),
* 要不要建則由 resource-resolver 依「已部署的 worker 綁著什麼」決定。
* 這裡保留成一份**文件與離線測試用的期望清單**(測試會比對 toml 沒有漏綁),
* 不再被任何執行路徑拿去「照名字 ensure」。
*
* 原始出處保留如下:
* 前 7 個權威來源:.claude/rules/01-tech-stack.md 資料儲存表(cypher-executor 用)。
* SUBMISSIONS_KVregistry worker 用(component 投稿)。漏建會讓 registry deploy 失敗 →
* 壓測 §2.6/#11「20/21」根因(registry/wrangler.toml 綁 SUBMISSIONS_KV,但注入清單沒有它,
* 殘留官方舊 id → wrangler deploy 因 KV 不存在而失敗)。補進來後回到 21/21。
* OAUTH_KVarcrun-mcp worker 用(OAuth 2.1 server 的短效 authorization code + access token
* 帶 TTL、key 用 SHA-256 hash)。mcp/wrangler.toml 綁 OAUTH_KV,占位 id 由 injectWranglerConfig
* 換成用戶帳號的真 id(比照上面同一套 title→binding 注入)。漏建 → mcp deploy 失敗(同 SUBMISSIONS_KV
* 家族)。見 mcp/OAUTH.md §4/§7。
*/
export const REQUIRED_KV_NAMESPACES = [
'WEBHOOKS',
'CREDENTIALS_KV',
'RECIPES',
'USERS_KV',
'SESSIONS_KV',
'ANALYTICS_KV',
'EXEC_CONTEXT',
'SUBMISSIONS_KV',
'OAUTH_KV',
] as const;
/** 共享部署依賴(downloadAndDeploy 2.5tarball root 裝一次,各 worker 往上 resolve)。
* 含全部 worker 的 runtime depstier1 component 只要 honotier2 cypher/registry/mcp/kbdb
* 另需 zod / @hono/zod-openapi / @modelcontextprotocol/sdk / js-yaml / yaml
* code 自足 Workerregistry/components/code)另需 quickjs-emscripten-core + wasmfile variant
* (版本對齊該零件 package.jsondrift 由 cli/tests/deploy-code-component.test.ts 看守)。
* 漏一個會讓該 worker deploy 失敗,故寧可多列。export 供離線測試驗清單完整。*/
export const SHARED_DEPLOY_DEPS: Record<string, string> = {
hono: '^4.7.0',
wrangler: '^4.0.0',
zod: '^3.23.0',
'@hono/zod-openapi': '^0.18.0',
'@modelcontextprotocol/sdk': '^1.0.0',
'js-yaml': '^4.1.0',
yaml: '^2.4.0',
'quickjs-emscripten-core': '^0.31.0',
'@jitl/quickjs-wasmfile-release-sync': '^0.32.0',
};
export interface DeployContext {
accountId: string;
apiToken: string;
workerSubdomain: string;
/** binding → KV namespace id。**由 downloadAndDeploy 內部的資源解析填入,呼叫端不要自己給**
* Arcrun#97:呼叫端「照名字 ensure 一輪再傳進來」正是把使用者實例洗空的那條路)。*/
kvNamespaceIds?: Record<string, string>;
/** KBDB Base D1 id;同上,由資源解析填入。*/
d1DatabaseId?: string;
// self-hosted 單租戶旗標。trueself-hosted)→ 注入 MULTI_TENANT="false" 到 worker [vars]
// 讓 MCP partner-auth 走 namespace 明碼分支(mcp-account-source §5.5)。
// 未設 / false → 不注入(官方 SaaS 多租戶,行為不變)。
selfHosted?: boolean;
// 語義查詢開關(issue #7 / SDD T2.4)。true → 部署前建 CF Vectorize index 並注入 kbdb worker 的
// [[vectorize]]+[ai] binding(取消 wrangler.toml 註解段)→ embed 模組啟用。未設/false → 不建、不注入,
// base 維持 LIKE keywordfree-tier 友善)。
kbdbEmbed?: boolean;
/**
* Arcrun#108:這台實例的知識命名空間(=`~/.arcrun/config.yaml` 的 `api_key`),
* 會寫進 cypher worker 的 `ARCRUN_NAMESPACE` var,讓「讀」用的 owner_id 與「寫」的一致。
*
* **只在驗證過該 namespace 底下真的有知識時才給值**(見 `resolveKnowledgeNamespace`)——
* 給了就會覆蓋 worker 上的既有值,沒給則原封保留(preservedVars)。
*/
knowledgeNamespace?: string;
}
/**
* 這把 namespace 底下到底有沒有知識?(Arcrun#108 的「先驗再寫」)
*
* 打的是實例自己的 `GET /kbdb/map?owner_id=<ns>`cypher 既有的純轉發端點,CLI 平常就在用
* 這條路 + `X-Arcrun-API-Key`)。回傳:
* true 這個 namespace 底下查得到庫 → 寫 ARCRUN_NAMESPACE 是安全的
* false = 查得到但是空的 → 不寫(可能知識其實在別的命名空間,蓋下去會把畫面弄空)
* null = 問不到(實例還沒起來 / 舊版沒這條路 / 網路斷)→ 不寫,也不宣稱任何事
*
* 誠實邊界:這支只回答「有沒有」,不猜「應該是哪一個」。猜錯的代價是把人家的資料藏起來。
*/
export async function namespaceHasKnowledge(
cypherUrl: string,
namespace: string,
): Promise<boolean | null> {
if (!cypherUrl || !namespace) return null;
try {
const res = await fetch(
`${cypherUrl.replace(/\/+$/, '')}/kbdb/map?owner_id=${encodeURIComponent(namespace)}`,
{ headers: { 'X-Arcrun-API-Key': namespace } },
);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { libraries?: unknown } | null;
if (!body || !Array.isArray(body.libraries)) return null;
return body.libraries.length > 0;
} catch {
return null;
}
}
/**
* Vectorize index 名(kbdb embed 模組用)。**bge-m3 = 1024 維、cosine。**
*
* 🔴 2026-08-03 換代(leo 拍板;5 組中文測資實證:舊 `bge-base-en-v1.5` 排序 2/5、
* margin 0.0413**中文根本不能用**`bge-m3` 5/5、+0.1410、959ms)。
* leo 08-05:「換 embed model 當然要合併,當然要換 vectorize,原本的根本不能用」。
*
* **換模型必須換 index,且必須換「名字」**:
* ① 維度 768→1024,舊 index 收不進新向量
* ② 就算維度相同也不能沿用——不同模型的向量混在同一 index,比對出來是垃圾;
* 而 #58Vectorize vector delete 未接)代表舊向量刪不掉
* ⇒ **開新名字的 index 反而順手繞開 #58**,且新舊並存可回滾。
*
* ⚠️ 這個常數同時被 `ensureVectorizeMetadataIndexes()` 使用(deploy.ts:426
* ⇒ t36 的四個 metadata indexowner_id/entry_type/source/libraryArcrun#11 根因修復)
* 會自動建在新 index 上,**不會因為改名而遺失**(已查證,非假設)。
*
* 既有實例遷移:部署後 `POST /embed/backfill {"reindex":true}` 重嵌到 remaining=0
* 確認語意查詢正常後,舊的 `arcrun-kbdb-embed` 可自行刪除。
*/
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed-m3';
export interface DeployResult {
implemented: boolean;
cypherExecutorUrl?: string;
mcpUrl?: string; // self-hosted 自己的 MCP worker URLmcp-account-source §3
message: string;
/** true = 資源解析階段就喊停(Arcrun#97),**一顆資源沒建、一個 worker 沒部**。
* 呼叫端要以非零結束並把 message 原文印出來,不要當成一般部分失敗帶過。*/
blocked?: boolean;
/** 這趟實際用上的資源(沿用/新建各是哪一顆)。呼叫端寫 config 用這個,不要自己再查一次。*/
resources?: Map<string, ResolvedResource>;
}
/** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/
export function wranglerAvailable(): boolean {
try {
execFileSync('wrangler', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
/**
* 下載 repo archive tarball(含預編譯 wasm)→ 注入用戶 KV id → wrangler deploy 全部 Worker。
*
* SDD self-hosted-init.md §6.4
* 1. 下載 Gitea archive tarballref 預設 main)→ 解壓到暫存目錄
* 2. 各 wrangler.toml 注入 ctx.kvNamespaceIds + cypher-executor WORKER_SUBDOMAIN
* 3. tier1=.component-builds/* 先 → tier2=cypher-executor/registry 後,逐一 wrangler deploy
* 4. 回 cypherExecutorUrl = https://arcrun-cypher-executor.<subdomain>.workers.dev
*
* 誠實(mindset §7):任一 worker deploy 失敗會收集進 message 回報,不假裝全綠。
*
* @param ctx 部署上下文
* @param ref git refbranch / tag),預設 mainacr update 可帶 tag
*/
export async function downloadAndDeploy(
ctx: DeployContext,
ref = 'main',
opts: { force?: boolean; mode?: 'init' | 'update'; api?: ResourceApi } = {},
): Promise<DeployResult> {
const mode = opts.mode ?? 'update';
const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
// 1. 下載 + 解壓 Gitea archive tarball
// #106:先把 ref 解析成確切 commit,**用 sha 下載**(不可變 → 順帶解掉 branch tarball 被快取的老問題),
// 同一個 sha 稍後也會被烙成版本標籤。解不出來就照舊用 ref 下載(行為不變)。
const commit = await resolveGiteaCommit(ref);
let root: string;
try {
root = await downloadRepoTarball(commit ?? ref, commit ? ref : undefined);
} catch (e) {
return {
implemented: true,
message: `下載部署物失敗(${e instanceof Error ? e.message : e})。確認網路 + ARCRUN_REPO=${ARCRUN_REPO} 可達。`,
};
}
// 2. 列出要部署的 worker 目錄(含 wrangler.toml),分 tier
const { tier1, tier2 } = discoverWorkerDirs(root);
if (tier1.length === 0 && tier2.length === 0) {
return { implemented: true, message: `部署物中找不到任何 wrangler.tomlroot=${root})。` };
}
// 2.5 共享依賴:23 個 component worker 的 runtime dep 全是 hono、devDep 全含 wrangler
// 舊版每個 worker 各 install 一份 ~324MB node_modules23× 重複,壓測 2026-06-12 慢的真因)。
// 改成在 tarball root 裝「一次」hono+wranglercomponent 目錄靠 node 往上 resolve(已驗證可行)。
// → 23×4.4s install 變 1×17s。失敗不致命:退回各 worker 自裝(runWranglerDeploy 仍有 fallback)。
let sharedBin = '';
try {
process.stdout.write(chalk.gray(' → 安裝共享部署依賴(一次,取代每個 worker 各裝)...'));
// 依賴清單抽出成 SHARED_DEPLOY_DEPSexport 供離線測試看守,見常數 doc)。
writeFileSync(
join(root, 'package.json'),
JSON.stringify({ name: 'arcrun-deploy-shared', private: true, type: 'module',
dependencies: SHARED_DEPLOY_DEPS }),
);
execFileSync('npm', ['install', '--no-audit', '--no-fund'],
{ cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
sharedBin = join(root, 'node_modules', '.bin', 'wrangler');
console.log(existsSync(sharedBin) ? chalk.green(' ✓') : chalk.yellow(' ⚠ 退回各 worker 自裝'));
if (!existsSync(sharedBin)) sharedBin = '';
} catch (e) {
const tail = (e as { stderr?: Buffer }).stderr?.toString().trim().split('\n').slice(-2).join(' | ').slice(0, 200) ?? '';
console.log(chalk.yellow(` ⚠ 共享安裝失敗,退回各 worker 自裝${tail ? `${tail}` : ''}`));
}
const failures: string[] = [];
const allDirs = [...tier1, ...tier2];
// ── 2.6 資源解析:先看「這些 worker 現在綁著什麼」,再決定沿用還是新建(Arcrun#97)──────
//
// 🔴 這一段取代了舊的「照名字 ensure 一輪 KV/D1/Vectorize 再注入」。
// 舊做法用 binding 名當資源標題去找,對不上就新建一顆空的綁上去——
// 安裝器建的資源本來就不叫那個名字,於是**每次更新都對不上、每次都新建**:
// 2026-08-12 一次更新生了 9 顆 KV + 1 顆 D1,使用者的工作流/登入/子庫全部從畫面上消失。
//
// 現在:已部署 worker 上的綁定=事實,原樣沿用;只有「確定沒人綁過」才建;
// 任何說不準的情況(讀不到綁定/綁著的資源不見了/同名綁定指向兩顆/一顆 worker 都找不到)
// → 整趟停手,**在動任何東西之前**。
//
// 需求是從「注入後的 toml」解析的(renderWranglerToml 帶空 map 當預覽),
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
const requirements: BindingRequirement[] = [];
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
const dirScript = new Map<string, string>(); // dir → worker script 名(#106var 沿用要逐顆對號)
for (const dir of allDirs) {
const tomlPath = join(dir, 'wrangler.toml');
if (!existsSync(tomlPath)) continue;
const raw = readFileSync(tomlPath, 'utf8');
tomlPreviews.set(dir, raw);
const preview = renderWranglerToml(raw, ctx, new Map());
const parsed = parseWranglerRequirements(preview);
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
dirScript.set(dir, parsed.script);
for (const b of parsed.bindings) {
requirements.push({ ...b, worker: parsed.script });
}
}
let resolved = new Map<string, ResolvedResource>();
let liveVars = new Map<string, Record<string, string>>();
if (requirements.length > 0) {
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
let plan;
try {
plan = await planResources(api, requirements, mode);
} catch (e) {
console.log(chalk.yellow(' ✗'));
return {
implemented: true,
blocked: true,
message:
`資源解析失敗(${e instanceof Error ? e.message : String(e)})。\n` +
`沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
if (plan.blockers.length > 0) {
console.log(chalk.yellow(' ✗'));
return {
implemented: true,
blocked: true,
message:
`停手:有 ${plan.blockers.length} 件事我不敢自己決定。\n` +
plan.blockers.map((b) => ` • ${b}`).join('\n') +
`\n\n沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
try {
resolved = await applyResourcePlan(api, plan);
} catch (e) {
console.log(chalk.yellow(' ✗'));
const raw = e instanceof Error ? e.message : String(e);
const detail = e instanceof ResourcePlanBlocked
? e.blockers.map((b) => ` • ${b}`).join('\n')
: ` • ${raw}`;
// D1 建不起來最常見的根因是 token 沒勾 D1 權限(KV/Worker 建得起來、只有 D1 報 auth error)。
// 這句提示在改版前就有,別隨著搬家弄丟——它是使用者唯一能自己解掉的那個錯。
const hint = /d1/i.test(raw) && /auth/i.test(raw)
? '\n → CF token 缺 D1 權限:補勾「Account / D1 / Edit」重產 token 填回 .env 再跑一次。'
: '';
return {
implemented: true,
blocked: true,
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
liveVars = plan.liveVars;
console.log(chalk.green(' ✓'));
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
const created = [...resolved.values()].filter((r) => r.origin === 'created');
if (adopted.length > 0) {
console.log(chalk.gray(` 沿用你既有的 ${adopted.length} 個資源(不論它們叫什麼名字):`));
for (const r of adopted) console.log(chalk.gray(` = ${r.binding}${r.value}(讀自 ${r.from}`));
}
if (created.length > 0) {
console.log(chalk.yellow(` 新建 ${created.length} 個(目前沒有任何已部署的 worker 綁著它們):`));
for (const r of created) console.log(chalk.yellow(` + ${r.binding}${r.value}`));
}
}
// 解析結果回填 ctx,供 applyD1Migration / 呼叫端寫 config 使用。
// KBDB 的 migration 打 kbdb worker 的 `DB`;沒有它才退回 cypher 的 `CREDENTIALS_DB`(同一顆庫)。
ctx.kvNamespaceIds = Object.fromEntries(
[...resolved.values()].filter((r) => r.kind === 'kv_namespace').map((r) => [r.binding, r.value]),
);
ctx.d1DatabaseId =
resolved.get(bindingKey('d1', 'DB'))?.value
?? resolved.get(bindingKey('d1', 'CREDENTIALS_DB'))?.value;
// 2.7 語義查詢(issue #7 / T2.4):index 本體已由上面的資源解析處理(沿用既有 / 需要才新建)。
// 這裡只補 metadata index——Vectorize 要 filter 某欄位必須先為該欄建 index,
// 否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0 命中(Arcrun#11 根因)。
// 冪等;失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
const vectorizeIndex = resolved.get(bindingKey('vectorize', 'VECTORIZE'))?.value;
if (vectorizeIndex) {
try {
process.stdout.write(chalk.gray(` → 語義查詢 metadata index${vectorizeIndex}...`));
await ensureVectorizeMetadataIndexes(ctx, vectorizeIndex);
console.log(chalk.green(' ✓'));
} catch (e) {
console.log(chalk.yellow(' ⚠'));
failures.push(`Vectorize metadata index (${vectorizeIndex}): ${e instanceof Error ? e.message : String(e)}`);
}
}
// ── 2.8 varplain_text):既有的沿用、版本標籤重烙(Arcrun#106)─────────────────
//
// 🔴 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),但 **var 這批「櫃子上的標籤」沒人管**:
// wrangler deploy 是整份覆蓋,toml 沒寫的 var 直接消失。leo 2026-08-12 實撞的畫面
// 「無法讀取目前版本(知識庫服務可能正在啟動)」就是 `ARCRUN_BUNDLE_VERSION` 被這樣洗掉的。
//
// 兩種 var 走**相反**的規則,這是本次的核心判斷:
// · 設定類(PORTAL_MAIL_RELAY_BASE / CONSOLE_TENANT / …)=**使用者實例的事實** → 沿用
// · 版本標籤(ARCRUN_BUNDLE_VERSION)=**這份成品的屬性** → 每趟重烙,沿用舊值就是假標籤
//
// 範圍註記:`liveVars` 來自資源解析那一趟讀到的 worker(=有資源綁定的那些:cypher/kbdb/mcp/registry)。
// 純零件 worker 沒有資源綁定、不在那份名單裡 → 這裡不會沿用它們的 var。目前它們的 var 只有
// toml 自己帶的 `COMPONENT_ID`,沒有東西可丟;若哪天有人往零件 worker 注入設定,要在這裡補讀。
const extraVarsByDir = new Map<string, Record<string, string>>();
let stamp: BundleStamp | undefined;
if (dirScript.size > 0) {
const needStamp = [...dirScript.values()].includes(VERSION_STAMP_WORKER);
if (needStamp) {
process.stdout.write(chalk.gray(' → 算這趟要烙上去的版本標籤...'));
stamp = await resolveBundleStamp(ref, commit);
console.log(chalk.green(' ✓'));
console.log(chalk.gray(` ARCRUN_BUNDLE_VERSION = ${stamp.note}`));
}
const preservedTotal: string[] = [];
for (const [dir, script] of dirScript) {
const raw = tomlPreviews.get(dir);
if (!raw) continue;
const keep = preservedVars(liveVars.get(script), raw);
for (const k of Object.keys(keep)) preservedTotal.push(`${script}:${k}`);
const vars: Record<string, string> = { ...keep };
if (stamp && script === VERSION_STAMP_WORKER) {
vars.ARCRUN_BUNDLE_VERSION = stamp.version;
if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit;
}
// Arcrun#108:把「你的知識實際住在哪個命名空間」告訴雲端。
//
// 為什麼需要:cypher 讀藏書地圖/搜尋/工作流時要用一個 owner_id 去過濾,而它以前拿的是
// repo toml 帶的官方預設值(`CONSOLE_TENANT = "leo"`)。寫入端(CLI push、小幫手上傳、
// MCP)用的卻是你 `~/.arcrun/config.yaml` 的 `api_key` ⇒ 兩邊對不上就整個空掉
//(leo 實撞:1854 條三元組被過濾成 0 個庫)。
//
// 🔴 **只在「這個 namespace 底下真的查得到知識」時才寫**(呼叫端已先驗過,見
// resolveKnowledgeNamespace)。理由是反過來的那個災難:一鍵安裝的實例,知識可能
// 本來就寫在 CONSOLE_TENANT 底下;若這裡無條件蓋成本機 api_key,會把一台**原本正常**
// 的實例改成指向空的那一格——跟 #97/#106 同一類「更新一次把人家的東西弄不見」。
// 驗不過就不寫;既有值由 preservedVars 原封保留,等於這趟什麼都沒改。
if (ctx.knowledgeNamespace && script === VERSION_STAMP_WORKER) {
vars.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
}
if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars);
}
if (preservedTotal.length > 0) {
console.log(chalk.gray(` 沿用你實例上既有的 ${preservedTotal.length} 個設定值(var):`));
for (const item of preservedTotal) console.log(chalk.gray(` = ${item}`));
}
}
// 3. 對每個 worker:注入 KV id+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
let deployed = 0;
let skipped = 0;
// 內容指紋 manifest:未變動且上次成功的 worker 跳過(key 用 worker 名,不用 temp 絕對路徑)。
// --force 清空 manifest → 全部重部。
const manifest = opts.force ? {} : loadManifest();
console.log(chalk.gray(` → 部署 ${allDirs.length} 個 worker(未變動者跳過,依序進行)...`));
for (let i = 0; i < allDirs.length; i++) {
const dir = allDirs[i];
const tomlPath = join(dir, 'wrangler.toml');
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
try {
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir), extraVarsByDir.get(dir));
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
const hash = dirContentHash(dir, ctx.accountId);
if (manifest[label] === hash) {
skipped++;
console.log(chalk.gray(' ⊘ 未變動,跳過'));
continue;
}
runWranglerDeploy(dir, ctx, sharedBin);
manifest[label] = hash; // 只在成功後記錄 → 失敗者下次必重試
saveManifest(manifest);
deployed++;
console.log(chalk.green(' ✓'));
} catch (e) {
delete manifest[label]; // 失敗 → 清掉舊指紋,確保下次重部
saveManifest(manifest);
failures.push(`${dir}: ${e instanceof Error ? e.message : String(e)}`);
console.log(chalk.yellow(' ⚠'));
}
}
if (skipped > 0) {
console.log(chalk.gray(` ${skipped} 個未變動已跳過;要強制全部重部跑 acr update --force`));
}
// 3.5 KBDB Base: D1 建好後套 migration(建三表 + recipe_stat seed)。
// 建 D1cf-api ensureD1Database)只產生空資料庫,schema 要靠這步套。
// migration 檔來自同一份 tarballroot/kbdb/migrations/0001_base.sql),與 wasm 同源,
// 不依賴本地 CLI 安裝路徑。0001_base.sql 全用 IF NOT EXISTS / INSERT OR IGNORE → 可重複套(idempotent)。
if (ctx.d1DatabaseId) {
const migPath = join(root, 'kbdb', 'migrations', '0001_base.sql');
if (existsSync(migPath)) {
try {
await applyD1Migration(ctx, readFileSync(migPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql${migPath}`);
}
// 3.6 credential template seedD38 圍牆修復,總管交辦,2026-08-07):credential 目錄改走
// KBDB template 機制(entries 表 entry_type='credential',比照 recipe_stat/execution_log
// 慣例),取代舊的獨立 credentials 表(0002,已退役,見該檔頭部說明)。冪等,套用機制
// 與 0001_base.sql 完全相同。密文本體仍住 Workers per-script Secrets(見
// cypher-executor/src/routes/credentials.ts),D19「擁有目錄不擁有內容物」不變。
const credTplMigPath = join(root, 'kbdb', 'migrations', '0005_credential_template.sql');
if (existsSync(credTplMigPath)) {
try {
await applyD1Migration(ctx, readFileSync(credTplMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0005_credential_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0005_credential_template.sql${credTplMigPath}`);
}
// 3.6b 退役舊 credentials 表(D382026-08-07):把該表殘留資料(若有)搬進 entries 後
// 拆表,讓 KBDB 回到「只有三張核心表」的狀態。冪等且對「從未跑過 0002」的全新實例
// 無害(表不存在時本檔第一步先補空殼再立刻拆掉,詳見檔頭)。每次部署都會重跑,
// 但真資料只搬一次(NOT EXISTS 判斷防重複)。
const dropCredMigPath = join(root, 'kbdb', 'migrations', '0006_drop_credentials_table.sql');
if (existsSync(dropCredMigPath)) {
try {
await applyD1Migration(ctx, readFileSync(dropCredMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0006_drop_credentials_table (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0006_drop_credentials_table.sql${dropCredMigPath}`);
}
// 3.7 execution_log template seedKV 額度事故修復,2026-08-07):workflow 執行紀錄改走
// KBDB template 機制(entries 表 entry_type='execution_log',比照 recipe_stat 慣例;
// schema 零異動,只 seed 一列 template 定義,同 0001_base.sql §3 手法,self-hosted 同步套用)。
const execLogMigPath = join(root, 'kbdb', 'migrations', '0004_execution_log_template.sql');
if (existsSync(execLogMigPath)) {
try {
await applyD1Migration(ctx, readFileSync(execLogMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0004_execution_log_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql${execLogMigPath}`);
}
// 3.8 樹狀 record 模型(0007v7 定稿 2026-08-15):record 有身分、關係是唯一機制、
// entry_values 拆表。**必須排在所有 template seed 之後**(它把 templates 表既有列
// 鏡射成池中 sheet/field entry)。逐句套用+容錯 duplicate column:檔內三句
// ADD COLUMN 在 SQLite 沒有 IF NOT EXISTS 形式,重跑(每次部署都會重跑本段)時
// 那三句報 duplicate column = 已套用,其餘語句全部語句級冪等(檔頭有完整說明)。
const treeMigPath = join(root, 'kbdb', 'migrations', '0007_tree_record_model.sql');
if (existsSync(treeMigPath)) {
try {
await applyD1MigrationTolerant(ctx, readFileSync(treeMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0007_tree_record_model (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0007_tree_record_model.sql${treeMigPath}`);
}
}
const cypherExecutorUrl = ctx.workerSubdomain
? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev`
: undefined;
// self-hosted 自己的 MCP worker URLmcp-account-source §3.mcp.json 指自己)。
// 端點是 /mcpstreamable http;根路徑 404)。仿 cypher 用 WORKER_SUBDOMAIN 組。
const mcpUrl = ctx.workerSubdomain
? `https://arcrun-mcp.${ctx.workerSubdomain}.workers.dev/mcp`
: undefined;
if (failures.length > 0) {
return {
implemented: true,
cypherExecutorUrl,
mcpUrl,
message:
`部署 ${deployed}/${tier1.length + tier2.length} 成功,${failures.length} 失敗(誠實回報,未假綠):\n` +
failures.map(f => ` ✗ ${f}`).join('\n'),
};
}
return {
implemented: true,
cypherExecutorUrl,
mcpUrl,
message: `部署完成:${deployed} 個 Worker 全部成功。`,
};
}
/**
* 逐句套 migration,容錯 duplicate column0007 專用)。
*
* 為什麼不能走 applyD1Migration 整檔送:/query 端點任何一句失敗整批中止——
* 0007 的三句 ADD COLUMN 在重跑時必然報 duplicate columnSQLite 沒有欄位級
* IF NOT EXISTS),整檔送 ⇒ 第二次部署起 migration 永遠假紅、後面的資料搬遷
* 語句永遠不被執行。逐句+把 duplicate column 視為「已套用」,其餘錯誤照樣拋。
* 切句手法與安裝器 compile-migrations.mjs 同款(剝 -- 註解、依分號切;
* 0007 的字串常值不含分號,前提成立)。
*/
async function applyD1MigrationTolerant(ctx: DeployContext, sql: string): Promise<void> {
const statements = sql
.split('\n')
.map((l) => l.replace(/--.*$/, ''))
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const stmt of statements) {
try {
await applyD1Migration(ctx, stmt);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (/duplicate column/i.test(msg)) continue; // ADD COLUMN 重跑=已套用
throw new Error(`${stmt.slice(0, 60)}… → ${msg}`);
}
}
}
/**
* 對 D1 套 SQL migration(透過 CF API `/d1/database/{id}/query`,非 wrangler)。
* 用 init 已驗的 ctx.apiToken + accountIdquery 端點接受多語句檔,一次送整份 0001_base.sql。
*/
async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/d1/database/${ctx.d1DatabaseId}/query`;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${ctx.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ sql }),
signal: AbortSignal.timeout(60_000),
});
const json = (await res.json().catch(() => null)) as
| { success?: boolean; errors?: Array<{ message?: string }> }
| null;
if (!res.ok || !json?.success) {
const detail = json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`;
throw new Error(detail);
}
}
/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const;
/**
* 確保 KBDB embed index 上的 metadata indexowner_id/entry_type/source)存在(Arcrun#11 根因修復)。
* Vectorize v2:要對某 metadata 欄位下 filter,必須先為該欄建 metadata index,否則帶過濾的語意查詢一律回 0。
* REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`indexType=string)。
* 冪等:已存在(409 / already exists)視為成功。async 生效(建立後才 upsert 的向量才會被收錄 → 既有向量另需 reindex)。
*
* 🔴 index 名由呼叫端傳入(= 資源解析沿用到的那顆),**不是**寫死 KBDB_VECTORIZE_INDEX
* 使用者實例上那顆 index 叫什麼是他那側的事實,我們把 metadata index 建到「他真的在用的那顆」上。
*/
async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: string): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/create`;
for (const propertyName of KBDB_VECTORIZE_META_FIELDS) {
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ propertyName, indexType: 'string' }),
signal: AbortSignal.timeout(60_000),
});
if (res.ok) continue;
const json = (await res.json().catch(() => null)) as
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
| null;
const msg = (json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`).toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) continue;
throw new Error(`metadata_index ${propertyName}: ${msg}`);
}
}
/** 下載 Gitea archive tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。
*
* ⚠️ Arcrun#13 P2 根因防護(沿用):branch archivearchive/main.tar.gz)可能被中間層快取,
* push 後該 ref 的 tarball 可能 stale 數分鐘。「push → 立刻 acr update」會抓到舊 tarball →
* wrangler deploy 仍回 ✓(部署成功)但 ship 的是**舊 code**(假綠:「deploy 成功」≠「部到修好的版本」)。
* 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過快取,每次抓到 ref 的最新內容。
*
* Arcrun#4:來源由 GitHub codeload 改為 Gitea archive API(走 GITEA_TOKEN,不寫死)。*/
async function downloadRepoTarball(ref: string, fromRef?: string): Promise<string> {
// 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const url = buildArchiveUrl(ref, bust);
// fromRef 有值 = ref 已被解析成 commit sha(#106),印出來讓人看得到「這趟到底部了哪個 commit」。
const label = fromRef ? `${fromRef}${ref.slice(0, 7)}` : ref;
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${label},約 1030 秒,視網速)...`));
const res = await fetch(url, {
signal: AbortSignal.timeout(120_000),
// 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball#13 P2 假綠根因)。
// 帶 Gitea token(若有,private repo 需要;public 省略也可)。
headers: buildDownloadHeaders(),
cache: 'no-store',
});
if (!res.ok) {
// 401/403 多半是 private repo 缺 token(或 token 無此 repo 讀權限)→ 給可行動的提示。
const hint = (res.status === 401 || res.status === 403)
? 'private repo?請設 GITEA_TOKEN 環境變數,需對此 repo 有讀取權限)'
: '';
throw new Error(`Gitea archive HTTP ${res.status}${hint}${url}`);
}
const buf = Buffer.from(await res.arrayBuffer());
const sizeMB = (buf.length / 1024 / 1024).toFixed(1);
console.log(chalk.gray(` → 下載完成(${sizeMB} MB),解壓中...`));
const dir = mkdtempSync(join(tmpdir(), 'arcrun-deploy-'));
const tarPath = join(dir, 'repo.tar.gz');
writeFileSync(tarPath, buf);
// 用系統 tar 解壓(macOS/Linux 內建)。tarball 解出單一頂層目錄 {repo}-{ref}/。
execFileSync('tar', ['-xzf', tarPath, '-C', dir], { stdio: 'ignore' });
const entries = readdirSync(dir).filter(n => n !== 'repo.tar.gz');
const top = entries.find(n => statSync(join(dir, n)).isDirectory());
if (!top) throw new Error('tarball 解壓後找不到頂層目錄');
return join(dir, top);
}
/** 自足 Worker 零件(非 TinyGo-wasm 家族):目錄相對 root + 部署 gate 必要產物(相對該目錄)。
* 目前只有 codequickjs 沙箱,registry/components/code)。gate 精神比照 tier1 的 component.wasm
* 必要產物(vendored quickjs.wasm,需 commit 進 repo)缺 → 誠實跳過,不讓 wrangler deploy 因缺檔失敗。
* export 供離線測試驗「部署清單含 code + 產物 gate 正確」。*/
export const SELF_CONTAINED_COMPONENT_WORKERS: ReadonlyArray<{ dir: string[]; requires: string[][] }> = [
{ dir: ['registry', 'components', 'code'], requires: [['vendor', 'quickjs.wasm']] },
];
/** 掃解壓出的部署物,回傳 tier1.component-builds/* + 自足 Worker 零件)與
* tier2cypher-executor/registry/kbdb/mcp 引擎)目錄清單。export 供離線測試。*/
export function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } {
const tier1: string[] = [];
const tier2: string[] = [];
const cbRoot = join(root, '.component-builds');
if (existsSync(cbRoot)) {
for (const name of readdirSync(cbRoot)) {
const dir = join(cbRoot, name);
// 需同時有 wrangler.toml 且有 component.wasm 才部署。
// 「錯做成零件」的(claude_api / km_writer / kbdb_upsert_blockwasm 沒 commit 進 repo
// .gitignore 排除,待降級成工作流/recipe)→ archive 拿到的目錄缺 wasm → 自然跳過,
// 不讓 wrangler deploy 因缺檔失敗。
if (existsSync(join(dir, 'wrangler.toml')) && existsSync(join(dir, 'component.wasm'))) {
tier1.push(dir);
}
}
}
// 自足 Worker 零件(如 code):與 TinyGo 家族不同(自帶 index.ts + 相依 npm 套件 +
// vendored quickjs.wasm),但同屬 tier1「零件」語義 → 一起先於引擎部署。
// depsquickjs-emscripten-core / wasmfile variant)由 root 共享安裝提供(SHARED_DEPLOY_DEPS),
// wrangler 對相對路徑 .wasm import 自動綁 CompiledWasm(見該零件 index.ts 頭註)。
for (const { dir: rel, requires } of SELF_CONTAINED_COMPONENT_WORKERS) {
const dir = join(root, ...rel);
const complete = existsSync(join(dir, 'wrangler.toml'))
&& requires.every(r => existsSync(join(dir, ...r)));
if (complete) tier1.push(dir);
}
// self-hosted 也部署自己的 MCP workermcp-account-source §5carchive 主庫即得 MCP
// .mcp.json 指自己的 mcp 而非官方 mcp.arcrun.dev)。
// kbdbMCP 的 partnerAuthMiddleware 透過 KBDB service binding 打 arcrun-kbdb workermcp/wrangler.toml)。
// D1 arcrun-kbdb 已由 init/update 建好,但 worker 本體要一併部署,否則 binding 指向不存在的 service
// → 每個 MCP 認證請求都 throwself-hosted MCP failed 根因,2026-06-10)。
for (const name of ['cypher-executor', 'registry', 'kbdb', 'mcp']) {
const dir = join(root, name);
if (existsSync(join(dir, 'wrangler.toml'))) tier2.push(dir);
}
return { tier1, tier2 };
}
/**
* 注入用戶的 KV namespace id(取代 wrangler.toml 中各 binding 的 id+ cypher WORKER_SUBDOMAIN
* 並 strip 掉只有 arcrun 官方帳號才有的綁定(self-hosted fork 帳號沒有)。
*
* 為何 strip 而非刪 repo 內 toml(壓測 2026-06-04 阻斷項 #1#2#3#4):
* - repo 內各 worker toml 的 `[[routes]] zone_name="arcrun.dev"` 是**官方 prod CI 部署**需要的
* (對外開放零件)。直接從 repo 刪會破壞官方部署。
* - 但 fork 用戶**沒有 arcrun.dev zone** → wrangler deploy 找不到 zone 而失敗。
* - deploy.ts 只在 self-hosted 路徑跑,且改的是「暫存目錄副本」(SDD self-hosted-init.md §3 step 4),
* 不碰用戶 repo。所以在注入時 strip 掉這些官方專屬綁定 = 對的層級。
* - 每個 worker toml 都有 `workers_dev = true` → strip routes 後純靠 workers.dev URL,自架可達。
* - R2`[[r2_buckets]]`)是 dead storageregistry-canon Phase 1.5),且綁卡違背開源免費 → 一併移除。
*/
function injectWranglerConfig(
tomlPath: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
original?: string,
extraVars: Record<string, string> = {},
): void {
if (!existsSync(tomlPath)) return;
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
const toml = original ?? readFileSync(tomlPath, 'utf8');
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved, extraVars), 'utf8');
}
/**
* 挑出「這顆已部署的 worker 上有、但這版 toml 不會自己帶的」plain_text varArcrun#106)。
*
* 規則就一句:**已部署 worker 上掛著什麼 var,那就是事實**(#97 對資源講的那句話,
* 原封不動套用在標籤上)。所以預設全部沿用,只有兩種例外:
* ① `CLI_MANAGED_VARS`——這趟由 CLI 自己算(帳號 id/subdomain/單租戶旗標/版本標籤),
* 沿用等於拿舊值蓋掉正解。
* ② 值一模一樣的(toml 已經寫了同樣的值)——寫進去只是雜訊,略過。
*
* ⚠️ 這裡刻意**不**做「toml 有宣告就以 toml 為準」:那正是這次的病
* ——repo toml 裡的 `CONSOLE_TENANT = "leo"``WORKER_SUBDOMAIN` 之類是**官方 prod 的值**
* 拿它蓋掉使用者實例上的值,就是「更新一次把人家的設定洗成官方預設」。
*/
export function preservedVars(
live: Record<string, string> | undefined,
toml: string,
): Record<string, string> {
const out: Record<string, string> = {};
if (!live) return out;
const managed = new Set<string>(CLI_MANAGED_VARS);
for (const key of Object.keys(live).sort()) {
if (managed.has(key)) continue;
if (!/^[A-Za-z0-9_]+$/.test(key)) continue; // 怪名字不碰(applyVars 也會擋,這裡先濾掉不誤報)
if (readVar(toml, key) === live[key]) continue; // toml 已經是同一個值 → 不必動
out[key] = live[key];
}
return out;
}
/** 讀 toml 裡某個 var 目前的值(只看未註解的行)。找不到回 undefined。 */
function readVar(toml: string, key: string): string | undefined {
const m = toml.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm'));
return m?.[1];
}
/** TOML basic string 轉義(值裡可能有引號/反斜線,例如網址或 JSON 片段)。 */
function tomlEscape(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
/**
* 把一組 var 寫進 toml 的 `[vars]`Arcrun#106)。純函式。
*
* 三種既有狀態各自處理(比照 injectMultiTenant,同一種文字操作層級):
* 1. 已有未註解的同名行 → 換值
* 2. 只有被註解掉的同名行 → 取消註解並填值
* 3. 都沒有 → 插在 `[vars]` header 下一行;連 `[vars]` 都沒有就在檔尾新開一段
*/
export function applyVars(toml: string, vars: Record<string, string>): string {
let out = toml;
for (const key of Object.keys(vars).sort()) {
// 只接受合法的 var 名(CF 那側本來就是這個字集)。怪名字寧可不寫,也不要拿它去組正規式。
if (!/^[A-Za-z0-9_]+$/.test(key)) continue;
const value = tomlEscape(vars[key]);
// 🔴 一律用「函式版 replace」:值裡若有 `$&``$1` 這種字元,字串版 replace 會把它當成
// 反向參照展開,寫出來的就不是使用者那個值了。
if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(out)) {
out = out.replace(
new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(".*)$`, 'm'),
(_m, head: string, tail: string) => `${head}${value}${tail}`,
);
continue;
}
if (new RegExp(`^\\s*#\\s*${key}\\s*=`, 'm').test(out)) {
out = out.replace(
new RegExp(`^(\\s*)#\\s*${key}\\s*=\\s*"[^"]*"(.*)$`, 'm'),
(_m, indent: string, tail: string) => `${indent}${key} = "${value}"${tail}`,
);
continue;
}
if (/^\s*\[vars\]\s*$/m.test(out)) {
out = out.replace(/^(\s*\[vars\]\s*)$/m, (_m, header: string) => `${header}\n${key} = "${value}"`);
continue;
}
out = `${out.replace(/\s*$/, '')}\n\n[vars]\n${key} = "${value}"\n`;
}
return out;
}
/**
* 把一份 repo 內的 wrangler.toml 轉成「要部到這個用戶帳號上的樣子」。
*
* 純函式(好離線測、也好當預覽用)。帶空 `resolved` 呼叫 = 預覽:得到的是
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
*
* `extraVars`Arcrun#106):這顆 worker 要**沿用的既有 var** + 這趟要**重烙的版本標籤**。
* 預覽時不傳(vars 不影響資源需求解析,傳不傳都是同一份需求清單)。
*/
export function renderWranglerToml(
toml: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
extraVars: Record<string, string> = {},
): string {
// cypher-executor 的 WORKER_SUBDOMAINvars)換成用戶帳號 subdomain
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
toml = toml.replace(
/(WORKER_SUBDOMAIN\s*=\s*")[^"]*(")/,
`$1${ctx.workerSubdomain}$2`,
);
}
// credential-store-migration T3CF_ACCOUNT_IDvars,非機密識別碼)換成用戶自己的帳號 id,
// 比照 WORKER_SUBDOMAIN 注入同一套機制。cypher 寫入 /credentials 時要用它組
// CF Workers Scripts secrets 管理 API URL(見 routes/credentials.ts)。
if (ctx.accountId && /CF_ACCOUNT_ID/.test(toml)) {
toml = toml.replace(
/(CF_ACCOUNT_ID\s*=\s*")[^"]*(")/,
`$1${ctx.accountId}$2`,
);
}
// self-hosted:注入 MULTI_TENANT="false" 到 [vars]mcp-account-source §5.5)。
// 修「部署沒注入 → worker c.env.MULTI_TENANT===undefined → MCP 走 partner-key → 401」。
// 只對有 [vars] 的 workermcp / cypher-executor)生效;其餘無 [vars] 的不動。
if (ctx.selfHosted) {
toml = injectMultiTenant(toml);
// self-hosted:把 cypher 的 KBDB_BASE_URL 從官方 arcrun-kbdb.uncle6-me 改成用戶自己帳號的
// arcrun-kbdb.<subdomain>.workers.devissue #2)。比照 database_id / MULTI_TENANT 注入模式。
// 漏這一個 → cypher /kbdb/* fallback 到官方 kbdb workerself-hosted 資料寫進官方庫(隔離破損)。
if (ctx.workerSubdomain) {
toml = toml.replace(
/(KBDB_BASE_URL\s*=\s*")[^"]*(")/,
`$1https://arcrun-kbdb.${ctx.workerSubdomain}.workers.dev$2`,
);
}
}
toml = stripOfficialOnlyBindings(toml);
// 語義查詢(issue #7 / T2.4):開 kbdb_embed → 取消 kbdb toml 的 [[vectorize]]+[ai] 註解段(注入 active binding)。
// **必須在 stripOfficialOnlyBindings 之後**strip 會移除 [ai] 區塊(官方專屬),若先注入會被它清掉。
// 只對含該註解段的 toml= kbdb)生效;其餘 worker toml 無此段,replace 不命中、不動。
// 未開 → 維持註解 → worker env 無 VECTORIZE/AI → embedEnabled()=false → base keyword(不花費)。
if (ctx.kbdbEmbed) {
toml = toml.replace(
/# (\[\[vectorize\]\])\n# (binding = "VECTORIZE")\n# (index_name = "[^"]*")/,
'$1\n$2\n$3',
);
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
}
// 沿用的既有 var + 這趟的版本標籤(#106)。**放在所有 CLI 注入之後**:
// CLI_MANAGED_VARS 已經在 preservedVars 排除掉,故這裡不會蓋掉上面剛算好的
// WORKER_SUBDOMAIN / CF_ACCOUNT_ID / MULTI_TENANT / KBDB_BASE_URL。
toml = applyVars(toml, extraVars);
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
// 空 map = 預覽模式,這步什麼也不做。
return applyResolvedBindings(toml, resolved);
}
/**
* 把解析好的資源 id 寫進對應的 binding 區塊。
*
* 逐個 `[[table]]` 區塊掃:先在區塊內找 `binding = "X"`,再改同一區塊裡的值欄位
* KV→`id`、D1→`database_id`、Vectorize→`index_name`)。
* 🔴 刻意**不用**「全檔第一個 database_id」這種寫法:cypher`CREDENTIALS_DB`)與
* kbdb(`DB`)各有自己的 D1 綁定,盲換會把兩邊當成同一個東西——而使用者的實例
* 完全可以兩邊指向不同庫。誰綁誰是使用者那側的事實,我們只是原樣搬過去。
*/
export function applyResolvedBindings(
toml: string,
resolved: Map<string, ResolvedResource>,
): string {
if (resolved.size === 0) return toml;
const VALUE_KEY: Record<ResourceKind, string> = {
kv_namespace: 'id',
d1: 'database_id',
vectorize: 'index_name',
};
const out: string[] = [];
let block: string[] = [];
let kind: ResourceKind | null = null;
const flush = (): void => {
if (kind) {
const binding = block
.map((l) => l.trim())
.filter((l) => !l.startsWith('#'))
.map((l) => l.match(/^binding\s*=\s*"([^"]*)"/)?.[1])
.find((b): b is string => !!b);
const hit = binding ? resolved.get(bindingKey(kind, binding)) : undefined;
if (hit) {
const key = VALUE_KEY[kind];
const re = new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(")(.*)$`);
const at = block.findIndex((l) => !l.trim().startsWith('#') && re.test(l));
if (at >= 0) {
block[at] = block[at].replace(re, `$1${hit.value}$2$3`);
} else {
// 區塊裡本來沒有這個欄位(例如新版 toml 只寫 binding)→ 補一行,不要靜默略過。
block.push(`${key} = "${hit.value}"`);
}
}
}
out.push(...block);
block = [];
};
for (const line of toml.split('\n')) {
const table = line.trim().match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
kind = TABLE_KIND[table[1]] ?? null;
}
block.push(line);
}
flush();
return out.join('\n');
}
/**
* self-hosted:確保 worker [vars] 有 `MULTI_TENANT = "false"`。處理三種既有狀態:
* 1. 已有 active `MULTI_TENANT = "..."` → 改成 "false"
* 2. 有註解的 `# MULTI_TENANT = "false"`mcp/cypher toml 預設這樣)→ 取消註解
* 3. 無此行但有 `[vars]` → 在 [vars] header 下一行加進去
* 4. 無 `[vars]`(該 worker 不吃此 var)→ 不動
* 純文字操作,與 WORKER_SUBDOMAIN/KV 注入同層級(mcp-account-source §5.5)。
*/
export function injectMultiTenant(toml: string): string {
// 1. 已有 active 行 → 設 false
if (/^\s*MULTI_TENANT\s*=/m.test(toml)) {
return toml.replace(/^(\s*MULTI_TENANT\s*=\s*")[^"]*(".*)$/m, `$1false$2`);
}
// 2. 註解掉的行 → 取消註解(保留原縮排)
if (/^\s*#\s*MULTI_TENANT\s*=/m.test(toml)) {
return toml.replace(/^(\s*)#\s*(MULTI_TENANT\s*=\s*)"[^"]*"(.*)$/m, `$1$2"false"$3`);
}
// 3. 有 [vars] → 在其後插入
if (/^\s*\[vars\]\s*$/m.test(toml)) {
return toml.replace(
/^(\s*\[vars\]\s*)$/m,
`$1\nMULTI_TENANT = "false" # self-hosted 單租戶(acr update 注入,mcp-account-source §5.5`,
);
}
// 4. 無 [vars] → 不動(該 worker 不用此 var
return toml;
}
/**
* 移除 self-hosted fork 帳號沒有、會導致 wrangler deploy 失敗的官方專屬 TOML 區塊:
* - `[[routes]]`(含 pattern/zone_name):fork 沒有 arcrun.dev zone
* - `[[r2_buckets]]`dead storage + 綁卡違背開源免費(registry-canon 1.5
* - `[ai]`Workers AI binding):免費帳號未必啟用,且自架預設不需要
* 純文字行級移除(TOML table 以空行 / 下一個 `[` 區塊結束)。worker 仍靠 `workers_dev = true` 對外。
*/
export function stripOfficialOnlyBindings(toml: string): string {
const lines = toml.split('\n');
const out: string[] = [];
let skipping = false;
const isBlockHeader = (l: string) =>
/^\s*\[\[?(routes|r2_buckets|ai)\]?\]\s*$/.test(l);
for (const line of lines) {
if (isBlockHeader(line)) {
skipping = true; // 進入要移除的區塊,連同 header 一起丟
continue;
}
if (skipping) {
// 區塊結束條件:遇到下一個 table header`[...]`)或空行
if (/^\s*\[/.test(line)) {
skipping = false; // 這行是新區塊的開頭,保留並由下方邏輯處理
} else if (line.trim() === '') {
skipping = false; // 空行結束區塊;空行本身丟掉避免堆疊空白
continue;
} else {
continue; // 仍在被移除區塊內(pattern/zone_name/binding/bucket_name 等)
}
}
out.push(line);
}
return out.join('\n');
}
/** 在 worker 目錄跑 wrangler deploy(用用戶的 CF token + account)。
* sharedBinroot 共享 wrangler binary 路徑(見 downloadAndDeploy 2.5)。有則用它且**跳過本地 install**
* deps 從 root node_modules 往上 resolve);空字串則退回舊行為(各 worker 自裝)。*/
function runWranglerDeploy(dir: string, ctx: DeployContext, sharedBin = ''): void {
if (!sharedBin && existsSync(join(dir, 'package.json'))) {
// fallback:共享安裝失敗時才走這條,各 worker 自裝
const installer = existsSync(join(dir, 'pnpm-lock.yaml'))
? ['pnpm', 'install', '--frozen-lockfile']
: ['npm', 'install', '--no-audit', '--no-fund'];
runStep(installer[0], installer.slice(1), dir, process.env);
}
const wranglerCmd = sharedBin || 'wrangler';
runStep(wranglerCmd, ['deploy'], dir, {
...process.env,
CLOUDFLARE_API_TOKEN: ctx.apiToken,
CLOUDFLARE_ACCOUNT_ID: ctx.accountId,
});
}
/** 跑一個部署步驟,失敗時把 stderr 尾段帶進錯誤訊息——stdio ignore 會吞掉真因,
* 用戶只看到「Command failed: pnpm install」無從診斷(壓測 2026-06-12
* ERR_PNPM_IGNORED_BUILDS 被吞,10/23 失敗查不到原因)。*/
function runStep(cmd: string, args: string[], dir: string, env: NodeJS.ProcessEnv): void {
try {
execFileSync(cmd, args, { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'], env });
} catch (e) {
const stderr = (e as { stderr?: Buffer }).stderr?.toString().trim() ?? '';
const tail = stderr.split('\n').slice(-3).join(' | ').slice(0, 300);
throw new Error(`${cmd} ${args.join(' ')} 失敗${tail ? `${tail}` : ''}`);
}
}