Files
Arcrun/cli/src/lib/config.ts
T
uncle6me-web 6846d6ddae fix(semantic): 故障照實說是故障——不再把壞掉說成「沒開通」(leo 2026-08-09 直令)
一、文案(portal/console/kbdb hint):語意搜尋是一安裝就提供的功能,
   降級=故障。橫幅改「語意搜尋目前故障/我們的問題/你不用做任何事」,
   拿掉「還沒開通、想開通請匯出診斷檔」這種要使用者申請開通的假框架。
   kbdb 降級回應加 degraded_reason(module_off / embed_query_failed)。

二、查詢向量化失敗不再偽裝成空結果(leo 點名的謊):
   semanticSearch 舊行為「AI 額度用完 → 回 []」會讓使用者以為
   自己的知識庫裡沒有這筆資料。改丟 EmbedQueryFailedError,
   route 誠實降級 keyword+照實告知是暫時故障。

三、源頭機制(裝好的實例為什麼會失去語意搜尋):
   - acr update:kbdb_embed 判斷 ===true → !==false。config 缺欄位時
     redeploy 會把 [[vectorize]]+[ai] binding 靜默剝掉(wrangler deploy
     整份覆蓋),一台正常實例就此壞掉。init 預設同步翻成 [Y/n]。
   -(另 repo)deploy-all.mjs ensureVectorizeIndex 失敗改致命中止。

四、順手自癒:孤兒向量/下架殘影搜尋時背景清除;空結果且 pending>0
   背景 backfill;no_index 拆「故障」vs「還沒有資料」兩態。

測試:kbdb 146/146(新增 degraded 6 案+selftest 1 案);cli 10/10;
瀏覽器端到端兩種故障畫面實測(local wrangler dev+portal 真登入)。
無 SDD 對應:leo 直令修故障(同 08-07 檢修孔前例的人閘直接授權路徑)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 02:05:12 +08:00

237 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 Keyak_前綴)
// 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.4self-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-29Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
// (讀到不報錯,不再寫入/檢查)。
exposure_consented?: Record<string, { confirmed_at: string; suppress_future?: boolean }>;
}
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<string, keyof ArcrunConfig> = {
ARCRUN_MODE: 'mode',
// NAMESPACE / ARCRUN_NAMESPACEself-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 URLmcp_url 未設時的 fallbackSaaS 用戶用)。
* MCP 搬進 arcrun 主庫後改用 arcrun.dev zonemcp/wrangler.toml route = mcp.arcrun.dev)。
*/
// MCP streamable-http 端點是 /mcp(根路徑 404)。少了 /mcp → client 連線 Failed。
export const DEFAULT_MCP_URL = 'https://mcp.arcrun.dev/mcp';
/**
* 公庫 URLrecipe 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<ArcrunConfig> | undefined {
if (!existsSync(CONFIG_PATH)) return undefined;
return (yaml.load(readFileSync(CONFIG_PATH, 'utf8')) as Partial<ArcrunConfig>) ?? undefined;
}
/** 讀專案層設定(不分層)。無檔回 undefined。*/
function readProjectConfig(): Partial<ArcrunConfig> | undefined {
const path = findProjectConfig();
if (!path) return undefined;
return (yaml.load(readFileSync(path, 'utf8')) as Partial<ArcrunConfig>) ?? undefined;
}
/**
* 載入 .env(就近往上找,同 .arcrun.yaml)到 process.env,讓用戶照 Node/Python 慣例
* 在 .env 設 NAMESPACE / CLOUDFLARE_* 等即生效。不覆蓋「已存在於 shell」的 envshell > .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<ArcrunConfig> {
loadDotEnvOnce();
const out: Partial<ArcrunConfig> = {};
for (const [envName, field] of Object.entries(ENV_MAP)) {
const v = process.env[envName];
if (v !== undefined && v !== '') {
// mode 需窄型別;其餘皆 string 欄位。
(out as Record<string, unknown>)[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<ArcrunConfig> = {
...(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_urlenv/專案/全域 任一層)→ 用它;否則 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;
}