Files
Arcrun/cli/src/commands/parts.ts
T
Leo 621cb8d948 feat(cli): code 零件接進 acr init/update 部署流程(自足 Worker 進部署清單 + vendored wasm 進 repo)
動機(Arcrun#4 後續,leo 批准):code 零件只有原始碼(registry/components/code),
downloadAndDeploy 完全沒涵蓋它——tier1 只掃 .component-builds/*(TinyGo 家族,要求
component.wasm),tier2 寫死四個引擎。merge 後用戶跑 acr update 應真的裝上 code。

接法(實查後裁定):
- code 是自足 Worker(quickjs-emscripten wasmfile variant,非 TinyGo;見其 index.ts 頭註),
  「缺 wasm」的真相是「這一類根本不在部署清單」+「vendored quickjs.wasm 是 gitignored
  build 產物、不進 archive」。
- deploy.ts 新增 SELF_CONTAINED_COMPONENT_WORKERS(目錄 + 必要產物 gate,比照 tier1
  component.wasm gate 的誠實跳過精神),discoverWorkerDirs 將其排進 tier1(零件先於引擎)。
- vendored quickjs.wasm(491KB)commit 進 repo:.gitignore 放行(完全比照
  !.component-builds/**/component.wasm 的「部署物 wasm 例外」先例)→ acr update 從
  Gitea archive 直接拿到,更新不需 npm build 工具鏈。
- 共享依賴抽成 SHARED_DEPLOY_DEPS 並補 quickjs-emscripten-core + wasmfile variant
  (版本對齊零件 package.json,測試看守 drift)→ root 裝一次、esbuild 往上 resolve。
- 注入零改動:既有 stripOfficialOnlyBindings 剝掉 code.arcrun.dev 官方 route、
  workers_dev=true 保留 → self-hosted 自動落 arcrun-code.<sub>.workers.dev。
- parts.ts BUILTIN_COMPONENTS 加 code 條目(issue #13 W3:零件=靜態清單)→ acr parts 可見。
  實查:init 本來就不對 registry 註冊任何零件(registry index 是官方 backfill 腳本的事),
  故「比照其他零件」=進 BUILTIN_COMPONENTS 即對齊。

測試:cli/tests/deploy-code-component.test.ts 9 顆全綠(node --test,零新依賴):
部署清單含 code / 缺產物誠實跳過 / wasm 已 git 追蹤(會進 archive)/ SHARED_DEPLOY_DEPS
涵蓋零件全部 runtime deps + 版本一致 / route 剝除與 [vars] 保留 / parts 清單含 code。
tsc --noEmit 綠;零件自身 12 顆 vitest 綠(沙箱行為未動)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
2026-07-07 09:01:11 +00:00

408 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 — 列出所有可用「零件(component)」(內建清單,不依賴 registry.arcrun.dev
* acr parts scaffold <component> — 輸出 config 範本(可直接貼入 workflow.yaml
* acr parts publish <component> — 提交零件至公眾 registryPhase 5,封測後)
*
* ⚠️ 分類原則(2026-06-29issue #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 <term>`**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 是自足 Workerquickjs 沙箱,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 §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}}"`,
},
// ⚠️ 此處曾 hardcode gmail_send / google_sheets_append / telegram_send / line_notify_send / notion
// 這 5 個是 **recipe(動態,存 store)不是零件(component**,已於 W3.1 移除(issue #13 根治)。
// 要找它們:`acr search <term>`(跨類)或 `acr recipe list` / `acr auth-recipe list`(動態清單)。
];
// ── 指令實作 ──────────────────────────────────────────────────────────────────
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} 個內建零件 / 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 <component> 取得 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<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);
}
}