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>
This commit is contained in:
+356
-175
@@ -1,149 +1,397 @@
|
||||
/**
|
||||
* acr parts — 列出所有可用零件(按類型分組,含統計與 author)
|
||||
* acr parts scaffold <component> — 輸出 config 範本
|
||||
* acr parts publish <component> — 提交零件至公眾 registry
|
||||
* acr parts — 列出所有可用零件(內建清單,不依賴 registry.arcrun.dev)
|
||||
* acr parts scaffold <component> — 輸出 config 範本(可直接貼入 workflow.yaml)
|
||||
* acr parts publish <component> — 提交零件至公眾 registry(Phase 5,封測後)
|
||||
*/
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
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';
|
||||
import { loadConfig, getCypherExecutorUrl } 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 }>;
|
||||
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:
|
||||
` # 先上傳 recipe:acr 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 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<string, ComponentInfo[]> = {};
|
||||
for (const comp of components) {
|
||||
const cat = comp.category ?? 'other';
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(comp);
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
api: '整合類(Integration)',
|
||||
logic: '控制類(Control Flow)',
|
||||
data: '資料類(Data)',
|
||||
api: '整合類(API / Integration)',
|
||||
ai: 'AI 類',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
console.log(chalk.bold('\n arcrun 零件庫\n'));
|
||||
const grouped: Record<string, ComponentDef[]> = {};
|
||||
for (const comp of BUILTIN_COMPONENTS) {
|
||||
if (!grouped[comp.category]) grouped[comp.category] = [];
|
||||
grouped[comp.category].push(comp);
|
||||
}
|
||||
|
||||
for (const [cat, comps] of Object.entries(grouped)) {
|
||||
console.log(chalk.bold.underline(` ${categoryLabels[cat] ?? cat}`));
|
||||
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 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(', ')}`)
|
||||
? 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(` • ${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(' 使用 acr parts publish <component> 提交零件至公眾庫\n'));
|
||||
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> {
|
||||
// 優先從本地 registry 讀取 contract.yaml
|
||||
const localContract = loadLocalContract(componentId);
|
||||
const comp = BUILTIN_COMPONENTS.find(c => c.canonical_id === componentId);
|
||||
|
||||
if (!localContract) {
|
||||
// 嘗試從 registry.arcrun.dev 取得
|
||||
if (!comp) {
|
||||
// 找不到內建零件 → 嘗試 auth recipe
|
||||
const config = loadConfig();
|
||||
const baseUrl = getCypherExecutorUrl(config);
|
||||
try {
|
||||
const res = await fetch(`${REGISTRY_URL}/components/${componentId}/contract`);
|
||||
if (!res.ok) {
|
||||
console.error(chalk.red(`零件 "${componentId}" 不存在,執行 acr parts 查看可用清單`));
|
||||
process.exit(1);
|
||||
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;
|
||||
}
|
||||
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;
|
||||
|
||||
console.error(chalk.red(`找不到零件 "${componentId}"。`));
|
||||
console.log(chalk.gray('執行 acr parts 查看內建零件。'));
|
||||
console.log(chalk.gray('執行 acr auth-recipe list 查看第三方服務整合。'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const configExample = extractYamlField(localContract, 'config_example');
|
||||
const credsRequired = extractCredentialsRequired(localContract);
|
||||
printScaffold(componentId, configExample, credsRequired);
|
||||
}
|
||||
console.log(chalk.bold(`\n ${comp.canonical_id} — ${comp.display_name}\n`));
|
||||
console.log(chalk.gray(` ${comp.description}\n`));
|
||||
|
||||
function printScaffold(
|
||||
componentId: string,
|
||||
configExample?: string,
|
||||
credsRequired?: ComponentInfo['credentials_required'],
|
||||
): void {
|
||||
console.log(chalk.bold(`\n ${componentId} — workflow.yaml config 範本\n`));
|
||||
console.log(chalk.cyan(' # 貼入 workflow.yaml 的 config: 區塊'));
|
||||
console.log(comp.config_example.split('\n').map(l => ` ${l}`).join('\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`);
|
||||
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 };
|
||||
@@ -165,7 +413,6 @@ export async function cmdPartsPublish(componentDir: string, options: { status?:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 讀取零件目錄
|
||||
const contractPath = join(componentDir, 'component.contract.yaml');
|
||||
const mainGoPath = join(componentDir, 'main.go');
|
||||
const wasmName = componentDir.split('/').pop() ?? componentDir;
|
||||
@@ -180,7 +427,7 @@ export async function cmdPartsPublish(componentDir: string, options: { status?:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const spinner = ora('提交零件至 registry.arcrun.dev').start();
|
||||
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');
|
||||
@@ -198,84 +445,18 @@ export async function cmdPartsPublish(componentDir: string, options: { status?:
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
spinner.fail(chalk.red(`提交失敗(${res.status}):${err.slice(0, 200)}`));
|
||||
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 };
|
||||
spinner.succeed(chalk.green(`✓ 提交成功`));
|
||||
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) {
|
||||
spinner.fail(chalk.red(`提交失敗:${e instanceof Error ? e.message : e}`));
|
||||
console.error(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[] = [];
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user