Files
Arcrun/cli/src/commands/parts.ts
T
uncle6me-web 225aa9f9e7 清 parts.ts 降級零件殘留:telegram/gmail/sheets/line 改走 recipe、移除已刪的 ai_transform(issue #13 步驟2)
telegram/gmail/google_sheets/line_notify 已於 2026-05-29 Phase 2 降級為 recipe,
但 acr parts 仍列舊 component: 形態誤導 CC。參照 notion 樣板改成「走 recipe」描述 +
canonical_id 對齊 api-recipe-seeds.ts(telegram_send/gmail_send/google_sheets_append/line_notify_send)。
ai_transform_compile/run 已刪除(mindset §2),整段移除。
canonical_id 均已核實存在於 cypher-executor/src/lib/api-recipe-seeds.ts。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:05:39 +08:00

439 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 類:已移除 ──────────────────────────────────────────────────────────
// ai_transform_compile / ai_transform_run 於 2026-05-29 刪除(mindset §2arcrun 是
// 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}}"`,
},
{
canonical_id: 'gmail_send',
display_name: 'Gmail Send',
category: 'api',
description: '透過 recipe 寄送 Gmailrecipe gmail_sendauth: google service_account)。注意:Gmail 讀取尚無對應 recipe(待 seed 補 gmail_list)。',
config_example:
` # 寄信用內建 recipe gmail_sendbody 帶 rawbase64url MIME
mail_node:
component: gmail_send
method: POST
body:
raw: "{{mime_base64url}}"`,
},
{
canonical_id: 'google_sheets_append',
display_name: 'Google Sheets Append',
category: 'api',
description: '透過 recipe 追加一列到 Sheetsrecipe google_sheets_append;讀取用 google_sheets_read。auth: google service_account',
config_example:
` # 追加用內建 recipe google_sheets_append(讀取改用 google_sheets_read
sheet_node:
component: google_sheets_append
method: POST
_path: "/v4/spreadsheets/{{spreadsheet_id}}/values/Sheet1!A:B:append?valueInputOption=RAW"
body:
values:
- ["{{name}}", "{{email}}"]`,
},
{
canonical_id: 'telegram_send',
display_name: 'Telegram Send',
category: 'api',
description: '透過 recipe 發送 Telegram 訊息(recipe telegram_sendauth: static_keytoken 注入 URL path',
config_example:
` # 內建 recipe telegram_sendtoken 由 auth 注入 URL,不寫在這)
tg_node:
component: telegram_send
chat_id: "123456789"
text: "{{message}}"`,
},
{
canonical_id: 'line_notify_send',
display_name: 'LINE Notify',
category: 'api',
description: '透過 recipe 發送 LINE Notify 通知(recipe line_notify_sendauth: static_key Bearer',
config_example:
` # 內建 recipe line_notify_sendPOST notifybody form-urlencoded
line_node:
component: line_notify_send
method: POST
body:
message: "{{notification}}"`,
},
{
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);
}
}