/** * acr parts — 列出所有可用「零件(component)」(內建清單,不依賴 registry.arcrun.dev) * acr parts scaffold — 輸出 config 範本(可直接貼入 workflow.yaml) * acr parts publish — 提交零件至公眾 registry(Phase 5,封測後) * * ⚠️ 分類原則(2026-06-29,issue #13 / component-gatekeeping W3): * - **零件(component)= 靜態清單**:WASM,只能走 GitHub PR + 人 merge 新增(mindset §4 人類閘門), * 固定慢增 → 用 BUILTIN_COMPONENTS hardcode 反映真實,正確。 * - **recipe / auth-recipe / workflow = 動態,存在 store**:任何人 `acr recipe push` 即新增 → * **絕不可 hardcode 在這裡**(會「submitted = invisible」+ 誤導查錯表)。它們各有動態清單: * recipe → `acr recipe list`(GET /recipes)|auth-recipe → `acr auth-recipe list`(GET /auth-recipes)|workflow → `acr list`(GET /webhooks/named) * - **跨類找東西用 `acr search `**(fan-out 上述 4 個來源,不必先知道它是哪一類)。 * 歷史教訓:本檔曾把 gmail_send/telegram_send/notion 等 5 個 recipe hardcode 進零件清單 → * 誤導「telegram 是零件 / 走不同機制」。已移除(W3.1)。 */ import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import chalk from 'chalk'; import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; // ── 內建零件定義 ──────────────────────────────────────────────────────────────── interface CredentialRequirement { key: string; type: string; inject_as: string; } interface ComponentDef { canonical_id: string; display_name: string; category: 'logic' | 'data' | 'api' | 'ai'; description: string; config_example: string; credentials_required?: CredentialRequirement[]; } export const BUILTIN_COMPONENTS: ComponentDef[] = [ // ── 控制類(Logic) ──────────────────────────────────────────────────────── { canonical_id: 'if_control', display_name: 'If Control', category: 'logic', description: '條件分支:condition 為 true 走 ON_SUCCESS,否則走 ON_FAIL', config_example: ` if_node: component: if_control condition: "status === active"`, }, { canonical_id: 'switch', display_name: 'Switch', category: 'logic', description: '多分支條件:根據 value 欄位選擇對應分支', config_example: ` switch_node: component: switch key: status cases: active: branch_a inactive: branch_b`, }, { canonical_id: 'foreach_control', display_name: 'Foreach', category: 'logic', description: '迭代:對陣列每個元素執行下游節點', config_example: ` loop_node: component: foreach_control iterator: item`, }, { canonical_id: 'filter', display_name: 'Filter', category: 'logic', description: '過濾陣列:保留符合 condition 的元素', config_example: ` filter_node: component: filter key: items condition: "status === active"`, }, { canonical_id: 'merge', display_name: 'Merge', category: 'logic', description: '合併多個上游節點的輸出(Fan-in)', config_example: ` merge_node: component: merge`, }, { canonical_id: 'try_catch', display_name: 'Try Catch', category: 'logic', description: '錯誤捕捉:下游失敗時執行 catch 分支', config_example: ` safe_node: component: try_catch`, }, { canonical_id: 'wait', display_name: 'Wait', category: 'logic', description: '延遲執行:等待指定毫秒後繼續', config_example: ` delay_node: component: wait ms: 1000`, }, { // code 是自足 Worker(quickjs 沙箱,registry/components/code),非 TinyGo-wasm 家族, // 但同屬靜態零件(走 PR + 人類閘門 merge 新增)→ 進本清單正確(issue #13 W3 原則)。 // 部署:acr init/update 的 downloadAndDeploy 自動部署(deploy.ts SELF_CONTAINED_COMPONENT_WORKERS)。 canonical_id: 'code', display_name: '程式碼(沙箱 inline JS)', category: 'logic', description: 'QuickJS-wasm 沙箱執行 inline JS:讀 input、return JSON-able 值;碰不到網路/檔案/env', config_example: ` my_code: component: code code: | const doubled = input.items.map(x => x * 2); return { doubled, count: doubled.length }; input: items: [1, 2, 3]`, }, // ── 資料類(Data) ───────────────────────────────────────────────────────── { canonical_id: 'set', display_name: 'Set', category: 'data', description: '設定欄位:將靜態值寫入 context', config_example: ` set_node: component: set values: status: active source: webhook`, }, { canonical_id: 'array_ops', display_name: 'Array Ops', category: 'data', description: '陣列操作:push / pop / slice / length', config_example: ` arr_node: component: array_ops operation: push key: items value: "{{new_item}}"`, }, { canonical_id: 'string_ops', display_name: 'String Ops', category: 'data', description: '字串操作:upper / lower / trim / replace / split / join / length', config_example: ` str_node: component: string_ops operation: upper input: "{{text}}"`, }, { canonical_id: 'number_ops', display_name: 'Number Ops', category: 'data', description: '數字操作:add / sub / mul / div / round / floor / ceil / abs', config_example: ` num_node: component: number_ops operation: add a: "{{price}}" b: 10`, }, { canonical_id: 'date_ops', display_name: 'Date Ops', category: 'data', description: '日期操作:now / format / diff / add_days', config_example: ` date_node: component: date_ops operation: now format: "2006-01-02 15:04:05"`, }, { canonical_id: 'validate_json', display_name: 'Validate JSON', category: 'data', description: '驗證 context 欄位是否符合 JSON Schema', config_example: ` validate_node: component: validate_json schema: type: object required: [email, name] properties: email: type: string format: email`, }, // ── AI 類:已移除 ────────────────────────────────────────────────────────── // ai_transform_compile / ai_transform_run 於 2026-05-29 刪除(mindset §2:arcrun 是 // AI 呼叫的工具,不是工具回頭呼叫 LLM)。需要 AI 判斷/轉換由操盤的 CC 自己做。 // ── API 整合類(Recipe 型,不需 deploy Worker) ──────────────────────────── { canonical_id: 'http_request', display_name: 'HTTP Request', category: 'api', description: '通用 HTTP 請求:支援任意 method / headers / body', config_example: ` api_node: component: http_request url: "https://api.example.com/data" method: POST headers: Content-Type: application/json body: key: "{{value}}"`, }, // ⚠️ 此處曾 hardcode gmail_send / google_sheets_append / telegram_send / line_notify_send / notion // 這 5 個是 **recipe(動態,存 store)不是零件(component)**,已於 W3.1 移除(issue #13 根治)。 // 要找它們:`acr search `(跨類)或 `acr recipe list` / `acr auth-recipe list`(動態清單)。 ]; // ── 指令實作 ────────────────────────────────────────────────────────────────── export async function cmdParts(): Promise { const categoryLabels: Record = { logic: '控制類(Control Flow)', data: '資料類(Data)', api: '整合類(API / Integration)', ai: 'AI 類', }; const grouped: Record = {}; for (const comp of BUILTIN_COMPONENTS) { if (!grouped[comp.category]) grouped[comp.category] = []; grouped[comp.category].push(comp); } console.log(chalk.bold(`\n arcrun 零件庫(${BUILTIN_COMPONENTS.length} 個內建零件 / component,靜態 PR-only)\n`)); for (const cat of ['logic', 'data', 'ai', 'api']) { const comps = grouped[cat]; if (!comps?.length) continue; console.log(chalk.bold.underline(` ${categoryLabels[cat]}`)); for (const comp of comps) { const credStr = comp.credentials_required?.length ? chalk.yellow(` (需要 ${comp.credentials_required.map(c => c.key).join(', ')})`) : ''; console.log(` • ${chalk.cyan(comp.canonical_id.padEnd(22))}${comp.display_name}${credStr}`); console.log(chalk.gray(` ${comp.description}`)); } console.log(''); } console.log(chalk.gray(' 使用 acr parts scaffold 取得 config 範本')); console.log(''); console.log(chalk.bold(' 零件之外(動態,存在 store,不在上面這份靜態清單):')); console.log(chalk.gray(' • API recipe(打外部服務) acr recipe list')); console.log(chalk.gray(' • 第三方服務認證(auth-recipe) acr auth-recipe list')); console.log(chalk.gray(' • 已部署的 workflow acr list')); console.log(chalk.cyan(' • 不確定某能力是哪一類? acr search <關鍵字> ← 跨類一次搜,免先選表')); console.log(''); } export async function cmdPartsScaffold(componentId: string): Promise { const comp = BUILTIN_COMPONENTS.find(c => c.canonical_id === componentId); if (!comp) { // 找不到內建零件 → 嘗試 auth recipe const config = loadConfig(); const baseUrl = getCypherExecutorUrl(config); try { const res = await fetch(`${baseUrl}/auth-recipes/${componentId}`); if (res.ok) { const data = await res.json() as { recipe: { display_name?: string; description?: string; required_secrets: Array<{ key: string; label: string; type?: string; help?: string; help_url?: string }> } }; const recipe = data.recipe; console.log(chalk.bold(`\n ${componentId} — ${recipe.display_name ?? componentId}\n`)); if (recipe.description) console.log(chalk.gray(` ${recipe.description}\n`)); console.log(chalk.cyan(' # credentials.yaml 範本(填入後執行 acr creds push)\n')); for (const s of recipe.required_secrets) { if (s.help) console.log(chalk.gray(` # ${s.label}`)); if (s.help) console.log(chalk.gray(` # ${s.help}`)); if (s.help_url) console.log(chalk.gray(` # 說明文件:${s.help_url}`)); if (s.type === 'json_blob') { console.log(` ${s.key}: |`); console.log(` {`); console.log(` "type": "service_account",`); console.log(` ...`); console.log(` }`); } else { console.log(` ${s.key}: ""`); } console.log(''); } console.log(chalk.cyan(' # workflow.yaml config 範例\n')); console.log(` ${componentId}_node:`); console.log(` component: ${componentId}`); console.log(` method: POST`); console.log(` _path: /your-endpoint-path`); console.log(''); console.log(chalk.gray(` 完整說明:acr auth-recipe info ${componentId}\n`)); return; } } catch { // 離線或服務不可用,繼續顯示錯誤 } console.error(chalk.red(`找不到零件 "${componentId}"。`)); console.log(chalk.gray('執行 acr parts 查看內建零件。')); console.log(chalk.gray('執行 acr auth-recipe list 查看第三方服務整合。')); process.exit(1); } console.log(chalk.bold(`\n ${comp.canonical_id} — ${comp.display_name}\n`)); console.log(chalk.gray(` ${comp.description}\n`)); console.log(chalk.cyan(' # 貼入 workflow.yaml 的 config: 區塊')); console.log(comp.config_example.split('\n').map(l => ` ${l}`).join('\n')); if (comp.credentials_required?.length) { console.log(chalk.bold('\n credentials.yaml 範本(填入後執行 acr creds push)\n')); for (const cred of comp.credentials_required) { console.log(chalk.cyan(` # ${cred.type}(執行時自動注入為 ${cred.inject_as} 欄位)`)); console.log(` ${cred.key}: "your-token-here"\n`); } } console.log(''); } export async function cmdPartsPublish(componentDir: string, options: { status?: string }): Promise { const REGISTRY_URL = 'https://registry.arcrun.dev'; if (options.status) { try { const res = await fetch(`${REGISTRY_URL}/submit/status/${options.status}`); const data = await res.json() as { status: string; visibility?: string; failed_step?: string; reason?: string; approved_at?: string }; console.log(chalk.bold(`\n 提交狀態:${options.status}\n`)); console.log(` 狀態:${data.status}`); if (data.visibility) console.log(` Visibility:${data.visibility}`); if (data.failed_step) console.log(chalk.red(` 失敗步驟:${data.failed_step}`)); if (data.reason) console.log(chalk.red(` 原因:${data.reason}`)); if (data.approved_at) console.log(chalk.green(` 核准時間:${data.approved_at}`)); } catch (e) { console.error(chalk.red(`查詢失敗:${e instanceof Error ? e.message : e}`)); } return; } const config = loadConfig(); if (!config.api_key) { console.error(chalk.red('缺少 API Key,請執行 acr init')); process.exit(1); } const contractPath = join(componentDir, 'component.contract.yaml'); const mainGoPath = join(componentDir, 'main.go'); const wasmName = componentDir.split('/').pop() ?? componentDir; const wasmPath = join(componentDir, `${wasmName}.wasm`); if (!existsSync(contractPath)) { console.error(chalk.red(`找不到 ${contractPath}`)); process.exit(1); } if (!existsSync(wasmPath)) { console.error(chalk.red(`找不到 ${wasmPath}(請先編譯:tinygo build -o ${wasmName}.wasm -target wasi .)`)); process.exit(1); } console.log(chalk.bold('\n 提交零件至 registry.arcrun.dev...\n')); const formData = new FormData(); formData.append('contract', new Blob([readFileSync(contractPath)], { type: 'application/yaml' }), 'component.contract.yaml'); if (existsSync(mainGoPath)) { formData.append('source', new Blob([readFileSync(mainGoPath)], { type: 'text/plain' }), 'main.go'); } formData.append('wasm', new Blob([readFileSync(wasmPath)], { type: 'application/wasm' }), `${wasmName}.wasm`); try { const res = await fetch(`${REGISTRY_URL}/submit`, { method: 'POST', headers: { 'X-Arcrun-API-Key': config.api_key }, body: formData, }); if (!res.ok) { const err = await res.text(); console.error(chalk.red(`提交失敗(${res.status}):${err.slice(0, 200)}`)); process.exit(1); } const data = await res.json() as { submission_id: string; status: string; visibility?: string }; console.log(chalk.green(`✓ 提交成功`)); console.log(`\n Submission ID:${chalk.cyan(data.submission_id)}`); console.log(` 狀態:${data.status}`); if (data.visibility) console.log(` Visibility:${data.visibility}`); console.log(chalk.gray(`\n 查詢進度:acr parts publish --status ${data.submission_id}\n`)); } catch (e) { console.error(chalk.red(`提交失敗:${e instanceof Error ? e.message : e}`)); process.exit(1); } }