/** * deploy.ts — self-hosted Worker 部署(installer 的「下載 repo tarball + wrangler deploy」段) * * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6(commit 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#4,2026-07-07):從 GitHub codeload 改指 Gitea(git.uncle6.me)archive 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-12:22/23 成功後重跑仍全部 * pnpm install + wrangler deploy,22 個沒變的白跑)。存 ~/.arcrun/。 * 指紋含 wrangler.toml 注入後的內容 → 換帳號/KV 會變更指紋 → 自動重部,不會誤跳。*/ const MANIFEST_PATH = join(homedir(), '.arcrun', 'deploy-manifest.json'); function loadManifest(): Record { try { return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Record; } catch { return {}; } } function saveManifest(m: Record): 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/name(archive tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。 * 注意:repo 名大小寫敏感(archive 路徑需完全一致)。 * Arcrun#4:真身在 Gitea(Leo/Arcrun),故預設從 GitHub 的 uncle6me-web/Arcrun 改為 Gitea 路徑。*/ const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'Leo/Arcrun'; /** Gitea 站台 base URL(archive API host)。fork/自架不同站台用 ARCRUN_GITEA_BASE env 覆蓋。 * 末尾斜線會被正規化掉,避免組出 `//api`。*/ const ARCRUN_GITEA_BASE = (process.env.ARCRUN_GITEA_BASE ?? 'https://git.uncle6.me').replace(/\/+$/, ''); /** Gitea access token(private 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+` 這個 * 舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」—— * 那正是我們想要的:**寧可說不準,也不要假裝已是最新**。 */ export async function resolveBundleStamp( ref: string, commit?: string, fetchImpl: typeof fetch = fetch, ): Promise { 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 sha(Arcrun#106)。 * * 兩個用途:① 版本標籤要烙「真的部了哪個 commit」;② 解出來之後**直接用 sha 下載 archive**—— * sha 是不可變的,順帶把 #13 P2 的「branch tarball 被中間層快取成舊的」整個病根拿掉。 * 查不到就回 undefined(呼叫端退回原本的用 ref 下載,行為不變)——這條路徑不該讓更新失敗。 */ export async function resolveGiteaCommit( ref: string, fetchImpl: typeof fetch = fetch, ): Promise { 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 param(Gitea 對不同 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 ` *(private repo 需要;public repo 省略也可下載)。token 不寫死,來自 giteaToken()。 */ export function buildDownloadHeaders(token = giteaToken()): Record { const headers: Record = { '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_KV:registry worker 用(component 投稿)。漏建會讓 registry deploy 失敗 → * 壓測 §2.6/#11「20/21」根因(registry/wrangler.toml 綁 SUBMISSIONS_KV,但注入清單沒有它, * 殘留官方舊 id → wrangler deploy 因 KV 不存在而失敗)。補進來後回到 21/21。 * OAUTH_KV:arcrun-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.5:tarball root 裝一次,各 worker 往上 resolve)。 * 含全部 worker 的 runtime deps:tier1 component 只要 hono;tier2 cypher/registry/mcp/kbdb * 另需 zod / @hono/zod-openapi / @modelcontextprotocol/sdk / js-yaml / yaml; * code 自足 Worker(registry/components/code)另需 quickjs-emscripten-core + wasmfile variant * (版本對齊該零件 package.json,drift 由 cli/tests/deploy-code-component.test.ts 看守)。 * 漏一個會讓該 worker deploy 失敗,故寧可多列。export 供離線測試驗清單完整。*/ export const SHARED_DEPLOY_DEPS: Record = { 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; /** KBDB Base D1 id;同上,由資源解析填入。*/ d1DatabaseId?: string; // self-hosted 單租戶旗標。true(self-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 keyword(free-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=`(cypher 既有的純轉發端點,CLI 平常就在用 * 這條路 + `X-Arcrun-API-Key`)。回傳: * true = 這個 namespace 底下查得到庫 → 寫 ARCRUN_NAMESPACE 是安全的 * false = 查得到但是空的 → 不寫(可能知識其實在別的命名空間,蓋下去會把畫面弄空) * null = 問不到(實例還沒起來 / 舊版沒這條路 / 網路斷)→ 不寫,也不宣稱任何事 * * 誠實邊界:這支只回答「有沒有」,不猜「應該是哪一個」。猜錯的代價是把人家的資料藏起來。 */ export async function namespaceHasKnowledge( cypherUrl: string, namespace: string, ): Promise { 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,比對出來是垃圾; * 而 #58(Vectorize vector delete 未接)代表舊向量刪不掉 * ⇒ **開新名字的 index 反而順手繞開 #58**,且新舊並存可回滾。 * * ⚠️ 這個常數同時被 `ensureVectorizeMetadataIndexes()` 使用(deploy.ts:426) * ⇒ t36 的四個 metadata index(owner_id/entry_type/source/library,Arcrun#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 URL(mcp-account-source §3) message: string; /** true = 資源解析階段就喊停(Arcrun#97),**一顆資源沒建、一個 worker 沒部**。 * 呼叫端要以非零結束並把 message 原文印出來,不要當成一般部分失敗帶過。*/ blocked?: boolean; /** 這趟實際用上的資源(沿用/新建各是哪一顆)。呼叫端寫 config 用這個,不要自己再查一次。*/ resources?: Map; } /** 偵測 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 tarball(ref 預設 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..workers.dev * * 誠實(mindset §7):任一 worker deploy 失敗會收集進 message 回報,不假裝全綠。 * * @param ctx 部署上下文 * @param ref git ref(branch / tag),預設 main;acr update 可帶 tag */ export async function downloadAndDeploy( ctx: DeployContext, ref = 'main', opts: { force?: boolean; mode?: 'init' | 'update'; api?: ResourceApi } = {}, ): Promise { 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.toml(root=${root})。` }; } // 2.5 共享依賴:23 個 component worker 的 runtime dep 全是 hono、devDep 全含 wrangler, // 舊版每個 worker 各 install 一份 ~324MB node_modules(23× 重複,壓測 2026-06-12 慢的真因)。 // 改成在 tarball root 裝「一次」hono+wrangler;component 目錄靠 node 往上 resolve(已驗證可行)。 // → 23×4.4s install 變 1×17s。失敗不致命:退回各 worker 自裝(runWranglerDeploy 仍有 fallback)。 let sharedBin = ''; try { process.stdout.write(chalk.gray(' → 安裝共享部署依賴(一次,取代每個 worker 各裝)...')); // 依賴清單抽出成 SHARED_DEPLOY_DEPS(export 供離線測試看守,見常數 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(); // dir → 注入前的原文 const dirScript = new Map(); // dir → worker script 名(#106:var 沿用要逐顆對號) 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(); let liveVars = new Map>(); 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 var(plain_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>(); 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 = { ...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)。 // 建 D1(cf-api ensureD1Database)只產生空資料庫,schema 要靠這步套。 // migration 檔來自同一份 tarball(root/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 seed(D38 圍牆修復,總管交辦,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 表(D38,2026-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 seed(KV 額度事故修復,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 模型(0007,v7 定稿 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 URL(mcp-account-source §3:.mcp.json 指自己)。 // 端點是 /mcp(streamable 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 column(0007 專用)。 * * 為什麼不能走 applyD1Migration 整檔送:/query 端點任何一句失敗整批中止—— * 0007 的三句 ADD COLUMN 在重跑時必然報 duplicate column(SQLite 沒有欄位級 * IF NOT EXISTS),整檔送 ⇒ 第二次部署起 migration 永遠假紅、後面的資料搬遷 * 語句永遠不被執行。逐句+把 duplicate column 視為「已套用」,其餘錯誤照樣拋。 * 切句手法與安裝器 compile-migrations.mjs 同款(剝 -- 註解、依分號切; * 0007 的字串常值不含分號,前提成立)。 */ async function applyD1MigrationTolerant(ctx: DeployContext, sql: string): Promise { 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 + accountId;query 端點接受多語句檔,一次送整份 0001_base.sql。 */ async function applyD1Migration(ctx: DeployContext, sql: string): Promise { 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 index(owner_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 { 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 archive(archive/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 { // 唯一 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},約 10–30 秒,視網速)...`)); 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 必要產物(相對該目錄)。 * 目前只有 code(quickjs 沙箱,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 零件)與 * tier2(cypher-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_block)wasm 沒 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「零件」語義 → 一起先於引擎部署。 // deps(quickjs-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 worker(mcp-account-source §5c:archive 主庫即得 MCP, // .mcp.json 指自己的 mcp 而非官方 mcp.arcrun.dev)。 // kbdb:MCP 的 partnerAuthMiddleware 透過 KBDB service binding 打 arcrun-kbdb worker(mcp/wrangler.toml)。 // D1 arcrun-kbdb 已由 init/update 建好,但 worker 本體要一併部署,否則 binding 指向不存在的 service // → 每個 MCP 認證請求都 throw(self-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 storage(registry-canon Phase 1.5),且綁卡違背開源免費 → 一併移除。 */ function injectWranglerConfig( tomlPath: string, ctx: DeployContext, resolved: Map, original?: string, extraVars: Record = {}, ): void { if (!existsSync(tomlPath)) return; // original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。 const toml = original ?? readFileSync(tomlPath, 'utf8'); writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved, extraVars), 'utf8'); } /** * 挑出「這顆已部署的 worker 上有、但這版 toml 不會自己帶的」plain_text var(Arcrun#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 | undefined, toml: string, ): Record { const out: Record = {}; if (!live) return out; const managed = new Set(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 { 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, extraVars: Record = {}, ): string { // cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) { toml = toml.replace( /(WORKER_SUBDOMAIN\s*=\s*")[^"]*(")/, `$1${ctx.workerSubdomain}$2`, ); } // credential-store-migration T3:CF_ACCOUNT_ID(vars,非機密識別碼)換成用戶自己的帳號 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] 的 worker(mcp / cypher-executor)生效;其餘無 [vars] 的不動。 if (ctx.selfHosted) { toml = injectMultiTenant(toml); // self-hosted:把 cypher 的 KBDB_BASE_URL 從官方 arcrun-kbdb.uncle6-me 改成用戶自己帳號的 // arcrun-kbdb..workers.dev(issue #2)。比照 database_id / MULTI_TENANT 注入模式。 // 漏這一個 → cypher /kbdb/* fallback 到官方 kbdb worker,self-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 { if (resolved.size === 0) return toml; const VALUE_KEY: Record = { 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)。 * sharedBin:root 共享 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}` : ''}`); } }