Files
Arcrun/cli/src/commands/recipe.ts
T
uncle6me-web 922a57fe34 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>
2026-06-03 15:52:38 +08:00

240 lines
8.9 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 recipe push <file> — 上傳 recipe YAML 到 arcrun.dev
* acr recipe list — 列出已上傳的 recipe
* acr recipe delete <id> — 刪除 recipecanonical_id 或 rec_hash
*/
import chalk from 'chalk';
import ora from 'ora';
import { readFileSync, existsSync } from 'node:fs';
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
import { obtainExposureConsent } from '../lib/exposure-warning.js';
import yaml from 'js-yaml';
interface RecipeYaml {
canonical_id?: string;
display_name?: string;
description?: string;
endpoint?: string;
method?: string;
headers?: Record<string, string>;
body?: Record<string, unknown>;
credentials_required?: Array<{ key: string; inject_as: string }>;
}
interface RecipeDefinition {
canonical_id: string;
hash_id: string;
display_name?: string;
description?: string;
endpoint: string;
method?: string;
credentials_required?: Array<{ key: string; inject_as: string }>;
created_at: number;
updated_at: number;
}
export async function cmdRecipePush(filePath: string): Promise<void> {
const config = loadConfig();
if (!config.api_key) {
console.error(chalk.red('缺少 API Key,請先執行 acr init 取得 API Key'));
process.exit(1);
}
if (!existsSync(filePath)) {
console.error(chalk.red(`找不到檔案:${filePath}`));
process.exit(1);
}
// 讀取並解析 YAML
let recipe: RecipeYaml;
try {
const raw = readFileSync(filePath, 'utf8');
recipe = yaml.load(raw) as RecipeYaml;
} catch (e) {
console.error(chalk.red(`YAML 解析失敗:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
if (!recipe.canonical_id) {
console.error(chalk.red('recipe YAML 缺少 canonical_id 欄位'));
process.exit(1);
}
if (!recipe.endpoint) {
console.error(chalk.red('recipe YAML 缺少 endpoint 欄位'));
process.exit(1);
}
const executorUrl = getCypherExecutorUrl(config);
// 資料外流警示:recipe 定義一個資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
const consent = await obtainExposureConsent({
kind: 'recipe',
resourceName: recipe.canonical_id,
destination: recipe.endpoint,
});
if (!consent) {
process.exit(1);
}
const spinner = ora(`上傳 recipe "${recipe.canonical_id}"`).start();
try {
const res = await fetch(`${executorUrl}/recipes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Arcrun-API-Key': config.api_key,
},
body: JSON.stringify({ ...recipe, exposure_consent: consent ?? undefined }),
});
const data = await res.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
if (!data.success || !data.recipe) {
spinner.fail(chalk.red(`上傳失敗:${data.error ?? '未知錯誤'}`));
process.exit(1);
}
spinner.succeed(chalk.green(`✓ recipe "${data.recipe.canonical_id}" 上傳成功`));
console.log(`\n Hash ID${chalk.cyan(data.recipe.hash_id)} (穩定引用,不受改名影響)`);
console.log(` Endpoint${chalk.gray(data.recipe.endpoint)}`);
// 打通檢查(SDD recipe-push-gatekeeping §1.2):recipe 是「指向外部 API 的指針」,
// 正確性一半在「打不打得通」(DECISIONS §1 recipe 驗收 = 2xx)。
// self-hosted 是提醒級:不硬擋、誠實標原因(缺 credential 打不到 2xx 就誠實說,不假綠 — mindset §7)。
await probeRecipeEndpoint(recipe);
console.log(chalk.bold('\n 在 workflow config 中使用:\n'));
console.log(chalk.cyan(` config:`));
console.log(chalk.cyan(` my_node:`));
console.log(chalk.cyan(` component: ${data.recipe.canonical_id} # 或用 hash: ${data.recipe.hash_id}`));
if (data.recipe.credentials_required?.length) {
console.log(chalk.yellow(`\n 此 recipe 需要 credentials${data.recipe.credentials_required.map(c => c.key).join(', ')}`));
console.log(chalk.gray(' 執行 acr creds push 上傳 token'));
}
console.log('');
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
/**
* 打通檢查:push 時對 recipe endpoint 實打一次,回報是否 2xx。
*
* 提醒級(self-hosted):只回報、不硬擋(用戶可能就是要先 push 再設 credential)。
* 誠實(mindset §7):
* - endpoint 含未填模板({{_path}} / {{auth.x}} 等)→ 執行期才有值,push 時無法驗,誠實說明。
* - 打不到 2xx → 誠實標 HTTP status(如 401 多半是缺 credential),不假裝成功。
* - arcrun 不做授權判斷:401/403 是對方服務裁決,不是 recipe 的 bugDECISIONS / mindset §3)。
*/
async function probeRecipeEndpoint(recipe: RecipeYaml): Promise<void> {
const endpoint = recipe.endpoint ?? '';
if (/\{\{.*?\}\}/.test(endpoint)) {
console.log(chalk.gray('\n 打通檢查:endpoint 含執行期變數({{...}}),push 時無法預打。'));
console.log(chalk.gray(' 實際是否打通待 acr run 時才知(recipe 驗收標準 = 執行回 2xx)。'));
return;
}
process.stdout.write(chalk.gray('\n 打通檢查(實打 endpoint...'));
try {
const method = (recipe.method ?? 'POST').toUpperCase();
const res = await fetch(endpoint, {
method,
headers: recipe.headers,
// 不帶 credential(push 端沒有明文)→ 打不通多半是缺 auth,下面誠實標
...(method !== 'GET' && method !== 'HEAD'
? { body: JSON.stringify(recipe.body ?? {}) }
: {}),
signal: AbortSignal.timeout(10_000),
});
if (res.ok) {
console.log(chalk.green(` ✓ HTTP ${res.status}(打通)`));
} else if (res.status === 401 || res.status === 403) {
console.log(chalk.yellow(` ⚠ HTTP ${res.status}`));
console.log(chalk.gray(' 未驗收:多半是缺 credential(過認證後才會 2xx)。先 acr creds push 對應 token。'));
console.log(chalk.gray(' 註:401/403 是對方服務在行使授權,不是 recipe 的 bug。'));
} else {
console.log(chalk.yellow(` ⚠ HTTP ${res.status}(未打通)`));
console.log(chalk.gray(' recipe 已上傳,但 endpoint 目前未回 2xx。請確認 endpoint / method 正確。'));
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.log(chalk.yellow(` ⚠ 無法連線`));
console.log(chalk.gray(` ${msg.slice(0, 120)}(recipe 已上傳;連線問題不擋 push)`));
}
}
export async function cmdRecipeList(): Promise<void> {
const config = loadConfig();
const executorUrl = getCypherExecutorUrl(config);
const spinner = ora('取得 recipe 清單').start();
try {
const headers: Record<string, string> = {};
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
const res = await fetch(`${executorUrl}/recipes`, { headers });
const data = await res.json() as { success: boolean; recipes?: RecipeDefinition[]; error?: string };
spinner.stop();
if (!data.success) {
console.error(chalk.red(`錯誤:${data.error}`));
process.exit(1);
}
const recipes = data.recipes ?? [];
if (recipes.length === 0) {
console.log(chalk.gray('\n 尚無 recipe。執行 acr recipe push <file> 上傳。\n'));
return;
}
console.log(chalk.bold(`\n arcrun recipes${recipes.length} 個)\n`));
for (const r of recipes) {
console.log(` • ${chalk.cyan(r.canonical_id.padEnd(20))} ${chalk.gray(r.hash_id)} ${r.display_name ?? ''}`);
console.log(` ${chalk.gray(r.endpoint)}`);
if (r.credentials_required?.length) {
console.log(` ${chalk.yellow('🔑 需要:' + r.credentials_required.map(c => c.key).join(', '))}`);
}
}
console.log('');
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
export async function cmdRecipeDelete(id: string): Promise<void> {
const config = loadConfig();
if (!config.api_key) {
console.error(chalk.red('缺少 API Key,請先執行 acr init'));
process.exit(1);
}
const executorUrl = getCypherExecutorUrl(config);
const spinner = ora(`刪除 recipe "${id}"`).start();
try {
const res = await fetch(`${executorUrl}/recipes/${id}`, {
method: 'DELETE',
headers: { 'X-Arcrun-API-Key': config.api_key },
});
const data = await res.json() as { success: boolean; deleted?: string; error?: string };
if (!data.success) {
spinner.fail(chalk.red(`刪除失敗:${data.error ?? '未知錯誤'}`));
process.exit(1);
}
spinner.succeed(chalk.green(`✓ recipe "${data.deleted}" 已刪除`));
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}