934b9265d9
按 issue 分段標明(檔 #5/#8 改動交疊處無法乾淨拆檔,故併一個 commit): #4 thin-shell §3.1 自力救濟階梯 + code-node 規則(純文檔/規則,code-node 零件未實作) #5 KBDB source filter(json_extract metadata_json 零建表)+ 能力對照;documents 聚合與 DELETE proxy 部分擱置等頂層 T8 #7 base embed 模組(kbdb/src/embed.ts)+ vectorize 開關(deploy/config/wrangler.toml 註解範本) + 語義查詢降級閉環(mode=semantic 未開→LIKE+capability_hint) #8 部分(workflow-discovery): - KBDB /entries/search 加 base 通用 entry_type filter(entry-crud/embed/route/kbdb-proxy 透傳) - /webhooks/named 強制 description(空→400,訊息要求操盤 AI 據實寫一句) - 部署雙寫 entry_type=workflow embeddable entry(waitUntil 非阻塞,供 search) - cypher GET /workflows/search + MCP u6u_search_workflows(優先語意、降級 hint) - cypher POST /workflows/backfill-search-entries(無 desc 列出不編造) - GET /webhooks/named 補回 description/created_at 欄位(為 list 來源收斂備) ⚠️ tsc 綠 = code done,非完成(mindset §7 禁假綠): - #7/#8 端到端待 leo21c 部署驗(Vectorize 需官方憑證、CC 跑不了) - #8 ①-a(MCP deploy 改打 /webhooks/named)未做、MCP deploy 那半仍 404 - #8 端到端(強制填擋空/語義命中/租戶隔離/降級 hint)未驗 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
573 lines
26 KiB
TypeScript
573 lines
26 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 + 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<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');
|
||
}
|
||
|
||
/** 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<string, string>; // 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.<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 } = {},
|
||
): Promise<DeployResult> {
|
||
// 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<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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 確保 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<void> {
|
||
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<string> {
|
||
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.<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');
|
||
}
|
||
|
||
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}` : ''}`);
|
||
}
|
||
}
|