/** * acr push * * 解析 workflow.yaml,透過 /cypher/search 取得執行圖, * 然後 POST 至 cypher.arcrun.dev/webhooks/named(帶 X-Arcrun-API-Key)。 * Server 以 {api_key}:wf:{name} 為 KV key 存入 WEBHOOKS KV。 * * 不再需要用戶的 CF API Token 或 KV Namespace ID。 */ import chalk from 'chalk'; import ora from 'ora'; import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; import { loadWorkflowYaml, parseTriplets, validateRelations } from '../lib/yaml-parser.js'; import { obtainExposureConsent } from '../lib/exposure-warning.js'; export async function cmdPush(filePath: string): Promise { const config = loadConfig(); if (config.mode === 'local') { console.error(chalk.red('Local 模式不支援 acr push(Webhook 部署需要 Standard 模式)。')); console.log(chalk.gray('請執行 acr init 取得 API Key,或直接用 acr run 本機測試。')); process.exit(1); } if (!config.api_key) { if (config.mode === 'self-hosted') { console.error(chalk.red('缺少 NAMESPACE(你的資料分區標籤)。')); console.log(chalk.gray('在專案 .env 設一行(明碼即可):')); console.log(chalk.cyan(' NAMESPACE=leo')); } else { console.error(chalk.red('缺少 api_key,請重新執行 acr init。')); } process.exit(1); } // 解析 YAML const spinner = ora('解析 workflow.yaml').start(); let workflow; try { workflow = loadWorkflowYaml(filePath); const triplets = parseTriplets(workflow.flow); validateRelations(triplets); spinner.succeed(`解析完成:${workflow.name}(${triplets.length} 條三元組)`); } catch (e) { spinner.fail(chalk.red(`解析失敗:${e instanceof Error ? e.message : e}`)); process.exit(1); } const executorUrl = getCypherExecutorUrl(config); const headers: Record = { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': config.api_key, }; // 向 /cypher/search 取得執行圖 const searchSpinner = ora('取得執行圖').start(); let graph: unknown; try { const res = await fetch(`${executorUrl}/cypher/search`, { method: 'POST', headers, body: JSON.stringify({ triplets: workflow.flow }), }); if (!res.ok) { const err = await res.text(); searchSpinner.fail(chalk.red(`執行圖解析失敗(${res.status}):${err.slice(0, 200)}`)); process.exit(1); } const data = await res.json() as { cypher: { nodes: unknown[]; edges: unknown[] }; missing: string[] }; if (data.missing?.length > 0) { searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`)); process.exit(1); } // 附上 id / name,並將 workflow.config 套入節點(componentId + data) const rawGraph = data.cypher as { nodes: Array<{ id: string; componentId?: string; data?: Record }>; edges: unknown[] }; const cfg = (workflow.config ?? {}) as Record>; const nodes = rawGraph.nodes.map(node => { const nodeCfg = cfg[node.id]; if (!nodeCfg) return node; const { component, ...params } = nodeCfg; return { ...node, componentId: typeof component === 'string' ? component : node.componentId, data: Object.keys(params).length > 0 ? { ...(node.data ?? {}), ...params } : node.data, }; }); graph = { id: workflow.name, name: workflow.name, nodes, edges: rawGraph.edges }; searchSpinner.succeed('執行圖解析完成'); } catch (e) { searchSpinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`)); process.exit(1); } // 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。 // 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。 // server 端獨立存法律憑證並強制(防 CLI 被繞過)。 const consent = await obtainExposureConsent({ kind: 'workflow', resourceName: workflow.name, destination: `${executorUrl}/webhooks/named/${workflow.name}/trigger`, }); if (!consent) { process.exit(1); } // POST 至 /webhooks/named const deploySpinner = ora(`部署 "${workflow.name}" 至 ${executorUrl}`).start(); try { const res = await fetch(`${executorUrl}/webhooks/named`, { method: 'POST', headers, body: JSON.stringify({ name: workflow.name, graph, config: workflow.config ?? {}, description: workflow.description ?? '', exposure_consent: consent ?? undefined, }), }); if (!res.ok) { const err = await res.text(); deploySpinner.fail(chalk.red(`部署失敗(${res.status}):${err.slice(0, 200)}`)); process.exit(1); } const data = await res.json() as { name: string; webhook_url: string; created_at: string }; deploySpinner.succeed(chalk.green(`✓ "${workflow.name}" 已部署`)); // self-hosted:namespace 明碼 → 給「namespace 進 path」的公開 URL(公開表單可直接打,免 header)。 // standard:仍走 header(平台多租戶,api_key 是密碼不可進 path)。 if (config.mode === 'self-hosted') { const pathUrl = `${executorUrl}/webhooks/named/${config.api_key}/${workflow.name}/trigger`; console.log(chalk.bold(`\n Webhook URL(公開可打,免 header):${chalk.cyan(pathUrl)}`)); console.log(chalk.gray(' namespace 在 path 是明碼分區標籤(非密碼);要防外部濫用請對 webhook 加保護。')); console.log(''); console.log(chalk.gray(' 公開表單 / curl 觸發:')); console.log(` ${chalk.cyan(`curl -X POST ${pathUrl} \\`)}`); console.log(` ${chalk.cyan(` -H 'Content-Type: application/json' -d '{"key": "value"}'`)}`); } else { console.log(chalk.bold(`\n Webhook URL:${chalk.cyan(data.webhook_url)}`)); console.log(chalk.gray(` 需帶 Header:X-Arcrun-API-Key: ${config.api_key.slice(0, 8)}...`)); console.log(''); console.log(chalk.gray(' curl 觸發範例:')); console.log(` ${chalk.cyan(`curl -X POST ${data.webhook_url} \\`)}`); console.log(` ${chalk.cyan(` -H 'X-Arcrun-API-Key: ${config.api_key}' \\`)}`); console.log(` ${chalk.cyan(` -H 'Content-Type: application/json' -d '{"key": "value"}'`)}`); } console.log(''); console.log(chalk.gray(' 測試執行:') + ` ${chalk.cyan(`acr run ${workflow.name}`)}`); console.log(''); } catch (e) { deploySpinner.fail(chalk.red(`部署失敗:${e instanceof Error ? e.message : e}`)); process.exit(1); } }