arcrun — AI workflow execution engine (clean history)

Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。

此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在
richblack/arcrun 與本地 backup 分支)。含:
- acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe)
- recipe push 把關(資料外流提醒 + 打通檢查)
- 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1)
- CLI / cypher-executor / registry / 完整 SDD

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-06-03 15:52:38 +08:00
commit 922a57fe34
485 changed files with 89356 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
/**
* 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,
REQUIRED_R2_BUCKET,
SECRET_TARGET_WORKERS,
wranglerAvailable,
downloadAndDeploy,
type DeployContext,
} from '../lib/deploy.js';
import { API_RECIPE_SEEDS } from '../lib/api-recipe-seeds.js';
const ARCRUN_REGISTER_URL = 'https://cypher.arcrun.dev/register';
async function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
const answer = await rl.question(chalk.cyan(`? ${question}: `));
return answer.trim();
}
export async function cmdInit(options: { local?: boolean; selfHosted?: boolean }): 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);
} else {
await initStandard(rl);
}
} finally {
rl.close();
}
}
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 模式:只需要 email,不需要 Cloudflare 帳號\n'));
const email = await prompt(rl, 'Email(用來取得 API Key');
process.stdout.write(chalk.gray('\n → 向 arcrun.dev 取得 API Key...'));
let apiKey = '';
let encryptionKey = '';
try {
const res = await fetch(ARCRUN_REGISTER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`取得失敗(${res.status}):${err}`);
}
const data = await res.json() as { api_key: string; encryption_key: string };
apiKey = data.api_key;
encryptionKey = data.encryption_key;
console.log(chalk.green(' ✓'));
} catch (e) {
console.log(chalk.yellow(`${e instanceof Error ? e.message : e}`));
console.log(chalk.yellow(' 請確認網路連線後重新執行 acr init\n'));
process.exit(1);
}
const config: ArcrunConfig = {
mode: 'standard',
api_key: apiKey,
encryption_key: encryptionKey,
};
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 → 建 7 KV + R2(冪等)→ 查 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> {
console.log(chalk.gray(' Self-hosted 模式:自動部署整套 arcrun 到你的 Cloudflare 帳號\n'));
console.log(chalk.gray(' 你只需提供 CF Account ID + API Token,其餘 CLI 自動完成。\n'));
// 前置:wranglerCF CLI
if (!wranglerAvailable()) {
console.log(chalk.yellow(' ✗ 找不到 wranglerCloudflare CLI)。'));
console.log(chalk.yellow(' 請先安裝:npm i -g wrangler,然後重新執行 acr init --self-hosted\n'));
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');
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(冪等)+ R2 bucket
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(' ✓'));
}
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);
}
// 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}),稍後可手動補`));
}
// 4. 下載 repo 部署物(含預編譯 wasm+ 注入 KV id + wrangler deploy 全部 Worker
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds };
const deploy = await downloadAndDeploy(deployCtx);
const cypherUrl = deploy.cypherExecutorUrl
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
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,
webhooks_kv_namespace_id: kvNamespaceIds['WEBHOOKS'],
credentials_kv_namespace_id: kvNamespaceIds['CREDENTIALS_KV'],
wasm_bucket: REQUIRED_R2_BUCKET,
multi_tenant: false,
};
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 會重試)`));
}
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
console.log(chalk.green('\n ✓ Cloudflare 資源就緒(7 KV + R2'));
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
// 手動 secret 提示(secret 不進自動化,rule 05
console.log(chalk.bold('\n 下一步(手動設定 runtime secret):'));
for (const w of SECRET_TARGET_WORKERS) {
console.log(chalk.cyan(` wrangler secret put ENCRYPTION_KEY --name ${w}`));
}
console.log(chalk.gray(' 三個 Worker 共用同一把 ENCRYPTION_KEY256-bit hex)。'));
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;最終回報數量
}
}
console.log(ok === API_RECIPE_SEEDS.length ? chalk.green(' ✓') : chalk.yellow(` ${ok}/${API_RECIPE_SEEDS.length}`));
}
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
const gitignorePath = join(process.cwd(), '.gitignore');
if (existsSync(gitignorePath)) {
const content = readFileSync(gitignorePath, 'utf8');
if (!content.includes('credentials.yaml')) {
appendFileSync(gitignorePath, '\ncredentials.yaml\n');
}
}
}