Files
Arcrun/cli/src/commands/parts.ts
T
Leo 066652f6e8 feat(cli): add recipe / auth-recipe commands + update push/creds/init
- New: acr recipe (push/list/get a user recipe to RECIPES KV)
- New: acr auth-recipe (inspect platform-seeded auth recipes)
- push/creds/init/parts/config updated to match the new cypher-executor
  routing (/auth, /credentials, webhooks-named).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 17:40:57 +08:00

463 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* acr parts — 列出所有可用零件(內建清單,不依賴 registry.arcrun.dev
* acr parts scaffold <component> — 輸出 config 範本(可直接貼入 workflow.yaml
* acr parts publish <component> — 提交零件至公眾 registryPhase 5,封測後)
*/
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[];
}
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`,
},
// ── 資料類(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 類 ──────────────────────────────────────────────────────────────────
{
canonical_id: 'ai_transform_compile',
display_name: 'AI Transform Compile',
category: 'ai',
description: '將自然語言規則編譯成可執行轉換程式',
config_example:
` compile_node:
component: ai_transform_compile
rule: "把 name 轉成大寫,並在前面加上 Hello "`,
},
{
canonical_id: 'ai_transform_run',
display_name: 'AI Transform Run',
category: 'ai',
description: '執行 ai_transform_compile 產生的轉換程式',
config_example:
` run_node:
component: ai_transform_run
program: "{{compiled_program}}"`,
},
// ── 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}}"`,
},
{
canonical_id: 'gmail',
display_name: 'Gmail',
category: 'api',
description: '寄送 Gmail(需要 gmail_token credential',
config_example:
` mail_node:
component: gmail
to: "recipient@example.com"
subject: "來自 arcrun 的通知"
body: "{{message}}"`,
credentials_required: [
{ key: 'gmail_token', type: 'OAuth2 access token', inject_as: 'access_token' },
],
},
{
canonical_id: 'google_sheets',
display_name: 'Google Sheets',
category: 'api',
description: 'Google Sheets 讀寫(需要 google_oauth credential',
config_example:
` sheet_node:
component: google_sheets
spreadsheet_id: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"
range: "Sheet1!A:B"
operation: append
values:
- ["{{name}}", "{{email}}"]`,
credentials_required: [
{ key: 'google_oauth', type: 'OAuth2 access token', inject_as: 'access_token' },
],
},
{
canonical_id: 'telegram',
display_name: 'Telegram',
category: 'api',
description: '發送 Telegram 訊息(需要 telegram_bot_token credential',
config_example:
` tg_node:
component: telegram
chat_id: "123456789"
text: "{{message}}"`,
credentials_required: [
{ key: 'telegram_bot_token', type: 'Bot token', inject_as: 'bot_token' },
],
},
{
canonical_id: 'line_notify',
display_name: 'LINE Notify',
category: 'api',
description: '發送 LINE Notify 通知(需要 line_token credential',
config_example:
` line_node:
component: line_notify
message: "{{notification}}"`,
credentials_required: [
{ key: 'line_token', type: 'LINE Notify token', inject_as: 'token' },
],
},
{
canonical_id: 'notion',
display_name: 'Notion',
category: 'api',
description: '透過 recipe 操作 Notion API(需先 acr recipe push',
config_example:
` # 先上傳 recipeacr recipe push notion.yaml
notion_node:
component: rec_xxxxxxxx # acr recipe push 後得到的 hash
database_id: "{{db_id}}"
properties:
Name: "{{title}}"`,
},
];
// ── 指令實作 ──────────────────────────────────────────────────────────────────
export async function cmdParts(): Promise<void> {
const categoryLabels: Record<string, string> = {
logic: '控制類(Control Flow',
data: '資料類(Data',
api: '整合類(API / Integration',
ai: 'AI 類',
};
const grouped: Record<string, ComponentDef[]> = {};
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} 個內建零件)\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 <component> 取得 config 範本'));
console.log(chalk.gray(' 第三方服務整合(Notion/Slack/GitHub 等):acr auth-recipe list'));
console.log(chalk.gray(' API 整合類若需打自訂服務,請用 acr recipe push 建立 recipe\n'));
}
export async function cmdPartsScaffold(componentId: string): Promise<void> {
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<void> {
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);
}
}