Files
Arcrun/cli/src/lib/deploy.ts
T
uncle6me-web 45a546a686 fix(cli): 更新不再照名字找資源——已部署 worker 綁著什麼就是什麼(Arcrun#97)
病根:更新會「確保」它需要的資源存在,而它是**照名字找**的。
使用者的資源是安裝器建的(`arcrun-rag-<x>-kv-webhooks`),更新找的是 `WEBHOOKS`
⇒ 找不到 ⇒ 新建一顆空的並綁上去。

2026-08-12 實撞(leo21c):一次例行更新後
  KV 9 顆 → 18 顆、D1 1 顆 → 2 顆,worker 全綁到新建的空的
  ⇒ 工作流一支都看不到、portal 登出、總圖空的、80 把 recipe 解不出來
  leo 原話:「leo21c 是掛掉的」。資料沒掉,但從他的角度就是東西全不見了。

修法方向:**已經部署上去的 worker 綁著什麼,那就是事實**——
名字是使用者那側的事,不是更新指令可以決定的。

📍 repo:matrix/arcrun(cli/)|📍 票:Leo/Arcrun#97
2026-08-12 13:30:17 +08:00

935 lines
46 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;
}
/**
* 組 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;
}
/**
* 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
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.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 → 注入前的原文
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 不該存在;跳過而非亂猜
for (const b of parsed.bindings) {
requirements.push({ ...b, worker: parsed.script });
}
}
let resolved = new Map<string, ResolvedResource>();
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——你現在的實例維持原樣。`,
};
}
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)}`);
}
}
// 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));
// 注入後算指紋:與 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}`);
}
}
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 全部成功。`,
};
}
/**
* 對 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): Promise<string> {
// 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const url = buildArchiveUrl(ref, bust);
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${ref},約 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,
): void {
if (!existsSync(tomlPath)) return;
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
const toml = original ?? readFileSync(tomlPath, 'utf8');
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved), 'utf8');
}
/**
* 把一份 repo 內的 wrangler.toml 轉成「要部到這個用戶帳號上的樣子」。
*
* 純函式(好離線測、也好當預覽用)。帶空 `resolved` 呼叫 = 預覽:得到的是
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
*/
export function renderWranglerToml(
toml: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
): 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');
}
// 資源 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}` : ''}`);
}
}