/** * CLI 設定檔管理 — 三層分層解析(SDD: sdk-and-website/config-layering.md) * 優先序:env 變數 > 專案層 .arcrun.yaml(就近往上找)> 全域 ~/.arcrun/config.yaml * 解壓測 #7(AI/CI 非互動)+ #8(接案多帳號),仿 git config / Claude Code MCP 模式。 */ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, dirname, parse as parsePath } from 'node:path'; import yaml from 'js-yaml'; export interface ArcrunConfig { mode: 'local' | 'standard' | 'self-hosted'; // Standard 模式 api_key?: string; // arcrun.dev API Key(ak_前綴) // Self-hosted 模式 cloudflare_account_id?: string; user_kv_namespace_id?: string; cf_api_token?: string; cypher_executor_url?: string; credentials_kv_namespace_id?: string; webhooks_kv_namespace_id?: string; // 共用 // MCP server URL(薄殼原則:CLI 與 MCP 同一份身份解析)。 // self-hosted / 接案:指向自己 / 客戶的 remote MCP Worker(綁該帳號的 cypher)。 // 未設 → fallback 平台預設(SaaS 用戶)。acr mcp-setup 依此寫專案 .mcp.json, // 讓「進哪個專案資料夾 → Claude Code 連那台 MCP」自動生效。 // SDD: sdk-and-website/mcp-account-source.md mcp_url?: string; multi_tenant?: boolean; // 語義查詢開關(issue #7 / SDD T2.4,self-hosted 從零做)。 // 🔴 2026-08-09 預設翻轉(leo:「語義搜尋已經確定是一安裝就提供的功能」): // 未設 → **視同開**(init/update 皆以 `!== false` 判斷)。只有顯式 false 才關。 // true/未設 → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding; // kbdb embed 模組啟用(寫入時對標記 embed 的 entry embed、search 支援 mode=semantic)。 // false → base 維持 LIKE keyword(顯式選擇才有這個狀態;缺欄位不再等於關—— // 舊語意會讓 acr update 把正常實例的 binding 靜默剝掉,畫面再謊稱「沒開通」)。 kbdb_embed?: boolean; // 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。此欄位保留只為向後相容舊 config.yaml // (讀到不報錯,不再寫入/檢查)。 exposure_consented?: Record; } const CONFIG_DIR = join(homedir(), '.arcrun'); const CONFIG_PATH = join(CONFIG_DIR, 'config.yaml'); /** 專案層設定檔名(就近往上找)。含憑證 → 必須 gitignore(見 createCredentialsYamlIfMissing)。*/ export const PROJECT_CONFIG_NAME = '.arcrun.yaml'; /** 設定來源層級(acr config --where 用,讓使用者知道每個值來自哪一層,避免用錯帳號)。*/ export type ConfigSource = 'env' | 'project' | 'global' | 'default'; /** env 變數 → config 欄位映射(最高層覆蓋)。CF 兩個沿用 wrangler 慣用名,CI 設一次兩邊通用。*/ const ENV_MAP: Record = { ARCRUN_MODE: 'mode', // NAMESPACE / ARCRUN_NAMESPACE:self-hosted 單租戶的資料分區標籤(明碼,用戶自填)。 // 沿用 api_key 欄位 + 路徑(KV key 前綴 {api_key}:cred:{name}),故 self-hosted 無需平台發 api_key。 // 這是「分區標籤」非「認證密碼」:你的 cypher 在你自己的 CF,無「別人」會冒用; // 要防外部呼叫請對 webhook 加保護(mindset §6)。SaaS 仍走 register 發的真 api_key(同一條路徑,不分叉)。 NAMESPACE: 'api_key', ARCRUN_NAMESPACE: 'api_key', ARCRUN_API_KEY: 'api_key', ARCRUN_CYPHER_EXECUTOR_URL: 'cypher_executor_url', ARCRUN_MCP_URL: 'mcp_url', CLOUDFLARE_ACCOUNT_ID: 'cloudflare_account_id', CLOUDFLARE_API_TOKEN: 'cf_api_token', }; /** * 平台預設 MCP URL(mcp_url 未設時的 fallback,SaaS 用戶用)。 * MCP 搬進 arcrun 主庫後改用 arcrun.dev zone(mcp/wrangler.toml route = mcp.arcrun.dev)。 */ // MCP streamable-http 端點是 /mcp(根路徑 404)。少了 /mcp → client 連線 Failed。 export const DEFAULT_MCP_URL = 'https://mcp.arcrun.dev/mcp'; /** * 公庫 URL(recipe pull/search/submit-p 的對象,kbdb-base §7.5)。 * 公庫 = 官方 SaaS cypher(唯一公共真相)。self-hosted 用戶的「私庫」是自己的 cypher * (getCypherExecutorUrl),但 pull/搜尋/投稿都對著**官方公庫**這個固定 URL。 * fork 者可用 ARCRUN_PUBLIC_LIBRARY_URL env 覆蓋。 */ export const DEFAULT_PUBLIC_LIBRARY_URL = process.env.ARCRUN_PUBLIC_LIBRARY_URL ?? 'https://cypher.arcrun.dev'; export function configExists(): boolean { return existsSync(CONFIG_PATH) || findProjectConfig() !== undefined; } /** 從 startDir 就近往上逐層找專案層 .arcrun.yaml,回傳第一個命中的路徑(停在檔案系統根)。*/ export function findProjectConfig(startDir: string = process.cwd()): string | undefined { let dir = startDir; const root = parsePath(dir).root; // 防呆上界:層數不會無限(root 一定到得了),但仍加保險避免異常路徑死迴圈。 for (let i = 0; i < 256; i++) { const candidate = join(dir, PROJECT_CONFIG_NAME); if (existsSync(candidate)) return candidate; if (dir === root) break; const parent = dirname(dir); if (parent === dir) break; dir = parent; } return undefined; } /** 讀全域設定(不分層)。無檔回 undefined。*/ function readGlobalConfig(): Partial | undefined { if (!existsSync(CONFIG_PATH)) return undefined; return (yaml.load(readFileSync(CONFIG_PATH, 'utf8')) as Partial) ?? undefined; } /** 讀專案層設定(不分層)。無檔回 undefined。*/ function readProjectConfig(): Partial | undefined { const path = findProjectConfig(); if (!path) return undefined; return (yaml.load(readFileSync(path, 'utf8')) as Partial) ?? undefined; } /** * 載入 .env(就近往上找,同 .arcrun.yaml)到 process.env,讓用戶照 Node/Python 慣例 * 在 .env 設 NAMESPACE / CLOUDFLARE_* 等即生效。不覆蓋「已存在於 shell」的 env(shell > .env)。 * 自己解析(不引入 dotenv 依賴)。只認單純 KEY=VALUE,忽略空行/註解/引號。 */ let _envFileLoaded = false; function loadDotEnvOnce(): void { if (_envFileLoaded) return; _envFileLoaded = true; // 從 cwd 就近往上找 .env(停在含 .arcrun.yaml 的專案根或檔案系統根) let dir = process.cwd(); const root = parsePath(dir).root; for (let i = 0; i < 256; i++) { const candidate = join(dir, '.env'); if (existsSync(candidate)) { try { for (const rawLine of readFileSync(candidate, 'utf8').split('\n')) { const line = rawLine.trim(); if (!line || line.startsWith('#')) continue; const eq = line.indexOf('='); if (eq < 1) continue; const k = line.slice(0, eq).trim(); let v = line.slice(eq + 1).trim(); if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { v = v.slice(1, -1); } // shell 已設的優先(不覆蓋),符合「env > .env」直覺 if (process.env[k] === undefined) process.env[k] = v; } } catch { /* .env 讀不到不致命 */ } break; } if (dir === root) break; const parent = dirname(dir); if (parent === dir) break; dir = parent; } } /** 蒐集 env 覆蓋(只取有設值的 env,欄位級)。*/ function readEnvOverrides(): Partial { loadDotEnvOnce(); const out: Partial = {}; for (const [envName, field] of Object.entries(ENV_MAP)) { const v = process.env[envName]; if (v !== undefined && v !== '') { // mode 需窄型別;其餘皆 string 欄位。 (out as Record)[field] = v; } } // bool 開關(issue #7):env 可選覆蓋,'true'/'1' → true。 const embedEnv = process.env.ARCRUN_KBDB_EMBED; if (embedEnv !== undefined && embedEnv !== '') { out.kbdb_embed = embedEnv === 'true' || embedEnv === '1'; } return out; } /** * 三層分層解析:全域 → 疊專案層 → 疊 env(欄位級 merge,高層只覆蓋它提供的欄位)。 * 任一層都沒有 mode 時 fallback 'local',讓 validate --offline 等在無設定下可運作。 */ export function loadConfig(): ArcrunConfig { const merged: Partial = { ...(readGlobalConfig() ?? {}), ...(readProjectConfig() ?? {}), ...readEnvOverrides(), }; if (!merged.mode) merged.mode = 'local'; return merged as ArcrunConfig; } /** 解析每個關鍵欄位的最終值與來源層(acr config --where 用)。*/ export function resolveConfigSources(): Array<{ field: keyof ArcrunConfig; value: string; source: ConfigSource }> { const global = readGlobalConfig() ?? {}; const project = readProjectConfig() ?? {}; const env = readEnvOverrides(); const fields: (keyof ArcrunConfig)[] = [ 'mode', 'api_key', 'cloudflare_account_id', 'cf_api_token', 'cypher_executor_url', 'mcp_url', ]; const rows: Array<{ field: keyof ArcrunConfig; value: string; source: ConfigSource }> = []; for (const f of fields) { let value: unknown; let source: ConfigSource = 'default'; if (f in env) { value = env[f]; source = 'env'; } else if (f in project) { value = project[f]; source = 'project'; } else if (f in global) { value = global[f]; source = 'global'; } else if (f === 'mode') { value = 'local'; source = 'default'; } else continue; rows.push({ field: f, value: String(value), source }); } return rows; } /** 回傳本次解析實際採用的專案層設定檔路徑(無則 undefined)。acr config --where 顯示用。*/ export function activeProjectConfigPath(): string | undefined { return findProjectConfig(); } export function saveConfig(config: ArcrunConfig): void { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(CONFIG_PATH, yaml.dump(config), 'utf8'); } export function getCypherExecutorUrl(config: ArcrunConfig): string { if (config.mode === 'self-hosted' && config.cypher_executor_url) { return config.cypher_executor_url; } return 'https://cypher.arcrun.dev'; } /** * 取得 MCP server URL(薄殼原則:與 cypher_url 同一份 config 解析)。 * config 有 mcp_url(env/專案/全域 任一層)→ 用它;否則 fallback 平台預設。 * acr mcp-setup 用此決定要寫進專案 .mcp.json 的 URL → 切資料夾自動切 MCP。 */ export function getMcpUrl(config: ArcrunConfig): string { return config.mcp_url && config.mcp_url.trim() !== '' ? config.mcp_url : DEFAULT_MCP_URL; }