/** * 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 } 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; body?: Record; 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 { 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 的 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); } }