45a546a686
病根:更新會「確保」它需要的資源存在,而它是**照名字找**的。 使用者的資源是安裝器建的(`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
935 lines
46 KiB
TypeScript
935 lines
46 KiB
TypeScript
/**
|
||
* 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<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/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;
|
||
}
|
||
|
||
/**
|
||
* 組 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 <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_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<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 單租戶旗標。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-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<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 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.<subdomain>.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<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.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<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)。
|
||
// 建 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})`);
|
||
}
|
||
}
|
||
|
||
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<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 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<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 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): 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},約 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<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_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.<subdomain>.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');
|
||
}
|
||
|
||
// 資源 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)。
|
||
* 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}` : ''}`);
|
||
}
|
||
}
|