/** * preflight.ts — self-hosted 安裝的「偵測先於動作 + 裝完驗收」(§7.8 P0,pip 式)。 * * 核心判準(self-hosted-init.md §7.8): * - **偵測先於動作**:init 先檢查各前置(node / wrangler / CF 可達),缺的才裝、有的跳過。 * 不是假設齊備直接動手 → 缺一個就卡(test_arcrun/4 的 D1 大跑去讀原始碼自己想辦法)。 * - **裝完驗收**:部署後逐項確認(KV / D1 / migration / cypher 可達),缺哪項明確報哪項 * + 給一鍵補裝指令。不是靜默印灰字(原本 harness/MCP 失敗只 console.log 灰字,用戶不知道)。 * - **冪等**:重跑檢查後「什麼也沒動」(ensureKvNamespace / ensureD1Database 本就冪等)。 * * 本檔只做「偵測 + 報告」,不自己建資源(建資源仍走 cf-api 的 ensure*,由 init 編排)。 */ import { execFileSync } from 'node:child_process'; import chalk from 'chalk'; import type { CfAccountClient } from './cf-api.js'; export interface PreflightItem { name: string; ok: boolean; detail?: string; /** 缺漏時給用戶的一鍵補救指令(沒有則留空)。*/ fix?: string; } /** node 是否可用 + 版本(init 本身是 node 跑的,能跑到這裡 node 必在,但仍印版本供診斷)。*/ function detectNode(): PreflightItem { try { const v = execFileSync('node', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] }) .toString().trim(); return { name: 'node', ok: true, detail: v }; } catch { return { name: 'node', ok: false, fix: '安裝 Node.js 18+:https://nodejs.org' }; } } /** wrangler(CF CLI)是否可用 + 版本。self-hosted 部署的硬前置。*/ function detectWrangler(): PreflightItem { try { const v = execFileSync('wrangler', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] }) .toString().trim(); return { name: 'wrangler', ok: true, detail: v }; } catch { return { name: 'wrangler', ok: false, fix: 'npm i -g wrangler' }; } } /** * 安裝前偵測(pip 式:先看環境有什麼)。 * CF 憑證可達由呼叫端用 CfAccountClient.verifyAccess 接著驗(需要 token,不在這層)。 * 回傳所有項目 + 是否有 fatal 缺漏(node/wrangler 缺 = 無法繼續)。 */ export function detectEnvironment(): { items: PreflightItem[]; fatal: boolean } { const items = [detectNode(), detectWrangler()]; const fatal = items.some((i) => !i.ok); return { items, fatal }; } /** 印一組偵測結果(✓/✗ + 版本 + 補救指令)。*/ export function printPreflight(title: string, items: PreflightItem[]): void { console.log(chalk.bold(`\n ${title}`)); for (const it of items) { if (it.ok) { console.log(chalk.green(` ✓ ${it.name}`) + (it.detail ? chalk.gray(` ${it.detail}`) : '')); } else { console.log(chalk.yellow(` ✗ ${it.name}`) + (it.detail ? chalk.gray(` ${it.detail}`) : '')); if (it.fix) console.log(chalk.gray(` → ${it.fix}`)); } } } /** * 裝完驗收:逐項確認 self-hosted 環境真的就緒(§7.8 D1 根因:安裝不偵測,缺了不報)。 * 各項以「實際查 CF / 打 cypher」確認,非看 config 有沒有寫——避免假綠(mindset §7)。 * * @returns items(每項 ok + detail/fix)。呼叫端依 allOk 決定是否 exit 非零 / 印補裝指引。 */ export async function verifyInstall(opts: { cf: CfAccountClient; requiredKv: readonly string[]; expectD1Name?: string; cypherUrl?: string; }): Promise<{ items: PreflightItem[]; allOk: boolean }> { const items: PreflightItem[] = []; // KV:實查 CF 上現有 namespace,比對必需清單 try { const existing = await opts.cf.listKvNamespaces(); const have = new Set(existing.keys()); const missing = opts.requiredKv.filter((t) => !have.has(t)); items.push( missing.length === 0 ? { name: `KV namespaces (${opts.requiredKv.length})`, ok: true } : { name: 'KV namespaces', ok: false, detail: `缺 ${missing.join(', ')}`, fix: 'acr update(冪等重建)' }, ); } catch (e) { items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: 'acr update' }); } // D1:實查 CF 上是否有該庫 if (opts.expectD1Name) { try { const dbs = await opts.cf.listD1Databases(); items.push( dbs.has(opts.expectD1Name) ? { name: `D1 ${opts.expectD1Name}`, ok: true } : { name: `D1 ${opts.expectD1Name}`, ok: false, detail: '不存在', fix: 'CF token 補勾「Account / D1 / Edit」權限 → 重產 token 填回 .env → acr update' }, ); } catch (e) { // D1 建失敗最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。 const m = msg(e); const fix = /auth/i.test(m) ? 'token 缺 D1 權限:CF token 補勾「Account / D1 / Edit」→ 重產 token 填回 .env → acr update' : 'acr update(冪等重試)'; items.push({ name: `D1 ${opts.expectD1Name}`, ok: false, detail: m, fix }); } } // cypher-executor 可達(打 /health,不只看 config 有 URL) if (opts.cypherUrl) { try { const res = await fetch(`${opts.cypherUrl}/health`, { method: 'GET' }); items.push( res.ok ? { name: 'cypher-executor 可達', ok: true, detail: opts.cypherUrl } : { name: 'cypher-executor 可達', ok: false, detail: `HTTP ${res.status} @ ${opts.cypherUrl}`, fix: 'acr update(重部署)' }, ); } catch (e) { items.push({ name: 'cypher-executor 可達', ok: false, detail: msg(e), fix: 'acr update(重部署);或 worker 剛部署稍候再試' }); } } return { items, allOk: items.every((i) => i.ok) }; } function msg(e: unknown): string { return e instanceof Error ? e.message : String(e); }