/** * deploy.ts — self-hosted Worker 部署(installer 的「下載 repo tarball + wrangler deploy」段) * * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6(commit wasm + codeload) * * 策略(richblack 2026-06-02):repo 自帶預編譯 wasm(.component-builds 下各 component.wasm, * 見 rule 05 慣例變更)→ CLI 從 GitHub codeload tarball 拿完整部署物 → 注入用戶的 KV id * → 用用戶自己的 CF token wrangler deploy。用戶不需 git / tinygo,只需 wrangler。 */ 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'; /** 部署狀態 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'); } /** GitHub repo(codeload tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。 * 注意:repo 名大小寫敏感(codeload 路徑需完全一致)。*/ const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'uncle6me-web/Arcrun'; /** * init 要建立的 KV namespace(title)。 * 前 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。 */ export const REQUIRED_KV_NAMESPACES = [ 'WEBHOOKS', 'CREDENTIALS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV', 'ANALYTICS_KV', 'EXEC_CONTEXT', 'SUBMISSIONS_KV', ] as const; /** 部署後要提示用戶手動 `wrangler secret put ENCRYPTION_KEY` 的 Worker。*/ export const SECRET_TARGET_WORKERS = [ 'arcrun-cypher-executor', 'arcrun-auth-static-key', 'arcrun-auth-service-account', ] as const; export interface DeployContext { accountId: string; apiToken: string; workerSubdomain: string; kvNamespaceIds: Record; // title → id d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml // 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; } /** Vectorize index 名(kbdb embed 模組用)。bge-base-en-v1.5 = 768 維、cosine。 */ export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed'; export interface DeployResult { implemented: boolean; cypherExecutorUrl?: string; mcpUrl?: string; // self-hosted 自己的 MCP worker URL(mcp-account-source §3) message: string; } /** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/ export function wranglerAvailable(): boolean { try { execFileSync('wrangler', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } } /** * 下載 repo codeload tarball(含預編譯 wasm)→ 注入用戶 KV id → wrangler deploy 全部 Worker。 * * SDD self-hosted-init.md §6.4: * 1. 下載 codeload 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 } = {}, ): Promise { // 1. 下載 + 解壓 codeload tarball let root: string; try { root = await downloadRepoTarball(ref); } 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 各裝)...')); // 含全部 worker 的 runtime deps(tier1 component 只要 hono;tier2 cypher/registry/mcp/kbdb // 另需 zod / @hono/zod-openapi / @modelcontextprotocol/sdk / js-yaml / yaml)→ 全裝 root, // 各 worker 往上 resolve,esbuild bundle 找得到。漏一個會讓該 worker deploy 失敗,故寧可多列。 writeFileSync( join(root, 'package.json'), JSON.stringify({ name: 'arcrun-deploy-shared', private: true, type: 'module', dependencies: { 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', } }), ); 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[] = []; // 2.6 語義查詢(issue #7 / T2.4):開 kbdb_embed → 先確保 Vectorize index 存在(REST,冪等), // 再由 injectWranglerConfig 取消 kbdb toml 的 [[vectorize]]+[ai] 註解 → embed 模組上線。 // 失敗不致命(收進 failures,base 仍可部署、維持 keyword)。 if (ctx.kbdbEmbed) { try { process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...')); await ensureVectorizeIndex(ctx); console.log(chalk.green(' ✓')); } catch (e) { console.log(chalk.yellow(' ⚠')); failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`); } } // 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。 // 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住—— // 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。 const allDirs = [...tier1, ...tier2]; 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); // 注入後算指紋:與 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})`); } } 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 全部成功。`, }; } /** * 對 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); } } /** * 確保 KBDB embed 用的 Vectorize index 存在(issue #7 / T2.4)。 * REST `POST /accounts/{id}/vectorize/v2/indexes`(dimensions=768/metric=cosine,對齊 bge-base-en-v1.5)。 * 冪等:已存在(CF 回「already exists」類錯)視為成功,不報錯。用 init 已驗的 apiToken+accountId。 */ async function ensureVectorizeIndex(ctx: DeployContext): Promise { const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes`; const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: KBDB_VECTORIZE_INDEX, config: { dimensions: 768, metric: 'cosine' }, description: 'arcrun KBDB optional embed module (issue #7)', }), signal: AbortSignal.timeout(60_000), }); if (res.ok) return; // 冪等:已存在 → 視為成功(CF 回 409 或 errors 含 already exists / duplicate)。 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)) return; throw new Error(msg); } /** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。*/ async function downloadRepoTarball(ref: string): Promise { const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}`; console.log(chalk.gray(` → 從 GitHub 下載最新版本(${ARCRUN_REPO}@${ref},約 10–30 秒,視網速)...`)); const res = await fetch(url, { signal: AbortSignal.timeout(120_000) }); if (!res.ok) throw new Error(`codeload HTTP ${res.status}(${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); } /** 掃解壓出的部署物,回傳 tier1(.component-builds/*)與 tier2(cypher-executor/registry)目錄清單。*/ 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)→ codeload 拿到的目錄缺 wasm → 自然跳過, // 不讓 wrangler deploy 因缺檔失敗。 if (existsSync(join(dir, 'wrangler.toml')) && existsSync(join(dir, 'component.wasm'))) { tier1.push(dir); } } } // self-hosted 也部署自己的 MCP worker(mcp-account-source §5c:codeload 主庫即得 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): void { if (!existsSync(tomlPath)) return; let toml = readFileSync(tomlPath, 'utf8'); // 對每個已建立的 KV namespace:把對應 binding 的 id 換成用戶的。 // 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。 for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) { if (!id) continue; const re = new RegExp( `(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`, 'g', ); toml = toml.replace(re, `$1${id}$2`); } // cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) { toml = toml.replace( /(WORKER_SUBDOMAIN\s*=\s*")[^"]*(")/, `$1${ctx.workerSubdomain}$2`, ); } // KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml) if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) { toml = toml.replace( /(database_id\s*=\s*")[^"]*(")/, `$1${ctx.d1DatabaseId}$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'); } writeFileSync(tomlPath, toml, 'utf8'); } /** * 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}` : ''}`); } }