feat: 薄殼原則落地 + seed 下沉 API + MCP 進主庫 + 部署一致性

壓測四橫向問題修正(docs 壓測報告):

① 薄殼原則成鐵律:能力長在 API,CLI/MCP/lib 只暴露
   - seed 下沉成 API 行為:cypher-executor POST /init/seed(一次灌 API+auth recipe),
     種子資料移到 server src/lib/api-recipe-seeds.ts,CLI 改薄殼一次呼叫
   - 解除 deployFullyOk 連坐 + init 補 seed auth recipe + update 補 seed/全 KV
   - registry SUBMISSIONS_KV 補進 REQUIRED_KV_NAMESPACES(修 20/21)

② MCP 統一帳號來源(單一 remote MCP + .env 切 MCP URL)
   - MCP 從 sibling repo 搬進 arcrun/mcp/(remote Worker,route 改 mcp.arcrun.dev)
   - config 加 mcp_url 三層解析 + getMcpUrl + DEFAULT_MCP_URL
   - 新增 acr mcp-setup:依 config 寫專案 .mcp.json(接案切資料夾自動切 MCP)
   - acr --version 改動態讀 package.json(根治漂移)

③ Deploy 一致性
   - tests/release.feature + scripts/check-release.sh
   - local-deploy.sh:CLI npm publish + auto patch bump + CHANGELOG
   - local-deploy.sh bash 3.2 相容修正(mapfile / 空陣列 set -u)
   - builtins/pnpm-lock.yaml

④ README self-hosted 同步現況(移除 R2 殘留、加 flag/env、多帳號)

CLI bump → 1.3.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-06-06 15:45:35 +08:00
parent 5f381a44a6
commit 3e65e22775
58 changed files with 8608 additions and 74 deletions
+36 -35
View File
@@ -16,8 +16,8 @@ import {
downloadAndDeploy,
type DeployContext,
} from '../lib/deploy.js';
import { API_RECIPE_SEEDS } from '../lib/api-recipe-seeds.js';
import { cmdInstallHarness } from './install-harness.js';
import { cmdMcpSetup } from './mcp-setup.js';
const ARCRUN_REGISTER_URL = 'https://cypher.arcrun.dev/register';
@@ -57,6 +57,14 @@ export async function cmdInit(options: InitOptions): Promise<void> {
} 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`));
}
}
async function initLocal(): Promise<void> {
@@ -129,7 +137,7 @@ async function initStandard(rl: ReturnType<typeof createInterface>): Promise<voi
/**
* Self-hosted installer:用戶只提供 CF Account ID + API Token,其餘自動。
* 驗 token → 建 7 KV(冪等)→ 查 subdomain → 下載 release 部署 Worker
* 驗 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
*/
@@ -208,6 +216,8 @@ async function initSelfHosted(
const deploy = await downloadAndDeploy(deployCtx);
const cypherUrl = deploy.cypherExecutorUrl
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
// 誠實回報部署結果;但**不**用「全部成功」字串 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}`));
@@ -224,15 +234,14 @@ async function initSelfHosted(
saveConfig(config);
createCredentialsYamlIfMissing();
// 6. seed API recipe部署成功 + 有 cypher URL 才打;否則提示稍後 acr update 後再 seed
if (deployFullyOk && cypherUrl) {
await seedApiRecipes(cypherUrl);
} else if (cypherUrl) {
console.log(chalk.gray(` → recipe seed 待部署穩定後再執行(${API_RECIPE_SEEDS.length} 個;acr update 會重試)`));
// 6. seed recipe薄殼:呼叫 API 的 /init/seed 一次,由 API 灌 API recipe + auth recipe)。
// 只要 cypher-executor 可達就 seed——不被無關 worker(registry)的失敗連坐(壓測 §4.1)。
if (cypherUrl) {
await callSeedEndpoint(cypherUrl);
}
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
console.log(chalk.green('\n ✓ Cloudflare 資源就緒(7 KV,免費額度即可,無需綁卡)'));
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
@@ -245,35 +254,27 @@ async function initSelfHosted(
console.log(chalk.gray(' 生成:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"\n'));
}
/** seed API recipe 到目標 cypher-executor(部署完成後)。*/
async function seedApiRecipes(cypherUrl: string): Promise<void> {
process.stdout.write(chalk.gray(` → seed ${API_RECIPE_SEEDS.length} 個 API recipe...`));
let ok = 0;
for (const r of API_RECIPE_SEEDS) {
try {
const res = await fetch(`${cypherUrl}/recipes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
canonical_id: r.canonical_id,
display_name: r.display_name,
description: r.description,
endpoint: r.endpoint,
method: r.method,
auth_service: r.auth_service,
exposure_consent: {
confirmed_by_human: true,
understood: `platform seed recipe: ${r.canonical_id}${r.endpoint}`,
confirmed_at: new Date().toISOString(),
},
}),
});
if (res.ok) ok++;
} catch {
// 單筆失敗不中斷整個 init;最終回報數量
/**
* 薄殼:呼叫 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 重跑`));
}
console.log(ok === API_RECIPE_SEEDS.length ? chalk.green(' ✓') : chalk.yellow(` ${ok}/${API_RECIPE_SEEDS.length}`));
}
function createHelloYamlIfMissing(): void {
+57
View File
@@ -0,0 +1,57 @@
/**
* acr mcp-setup — 在目前資料夾寫 / 更新 .mcp.json,讓 Claude Code 連對的 arcrun MCP。
*
* 薄殼原則(rule 07 + SDD mcp-account-source.md):
* MCP URL 與 cypher URL 一樣,由同一份三層 config 解析(env > 專案 .arcrun.yaml > 全域)決定。
* 「切換帳號」= 在客戶資料夾跑 acr mcp-setup(讀該資料夾的 .arcrun.yaml)→ 產指向客戶 MCP 的 .mcp.json。
* 進哪個專案資料夾 → Claude Code 讀該專案 .mcp.json → 連對應 MCP(綁該帳號的 cypher)。
*
* 不重實作身份解析:呼叫 config.ts 的 getMcpUrl()(唯一解析來源)。
*/
import chalk from 'chalk';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { loadConfig, getMcpUrl, DEFAULT_MCP_URL } from '../lib/config.js';
/** Claude Code .mcp.json 的 server 條目(remote HTTP MCP)。 */
interface McpServerEntry {
type: 'http';
url: string;
}
interface McpJson {
mcpServers?: Record<string, McpServerEntry | Record<string, unknown>>;
[k: string]: unknown;
}
const SERVER_KEY = 'arcrun';
export function cmdMcpSetup(): void {
const config = loadConfig();
const mcpUrl = getMcpUrl(config);
const target = join(process.cwd(), '.mcp.json');
// 讀既有 .mcp.json(保留其他 MCP server 條目,只覆寫 arcrun 這條)
let doc: McpJson = {};
if (existsSync(target)) {
try {
doc = JSON.parse(readFileSync(target, 'utf8')) as McpJson;
} catch {
console.log(chalk.yellow(` ⚠ 既有 .mcp.json 解析失敗,將以新內容覆寫整檔。`));
doc = {};
}
}
if (!doc.mcpServers || typeof doc.mcpServers !== 'object') doc.mcpServers = {};
doc.mcpServers[SERVER_KEY] = { type: 'http', url: mcpUrl };
writeFileSync(target, JSON.stringify(doc, null, 2) + '\n', 'utf8');
console.log(chalk.green(`\n ✓ 已寫入 ${target}`));
console.log(chalk.gray(` arcrun MCP → ${mcpUrl}`));
if (mcpUrl === DEFAULT_MCP_URL && (!config.mcp_url || config.mcp_url.trim() === '')) {
console.log(chalk.gray(` (用平台預設;要連自己/客戶的 MCP,在 config 設 mcp_url 或 ARCRUN_MCP_URL env,再重跑)`));
}
console.log(chalk.gray(` Claude Code 進此資料夾會自動連這台 MCP。切帳號 = 在對應資料夾重跑 acr mcp-setup。\n`));
}
+37 -5
View File
@@ -12,9 +12,11 @@
import chalk from 'chalk';
import { loadConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
wranglerAvailable,
downloadAndDeploy,
REQUIRED_KV_NAMESPACES,
type DeployContext,
} from '../lib/deploy.js';
@@ -40,20 +42,50 @@ export async function cmdUpdate(): Promise<void> {
console.log(chalk.bold('\n acr update — 拉新 release 並重新部署\n'));
// 重新解析「全部」KV namespace id(冪等:已存在則重用),不只 config 存的兩個。
// 壓測 §4.1.3:舊版 update 只注入 WEBHOOKS+CREDENTIALS_KV,其餘 6 個注入成空字串 →
// 重部署反而可能弄壞需要 RECIPES/EXEC_CONTEXT/... 的 worker。改為與 init 同樣全建妥。
const cf = new CfAccountClient(config.cloudflare_account_id, config.cf_api_token);
const kvNamespaceIds: Record<string, string> = {};
try {
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
kvNamespaceIds[title] = await cf.ensureKvNamespace(title, existing);
}
} catch (e) {
console.log(chalk.yellow(`\n ✗ 解析 KV namespace 失敗:${e instanceof Error ? e.message : e}\n`));
process.exit(1);
}
const ctx: DeployContext = {
accountId: config.cloudflare_account_id,
apiToken: config.cf_api_token,
workerSubdomain: extractSubdomain(config.cypher_executor_url),
kvNamespaceIds: {
WEBHOOKS: config.webhooks_kv_namespace_id ?? '',
CREDENTIALS_KV: config.credentials_kv_namespace_id ?? '',
},
kvNamespaceIds,
};
const result = await downloadAndDeploy(ctx);
if (result.implemented) {
console.log(chalk.green('\n ✓ 更新完成\n'));
console.log(chalk.green('\n ✓ 部署完成'));
// 重跑 seed(薄殼:呼叫 API /init/seed;冪等,覆寫既有)。
// 修壓測 §4.1.3「update 不做 seed,但 init 提示說 update 會重試 seed」的矛盾。
const cypherUrl = config.cypher_executor_url
?? result.cypherExecutorUrl
?? (ctx.workerSubdomain ? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev` : '');
if (cypherUrl) {
process.stdout.write(chalk.gray(' → 重新 seed recipeAPI + auth,由 API 灌入)...'));
try {
const res = await fetch(`${cypherUrl}/init/seed`, { method: 'POST' });
const body = await res.json().catch(() => null) as { success?: boolean; message?: string } | null;
console.log(res.ok && body?.success
? chalk.green(`${body.message ?? ''}`)
: chalk.yellow(`${body?.message ?? `HTTP ${res.status}`}`));
} catch (e) {
console.log(chalk.yellow(` ⚠ seed 失敗(${e instanceof Error ? e.message : e}`));
}
}
console.log('');
} else {
console.log(chalk.yellow(' ⚠ 更新尚未自動化:'));
console.log(chalk.gray(' ' + result.message.split('\n').join('\n ')) + '\n');