Files
Arcrun/cli/src/lib/config.ts
T
Leo 20c7610371 refactor: 移除已廢棄的自管加密金鑰機制(credential 全面託管 CF Workers Secrets)
leo 2026-07-20 明令:「已經改用 cf 自己的 secrets,不要再說它了」
「我希望以後再也看不到這個詞再出現」

背景:credential 早已遷移至 CF Workers per-script Secrets + D1 目錄,
舊的自管金鑰(client 端 AES-GCM + KV 密文 + crypto_decrypt)是遷移期遺留。
本次連根移除,含一併作廢的死 SaaS 碼。

移除:
- 舊 KV 密文解密路徑(credential-injector.ts 整檔、dual-read fallback)
  前置驗證:leo21c / youlin 兩帳號 CREDENTIALS_KV 實測 *:cred:* 皆 0 筆
- migrate-to-workers-secrets 搬家端點(回填已完成,無可回填)
- /register 路由與 generateApiKey(HMAC 產 ak_ key 是 SaaS 遺物;
  self-hosted 走 namespace 明碼 D21,已無人使用)
- platform_crypto component(三帳號實測 404 已退役,無 workflow 引用)

保留(附理由):
- crypto_decrypt 保留為永遠回失敗的 stub——現役三個 auth .wasm 仍宣告該
  import,缺項會讓 WASM instantiate 直接失敗。待零件重編後可真正刪除。

順帶修復(原不在範圍,但會實際壞事):
- /auth/callback 有 `if (!key) redirect(server_error)` 閘,未設該 secret 的
  實例會登入直接失敗 → 已移除
- OAuth 兩處把 provider token 寫進舊加密 KV(租戶鍵與實際 api_key 在 rotate
  後必然分歧,已失效)→ 改導向 Workers Secrets,包 try/catch 不影響登入
- acr init Standard 模式呼叫已刪除的 /register → 改引導 OAuth 取 key
- .claude/rules 與 system-dev/docs 是同一規範的兩份鏡像,先前只改 rules
  導致鏡像仍在教舊做法 → 已同步(此類雙檔同步應納入檢查)

新用戶安裝從此零 secret 前置。
測試 187/188(唯一 fail 為 pre-existing,stash 驗證與本次無關);
cypher-executor 與 cli typecheck 全綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:32:48 +08:00

235 lines
10 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 從零做)。
// true → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding
// kbdb embed 模組啟用(寫入時對標記 embed 的 entry embed、search 支援 mode=semantic)。
// 未設/false → base 維持 LIKE keywordfree-tier 友善,不建 index、不花費)。
// 開法:設 kbdb_embed:true → redeployacr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update。
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;
}