/** * acr recipe push — 上傳 recipe YAML 到 arcrun.dev * acr recipe list — 列出已上傳的 recipe * acr recipe delete — 刪除 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; body?: Record; 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 { 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 { 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 { const config = loadConfig(); const executorUrl = getCypherExecutorUrl(config); const spinner = ora('取得 recipe 清單').start(); try { const headers: Record = {}; 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 上傳。\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 { 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 — 搜尋公庫(GET /public-recipes?q=)。落空回創作引導(§7.5.6)。*/ export async function cmdRecipeSearch(query: string): Promise { 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 [--author=]\n')); } catch (e) { spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`)); process.exit(1); } } /** acr recipe pull [--author] — 從公庫取一份 recipe 寫進自己私庫。*/ export async function cmdRecipePull(canonicalId: string, author?: string): Promise { 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 — 把私庫某 recipe 投稿到公庫(新增作者版本,需暴露同意)。*/ export async function cmdRecipeSubmitP(canonicalId: string, author?: string): Promise { 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); } }