/** * acr parts — 列出所有可用零件(按類型分組,含統計與 author) * acr parts scaffold — 輸出 config 範本 * acr parts publish — 提交零件至公眾 registry */ import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import chalk from 'chalk'; import ora from 'ora'; import { loadConfig } from '../lib/config.js'; const REGISTRY_URL = 'https://registry.arcrun.dev'; interface ComponentInfo { canonical_id: string; display_name: string; category: string; description: string; author?: string; total_runs?: number; success_rate?: number; avg_duration_ms?: number; visibility?: 'public' | 'author_only'; credentials_required?: Array<{ key: string; type: string; inject_as: string }>; } export async function cmdParts(): Promise { const spinner = ora('從 registry.arcrun.dev 取得零件清單').start(); let components: ComponentInfo[] = []; try { const res = await fetch(`${REGISTRY_URL}/components`); if (res.ok) { const data = await res.json() as { components: ComponentInfo[] }; components = data.components ?? []; } spinner.stop(); } catch { spinner.stop(); console.log(chalk.yellow(' 無法連線 registry.arcrun.dev,顯示本地零件清單\n')); } if (components.length === 0) { // fallback:顯示本地 registry 目錄中的零件 components = loadLocalComponents(); } // 依 category 分組 const grouped: Record = {}; for (const comp of components) { const cat = comp.category ?? 'other'; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(comp); } const categoryLabels: Record = { api: '整合類(Integration)', logic: '控制類(Control Flow)', data: '資料類(Data)', ai: 'AI 類', other: '其他', }; console.log(chalk.bold('\n arcrun 零件庫\n')); for (const [cat, comps] of Object.entries(grouped)) { console.log(chalk.bold.underline(` ${categoryLabels[cat] ?? cat}`)); for (const comp of comps) { const isAuthorOnly = comp.visibility === 'author_only'; const tag = isAuthorOnly ? chalk.yellow(' [待審核]') : ''; let statsLine = ''; if (!isAuthorOnly && comp.total_runs !== undefined) { const rate = ((comp.success_rate ?? 1) * 100).toFixed(1); const runs = comp.total_runs.toLocaleString(); const ms = Math.round(comp.avg_duration_ms ?? 0); statsLine = chalk.gray(` ★ ${rate}% 成功 | ${runs} 次執行 | 平均 ${ms}ms`); } const authorStr = comp.author ? chalk.gray(` by ${comp.author}`) : ''; const credStr = comp.credentials_required?.length ? chalk.yellow(` 🔑 需要 ${comp.credentials_required.map(c => c.key).join(', ')}`) : ''; console.log(` • ${chalk.cyan(comp.canonical_id.padEnd(20))}${comp.display_name}${tag}${authorStr}${credStr}`); if (statsLine) console.log(statsLine); } console.log(''); } console.log(chalk.gray(' 使用 acr parts scaffold 取得 config 範本')); console.log(chalk.gray(' 使用 acr parts publish 提交零件至公眾庫\n')); } export async function cmdPartsScaffold(componentId: string): Promise { // 優先從本地 registry 讀取 contract.yaml const localContract = loadLocalContract(componentId); if (!localContract) { // 嘗試從 registry.arcrun.dev 取得 try { const res = await fetch(`${REGISTRY_URL}/components/${componentId}/contract`); if (!res.ok) { console.error(chalk.red(`零件 "${componentId}" 不存在,執行 acr parts 查看可用清單`)); process.exit(1); } const data = await res.json() as { config_example?: string; credentials_required?: unknown[] }; printScaffold(componentId, data.config_example, data.credentials_required as ComponentInfo['credentials_required']); } catch { console.error(chalk.red(`無法取得 "${componentId}" 的 contract,請確認零件名稱`)); process.exit(1); } return; } const configExample = extractYamlField(localContract, 'config_example'); const credsRequired = extractCredentialsRequired(localContract); printScaffold(componentId, configExample, credsRequired); } function printScaffold( componentId: string, configExample?: string, credsRequired?: ComponentInfo['credentials_required'], ): void { console.log(chalk.bold(`\n ${componentId} — workflow.yaml config 範本\n`)); if (configExample) { console.log(chalk.cyan(' # 貼入 workflow.yaml 的 config: 區塊')); console.log(configExample.split('\n').map(l => ` ${l}`).join('\n')); } else { console.log(chalk.yellow(' (無 config_example,請參考文檔)')); } if (credsRequired && credsRequired.length > 0) { console.log(chalk.bold('\n credentials.yaml 範本(加入後執行 acr creds push)\n')); for (const cred of credsRequired) { console.log(chalk.cyan(` # ${cred.type}(${cred.inject_as} 欄位自動注入)`)); console.log(` ${cred.key}: "your-${cred.type}-token"\n`); } } } export async function cmdPartsPublish(componentDir: string, options: { status?: string }): Promise { 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); } const spinner = ora('提交零件至 registry.arcrun.dev').start(); 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(); spinner.fail(chalk.red(`提交失敗(${res.status}):${err.slice(0, 200)}`)); process.exit(1); } const data = await res.json() as { submission_id: string; status: string; visibility?: string }; spinner.succeed(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) { spinner.fail(chalk.red(`提交失敗:${e instanceof Error ? e.message : e}`)); process.exit(1); } } // ── helpers ────────────────────────────────────────────────────────────────── function loadLocalComponents(): ComponentInfo[] { // 嘗試從相對路徑尋找 registry/components const dirs = [ join(process.cwd(), 'registry/components'), join(process.cwd(), '../registry/components'), ]; for (const dir of dirs) { if (existsSync(dir)) { const components: ComponentInfo[] = []; const { readdirSync } = require('node:fs'); for (const name of readdirSync(dir)) { const contractPath = join(dir, name, 'component.contract.yaml'); if (existsSync(contractPath)) { const raw = readFileSync(contractPath, 'utf8'); const canonical_id = extractYamlScalar(raw, 'canonical_id') ?? name; const display_name = extractYamlScalar(raw, 'display_name') ?? name; const category = extractYamlScalar(raw, 'category') ?? 'other'; const description = extractYamlScalar(raw, 'description') ?? ''; components.push({ canonical_id, display_name, category, description }); } } return components; } } return []; } function loadLocalContract(componentId: string): string | null { const dirs = [ join(process.cwd(), `registry/components/${componentId}/component.contract.yaml`), join(process.cwd(), `../registry/components/${componentId}/component.contract.yaml`), ]; for (const p of dirs) { if (existsSync(p)) return readFileSync(p, 'utf8'); } return null; } function extractYamlScalar(yaml: string, key: string): string | undefined { const m = yaml.match(new RegExp(`^${key}:\\s*["']?([^"'\\n]+)["']?`, 'm')); return m?.[1]?.trim(); } function extractYamlField(yaml: string, field: string): string | undefined { const m = yaml.match(new RegExp(`^${field}:\\s*\\|\\n((?:[ \\t]+[^\\n]*\\n?)*)`, 'm')); return m?.[1]; } function extractCredentialsRequired(yaml: string): ComponentInfo['credentials_required'] { const section = yaml.match(/credentials_required:\s*([\s\S]*?)(?=\n\w|\n#|$)/); if (!section) return []; const items: ComponentInfo['credentials_required'] = []; const blocks = section[1].split(/\n - /).slice(1); for (const block of blocks) { const key = block.match(/key:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim(); const type = block.match(/type:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim(); const inject_as = block.match(/inject_as:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim(); if (key && type && inject_as) { items!.push({ key, type, inject_as }); } } return items; }