Files
Arcrun/cli/src/commands/recipe.ts
T
uncle6me-web c1a06df68f feat(exposure): 完全移除 acr push 暴露 consent 閘 (Arcrun#13 P1)
leo 2026-06-29 拍板:arcrun 是給 AI 用的系統,push/暴露不再需要人類確認。
- 刪 cypher-executor/src/lib/exposure-consent.ts(server 閘,MCP push 的真正擋點)
- 刪 cli/src/lib/exposure-warning.ts(CLI 互動 + 非 TTY 拒絕)
- recipes.ts / webhooks-named.ts:移除 checkExposureConsent 403 閘,直接放行
- recipe.ts / push.ts:移除 obtainExposureConsent 呼叫,不再 prompt/拒絕
- init-seed / seed-api-recipes:移除種子層級 consent
- exposure_consent 欄位降為向後相容(讀舊 record 不報錯,不再寫入/檢查)
不補審計線索、不做替代防護(leo:先拿掉,出問題再設置)。
tsc 全綠(cypher-executor + cli)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:58:32 +08:00

369 lines
15 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, DEFAULT_PUBLIC_LIBRARY_URL } from '../lib/config.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 {
uuid?: string; // UUID 身份模型(kbdb-base §7.5.5
author?: string;
derived_from?: string;
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);
// 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13):recipe push 不再需要人類確認。
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),
});
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);
}
}
// ── 公庫互動(kbdb-base §7.5,薄殼:只呼叫 API + 格式化,無業務邏輯)─────────────────
interface PublicRecipeSummary {
uuid?: string;
canonical_id: string;
author?: string;
display_name?: string;
description?: string;
market_stat?: { success_count: number; failure_count: number } | null;
}
/** acr recipe search <q> — 搜尋公庫(GET /public-recipes?q=)。落空回創作引導(§7.5.6)。*/
export async function cmdRecipeSearch(query: string): Promise<void> {
const spinner = ora(`搜尋公庫「${query}」`).start();
try {
const url = `${DEFAULT_PUBLIC_LIBRARY_URL}/public-recipes?q=${encodeURIComponent(query)}`;
const res = await fetch(url);
const data = await res.json() as
| { found: true; recipes: PublicRecipeSummary[]; count: number }
| { found: false; query: string; hint: string };
spinner.stop();
if (!data.found) {
console.log(chalk.yellow(`\n 公庫無符合「${query}」的 recipe。`));
console.log(chalk.gray(` ${data.hint}`));
console.log(chalk.gray(' 做一個:建 recipe YAML → acr recipe push(私庫)→ acr recipe submit-p(投稿成為作者)。\n'));
return;
}
console.log(chalk.bold(`\n 公庫 recipes${data.count} 個,同名可多作者)\n`));
for (const r of data.recipes) {
const s = r.market_stat;
const stat = s ? chalk.gray(` ✓${s.success_count}/✗${s.failure_count}`) : chalk.gray(' (無市場數據)');
console.log(` • ${chalk.cyan(r.canonical_id.padEnd(20))} ${chalk.magenta('@' + (r.author ?? '?'))}${stat} ${r.display_name ?? ''}`);
if (r.description) console.log(` ${chalk.gray(r.description)}`);
}
console.log(chalk.gray('\n 取用:acr recipe pull <canonical_id> [--author=<name>]\n'));
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
/** acr recipe pull <canonical_id> [--author] — 從公庫取一份 recipe 寫進自己私庫。*/
export async function cmdRecipePull(canonicalId: string, author?: string): Promise<void> {
const config = loadConfig();
if (!config.api_key) {
console.error(chalk.red('缺少 API Key,請先執行 acr init'));
process.exit(1);
}
const spinner = ora(`從公庫取 recipe「${canonicalId}${author ? `@${author}` : ''}`).start();
try {
// 1. 從公庫取全文(不指定 author → 公庫回市場最佳版本)。
const q = author ? `?author=${encodeURIComponent(author)}` : '';
const pubRes = await fetch(`${DEFAULT_PUBLIC_LIBRARY_URL}/public-recipes/${encodeURIComponent(canonicalId)}${q}`);
const pub = await pubRes.json() as
| { found: true; recipe: RecipeDefinition & { uuid?: string; author?: string }; market_stat?: unknown }
| { found: false; canonical_id: string; hint: string };
if (!pub.found) {
spinner.stop();
console.log(chalk.yellow(`\n 公庫無 recipe「${canonicalId}」。`));
console.log(chalk.gray(` ${pub.hint}\n`));
return;
}
// 2. 寫進自己私庫(POST /recipes,帶 derived_from 溯源)。
const r = pub.recipe;
const executorUrl = getCypherExecutorUrl(config);
const installRes = await fetch(`${executorUrl}/recipes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': config.api_key },
body: JSON.stringify({
...r,
derived_from: r.uuid, // 溯源:私庫這份來自公庫哪個 uuid
}),
});
const inst = await installRes.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
if (!inst.success) {
spinner.fail(chalk.red(`寫入私庫失敗:${inst.error ?? '未知錯誤'}`));
process.exit(1);
}
spinner.succeed(chalk.green(`✓ recipe「${canonicalId}${author ? `@${author}` : ''} 已拉進私庫`));
console.log(chalk.gray(` 在 workflow 用 component: ${canonicalId}\n`));
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
/** acr recipe submit-p <canonical_id> — 把私庫某 recipe 投稿到公庫(新增作者版本,需暴露同意)。*/
export async function cmdRecipeSubmitP(canonicalId: string, author?: 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);
// 1. 從私庫取這份 recipe 全文。
const myRes = await fetch(`${executorUrl}/recipes/${encodeURIComponent(canonicalId)}`, {
headers: { 'X-Arcrun-API-Key': config.api_key },
});
const my = await myRes.json() as { success: boolean; recipe?: RecipeDefinition & { uuid?: string }; error?: string };
if (!my.success || !my.recipe) {
console.error(chalk.red(`私庫找不到 recipe「${canonicalId}」:${my.error ?? ''}`));
process.exit(1);
}
// 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13):投稿公庫不再需要人類確認。
const spinner = ora(`投稿 recipe「${canonicalId}」到公庫`).start();
try {
const res = await fetch(`${DEFAULT_PUBLIC_LIBRARY_URL}/recipes/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...my.recipe,
author: author ?? my.recipe.author,
derived_from: my.recipe.derived_from ?? my.recipe.uuid,
submitter: author ?? config.api_key,
}),
});
const data = await res.json() as { success: boolean; recipe?: { uuid?: string; author?: string }; error?: string };
if (!data.success) {
spinner.fail(chalk.red(`投稿失敗:${data.error ?? '未知錯誤'}`));
process.exit(1);
}
spinner.succeed(chalk.green(`✓ recipe「${canonicalId}」已投稿公庫(新增作者版本 @${data.recipe?.author ?? '?'}`));
console.log(chalk.gray(' 別人能搜到並 pull;市場數據累積後決定它被不被選用。\n'));
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}