2707fca32b
Phase 1-5 complete per .agents/specs/u6u-core-mvp/: **Phase 1 — Cherry-pick & cleanup** - Create arcrun/ from cypher-executor, credentials, builtins, registry - Remove 9 InkStone Service Bindings (KBDB, REGISTRY, CLINIC_*, AICEO, MINI_ME) - Rewrite component-loader: 3-layer (builtin → WASM_BUCKET R2 → error) - Remove autoPublishMissing.ts, proxy.ts (AICEO), execution-logger.ts (KBDB) - Clean all KV namespace IDs and InkStone internal URLs from config files **Phase 2 — contract.yaml completeness** - Add credentials_required to gmail, google_sheets, telegram, line_notify - Add config_example to all 21 components with annotated field descriptions **Phase 3 — Credential injection** - Add credential-injector.ts: AES-GCM decrypt from CREDENTIALS_KV - Integrate into GraphExecutor before WASM execution - Structured errors with repair instructions when credential missing **Phase 4 — CLI (acr)** - cli/package.json: arcrun package, bin: acr, deps: commander/js-yaml/chalk/ora - 8 commands: init, creds push, push, run, validate, parts, list, logs - Standard mode: writes directly to user's CF KV via CF REST API - acr init: interactive setup with arcrun.dev API Key registration **Phase 5 — Open source release prep** - README.md: 5-minute quickstart, component table, workflow YAML syntax - CONTRIBUTING.md: TinyGo dev env, component scaffolding, submission flow - Security audit: no InkStone internal URLs/IDs in committed files - .gitignore: exclude credentials.yaml, .wrangler, *.wasm https://claude.ai/code/session_01BnCdSLVH8tUed9VrrPavgT
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
/**
|
||
* acr parts — 列出所有可用零件(按類型分組,含統計與 author)
|
||
* acr parts scaffold <component> — 輸出 config 範本
|
||
* acr parts publish <component> — 提交零件至公眾 registry
|
||
*/
|
||
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';
|
||
|
||
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 }>;
|
||
}
|
||
|
||
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)',
|
||
ai: 'AI 類',
|
||
other: '其他',
|
||
};
|
||
|
||
console.log(chalk.bold('\n arcrun 零件庫\n'));
|
||
|
||
for (const [cat, comps] of Object.entries(grouped)) {
|
||
console.log(chalk.bold.underline(` ${categoryLabels[cat] ?? 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(', ')}`)
|
||
: '';
|
||
|
||
console.log(` • ${chalk.cyan(comp.canonical_id.padEnd(20))}${comp.display_name}${tag}${authorStr}${credStr}`);
|
||
if (statsLine) console.log(statsLine);
|
||
}
|
||
console.log('');
|
||
}
|
||
|
||
console.log(chalk.gray(' 使用 acr parts scaffold <component> 取得 config 範本'));
|
||
console.log(chalk.gray(' 使用 acr parts publish <component> 提交零件至公眾庫\n'));
|
||
}
|
||
|
||
export async function cmdPartsScaffold(componentId: string): Promise<void> {
|
||
// 優先從本地 registry 讀取 contract.yaml
|
||
const localContract = loadLocalContract(componentId);
|
||
|
||
if (!localContract) {
|
||
// 嘗試從 registry.arcrun.dev 取得
|
||
try {
|
||
const res = await fetch(`${REGISTRY_URL}/components/${componentId}/contract`);
|
||
if (!res.ok) {
|
||
console.error(chalk.red(`零件 "${componentId}" 不存在,執行 acr parts 查看可用清單`));
|
||
process.exit(1);
|
||
}
|
||
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;
|
||
}
|
||
|
||
const configExample = extractYamlField(localContract, 'config_example');
|
||
const credsRequired = extractCredentialsRequired(localContract);
|
||
printScaffold(componentId, configExample, credsRequired);
|
||
}
|
||
|
||
function printScaffold(
|
||
componentId: string,
|
||
configExample?: string,
|
||
credsRequired?: ComponentInfo['credentials_required'],
|
||
): void {
|
||
console.log(chalk.bold(`\n ${componentId} — workflow.yaml config 範本\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`);
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function cmdPartsPublish(componentDir: string, options: { status?: string }): Promise<void> {
|
||
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);
|
||
}
|
||
|
||
const spinner = ora('提交零件至 registry.arcrun.dev').start();
|
||
|
||
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();
|
||
spinner.fail(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(`\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}`));
|
||
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[] = [];
|
||
const { readdirSync } = require('node:fs');
|
||
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;
|
||
}
|