Files
Arcrun/cli/src/commands/init.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

395 lines
19 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.
/**
* acr init — 互動式初始化設定
* 詢問 CF Account ID、KV namespace、API Token、email
* 呼叫 arcrun.dev 取得 API Key,寫入 ~/.arcrun/config.yaml
*/
import { createInterface } from 'node:readline/promises';
import { writeFileSync, existsSync, readFileSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import chalk from 'chalk';
import { saveConfig, type ArcrunConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
REQUIRED_KV_NAMESPACES,
downloadAndDeploy,
type DeployContext,
} from '../lib/deploy.js';
import { cmdInstallHarness } from './install-harness.js';
import { cmdMcpSetup } from './mcp-setup.js';
import { detectEnvironment, printPreflight, verifyInstall } from '../lib/preflight.js';
const ARCRUN_LOGIN_URL = 'https://arcrun.dev/login';
async function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
const answer = await rl.question(chalk.cyan(`? ${question}: `));
return answer.trim();
}
export interface InitOptions {
local?: boolean;
selfHosted?: boolean;
accountId?: string;
apiToken?: string;
}
export async function cmdInit(options: InitOptions): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
console.log(chalk.bold('\n arcrun 初始化設定\n'));
try {
if (options.local) {
await initLocal();
} else if (options.selfHosted) {
await initSelfHosted(rl, options);
} else {
await initStandard(rl);
}
} finally {
rl.close();
}
// init 末尾順便裝 CC harness 進當前專案(SDD user-cc-harness §2init 裝 + 可單獨裝)。
// 失敗不影響 init 本身(harness 是加分,可事後 acr install-harness 補)。
try {
await cmdInstallHarness();
} catch (e) {
console.log(chalk.gray(` harness 安裝略過:${e instanceof Error ? e.message : e};可稍後跑 acr install-harness`));
}
// 順便寫專案 .mcp.json,讓 Claude Code 連對的 MCP(依 config 的 mcp_urlSDD mcp-account-source.md)。
// 失敗不影響 init(可事後 acr mcp-setup 補)。
try {
cmdMcpSetup();
} catch (e) {
console.log(chalk.gray(` .mcp.json 略過:${e instanceof Error ? e.message : e};可稍後跑 acr mcp-setup`));
}
// 冷啟動環境檢查(harness:機制強制,不靠提醒)。
// 失敗會 exit 2,用戶必須修復後重跑 acr init。
console.log('');
try {
const { execSync } = await import('child_process');
const hookPath = new URL('../../../.claude/hooks/pre-cold-startup-check.sh', import.meta.url);
execSync(`bash "${hookPath.pathname}"`, { stdio: 'inherit' });
} catch (e) {
if ((e as any)?.status === 2) {
// exit 2 = 環境檢查失敗,停下,不繼續
process.exit(2);
}
// 其他錯誤(hook 不存在等)只警告,不擋 init
console.log(chalk.gray(` (環境檢查略過,可手動跑 bash .claude/hooks/pre-cold-startup-check.sh`));
}
}
async function initLocal(): Promise<void> {
console.log(chalk.gray(' Local 模式:不需要 Cloudflare 帳號,workflow 由 arcrun.dev 雲端引擎執行\n'));
const config: ArcrunConfig = {
mode: 'local',
};
saveConfig(config);
createHelloYamlIfMissing();
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yamllocal 模式)'));
console.log(chalk.green(' ✓ 建立 hello.yaml 範例 workflow\n'));
console.log(' 你可以立刻開始:');
console.log(chalk.cyan(' acr validate hello.yaml --offline') + ' # 驗證 workflow 格式');
console.log(chalk.cyan(' acr run hello --input input="Hello, arcrun!"') + ' # 執行,輸出大寫字串\n');
console.log(chalk.gray(' Local 模式:YAML 留在本機,workflow 由 arcrun.dev 引擎執行。'));
console.log(chalk.gray(' 需要用自己的 CF 帳號存放 credentials?執行 acr initStandard 模式)。\n'));
}
async function initStandard(rl: ReturnType<typeof createInterface>): Promise<void> {
console.log(chalk.gray(' Standard 模式:用 arcrun.dev 帳號登入取得 API Key\n'));
// API Key 發放走網站 OAuth 登入(/auth/google/start、/auth/github/start)。
// CLI 是薄殼,不自己發 key(rule 07),只引導用戶去拿再貼回來。
console.log(' 1. 開啟 ' + chalk.cyan(ARCRUN_LOGIN_URL) + ' 用 Google / GitHub 登入');
console.log(' 2. 在 Dashboard 複製你的 API Keyak_ 開頭)\n');
const apiKey = (await prompt(rl, 'API Keyak_...')).trim();
if (!apiKey.startsWith('ak_')) {
console.log(chalk.yellow('\n ✗ API Key 應以 ak_ 開頭,請重新執行 acr init\n'));
process.exit(1);
}
const config: ArcrunConfig = {
mode: 'standard',
api_key: apiKey,
};
saveConfig(config);
createCredentialsYamlIfMissing();
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml'));
console.log(chalk.green(` ✓ API Key${apiKey.slice(0, 8)}...`));
console.log(chalk.green(' ✓ 建立 credentials.yaml(已加入 .gitignore\n'));
console.log(' 下一步:');
console.log(chalk.cyan(' acr parts scaffold <component>') + ' # 查看零件 config 範本');
console.log(chalk.cyan(' acr creds push credentials.yaml') + ' # 上傳加密 credentials');
console.log(chalk.cyan(' acr push workflow.yaml') + ' # 部署 workflow 並取得 Webhook URL\n');
}
/**
* Self-hosted installer:用戶只提供 CF Account ID + API Token,其餘自動。
* 驗 token → 建 KV(冪等,數量見 REQUIRED_KV_NAMESPACES)→ 查 subdomain → 下載 release 部署 Worker
* → seed auth+api recipe → 寫 config → 印手動 secret 提示。
* SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md
*/
async function initSelfHosted(
rl: ReturnType<typeof createInterface>,
options: InitOptions,
): Promise<void> {
console.log(chalk.gray(' Self-hosted 模式:自動部署整套 arcrun 到你的 Cloudflare 帳號\n'));
console.log(chalk.gray(' 你只需提供 CF Account ID + API Token,其餘 CLI 自動完成。\n'));
// §7.8 P0:偵測先於動作(pip 式)——先看環境有什麼,缺前置就停下給補救指令,
// 不假設齊備直接動手(test_arcrun/4:D1 缺了 AI 跑去讀原始碼自己想辦法的災難)。
const pre = detectEnvironment();
printPreflight('環境偵測(安裝前)', pre.items);
if (pre.fatal) {
console.log(chalk.yellow('\n ✗ 缺少必要前置(見上方 →)。補齊後重新執行 acr init --self-hosted。'));
console.log(chalk.gray(' init 冪等:補好重跑,已就緒的會自動跳過。\n'));
process.exit(1);
}
// account-id / api-token 取得順序:flag > envCLOUDFLARE_*> 互動問答。
// 解壓測 #7:AI/CI 可非互動完成。帳號設定值非風險確認(mindset §7),flag/env 合法。
// SDD: sdk-and-website/config-layering.md §2.3
const accountId =
options.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID ?? (await prompt(rl, '你的 Cloudflare Account ID'));
const cfApiToken =
options.apiToken ?? process.env.CLOUDFLARE_API_TOKEN
?? (await prompt(rl, 'CF API Token(需 Workers Scripts Edit + Workers KV Storage Edit'));
if (!accountId || !cfApiToken) {
console.log(chalk.yellow('\n ✗ 缺少 Account ID 或 API Token。'));
console.log(chalk.yellow(' 非互動用法:acr init --self-hosted --account-id <id> --api-token <token>'));
console.log(chalk.yellow(' 或設環境變數 CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN\n'));
process.exit(1);
}
const cf = new CfAccountClient(accountId, cfApiToken);
// 1. 驗 token / account 可達
process.stdout.write(chalk.gray('\n → 驗證 Cloudflare 憑證...'));
try {
await cf.verifyAccess();
console.log(chalk.green(' ✓'));
} catch (e) {
console.log(chalk.yellow(` ✗ ${e instanceof Error ? e.message : e}`));
console.log(chalk.yellow(' 請確認 Account ID 與 API Token(含權限)正確後重試\n'));
process.exit(1);
}
// 2. 建 KV namespace(冪等)
// 不建 R2R2 是 dead storageregistry-canon Phase 1.5),且 CF R2 首次啟用強制綁信用卡,
// 違背 arcrun「開源免費自架,Workers + KV 免費額度即可運行」核心理念(壓測 2026-06-04 #3)。
const kvNamespaceIds: Record<string, string> = {};
try {
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
process.stdout.write(chalk.gray(` → KV ${title}...`));
const id = await cf.ensureKvNamespace(title, existing);
kvNamespaceIds[title] = id;
console.log(chalk.green(' ✓'));
}
} catch (e) {
console.log(chalk.yellow(`\n ✗ 建立資源失敗:${e instanceof Error ? e.message : e}\n`));
process.exit(1);
}
// 2.5 build D1 for KBDB Base (atomic universal table). Free on Workers Free, no credit card
// (kbdb-base SDD Q4). idempotent: reuse if exists.
let d1DatabaseId = '';
try {
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb...'));
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
console.log(chalk.green(' ✓'));
} catch (e) {
const em = e instanceof Error ? e.message : String(e);
console.log(chalk.yellow(`\n ⚠ D1 build failed (${em})`));
if (/auth/i.test(em)) {
// 最常見根因:CF token 沒勾 D1 權限(KV/Worker 建得起來但 D1 報 Authentication error)。
console.log(chalk.yellow(' 多半是 CF token 缺 D1 權限 → 去 token 補勾「Account / D1 / Edit」'));
console.log(chalk.gray(' 重產 token 填回 .env 後跑 acr update。D1 存 workflow/recipe,沒它後續會受限。'));
} else {
console.log(chalk.gray(' KBDB Base 暫不可用,可 acr update 重試。'));
}
}
// 3. 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用)
let workerSubdomain = '';
try {
workerSubdomain = await cf.getWorkersSubdomain();
console.log(chalk.gray(` → workers.dev subdomain: ${workerSubdomain}`));
} catch (e) {
console.log(chalk.yellow(` ⚠ 查 subdomain 失敗(${e instanceof Error ? e.message : e}),稍後可手動補`));
}
// 3.5 語義查詢(issue #7 / T2.4):**預設開**2026-08-09 翻轉,leo:「語義搜尋已經
// 確定是一安裝就提供的功能」——預設關會產出一批「看起來裝好了、其實少一條腿」的
// 實例,之後畫面上還被誤說成「沒開通」)。顯式回答 n 才關(極端省額度者自選)。
// 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword。
const embedAns = (await prompt(
rl,
'要開語義查詢嗎?(內建功能,建議保持開啟;用 CF Vectorize,有免費額度) [Y/n]',
)).trim().toLowerCase();
const kbdbEmbed = !(embedAns === 'n' || embedAns === 'no');
if (!kbdbEmbed) console.log(chalk.yellow(' → 已選關語義查詢:這台實例將只有關鍵字搜尋(之後可設 kbdb_embed:true + acr update 補開)。'));
// 4. 下載 repo 部署物(含預編譯 wasm+ 注入 KV id + wrangler deploy 全部 Worker
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
// selfHosted: true → deploy 注入 MULTI_TENANT="false"mcp-account-source §5.5,修 MCP 401)。
// init.ts 這條本就是 --self-hosted 分支(config.mode 稍後寫 'self-hosted')。
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds, d1DatabaseId, selfHosted: true, kbdbEmbed };
const deploy = await downloadAndDeploy(deployCtx);
const cypherUrl = deploy.cypherExecutorUrl
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
// self-hosted 自己的 MCP worker URLmcp-account-source §3.mcp.json 指自己,不 fallback 官方)。
const mcpUrl = deploy.mcpUrl
?? (workerSubdomain ? `https://arcrun-mcp.${workerSubdomain}.workers.dev/mcp` : '');
// 誠實回報部署結果;但**不**用「全部成功」字串 gate 後續 seed(壓測 §4.1
// registry 一個無關 worker 失敗就連坐讓 seed 永遠被跳過)。seed 只看 cypher-executor 是否可達。
const deployFullyOk = /全部成功/.test(deploy.message);
console.log(deployFullyOk ? chalk.green(` ✓ ${deploy.message}`) : chalk.yellow(` ⚠ ${deploy.message}`));
// 5. 寫 config(資源資訊存好,供後續 acr push / update / seed
const config: ArcrunConfig = {
mode: 'self-hosted',
cloudflare_account_id: accountId,
cf_api_token: cfApiToken,
cypher_executor_url: cypherUrl,
mcp_url: mcpUrl || undefined, // 指自己的 MCPmcp-account-source §3),無 subdomain 才留空 fallback 官方
webhooks_kv_namespace_id: kvNamespaceIds['WEBHOOKS'],
credentials_kv_namespace_id: kvNamespaceIds['CREDENTIALS_KV'],
multi_tenant: false,
kbdb_embed: kbdbEmbed, // 語義查詢開關(issue #7);存進 config 讓後續 acr update 維持一致
};
saveConfig(config);
createCredentialsYamlIfMissing();
// 6.5 config 寫好後重寫 .mcp.json,讓它指向「自己的」MCPinit 開頭的 cmdMcpSetup 在 config 前跑,
// 那時 mcp_url 還沒設 → 會 fallback 官方;這裡 config 已含自己的 mcp_url,重跑一次蓋成自己的)。
try {
cmdMcpSetup();
} catch (e) {
console.log(chalk.gray(` .mcp.json 重寫略過:${e instanceof Error ? e.message : e}`));
}
// 6. seed recipe(薄殼:呼叫 API 的 /init/seed 一次,由 API 灌 API recipe + auth recipe)。
// 只要 cypher-executor 可達就 seed——不被無關 worker(registry)的失敗連坐(壓測 §4.1)。
if (cypherUrl) {
await callSeedEndpoint(cypherUrl);
}
// §7.8 P0 裝完驗收:實查 CFKV/D1+ 打 cypher /health 確認真就緒,缺哪項明確報哪項
// + 給一鍵補裝指令(不靜默印灰字)。假綠零容忍(mindset §7):看實際狀態,非看 config 寫了沒。
const verify = await verifyInstall({
cf,
requiredKv: REQUIRED_KV_NAMESPACES,
expectD1Name: d1DatabaseId ? 'arcrun-kbdb' : undefined,
cypherUrl,
});
printPreflight('安裝驗收(裝完檢查)', verify.items);
if (!verify.allOk) {
console.log(chalk.yellow('\n ⚠ 部分項目未就緒(見上方 →)。多數可跑 acr update 冪等補裝。'));
console.log(chalk.gray(' (worker 剛部署可能需數十秒生效,可稍候再跑 acr update 重驗。)'));
}
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
// 下一步:身份設定(self-hosted 單租戶——namespace 明碼用戶自填)。
// 工具不生成、不 hash、不外傳任何 key(守 rule 05 精神:secret 不進自動化,由用戶持有)。
console.log(chalk.bold('\n 下一步 ①:在這個專案建 .env(你自己填,工具不碰):'));
console.log(chalk.cyan(' NAMESPACE=leo # 你的資料分區標籤(明碼即可,不是密碼)'));
console.log(chalk.gray(' NAMESPACE 是分區標籤非密碼;要防外部呼叫請對 webhook 加保護。'));
console.log(chalk.gray(' .env 已被 gitignore。)'));
console.log(chalk.gray(' credential 不需要自管加密金鑰:明文由 CF Workers Secrets 託管。\n'));
// credential-store-migration T3(§2.3):cypher worker 要有一把「能打 CF Workers Scripts
// secrets 管理 API 的 token」才能讓 POST/PUT /credentials 把密文寫進 Workers Secrets。
// 印手動指令而非工具自動 put——CF_ACCOUNT_ID 非機密,已由 downloadAndDeploy/
// injectWranglerConfig 自動注入(同 WORKER_SUBDOMAIN 模式),
// 只有 CF_SECRETS_API_TOKEN(機密)需要用戶手動 put。
console.log(chalk.bold(' 下一步 ②:把能打 Workers Scripts secrets API 的 CF token 設進 cypher worker'));
console.log(chalk.cyan(` wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor`));
console.log(chalk.gray(' 貼你剛才用來部署的同一個 CF API Token(需含 Workers Scripts:Edit 權限)。'));
console.log(chalk.gray(' 用途:POST/PUT /credentials 把密文寫進 Workers per-script Secretscredential-store-migration T5)。'));
console.log(chalk.gray(' CF_ACCOUNT_ID 已自動注入(非機密),不需手動設定。\n'));
}
/**
* 薄殼:呼叫 API 的 /init/seed 一次(rule 07)。
* seed 的編排 + 種子資料全在 cypher-executorroutes/init-seed.ts),CLI 不自己迴圈 POST。
* 之前 seedApiRecipes 在 CLI 迴圈 POST + deployFullyOk gate 是壓測 §4.1 的反例,已移除。
*/
async function callSeedEndpoint(cypherUrl: string): Promise<void> {
process.stdout.write(chalk.gray(' → seed recipeAPI recipe + auth recipe,由 API 灌入)...'));
try {
const res = await fetch(`${cypherUrl}/init/seed`, { method: 'POST' });
const body = await res.json().catch(() => null) as
| { success?: boolean; message?: string }
| null;
if (res.ok && body?.success) {
console.log(chalk.green(` ✓ ${body.message ?? ''}`));
} else {
// 誠實:不假綠。seed 沒全成就明說,提示 acr update 可重跑(冪等)。
console.log(chalk.yellow(` ⚠ ${body?.message ?? `HTTP ${res.status}`}(可 acr update 重跑,seed 冪等)`));
}
} catch (e) {
console.log(chalk.yellow(` ⚠ seed 端點呼叫失敗(${e instanceof Error ? e.message : e});cypher 穩定後 acr update 重跑`));
}
}
function createHelloYamlIfMissing(): void {
const helloPath = join(process.cwd(), 'hello.yaml');
if (!existsSync(helloPath)) {
writeFileSync(helloPath,
'# arcrun hello world workflow\n' +
'# 執行:acr run hello --input input="Hello, arcrun!"\n\n' +
'name: hello\n' +
'description: "Hello world — 示範字串轉大寫"\n\n' +
'flow:\n' +
' - "input >> ON_SUCCESS >> transform"\n\n' +
'config:\n' +
' transform:\n' +
' component: string_ops\n' +
' operation: upper\n',
'utf8'
);
}
}
function createCredentialsYamlIfMissing(): void {
const credPath = join(process.cwd(), 'credentials.yaml');
if (!existsSync(credPath)) {
writeFileSync(credPath,
'# arcrun credentials — 不要提交至 git\n' +
'# 執行 acr creds push 上傳加密後的 credential 到你的 CF KV\n\n' +
'# gmail_token: "your-google-oauth-token"\n' +
'# telegram_bot_token: "your-telegram-bot-token"\n' +
'# google_oauth: "your-google-oauth-token"\n' +
'# line_token: "your-line-notify-token"\n',
'utf8'
);
}
// 確保 .gitignore 排除 credentials.yaml + 專案層 .arcrun.yaml(可能含 cf_api_token
// 壓測 §1.2 安全附帶發現:憑證進版控 = 帳號控制權外洩。
const gitignorePath = join(process.cwd(), '.gitignore');
if (existsSync(gitignorePath)) {
const content = readFileSync(gitignorePath, 'utf8');
const toAdd: string[] = [];
if (!content.includes('credentials.yaml')) toAdd.push('credentials.yaml');
if (!content.includes('.arcrun.yaml')) toAdd.push('.arcrun.yaml');
if (toAdd.length > 0) appendFileSync(gitignorePath, '\n' + toAdd.join('\n') + '\n');
}
}