ceb7638d74
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。 模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」 - 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數 (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+ 每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列 (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。 - record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留, 驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。 - entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry 接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。 - 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列, LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀), GET /maintenance/relation-orphans 唯讀巡檢。 - cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS, 整檔送 /query 會在重跑時假紅)。 - 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描; 釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。 遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型 與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1286 lines
65 KiB
TypeScript
1286 lines
65 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;
|
||
}
|
||
|
||
/**
|
||
* 版本標籤的「發行頻道」來源(Arcrun#106)。
|
||
*
|
||
* Portal 設定頁與 daemon `cloudVersionStale()` 都是拿**這支**回的 `release` 當「最新版」,
|
||
* 再跟實例 `/health` 的 `bundle_version` 比。CLI 更新完若不烙一個同一把尺量得出來的版號,
|
||
* 使用者就只會看到「無法讀取目前版本」或永遠「落後」。
|
||
* fork/自架另有發行頻道者用 ARCRUN_RELEASE_API 覆蓋,不寫死。
|
||
*/
|
||
const ARCRUN_RELEASE_API = process.env.ARCRUN_RELEASE_API ?? 'https://install.arcrun.dev/api/latest';
|
||
|
||
/** CLI 自己負責注入 / 自己烙的 var——**不從已部署的 worker 沿用**(沿用會蓋掉這趟算出來的正解)。 */
|
||
export const CLI_MANAGED_VARS = [
|
||
'WORKER_SUBDOMAIN', // 由 ctx.workerSubdomain 注入
|
||
'CF_ACCOUNT_ID', // 由 ctx.accountId 注入
|
||
'MULTI_TENANT', // 由 selfHosted 注入
|
||
'KBDB_BASE_URL', // 由 workerSubdomain 組
|
||
'ARCRUN_BUNDLE_VERSION', // 版本標籤:每趟重烙,**絕不沿用舊值**(見 resolveBundleStamp)
|
||
'ARCRUN_BUNDLE_COMMIT',
|
||
] as const;
|
||
|
||
/** 烙版本標籤的那顆 worker(`/health` 就是它吐的)。其餘 worker 不需要版本標籤。 */
|
||
export const VERSION_STAMP_WORKER = 'arcrun-cypher-executor';
|
||
|
||
/** 這趟部署要烙上去的版本標籤。 */
|
||
export interface BundleStamp {
|
||
/** 寫進 `ARCRUN_BUNDLE_VERSION`。 */
|
||
version: string;
|
||
/** 寫進 `ARCRUN_BUNDLE_COMMIT`(查得到才有)。 */
|
||
commit?: string;
|
||
/** 給人看的一句話(CLI 會印出來),說明這個版號是怎麼來的。 */
|
||
note: string;
|
||
}
|
||
|
||
/**
|
||
* 算「這趟部署上去的東西,該叫幾版」(Arcrun#106)。
|
||
*
|
||
* 🔴 為什麼**不是沿用實例上原本那個值**:那個值描述的是**當時裝上去的那份程式碼**。
|
||
* 更新完程式碼換了,標籤沒換 = 一個永遠停在安裝當天的假標籤——比沒有標籤更糟,
|
||
* 因為 leo 會拿它當「我驗收過了」。版本標籤是**成品的屬性**,不是使用者的設定,
|
||
* 所以它是唯一一個「不沿用、每趟重烙」的 var(其餘 plain_text var 一律沿用,見 preservedVars)。
|
||
*
|
||
* 誠實邊界(mindset §7,這段要留著):
|
||
* - CLI 部的是 `ARCRUN_REPO@ref` 的**原始碼**,發行版號(semver)是**安裝器頻道**在發的,
|
||
* 兩者不是同一套編號。這裡取的是「部署當下該頻道公告的 release」,
|
||
* 語義=「我跟這個頻道的最新發行同源」,並**另外把真正的 commit 一起烙上去**
|
||
* (`ARCRUN_BUNDLE_COMMIT`/`/health` 的 `bundle_commit`)→ 有沒有漂掉,看 commit 就查得出來。
|
||
* - 查不到 release(離線/頻道掛了)→ **不猜、不掰**,退成 `YYYY-MM-DD+<commit7>` 這個
|
||
* 舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」——
|
||
* 那正是我們想要的:**寧可說不準,也不要假裝已是最新**。
|
||
*/
|
||
export async function resolveBundleStamp(
|
||
ref: string,
|
||
commit?: string,
|
||
fetchImpl: typeof fetch = fetch,
|
||
): Promise<BundleStamp> {
|
||
const short = commit ? commit.slice(0, 7) : ref;
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
try {
|
||
const res = await fetchImpl(ARCRUN_RELEASE_API, { signal: AbortSignal.timeout(15_000) });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const body = (await res.json()) as { release?: string } | null;
|
||
const release = String(body?.release ?? '').trim();
|
||
if (!/^\d+\.\d+\.\d+$/.test(release)) throw new Error(`發行頻道回的版號不是 semver(${release || '空'})`);
|
||
return {
|
||
version: release,
|
||
commit,
|
||
note: `${release}(發行頻道 ${ARCRUN_RELEASE_API}${commit ? `;實際部署 commit ${short}` : ''})`,
|
||
};
|
||
} catch (e) {
|
||
const version = `${today}+${short}`;
|
||
return {
|
||
version,
|
||
commit,
|
||
note:
|
||
`${version}(查不到發行版號:${e instanceof Error ? e.message : String(e)})` +
|
||
`\n → 誠實標成 commit 版;Portal 會顯示成「較舊版本」而不是假裝已是最新。`,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 把 `ref`(branch / tag / sha)解析成確切的 commit sha(Arcrun#106)。
|
||
*
|
||
* 兩個用途:① 版本標籤要烙「真的部了哪個 commit」;② 解出來之後**直接用 sha 下載 archive**——
|
||
* sha 是不可變的,順帶把 #13 P2 的「branch tarball 被中間層快取成舊的」整個病根拿掉。
|
||
* 查不到就回 undefined(呼叫端退回原本的用 ref 下載,行為不變)——這條路徑不該讓更新失敗。
|
||
*/
|
||
export async function resolveGiteaCommit(
|
||
ref: string,
|
||
fetchImpl: typeof fetch = fetch,
|
||
): Promise<string | undefined> {
|
||
const headers = buildDownloadHeaders();
|
||
const tryUrls = [
|
||
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/branches/${encodeURIComponent(ref)}`,
|
||
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/commits?sha=${encodeURIComponent(ref)}&limit=1&stat=false`,
|
||
];
|
||
for (const url of tryUrls) {
|
||
try {
|
||
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(20_000) });
|
||
if (!res.ok) continue;
|
||
const body = (await res.json()) as
|
||
| { commit?: { id?: string } }
|
||
| Array<{ sha?: string }>
|
||
| null;
|
||
const sha = Array.isArray(body) ? body[0]?.sha : body?.commit?.id;
|
||
if (typeof sha === 'string' && /^[0-9a-f]{7,64}$/i.test(sha)) return sha;
|
||
} catch {
|
||
/* 換下一種問法;全都問不到就回 undefined */
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* 組 Gitea archive 下載 URL(純函式,好離線測 URL 組裝)。
|
||
* Gitea archive API:`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。
|
||
* bust:唯一 cache-buster query param(Gitea 對不同 query 視為不同請求 → 繞過任何中間快取;
|
||
* 對齊原 codeload #13 P2 假綠防護,行為保留)。
|
||
*/
|
||
export function buildArchiveUrl(ref: string, bust: string, repo = ARCRUN_REPO, base = ARCRUN_GITEA_BASE): string {
|
||
return `${base}/api/v1/repos/${repo}/archive/${ref}.tar.gz?_cb=${encodeURIComponent(bust)}`;
|
||
}
|
||
|
||
/**
|
||
* 組下載用的 request headers(純函式,好離線測 token → header 對映)。
|
||
* 帶 no-cache(保留 #13 P2 stale 防護);有 token 才加 Gitea 慣用的 `Authorization: token <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;
|
||
/**
|
||
* Arcrun#108:這台實例的知識命名空間(=`~/.arcrun/config.yaml` 的 `api_key`),
|
||
* 會寫進 cypher worker 的 `ARCRUN_NAMESPACE` var,讓「讀」用的 owner_id 與「寫」的一致。
|
||
*
|
||
* **只在驗證過該 namespace 底下真的有知識時才給值**(見 `resolveKnowledgeNamespace`)——
|
||
* 給了就會覆蓋 worker 上的既有值,沒給則原封保留(preservedVars)。
|
||
*/
|
||
knowledgeNamespace?: string;
|
||
}
|
||
|
||
/**
|
||
* 這把 namespace 底下到底有沒有知識?(Arcrun#108 的「先驗再寫」)
|
||
*
|
||
* 打的是實例自己的 `GET /kbdb/map?owner_id=<ns>`(cypher 既有的純轉發端點,CLI 平常就在用
|
||
* 這條路 + `X-Arcrun-API-Key`)。回傳:
|
||
* true = 這個 namespace 底下查得到庫 → 寫 ARCRUN_NAMESPACE 是安全的
|
||
* false = 查得到但是空的 → 不寫(可能知識其實在別的命名空間,蓋下去會把畫面弄空)
|
||
* null = 問不到(實例還沒起來 / 舊版沒這條路 / 網路斷)→ 不寫,也不宣稱任何事
|
||
*
|
||
* 誠實邊界:這支只回答「有沒有」,不猜「應該是哪一個」。猜錯的代價是把人家的資料藏起來。
|
||
*/
|
||
export async function namespaceHasKnowledge(
|
||
cypherUrl: string,
|
||
namespace: string,
|
||
): Promise<boolean | null> {
|
||
if (!cypherUrl || !namespace) return null;
|
||
try {
|
||
const res = await fetch(
|
||
`${cypherUrl.replace(/\/+$/, '')}/kbdb/map?owner_id=${encodeURIComponent(namespace)}`,
|
||
{ headers: { 'X-Arcrun-API-Key': namespace } },
|
||
);
|
||
if (!res.ok) return null;
|
||
const body = (await res.json().catch(() => null)) as { libraries?: unknown } | null;
|
||
if (!body || !Array.isArray(body.libraries)) return null;
|
||
return body.libraries.length > 0;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Vectorize index 名(kbdb embed 模組用)。**bge-m3 = 1024 維、cosine。**
|
||
*
|
||
* 🔴 2026-08-03 換代(leo 拍板;5 組中文測資實證:舊 `bge-base-en-v1.5` 排序 2/5、
|
||
* margin −0.0413=**中文根本不能用**;`bge-m3` 5/5、+0.1410、959ms)。
|
||
* leo 08-05:「換 embed model 當然要合併,當然要換 vectorize,原本的根本不能用」。
|
||
*
|
||
* **換模型必須換 index,且必須換「名字」**:
|
||
* ① 維度 768→1024,舊 index 收不進新向量
|
||
* ② 就算維度相同也不能沿用——不同模型的向量混在同一 index,比對出來是垃圾;
|
||
* 而 #58(Vectorize vector delete 未接)代表舊向量刪不掉
|
||
* ⇒ **開新名字的 index 反而順手繞開 #58**,且新舊並存可回滾。
|
||
*
|
||
* ⚠️ 這個常數同時被 `ensureVectorizeMetadataIndexes()` 使用(deploy.ts:426)
|
||
* ⇒ t36 的四個 metadata index(owner_id/entry_type/source/library,Arcrun#11 根因修復)
|
||
* 會自動建在新 index 上,**不會因為改名而遺失**(已查證,非假設)。
|
||
*
|
||
* 既有實例遷移:部署後 `POST /embed/backfill {"reindex":true}` 重嵌到 remaining=0,
|
||
* 確認語意查詢正常後,舊的 `arcrun-kbdb-embed` 可自行刪除。
|
||
*/
|
||
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed-m3';
|
||
|
||
export interface DeployResult {
|
||
implemented: boolean;
|
||
cypherExecutorUrl?: string;
|
||
mcpUrl?: string; // self-hosted 自己的 MCP worker URL(mcp-account-source §3)
|
||
message: string;
|
||
/** true = 資源解析階段就喊停(Arcrun#97),**一顆資源沒建、一個 worker 沒部**。
|
||
* 呼叫端要以非零結束並把 message 原文印出來,不要當成一般部分失敗帶過。*/
|
||
blocked?: boolean;
|
||
/** 這趟實際用上的資源(沿用/新建各是哪一顆)。呼叫端寫 config 用這個,不要自己再查一次。*/
|
||
resources?: Map<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
|
||
// #106:先把 ref 解析成確切 commit,**用 sha 下載**(不可變 → 順帶解掉 branch tarball 被快取的老問題),
|
||
// 同一個 sha 稍後也會被烙成版本標籤。解不出來就照舊用 ref 下載(行為不變)。
|
||
const commit = await resolveGiteaCommit(ref);
|
||
let root: string;
|
||
try {
|
||
root = await downloadRepoTarball(commit ?? ref, commit ? ref : undefined);
|
||
} catch (e) {
|
||
return {
|
||
implemented: true,
|
||
message: `下載部署物失敗(${e instanceof Error ? e.message : e})。確認網路 + ARCRUN_REPO=${ARCRUN_REPO} 可達。`,
|
||
};
|
||
}
|
||
|
||
// 2. 列出要部署的 worker 目錄(含 wrangler.toml),分 tier
|
||
const { tier1, tier2 } = discoverWorkerDirs(root);
|
||
if (tier1.length === 0 && tier2.length === 0) {
|
||
return { implemented: true, message: `部署物中找不到任何 wrangler.toml(root=${root})。` };
|
||
}
|
||
|
||
// 2.5 共享依賴:23 個 component worker 的 runtime dep 全是 hono、devDep 全含 wrangler,
|
||
// 舊版每個 worker 各 install 一份 ~324MB node_modules(23× 重複,壓測 2026-06-12 慢的真因)。
|
||
// 改成在 tarball root 裝「一次」hono+wrangler;component 目錄靠 node 往上 resolve(已驗證可行)。
|
||
// → 23×4.4s install 變 1×17s。失敗不致命:退回各 worker 自裝(runWranglerDeploy 仍有 fallback)。
|
||
let sharedBin = '';
|
||
try {
|
||
process.stdout.write(chalk.gray(' → 安裝共享部署依賴(一次,取代每個 worker 各裝)...'));
|
||
// 依賴清單抽出成 SHARED_DEPLOY_DEPS(export 供離線測試看守,見常數 doc)。
|
||
writeFileSync(
|
||
join(root, 'package.json'),
|
||
JSON.stringify({ name: 'arcrun-deploy-shared', private: true, type: 'module',
|
||
dependencies: SHARED_DEPLOY_DEPS }),
|
||
);
|
||
execFileSync('npm', ['install', '--no-audit', '--no-fund'],
|
||
{ cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
|
||
sharedBin = join(root, 'node_modules', '.bin', 'wrangler');
|
||
console.log(existsSync(sharedBin) ? chalk.green(' ✓') : chalk.yellow(' ⚠ 退回各 worker 自裝'));
|
||
if (!existsSync(sharedBin)) sharedBin = '';
|
||
} catch (e) {
|
||
const tail = (e as { stderr?: Buffer }).stderr?.toString().trim().split('\n').slice(-2).join(' | ').slice(0, 200) ?? '';
|
||
console.log(chalk.yellow(` ⚠ 共享安裝失敗,退回各 worker 自裝${tail ? `:${tail}` : ''}`));
|
||
}
|
||
|
||
const failures: string[] = [];
|
||
const allDirs = [...tier1, ...tier2];
|
||
|
||
// ── 2.6 資源解析:先看「這些 worker 現在綁著什麼」,再決定沿用還是新建(Arcrun#97)──────
|
||
//
|
||
// 🔴 這一段取代了舊的「照名字 ensure 一輪 KV/D1/Vectorize 再注入」。
|
||
// 舊做法用 binding 名當資源標題去找,對不上就新建一顆空的綁上去——
|
||
// 安裝器建的資源本來就不叫那個名字,於是**每次更新都對不上、每次都新建**:
|
||
// 2026-08-12 一次更新生了 9 顆 KV + 1 顆 D1,使用者的工作流/登入/子庫全部從畫面上消失。
|
||
//
|
||
// 現在:已部署 worker 上的綁定=事實,原樣沿用;只有「確定沒人綁過」才建;
|
||
// 任何說不準的情況(讀不到綁定/綁著的資源不見了/同名綁定指向兩顆/一顆 worker 都找不到)
|
||
// → 整趟停手,**在動任何東西之前**。
|
||
//
|
||
// 需求是從「注入後的 toml」解析的(renderWranglerToml 帶空 map 當預覽),
|
||
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
|
||
const requirements: BindingRequirement[] = [];
|
||
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
|
||
const dirScript = new Map<string, string>(); // dir → worker script 名(#106:var 沿用要逐顆對號)
|
||
for (const dir of allDirs) {
|
||
const tomlPath = join(dir, 'wrangler.toml');
|
||
if (!existsSync(tomlPath)) continue;
|
||
const raw = readFileSync(tomlPath, 'utf8');
|
||
tomlPreviews.set(dir, raw);
|
||
const preview = renderWranglerToml(raw, ctx, new Map());
|
||
const parsed = parseWranglerRequirements(preview);
|
||
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
|
||
dirScript.set(dir, parsed.script);
|
||
for (const b of parsed.bindings) {
|
||
requirements.push({ ...b, worker: parsed.script });
|
||
}
|
||
}
|
||
|
||
let resolved = new Map<string, ResolvedResource>();
|
||
let liveVars = new Map<string, Record<string, string>>();
|
||
if (requirements.length > 0) {
|
||
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
|
||
let plan;
|
||
try {
|
||
plan = await planResources(api, requirements, mode);
|
||
} catch (e) {
|
||
console.log(chalk.yellow(' ✗'));
|
||
return {
|
||
implemented: true,
|
||
blocked: true,
|
||
message:
|
||
`資源解析失敗(${e instanceof Error ? e.message : String(e)})。\n` +
|
||
`沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
|
||
};
|
||
}
|
||
if (plan.blockers.length > 0) {
|
||
console.log(chalk.yellow(' ✗'));
|
||
return {
|
||
implemented: true,
|
||
blocked: true,
|
||
message:
|
||
`停手:有 ${plan.blockers.length} 件事我不敢自己決定。\n` +
|
||
plan.blockers.map((b) => ` • ${b}`).join('\n') +
|
||
`\n\n沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
|
||
};
|
||
}
|
||
try {
|
||
resolved = await applyResourcePlan(api, plan);
|
||
} catch (e) {
|
||
console.log(chalk.yellow(' ✗'));
|
||
const raw = e instanceof Error ? e.message : String(e);
|
||
const detail = e instanceof ResourcePlanBlocked
|
||
? e.blockers.map((b) => ` • ${b}`).join('\n')
|
||
: ` • ${raw}`;
|
||
// D1 建不起來最常見的根因是 token 沒勾 D1 權限(KV/Worker 建得起來、只有 D1 報 auth error)。
|
||
// 這句提示在改版前就有,別隨著搬家弄丟——它是使用者唯一能自己解掉的那個錯。
|
||
const hint = /d1/i.test(raw) && /auth/i.test(raw)
|
||
? '\n → CF token 缺 D1 權限:補勾「Account / D1 / Edit」重產 token 填回 .env 再跑一次。'
|
||
: '';
|
||
return {
|
||
implemented: true,
|
||
blocked: true,
|
||
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
|
||
};
|
||
}
|
||
liveVars = plan.liveVars;
|
||
console.log(chalk.green(' ✓'));
|
||
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
|
||
const created = [...resolved.values()].filter((r) => r.origin === 'created');
|
||
if (adopted.length > 0) {
|
||
console.log(chalk.gray(` 沿用你既有的 ${adopted.length} 個資源(不論它們叫什麼名字):`));
|
||
for (const r of adopted) console.log(chalk.gray(` = ${r.binding} → ${r.value}(讀自 ${r.from})`));
|
||
}
|
||
if (created.length > 0) {
|
||
console.log(chalk.yellow(` 新建 ${created.length} 個(目前沒有任何已部署的 worker 綁著它們):`));
|
||
for (const r of created) console.log(chalk.yellow(` + ${r.binding} → ${r.value}`));
|
||
}
|
||
}
|
||
|
||
// 解析結果回填 ctx,供 applyD1Migration / 呼叫端寫 config 使用。
|
||
// KBDB 的 migration 打 kbdb worker 的 `DB`;沒有它才退回 cypher 的 `CREDENTIALS_DB`(同一顆庫)。
|
||
ctx.kvNamespaceIds = Object.fromEntries(
|
||
[...resolved.values()].filter((r) => r.kind === 'kv_namespace').map((r) => [r.binding, r.value]),
|
||
);
|
||
ctx.d1DatabaseId =
|
||
resolved.get(bindingKey('d1', 'DB'))?.value
|
||
?? resolved.get(bindingKey('d1', 'CREDENTIALS_DB'))?.value;
|
||
|
||
// 2.7 語義查詢(issue #7 / T2.4):index 本體已由上面的資源解析處理(沿用既有 / 需要才新建)。
|
||
// 這裡只補 metadata index——Vectorize 要 filter 某欄位必須先為該欄建 index,
|
||
// 否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0 命中(Arcrun#11 根因)。
|
||
// 冪等;失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
|
||
const vectorizeIndex = resolved.get(bindingKey('vectorize', 'VECTORIZE'))?.value;
|
||
if (vectorizeIndex) {
|
||
try {
|
||
process.stdout.write(chalk.gray(` → 語義查詢 metadata index(${vectorizeIndex})...`));
|
||
await ensureVectorizeMetadataIndexes(ctx, vectorizeIndex);
|
||
console.log(chalk.green(' ✓'));
|
||
} catch (e) {
|
||
console.log(chalk.yellow(' ⚠'));
|
||
failures.push(`Vectorize metadata index (${vectorizeIndex}): ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
}
|
||
|
||
// ── 2.8 var(plain_text):既有的沿用、版本標籤重烙(Arcrun#106)─────────────────
|
||
//
|
||
// 🔴 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),但 **var 這批「櫃子上的標籤」沒人管**:
|
||
// wrangler deploy 是整份覆蓋,toml 沒寫的 var 直接消失。leo 2026-08-12 實撞的畫面
|
||
// 「無法讀取目前版本(知識庫服務可能正在啟動)」就是 `ARCRUN_BUNDLE_VERSION` 被這樣洗掉的。
|
||
//
|
||
// 兩種 var 走**相反**的規則,這是本次的核心判斷:
|
||
// · 設定類(PORTAL_MAIL_RELAY_BASE / CONSOLE_TENANT / …)=**使用者實例的事實** → 沿用
|
||
// · 版本標籤(ARCRUN_BUNDLE_VERSION)=**這份成品的屬性** → 每趟重烙,沿用舊值就是假標籤
|
||
//
|
||
// 範圍註記:`liveVars` 來自資源解析那一趟讀到的 worker(=有資源綁定的那些:cypher/kbdb/mcp/registry)。
|
||
// 純零件 worker 沒有資源綁定、不在那份名單裡 → 這裡不會沿用它們的 var。目前它們的 var 只有
|
||
// toml 自己帶的 `COMPONENT_ID`,沒有東西可丟;若哪天有人往零件 worker 注入設定,要在這裡補讀。
|
||
const extraVarsByDir = new Map<string, Record<string, string>>();
|
||
let stamp: BundleStamp | undefined;
|
||
if (dirScript.size > 0) {
|
||
const needStamp = [...dirScript.values()].includes(VERSION_STAMP_WORKER);
|
||
if (needStamp) {
|
||
process.stdout.write(chalk.gray(' → 算這趟要烙上去的版本標籤...'));
|
||
stamp = await resolveBundleStamp(ref, commit);
|
||
console.log(chalk.green(' ✓'));
|
||
console.log(chalk.gray(` ARCRUN_BUNDLE_VERSION = ${stamp.note}`));
|
||
}
|
||
const preservedTotal: string[] = [];
|
||
for (const [dir, script] of dirScript) {
|
||
const raw = tomlPreviews.get(dir);
|
||
if (!raw) continue;
|
||
const keep = preservedVars(liveVars.get(script), raw);
|
||
for (const k of Object.keys(keep)) preservedTotal.push(`${script}:${k}`);
|
||
const vars: Record<string, string> = { ...keep };
|
||
if (stamp && script === VERSION_STAMP_WORKER) {
|
||
vars.ARCRUN_BUNDLE_VERSION = stamp.version;
|
||
if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit;
|
||
}
|
||
// Arcrun#108:把「你的知識實際住在哪個命名空間」告訴雲端。
|
||
//
|
||
// 為什麼需要:cypher 讀藏書地圖/搜尋/工作流時要用一個 owner_id 去過濾,而它以前拿的是
|
||
// repo toml 帶的官方預設值(`CONSOLE_TENANT = "leo"`)。寫入端(CLI push、小幫手上傳、
|
||
// MCP)用的卻是你 `~/.arcrun/config.yaml` 的 `api_key` ⇒ 兩邊對不上就整個空掉
|
||
//(leo 實撞:1854 條三元組被過濾成 0 個庫)。
|
||
//
|
||
// 🔴 **只在「這個 namespace 底下真的查得到知識」時才寫**(呼叫端已先驗過,見
|
||
// resolveKnowledgeNamespace)。理由是反過來的那個災難:一鍵安裝的實例,知識可能
|
||
// 本來就寫在 CONSOLE_TENANT 底下;若這裡無條件蓋成本機 api_key,會把一台**原本正常**
|
||
// 的實例改成指向空的那一格——跟 #97/#106 同一類「更新一次把人家的東西弄不見」。
|
||
// 驗不過就不寫;既有值由 preservedVars 原封保留,等於這趟什麼都沒改。
|
||
if (ctx.knowledgeNamespace && script === VERSION_STAMP_WORKER) {
|
||
vars.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
|
||
}
|
||
if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars);
|
||
}
|
||
if (preservedTotal.length > 0) {
|
||
console.log(chalk.gray(` 沿用你實例上既有的 ${preservedTotal.length} 個設定值(var):`));
|
||
for (const item of preservedTotal) console.log(chalk.gray(` = ${item}`));
|
||
}
|
||
}
|
||
|
||
// 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
|
||
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
|
||
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
|
||
let deployed = 0;
|
||
let skipped = 0;
|
||
// 內容指紋 manifest:未變動且上次成功的 worker 跳過(key 用 worker 名,不用 temp 絕對路徑)。
|
||
// --force 清空 manifest → 全部重部。
|
||
const manifest = opts.force ? {} : loadManifest();
|
||
console.log(chalk.gray(` → 部署 ${allDirs.length} 個 worker(未變動者跳過,依序進行)...`));
|
||
for (let i = 0; i < allDirs.length; i++) {
|
||
const dir = allDirs[i];
|
||
const tomlPath = join(dir, 'wrangler.toml');
|
||
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
|
||
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
|
||
try {
|
||
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir), extraVarsByDir.get(dir));
|
||
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
|
||
const hash = dirContentHash(dir, ctx.accountId);
|
||
if (manifest[label] === hash) {
|
||
skipped++;
|
||
console.log(chalk.gray(' ⊘ 未變動,跳過'));
|
||
continue;
|
||
}
|
||
runWranglerDeploy(dir, ctx, sharedBin);
|
||
manifest[label] = hash; // 只在成功後記錄 → 失敗者下次必重試
|
||
saveManifest(manifest);
|
||
deployed++;
|
||
console.log(chalk.green(' ✓'));
|
||
} catch (e) {
|
||
delete manifest[label]; // 失敗 → 清掉舊指紋,確保下次重部
|
||
saveManifest(manifest);
|
||
failures.push(`${dir}: ${e instanceof Error ? e.message : String(e)}`);
|
||
console.log(chalk.yellow(' ⚠'));
|
||
}
|
||
}
|
||
if (skipped > 0) {
|
||
console.log(chalk.gray(` (${skipped} 個未變動已跳過;要強制全部重部跑 acr update --force)`));
|
||
}
|
||
|
||
// 3.5 KBDB Base: D1 建好後套 migration(建三表 + recipe_stat seed)。
|
||
// 建 D1(cf-api ensureD1Database)只產生空資料庫,schema 要靠這步套。
|
||
// migration 檔來自同一份 tarball(root/kbdb/migrations/0001_base.sql),與 wasm 同源,
|
||
// 不依賴本地 CLI 安裝路徑。0001_base.sql 全用 IF NOT EXISTS / INSERT OR IGNORE → 可重複套(idempotent)。
|
||
if (ctx.d1DatabaseId) {
|
||
const migPath = join(root, 'kbdb', 'migrations', '0001_base.sql');
|
||
if (existsSync(migPath)) {
|
||
try {
|
||
await applyD1Migration(ctx, readFileSync(migPath, 'utf8'));
|
||
} catch (e) {
|
||
failures.push(`D1 migration (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
} else {
|
||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql(${migPath})`);
|
||
}
|
||
|
||
// 3.6 credential template seed(D38 圍牆修復,總管交辦,2026-08-07):credential 目錄改走
|
||
// KBDB template 機制(entries 表 entry_type='credential',比照 recipe_stat/execution_log
|
||
// 慣例),取代舊的獨立 credentials 表(0002,已退役,見該檔頭部說明)。冪等,套用機制
|
||
// 與 0001_base.sql 完全相同。密文本體仍住 Workers per-script Secrets(見
|
||
// cypher-executor/src/routes/credentials.ts),D19「擁有目錄不擁有內容物」不變。
|
||
const credTplMigPath = join(root, 'kbdb', 'migrations', '0005_credential_template.sql');
|
||
if (existsSync(credTplMigPath)) {
|
||
try {
|
||
await applyD1Migration(ctx, readFileSync(credTplMigPath, 'utf8'));
|
||
} catch (e) {
|
||
failures.push(`D1 migration 0005_credential_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
} else {
|
||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0005_credential_template.sql(${credTplMigPath})`);
|
||
}
|
||
|
||
// 3.6b 退役舊 credentials 表(D38,2026-08-07):把該表殘留資料(若有)搬進 entries 後
|
||
// 拆表,讓 KBDB 回到「只有三張核心表」的狀態。冪等且對「從未跑過 0002」的全新實例
|
||
// 無害(表不存在時本檔第一步先補空殼再立刻拆掉,詳見檔頭)。每次部署都會重跑,
|
||
// 但真資料只搬一次(NOT EXISTS 判斷防重複)。
|
||
const dropCredMigPath = join(root, 'kbdb', 'migrations', '0006_drop_credentials_table.sql');
|
||
if (existsSync(dropCredMigPath)) {
|
||
try {
|
||
await applyD1Migration(ctx, readFileSync(dropCredMigPath, 'utf8'));
|
||
} catch (e) {
|
||
failures.push(`D1 migration 0006_drop_credentials_table (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
} else {
|
||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0006_drop_credentials_table.sql(${dropCredMigPath})`);
|
||
}
|
||
|
||
// 3.7 execution_log template seed(KV 額度事故修復,2026-08-07):workflow 執行紀錄改走
|
||
// KBDB template 機制(entries 表 entry_type='execution_log',比照 recipe_stat 慣例;
|
||
// schema 零異動,只 seed 一列 template 定義,同 0001_base.sql §3 手法,self-hosted 同步套用)。
|
||
const execLogMigPath = join(root, 'kbdb', 'migrations', '0004_execution_log_template.sql');
|
||
if (existsSync(execLogMigPath)) {
|
||
try {
|
||
await applyD1Migration(ctx, readFileSync(execLogMigPath, 'utf8'));
|
||
} catch (e) {
|
||
failures.push(`D1 migration 0004_execution_log_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
} else {
|
||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql(${execLogMigPath})`);
|
||
}
|
||
|
||
// 3.8 樹狀 record 模型(0007,v7 定稿 2026-08-15):record 有身分、關係是唯一機制、
|
||
// entry_values 拆表。**必須排在所有 template seed 之後**(它把 templates 表既有列
|
||
// 鏡射成池中 sheet/field entry)。逐句套用+容錯 duplicate column:檔內三句
|
||
// ADD COLUMN 在 SQLite 沒有 IF NOT EXISTS 形式,重跑(每次部署都會重跑本段)時
|
||
// 那三句報 duplicate column = 已套用,其餘語句全部語句級冪等(檔頭有完整說明)。
|
||
const treeMigPath = join(root, 'kbdb', 'migrations', '0007_tree_record_model.sql');
|
||
if (existsSync(treeMigPath)) {
|
||
try {
|
||
await applyD1MigrationTolerant(ctx, readFileSync(treeMigPath, 'utf8'));
|
||
} catch (e) {
|
||
failures.push(`D1 migration 0007_tree_record_model (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
} else {
|
||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0007_tree_record_model.sql(${treeMigPath})`);
|
||
}
|
||
}
|
||
|
||
const cypherExecutorUrl = ctx.workerSubdomain
|
||
? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev`
|
||
: undefined;
|
||
// self-hosted 自己的 MCP worker URL(mcp-account-source §3:.mcp.json 指自己)。
|
||
// 端點是 /mcp(streamable http;根路徑 404)。仿 cypher 用 WORKER_SUBDOMAIN 組。
|
||
const mcpUrl = ctx.workerSubdomain
|
||
? `https://arcrun-mcp.${ctx.workerSubdomain}.workers.dev/mcp`
|
||
: undefined;
|
||
|
||
if (failures.length > 0) {
|
||
return {
|
||
implemented: true,
|
||
cypherExecutorUrl,
|
||
mcpUrl,
|
||
message:
|
||
`部署 ${deployed}/${tier1.length + tier2.length} 成功,${failures.length} 失敗(誠實回報,未假綠):\n` +
|
||
failures.map(f => ` ✗ ${f}`).join('\n'),
|
||
};
|
||
}
|
||
|
||
return {
|
||
implemented: true,
|
||
cypherExecutorUrl,
|
||
mcpUrl,
|
||
message: `部署完成:${deployed} 個 Worker 全部成功。`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 逐句套 migration,容錯 duplicate column(0007 專用)。
|
||
*
|
||
* 為什麼不能走 applyD1Migration 整檔送:/query 端點任何一句失敗整批中止——
|
||
* 0007 的三句 ADD COLUMN 在重跑時必然報 duplicate column(SQLite 沒有欄位級
|
||
* IF NOT EXISTS),整檔送 ⇒ 第二次部署起 migration 永遠假紅、後面的資料搬遷
|
||
* 語句永遠不被執行。逐句+把 duplicate column 視為「已套用」,其餘錯誤照樣拋。
|
||
* 切句手法與安裝器 compile-migrations.mjs 同款(剝 -- 註解、依分號切;
|
||
* 0007 的字串常值不含分號,前提成立)。
|
||
*/
|
||
async function applyD1MigrationTolerant(ctx: DeployContext, sql: string): Promise<void> {
|
||
const statements = sql
|
||
.split('\n')
|
||
.map((l) => l.replace(/--.*$/, ''))
|
||
.join('\n')
|
||
.split(';')
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 0);
|
||
for (const stmt of statements) {
|
||
try {
|
||
await applyD1Migration(ctx, stmt);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
if (/duplicate column/i.test(msg)) continue; // ADD COLUMN 重跑=已套用
|
||
throw new Error(`${stmt.slice(0, 60)}… → ${msg}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 對 D1 套 SQL migration(透過 CF API `/d1/database/{id}/query`,非 wrangler)。
|
||
* 用 init 已驗的 ctx.apiToken + accountId;query 端點接受多語句檔,一次送整份 0001_base.sql。
|
||
*/
|
||
async function applyD1Migration(ctx: DeployContext, sql: string): Promise<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, fromRef?: string): Promise<string> {
|
||
// 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。
|
||
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||
const url = buildArchiveUrl(ref, bust);
|
||
// fromRef 有值 = ref 已被解析成 commit sha(#106),印出來讓人看得到「這趟到底部了哪個 commit」。
|
||
const label = fromRef ? `${fromRef} → ${ref.slice(0, 7)}` : ref;
|
||
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${label},約 10–30 秒,視網速)...`));
|
||
const res = await fetch(url, {
|
||
signal: AbortSignal.timeout(120_000),
|
||
// 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball(#13 P2 假綠根因)。
|
||
// 帶 Gitea token(若有,private repo 需要;public 省略也可)。
|
||
headers: buildDownloadHeaders(),
|
||
cache: 'no-store',
|
||
});
|
||
if (!res.ok) {
|
||
// 401/403 多半是 private repo 缺 token(或 token 無此 repo 讀權限)→ 給可行動的提示。
|
||
const hint = (res.status === 401 || res.status === 403)
|
||
? '(private repo?請設 GITEA_TOKEN 環境變數,需對此 repo 有讀取權限)'
|
||
: '';
|
||
throw new Error(`Gitea archive HTTP ${res.status}${hint}(${url})`);
|
||
}
|
||
|
||
const buf = Buffer.from(await res.arrayBuffer());
|
||
const sizeMB = (buf.length / 1024 / 1024).toFixed(1);
|
||
console.log(chalk.gray(` → 下載完成(${sizeMB} MB),解壓中...`));
|
||
const dir = mkdtempSync(join(tmpdir(), 'arcrun-deploy-'));
|
||
const tarPath = join(dir, 'repo.tar.gz');
|
||
writeFileSync(tarPath, buf);
|
||
|
||
// 用系統 tar 解壓(macOS/Linux 內建)。tarball 解出單一頂層目錄 {repo}-{ref}/。
|
||
execFileSync('tar', ['-xzf', tarPath, '-C', dir], { stdio: 'ignore' });
|
||
const entries = readdirSync(dir).filter(n => n !== 'repo.tar.gz');
|
||
const top = entries.find(n => statSync(join(dir, n)).isDirectory());
|
||
if (!top) throw new Error('tarball 解壓後找不到頂層目錄');
|
||
return join(dir, top);
|
||
}
|
||
|
||
/** 自足 Worker 零件(非 TinyGo-wasm 家族):目錄相對 root + 部署 gate 必要產物(相對該目錄)。
|
||
* 目前只有 code(quickjs 沙箱,registry/components/code)。gate 精神比照 tier1 的 component.wasm:
|
||
* 必要產物(vendored quickjs.wasm,需 commit 進 repo)缺 → 誠實跳過,不讓 wrangler deploy 因缺檔失敗。
|
||
* export 供離線測試驗「部署清單含 code + 產物 gate 正確」。*/
|
||
export const SELF_CONTAINED_COMPONENT_WORKERS: ReadonlyArray<{ dir: string[]; requires: string[][] }> = [
|
||
{ dir: ['registry', 'components', 'code'], requires: [['vendor', 'quickjs.wasm']] },
|
||
];
|
||
|
||
/** 掃解壓出的部署物,回傳 tier1(.component-builds/* + 自足 Worker 零件)與
|
||
* tier2(cypher-executor/registry/kbdb/mcp 引擎)目錄清單。export 供離線測試。*/
|
||
export function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } {
|
||
const tier1: string[] = [];
|
||
const tier2: string[] = [];
|
||
|
||
const cbRoot = join(root, '.component-builds');
|
||
if (existsSync(cbRoot)) {
|
||
for (const name of readdirSync(cbRoot)) {
|
||
const dir = join(cbRoot, name);
|
||
// 需同時有 wrangler.toml 且有 component.wasm 才部署。
|
||
// 「錯做成零件」的(claude_api / km_writer / kbdb_upsert_block)wasm 沒 commit 進 repo
|
||
// (.gitignore 排除,待降級成工作流/recipe)→ archive 拿到的目錄缺 wasm → 自然跳過,
|
||
// 不讓 wrangler deploy 因缺檔失敗。
|
||
if (existsSync(join(dir, 'wrangler.toml')) && existsSync(join(dir, 'component.wasm'))) {
|
||
tier1.push(dir);
|
||
}
|
||
}
|
||
}
|
||
// 自足 Worker 零件(如 code):與 TinyGo 家族不同(自帶 index.ts + 相依 npm 套件 +
|
||
// vendored quickjs.wasm),但同屬 tier1「零件」語義 → 一起先於引擎部署。
|
||
// deps(quickjs-emscripten-core / wasmfile variant)由 root 共享安裝提供(SHARED_DEPLOY_DEPS),
|
||
// wrangler 對相對路徑 .wasm import 自動綁 CompiledWasm(見該零件 index.ts 頭註)。
|
||
for (const { dir: rel, requires } of SELF_CONTAINED_COMPONENT_WORKERS) {
|
||
const dir = join(root, ...rel);
|
||
const complete = existsSync(join(dir, 'wrangler.toml'))
|
||
&& requires.every(r => existsSync(join(dir, ...r)));
|
||
if (complete) tier1.push(dir);
|
||
}
|
||
// self-hosted 也部署自己的 MCP worker(mcp-account-source §5c:archive 主庫即得 MCP,
|
||
// .mcp.json 指自己的 mcp 而非官方 mcp.arcrun.dev)。
|
||
// kbdb:MCP 的 partnerAuthMiddleware 透過 KBDB service binding 打 arcrun-kbdb worker(mcp/wrangler.toml)。
|
||
// D1 arcrun-kbdb 已由 init/update 建好,但 worker 本體要一併部署,否則 binding 指向不存在的 service
|
||
// → 每個 MCP 認證請求都 throw(self-hosted MCP failed 根因,2026-06-10)。
|
||
for (const name of ['cypher-executor', 'registry', 'kbdb', 'mcp']) {
|
||
const dir = join(root, name);
|
||
if (existsSync(join(dir, 'wrangler.toml'))) tier2.push(dir);
|
||
}
|
||
return { tier1, tier2 };
|
||
}
|
||
|
||
/**
|
||
* 注入用戶的 KV namespace id(取代 wrangler.toml 中各 binding 的 id)+ cypher WORKER_SUBDOMAIN,
|
||
* 並 strip 掉只有 arcrun 官方帳號才有的綁定(self-hosted fork 帳號沒有)。
|
||
*
|
||
* 為何 strip 而非刪 repo 內 toml(壓測 2026-06-04 阻斷項 #1#2#3#4):
|
||
* - repo 內各 worker toml 的 `[[routes]] zone_name="arcrun.dev"` 是**官方 prod CI 部署**需要的
|
||
* (對外開放零件)。直接從 repo 刪會破壞官方部署。
|
||
* - 但 fork 用戶**沒有 arcrun.dev zone** → wrangler deploy 找不到 zone 而失敗。
|
||
* - deploy.ts 只在 self-hosted 路徑跑,且改的是「暫存目錄副本」(SDD self-hosted-init.md §3 step 4),
|
||
* 不碰用戶 repo。所以在注入時 strip 掉這些官方專屬綁定 = 對的層級。
|
||
* - 每個 worker toml 都有 `workers_dev = true` → strip routes 後純靠 workers.dev URL,自架可達。
|
||
* - R2(`[[r2_buckets]]`)是 dead storage(registry-canon Phase 1.5),且綁卡違背開源免費 → 一併移除。
|
||
*/
|
||
function injectWranglerConfig(
|
||
tomlPath: string,
|
||
ctx: DeployContext,
|
||
resolved: Map<string, ResolvedResource>,
|
||
original?: string,
|
||
extraVars: Record<string, string> = {},
|
||
): void {
|
||
if (!existsSync(tomlPath)) return;
|
||
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
|
||
const toml = original ?? readFileSync(tomlPath, 'utf8');
|
||
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved, extraVars), 'utf8');
|
||
}
|
||
|
||
/**
|
||
* 挑出「這顆已部署的 worker 上有、但這版 toml 不會自己帶的」plain_text var(Arcrun#106)。
|
||
*
|
||
* 規則就一句:**已部署 worker 上掛著什麼 var,那就是事實**(#97 對資源講的那句話,
|
||
* 原封不動套用在標籤上)。所以預設全部沿用,只有兩種例外:
|
||
* ① `CLI_MANAGED_VARS`——這趟由 CLI 自己算(帳號 id/subdomain/單租戶旗標/版本標籤),
|
||
* 沿用等於拿舊值蓋掉正解。
|
||
* ② 值一模一樣的(toml 已經寫了同樣的值)——寫進去只是雜訊,略過。
|
||
*
|
||
* ⚠️ 這裡刻意**不**做「toml 有宣告就以 toml 為準」:那正是這次的病
|
||
* ——repo toml 裡的 `CONSOLE_TENANT = "leo"`/`WORKER_SUBDOMAIN` 之類是**官方 prod 的值**,
|
||
* 拿它蓋掉使用者實例上的值,就是「更新一次把人家的設定洗成官方預設」。
|
||
*/
|
||
export function preservedVars(
|
||
live: Record<string, string> | undefined,
|
||
toml: string,
|
||
): Record<string, string> {
|
||
const out: Record<string, string> = {};
|
||
if (!live) return out;
|
||
const managed = new Set<string>(CLI_MANAGED_VARS);
|
||
for (const key of Object.keys(live).sort()) {
|
||
if (managed.has(key)) continue;
|
||
if (!/^[A-Za-z0-9_]+$/.test(key)) continue; // 怪名字不碰(applyVars 也會擋,這裡先濾掉不誤報)
|
||
if (readVar(toml, key) === live[key]) continue; // toml 已經是同一個值 → 不必動
|
||
out[key] = live[key];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 讀 toml 裡某個 var 目前的值(只看未註解的行)。找不到回 undefined。 */
|
||
function readVar(toml: string, key: string): string | undefined {
|
||
const m = toml.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm'));
|
||
return m?.[1];
|
||
}
|
||
|
||
/** TOML basic string 轉義(值裡可能有引號/反斜線,例如網址或 JSON 片段)。 */
|
||
function tomlEscape(value: string): string {
|
||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||
}
|
||
|
||
/**
|
||
* 把一組 var 寫進 toml 的 `[vars]`(Arcrun#106)。純函式。
|
||
*
|
||
* 三種既有狀態各自處理(比照 injectMultiTenant,同一種文字操作層級):
|
||
* 1. 已有未註解的同名行 → 換值
|
||
* 2. 只有被註解掉的同名行 → 取消註解並填值
|
||
* 3. 都沒有 → 插在 `[vars]` header 下一行;連 `[vars]` 都沒有就在檔尾新開一段
|
||
*/
|
||
export function applyVars(toml: string, vars: Record<string, string>): string {
|
||
let out = toml;
|
||
for (const key of Object.keys(vars).sort()) {
|
||
// 只接受合法的 var 名(CF 那側本來就是這個字集)。怪名字寧可不寫,也不要拿它去組正規式。
|
||
if (!/^[A-Za-z0-9_]+$/.test(key)) continue;
|
||
const value = tomlEscape(vars[key]);
|
||
// 🔴 一律用「函式版 replace」:值裡若有 `$&`/`$1` 這種字元,字串版 replace 會把它當成
|
||
// 反向參照展開,寫出來的就不是使用者那個值了。
|
||
if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(out)) {
|
||
out = out.replace(
|
||
new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(".*)$`, 'm'),
|
||
(_m, head: string, tail: string) => `${head}${value}${tail}`,
|
||
);
|
||
continue;
|
||
}
|
||
if (new RegExp(`^\\s*#\\s*${key}\\s*=`, 'm').test(out)) {
|
||
out = out.replace(
|
||
new RegExp(`^(\\s*)#\\s*${key}\\s*=\\s*"[^"]*"(.*)$`, 'm'),
|
||
(_m, indent: string, tail: string) => `${indent}${key} = "${value}"${tail}`,
|
||
);
|
||
continue;
|
||
}
|
||
if (/^\s*\[vars\]\s*$/m.test(out)) {
|
||
out = out.replace(/^(\s*\[vars\]\s*)$/m, (_m, header: string) => `${header}\n${key} = "${value}"`);
|
||
continue;
|
||
}
|
||
out = `${out.replace(/\s*$/, '')}\n\n[vars]\n${key} = "${value}"\n`;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 把一份 repo 內的 wrangler.toml 轉成「要部到這個用戶帳號上的樣子」。
|
||
*
|
||
* 純函式(好離線測、也好當預覽用)。帶空 `resolved` 呼叫 = 預覽:得到的是
|
||
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
|
||
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
|
||
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
|
||
*
|
||
* `extraVars`(Arcrun#106):這顆 worker 要**沿用的既有 var** + 這趟要**重烙的版本標籤**。
|
||
* 預覽時不傳(vars 不影響資源需求解析,傳不傳都是同一份需求清單)。
|
||
*/
|
||
export function renderWranglerToml(
|
||
toml: string,
|
||
ctx: DeployContext,
|
||
resolved: Map<string, ResolvedResource>,
|
||
extraVars: Record<string, string> = {},
|
||
): 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');
|
||
}
|
||
|
||
// 沿用的既有 var + 這趟的版本標籤(#106)。**放在所有 CLI 注入之後**:
|
||
// CLI_MANAGED_VARS 已經在 preservedVars 排除掉,故這裡不會蓋掉上面剛算好的
|
||
// WORKER_SUBDOMAIN / CF_ACCOUNT_ID / MULTI_TENANT / KBDB_BASE_URL。
|
||
toml = applyVars(toml, extraVars);
|
||
|
||
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
|
||
// 空 map = 預覽模式,這步什麼也不做。
|
||
return applyResolvedBindings(toml, resolved);
|
||
}
|
||
|
||
/**
|
||
* 把解析好的資源 id 寫進對應的 binding 區塊。
|
||
*
|
||
* 逐個 `[[table]]` 區塊掃:先在區塊內找 `binding = "X"`,再改同一區塊裡的值欄位
|
||
* (KV→`id`、D1→`database_id`、Vectorize→`index_name`)。
|
||
* 🔴 刻意**不用**「全檔第一個 database_id」這種寫法:cypher(`CREDENTIALS_DB`)與
|
||
* kbdb(`DB`)各有自己的 D1 綁定,盲換會把兩邊當成同一個東西——而使用者的實例
|
||
* 完全可以兩邊指向不同庫。誰綁誰是使用者那側的事實,我們只是原樣搬過去。
|
||
*/
|
||
export function applyResolvedBindings(
|
||
toml: string,
|
||
resolved: Map<string, 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}` : ''}`);
|
||
}
|
||
}
|