621cb8d948
動機(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
408 lines
16 KiB
TypeScript
408 lines
16 KiB
TypeScript
/**
|
||
* acr parts — 列出所有可用「零件(component)」(內建清單,不依賴 registry.arcrun.dev)
|
||
* acr parts scaffold <component> — 輸出 config 範本(可直接貼入 workflow.yaml)
|
||
* acr parts publish <component> — 提交零件至公眾 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 <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 是自足 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 <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);
|
||
}
|
||
}
|