fix(self-hosted): 修壓測四阻斷項 + 設定分層 + init 非互動

壓測(docs/壓測報告.md)發現 acr init --self-hosted 對任何非官方 CF
帳號都裝不起來,且設定寫死全域單檔 + 強制 TTY。本次一併修:

R2 dead storage 全清(#3#4,registry-canon Phase 1.5 補完):
- cypher-executor wrangler.toml/test.toml/types.ts 移除 WASM_BUCKET binding
- CLI deploy.ts/init.ts/cf-api.ts/config.ts 移除 R2 建立邏輯與 wasm_bucket
- R2 綁信用卡違背「開源免費自架」核心;bucket 名 WASM_BUCKET 本就非法
  → self-hosted 改為只需 Workers + KV(皆免費額度、不綁卡)

fork 帳號部署阻斷(#1#2):
- deploy.ts 新增 stripOfficialOnlyBindings(),注入暫存副本時移除
  [[routes]]/zone_name/[[r2_buckets]]/[ai](fork 沒有 arcrun.dev zone)
- 不刪 repo 內 toml(官方 prod CI 部署仍需 routes),只在 CLI self-hosted 路徑 strip

設定分層 + 非互動(#7#8):
- config.ts loadConfig 改三層:env > 專案層 .arcrun.yaml(就近往上找)> 全域
- init 支援 --account-id/--api-token flag + CLOUDFLARE_* env,缺才互動
- 新增 acr config --where 顯示每個值的來源層(token 自動遮罩)
- gitignore 一併排除 .arcrun.yaml

驗收:tsc 全綠;三層 merge 端對端測試 8/8;strip 對真實 toml 驗證
routes/R2/AI 移除而 name/workers_dev/KV 保留。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-06-05 07:22:37 +08:00
parent 1d79ae038c
commit 5f381a44a6
12 changed files with 268 additions and 65 deletions
+49
View File
@@ -0,0 +1,49 @@
/**
* acr config [--where] — 顯示目前生效的設定與每個值的來源層。
* 解壓測 §1.2 建議 #3:讓使用者一眼確認「現在這個資料夾正用哪個帳號」,避免用錯帳號部署。
* SDD: sdk-and-website/config-layering.md §3.1
*/
import chalk from 'chalk';
import {
resolveConfigSources,
activeProjectConfigPath,
type ConfigSource,
} from '../lib/config.js';
const SOURCE_LABEL: Record<ConfigSource, string> = {
env: 'env 變數',
project: '專案層 .arcrun.yaml',
global: '全域 ~/.arcrun/config.yaml',
default: '預設值',
};
/** 敏感欄位只印前綴,避免把 token 完整印到終端 / log。*/
const SENSITIVE = new Set(['api_key', 'encryption_key', 'cf_api_token']);
function mask(field: string, value: string): string {
if (SENSITIVE.has(field) && value.length > 8) return `${value.slice(0, 8)}`;
return value;
}
export async function cmdConfig(_options: { where?: boolean }): Promise<void> {
const rows = resolveConfigSources();
const projectPath = activeProjectConfigPath();
console.log(chalk.bold('\n arcrun 目前生效的設定\n'));
if (projectPath) {
console.log(chalk.gray(` 專案層設定:${projectPath}(覆蓋全域)`));
} else {
console.log(chalk.gray(' 專案層設定:無(此資料夾未放 .arcrun.yaml,使用全域)'));
}
console.log('');
const fieldWidth = Math.max(...rows.map(r => r.field.length), 4);
for (const { field, value, source } of rows) {
const name = field.padEnd(fieldWidth);
console.log(
` ${chalk.cyan(name)} ${mask(field, value)} ${chalk.gray(`${SOURCE_LABEL[source]}`)}`,
);
}
console.log(chalk.gray('\n 優先序:env 變數 > 專案層 .arcrun.yaml > 全域 ~/.arcrun/config.yaml\n'));
}
+39 -17
View File
@@ -11,7 +11,6 @@ import { saveConfig, type ArcrunConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
REQUIRED_KV_NAMESPACES,
REQUIRED_R2_BUCKET,
SECRET_TARGET_WORKERS,
wranglerAvailable,
downloadAndDeploy,
@@ -27,7 +26,14 @@ async function prompt(rl: ReturnType<typeof createInterface>, question: string):
return answer.trim();
}
export async function cmdInit(options: { local?: boolean; selfHosted?: boolean }): Promise<void> {
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'));
@@ -36,7 +42,7 @@ export async function cmdInit(options: { local?: boolean; selfHosted?: boolean }
if (options.local) {
await initLocal();
} else if (options.selfHosted) {
await initSelfHosted(rl);
await initSelfHosted(rl, options);
} else {
await initStandard(rl);
}
@@ -123,11 +129,14 @@ async function initStandard(rl: ReturnType<typeof createInterface>): Promise<voi
/**
* Self-hosted installer:用戶只提供 CF Account ID + API Token,其餘自動。
* 驗 token → 建 7 KV + R2(冪等)→ 查 subdomain → 下載 release 部署 Worker
* 驗 token → 建 7 KV(冪等)→ 查 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>): Promise<void> {
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'));
@@ -138,8 +147,21 @@ async function initSelfHosted(rl: ReturnType<typeof createInterface>): Promise<v
process.exit(1);
}
const accountId = await prompt(rl, '你的 Cloudflare Account ID');
const cfApiToken = await prompt(rl, 'CF API Token(需 Workers Scripts Edit + KV Edit + R2 Edit');
// 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);
@@ -154,7 +176,9 @@ async function initSelfHosted(rl: ReturnType<typeof createInterface>): Promise<v
process.exit(1);
}
// 2. 建 KV namespace(冪等)+ R2 bucket
// 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();
@@ -164,9 +188,6 @@ async function initSelfHosted(rl: ReturnType<typeof createInterface>): Promise<v
kvNamespaceIds[title] = id;
console.log(chalk.green(' ✓'));
}
process.stdout.write(chalk.gray(` → R2 ${REQUIRED_R2_BUCKET}...`));
await cf.ensureR2Bucket(REQUIRED_R2_BUCKET);
console.log(chalk.green(' ✓'));
} catch (e) {
console.log(chalk.yellow(`\n ✗ 建立資源失敗:${e instanceof Error ? e.message : e}\n`));
process.exit(1);
@@ -198,7 +219,6 @@ async function initSelfHosted(rl: ReturnType<typeof createInterface>): Promise<v
cypher_executor_url: cypherUrl,
webhooks_kv_namespace_id: kvNamespaceIds['WEBHOOKS'],
credentials_kv_namespace_id: kvNamespaceIds['CREDENTIALS_KV'],
wasm_bucket: REQUIRED_R2_BUCKET,
multi_tenant: false,
};
saveConfig(config);
@@ -212,7 +232,7 @@ async function initSelfHosted(rl: ReturnType<typeof createInterface>): Promise<v
}
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
console.log(chalk.green('\n ✓ Cloudflare 資源就緒(7 KV + R2'));
console.log(chalk.green('\n ✓ Cloudflare 資源就緒(7 KV,免費額度即可,無需綁卡'));
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
@@ -289,12 +309,14 @@ function createCredentialsYamlIfMissing(): void {
);
}
// 確保 .gitignore 排除 credentials.yaml
// 確保 .gitignore 排除 credentials.yaml + 專案層 .arcrun.yaml(可能含 cf_api_token
// 壓測 §1.2 安全附帶發現:憑證進版控 = 帳號控制權外洩。
const gitignorePath = join(process.cwd(), '.gitignore');
if (existsSync(gitignorePath)) {
const content = readFileSync(gitignorePath, 'utf8');
if (!content.includes('credentials.yaml')) {
appendFileSync(gitignorePath, '\ncredentials.yaml\n');
}
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');
}
}
+2 -2
View File
@@ -82,10 +82,10 @@ export async function cmdValidate(filePath: string, options: { offline?: boolean
if (res.ok) {
const data = await res.json() as { missing: string[] };
if (data.missing.length > 0) {
check('零件存在性', false, `WASM_BUCKET 中找不到:${data.missing.join(', ')}`);
check('零件存在性', false, `registry 中找不到零件${data.missing.join(', ')}`);
allPassed = false;
} else {
check('零件存在性', true, '所有零件均已在 WASM_BUCKET');
check('零件存在性', true, '所有零件均已在 registry');
}
} else {
check('零件存在性', false, `無法連線 ${executorUrl}(加 --offline 跳過此檢查)`);