6846d6ddae
一、文案(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>
237 lines
11 KiB
TypeScript
237 lines
11 KiB
TypeScript
/**
|
||
* 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<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_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<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」的 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<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_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;
|
||
}
|