c1a06df68f
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>
369 lines
15 KiB
TypeScript
369 lines
15 KiB
TypeScript
/**
|
||
* acr recipe push <file> — 上傳 recipe YAML 到 arcrun.dev
|
||
* acr recipe list — 列出已上傳的 recipe
|
||
* acr recipe delete <id> — 刪除 recipe(canonical_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-29,Arcrun#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 的 bug(DECISIONS / 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-29,Arcrun#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);
|
||
}
|
||
}
|