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:
@@ -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.yaml(local 模式)'));
|
||||
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 init(Standard 模式)。\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'));
|
||||
|
||||
// 前置:wrangler(CF CLI)
|
||||
if (!wranglerAvailable()) {
|
||||
console.log(chalk.yellow(' ✗ 找不到 wrangler(Cloudflare 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 subdomain(cypher-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_KEY(256-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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user