feat(arcrun): implement arcrun MVP — open-source AI workflow engine
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
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "arcrun",
|
||||
"version": "1.0.0",
|
||||
"description": "AI Workflow CLI for arcrun — deploy and run WASM-based AI workflows on Cloudflare",
|
||||
"bin": {
|
||||
"acr": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"ora": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": ["cloudflare", "workers", "wasm", "workflow", "ai", "arcrun"],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/arcrun/arcrun.git"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* acr creds push [credentials.yaml]
|
||||
* 讀取 credentials.yaml,加密後上傳至用戶自己的 CF KV(key 格式:cred:{name})
|
||||
* 不經過 arcrun.dev
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import yaml from 'js-yaml';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { CfKvClient, encryptCredential } from '../lib/cf-api.js';
|
||||
|
||||
export async function cmdCredsPush(filePath: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.cloudflare_account_id || !config.cf_api_token) {
|
||||
console.error(chalk.red('缺少 Cloudflare 設定,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 讀取 credentials.yaml
|
||||
let creds: Record<string, string>;
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf8');
|
||||
creds = yaml.load(raw) as Record<string, string>;
|
||||
} catch (e) {
|
||||
console.error(chalk.red(`無法讀取 ${filePath}:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const entries = Object.entries(creds).filter(([, v]) => typeof v === 'string' && v.length > 0);
|
||||
if (entries.length === 0) {
|
||||
console.log(chalk.yellow('credentials.yaml 中沒有有效的 credential(請取消注解並填入值)'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 決定要寫入哪個 KV namespace
|
||||
const namespaceId = config.mode === 'standard'
|
||||
? config.user_kv_namespace_id!
|
||||
: config.credentials_kv_namespace_id!;
|
||||
|
||||
if (!namespaceId) {
|
||||
console.error(chalk.red('缺少 KV Namespace ID,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kv = new CfKvClient({
|
||||
accountId: config.cloudflare_account_id,
|
||||
namespaceId,
|
||||
apiToken: config.cf_api_token,
|
||||
});
|
||||
|
||||
// 加密金鑰(若無則用 dev 模式 base64)
|
||||
const encryptionKey = process.env.ARCRUN_ENCRYPTION_KEY ?? '';
|
||||
|
||||
console.log(chalk.bold(`\n 上傳 ${entries.length} 個 credentials 至你的 CF KV\n`));
|
||||
|
||||
for (const [name, value] of entries) {
|
||||
const spinner = ora(` ${name}`).start();
|
||||
try {
|
||||
const encrypted = await encryptCredential(String(value), encryptionKey);
|
||||
await kv.put(`cred:${name}`, encrypted);
|
||||
spinner.succeed(chalk.green(` ✓ ${name} 已加密上傳至你的 CF KV`));
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.gray('\n 你的 credential 存在你自己的 CF KV,arcrun 不會儲存它們。\n'));
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* acr init — 互動式初始化設定
|
||||
* 詢問 CF Account ID、KV namespace、API Token、email,
|
||||
* 呼叫 arcrun.dev 取得 API Key,寫入 ~/.arcrun/config.yaml
|
||||
*/
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import chalk from 'chalk';
|
||||
import { saveConfig, type ArcrunConfig } from '../lib/config.js';
|
||||
|
||||
const ARCRUN_REGISTER_URL = 'https://api.arcrun.dev/register';
|
||||
|
||||
async function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
const answer = await rl.question(chalk.cyan(`? ${question}: `));
|
||||
return answer.trim();
|
||||
}
|
||||
|
||||
export async function cmdInit(options: { selfHosted?: boolean }): Promise<void> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
console.log(chalk.bold('\n arcrun 初始化設定\n'));
|
||||
|
||||
try {
|
||||
if (options.selfHosted) {
|
||||
await initSelfHosted(rl);
|
||||
} else {
|
||||
await initStandard(rl);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function initStandard(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||
console.log(chalk.gray(' Standard 模式:使用 arcrun.dev 的執行引擎,credential 存在你自己的 CF KV\n'));
|
||||
|
||||
const accountId = await prompt(rl, '你的 Cloudflare Account ID');
|
||||
const kvNamespaceId = await prompt(rl,
|
||||
'USER_KV Namespace ID(先至 CF Dashboard 建立一個 KV 後貼上)'
|
||||
);
|
||||
const cfApiToken = await prompt(rl,
|
||||
'CF API Token(只需 KV Edit 權限,供 acr 讀寫你的 KV)'
|
||||
);
|
||||
const email = await prompt(rl, 'Email(取得 arcrun.dev API Key)');
|
||||
|
||||
process.stdout.write(chalk.gray('\n → 向 arcrun.dev 取得 API Key...'));
|
||||
|
||||
let apiKey: string;
|
||||
try {
|
||||
const res = await fetch(ARCRUN_REGISTER_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, cf_api_token: cfApiToken }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`API Key 取得失敗(${res.status}):${err}`);
|
||||
}
|
||||
|
||||
const data = await res.json() as { api_key: string; tenant_id: string };
|
||||
apiKey = data.api_key;
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ✗(離線模式,請稍後執行 acr init 重試)'));
|
||||
apiKey = '';
|
||||
}
|
||||
|
||||
const config: ArcrunConfig = {
|
||||
mode: 'standard',
|
||||
cloudflare_account_id: accountId,
|
||||
user_kv_namespace_id: kvNamespaceId,
|
||||
cf_api_token: cfApiToken,
|
||||
api_key: apiKey,
|
||||
};
|
||||
|
||||
saveConfig(config);
|
||||
|
||||
// 建立空白 credentials.yaml
|
||||
createCredentialsYamlIfMissing();
|
||||
|
||||
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml'));
|
||||
if (apiKey) {
|
||||
console.log(chalk.green(` ✓ API Key:${apiKey.slice(0, 8)}...(已安全儲存)`));
|
||||
}
|
||||
console.log(chalk.green(' ✓ 建立 credentials.yaml(已加入 .gitignore)\n'));
|
||||
console.log(chalk.gray(' 你的 credential 與 workflow 存在你自己的 CF KV,arcrun 不會儲存它們。\n'));
|
||||
console.log(' 下一步:');
|
||||
console.log(chalk.cyan(' acr creds push credentials.yaml') + ' # 上傳加密 credentials');
|
||||
console.log(chalk.cyan(' acr push workflow.yaml') + ' # 部署 workflow');
|
||||
console.log(chalk.cyan(' acr run <workflow_name>') + ' # 執行 workflow\n');
|
||||
}
|
||||
|
||||
async function initSelfHosted(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||
console.log(chalk.gray(' Self-hosted 模式:自行部署所有 Worker 到你的 Cloudflare 帳號\n'));
|
||||
|
||||
const accountId = await prompt(rl, '你的 Cloudflare Account ID');
|
||||
const cypherUrl = await prompt(rl, 'Cypher Executor URL(部署後的 workers.dev URL)');
|
||||
const webhooksKvId = await prompt(rl, 'WEBHOOKS KV Namespace ID');
|
||||
const credentialsKvId = await prompt(rl, 'CREDENTIALS_KV Namespace ID');
|
||||
const wasmBucket = await prompt(rl, 'WASM_BUCKET 名稱');
|
||||
const cfApiToken = await prompt(rl, 'CF API Token(KV Edit 權限)');
|
||||
|
||||
const config: ArcrunConfig = {
|
||||
mode: 'self-hosted',
|
||||
cloudflare_account_id: accountId,
|
||||
cypher_executor_url: cypherUrl,
|
||||
webhooks_kv_namespace_id: webhooksKvId,
|
||||
credentials_kv_namespace_id: credentialsKvId,
|
||||
wasm_bucket: wasmBucket,
|
||||
cf_api_token: cfApiToken,
|
||||
multi_tenant: false,
|
||||
};
|
||||
|
||||
saveConfig(config);
|
||||
createCredentialsYamlIfMissing();
|
||||
|
||||
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml'));
|
||||
console.log(chalk.green(' ✓ 建立 credentials.yaml\n'));
|
||||
}
|
||||
|
||||
function createCredentialsYamlIfMissing(): void {
|
||||
const credPath = join(process.cwd(), 'credentials.yaml');
|
||||
if (!existsSync(credPath)) {
|
||||
writeFileSync(credPath,
|
||||
'# arcrun credentials — 不要提交至 git!\n' +
|
||||
'# 執行 acr creds push 上傳加密後的 credential 到你的 CF KV\n\n' +
|
||||
'# gmail_token: "your-google-oauth-token"\n' +
|
||||
'# telegram_bot_token: "your-telegram-bot-token"\n' +
|
||||
'# google_oauth: "your-google-oauth-token"\n' +
|
||||
'# line_token: "your-line-notify-token"\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
// 確保 .gitignore 排除 credentials.yaml
|
||||
const gitignorePath = join(process.cwd(), '.gitignore');
|
||||
if (existsSync(gitignorePath)) {
|
||||
const content = require('node:fs').readFileSync(gitignorePath, 'utf8');
|
||||
if (!content.includes('credentials.yaml')) {
|
||||
require('node:fs').appendFileSync(gitignorePath, '\ncredentials.yaml\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* acr list — 列出 USER_KV 中所有已上傳的 workflow
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { CfKvClient } from '../lib/cf-api.js';
|
||||
|
||||
export async function cmdList(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.cloudflare_account_id || !config.cf_api_token) {
|
||||
console.error(chalk.red('缺少 Cloudflare 設定,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const namespaceId = config.mode === 'standard'
|
||||
? config.user_kv_namespace_id!
|
||||
: config.webhooks_kv_namespace_id!;
|
||||
|
||||
if (!namespaceId) {
|
||||
console.error(chalk.red('缺少 KV Namespace ID,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kv = new CfKvClient({
|
||||
accountId: config.cloudflare_account_id,
|
||||
namespaceId,
|
||||
apiToken: config.cf_api_token,
|
||||
});
|
||||
|
||||
const spinner = ora('讀取 workflow 清單').start();
|
||||
|
||||
try {
|
||||
const keys = await kv.list('workflow:');
|
||||
spinner.stop();
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log(chalk.yellow('\n 沒有已部署的 workflow。執行 acr push <workflow.yaml> 部署第一個。\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`\n 已部署 ${keys.length} 個 workflow\n`));
|
||||
|
||||
for (const key of keys) {
|
||||
const name = key.name.replace('workflow:', '');
|
||||
// 嘗試讀取 workflow 定義取得 created_at
|
||||
try {
|
||||
const raw = await kv.get(key.name);
|
||||
if (raw) {
|
||||
const def = JSON.parse(raw) as { name: string; description?: string; created_at?: string };
|
||||
const date = def.created_at ? new Date(def.created_at).toLocaleString('zh-TW') : '未知';
|
||||
const desc = def.description ? chalk.gray(` — ${def.description}`) : '';
|
||||
console.log(` • ${chalk.cyan(name.padEnd(25))} ${date}${desc}`);
|
||||
} else {
|
||||
console.log(` • ${chalk.cyan(name)}`);
|
||||
}
|
||||
} catch {
|
||||
console.log(` • ${chalk.cyan(name)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`KV 讀取失敗:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* acr logs <workflow_name> — 顯示最近執行記錄
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { CfKvClient } from '../lib/cf-api.js';
|
||||
|
||||
export async function cmdLogs(workflowName: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.cloudflare_account_id || !config.cf_api_token) {
|
||||
console.error(chalk.red('缺少 Cloudflare 設定,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const namespaceId = config.mode === 'standard'
|
||||
? config.user_kv_namespace_id!
|
||||
: config.webhooks_kv_namespace_id!;
|
||||
|
||||
if (!namespaceId) {
|
||||
console.error(chalk.red('缺少 KV Namespace ID,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kv = new CfKvClient({
|
||||
accountId: config.cloudflare_account_id,
|
||||
namespaceId,
|
||||
apiToken: config.cf_api_token,
|
||||
});
|
||||
|
||||
const spinner = ora(`讀取 "${workflowName}" 執行記錄`).start();
|
||||
|
||||
try {
|
||||
const keys = await kv.list(`log:${workflowName}:`);
|
||||
spinner.stop();
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log(chalk.yellow(`\n "${workflowName}" 沒有執行記錄。\n`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 依時間排序(key 格式:log:{name}:{timestamp})
|
||||
const sorted = keys.sort((a, b) => b.name.localeCompare(a.name)).slice(0, 20);
|
||||
|
||||
console.log(chalk.bold(`\n "${workflowName}" 最近 ${sorted.length} 次執行記錄\n`));
|
||||
|
||||
for (const key of sorted) {
|
||||
try {
|
||||
const raw = await kv.get(key.name);
|
||||
if (!raw) continue;
|
||||
|
||||
const log = JSON.parse(raw) as {
|
||||
success: boolean;
|
||||
duration_ms: number;
|
||||
executed_at: string;
|
||||
failed_node?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const icon = log.success ? chalk.green('✓') : chalk.red('✗');
|
||||
const date = new Date(log.executed_at).toLocaleString('zh-TW');
|
||||
const duration = chalk.gray(`${log.duration_ms}ms`);
|
||||
|
||||
if (log.success) {
|
||||
console.log(` ${icon} ${date} ${duration}`);
|
||||
} else {
|
||||
console.log(` ${icon} ${date} ${duration} ${chalk.red(`失敗節點:${log.failed_node ?? '未知'}`)}`);
|
||||
if (log.error) {
|
||||
console.log(chalk.red(` ${log.error.slice(0, 100)}`));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 跳過無法解析的記錄
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`KV 讀取失敗:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* acr push <workflow.yaml>
|
||||
* 解析三元組,轉成執行圖,直接寫入用戶的 USER_KV(key = workflow:{name})
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||
import { CfKvClient } from '../lib/cf-api.js';
|
||||
import { loadWorkflowYaml, parseTriplets, validateRelations } from '../lib/yaml-parser.js';
|
||||
|
||||
export async function cmdPush(filePath: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
const spinner = ora('解析 workflow.yaml').start();
|
||||
let workflow;
|
||||
try {
|
||||
workflow = loadWorkflowYaml(filePath);
|
||||
spinner.text = `驗證三元組(${workflow.flow.length} 條)`;
|
||||
const triplets = parseTriplets(workflow.flow);
|
||||
validateRelations(triplets);
|
||||
spinner.succeed(`解析完成:${workflow.name}(${triplets.length} 條三元組)`);
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`解析失敗:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// POST 到 cypher-executor 取得執行圖
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
const triplets = parseTriplets(workflow.flow);
|
||||
const searchSpinner = ora(`向 ${executorUrl} 解析執行圖`).start();
|
||||
|
||||
let graph: unknown;
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
|
||||
|
||||
const res = await fetch(`${executorUrl}/cypher/search`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ triplets: workflow.flow }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
searchSpinner.fail(chalk.red(`執行圖解析失敗(${res.status}):${err.slice(0, 200)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = await res.json() as { cypher: unknown; missing: string[] };
|
||||
if (data.missing.length > 0) {
|
||||
searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
graph = data.cypher;
|
||||
searchSpinner.succeed('執行圖解析完成');
|
||||
} catch (e) {
|
||||
searchSpinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 寫入 USER_KV(key = workflow:{name})
|
||||
const namespaceId = config.mode === 'standard'
|
||||
? config.user_kv_namespace_id!
|
||||
: config.webhooks_kv_namespace_id!;
|
||||
|
||||
if (!namespaceId || !config.cloudflare_account_id || !config.cf_api_token) {
|
||||
console.error(chalk.red('缺少 KV 設定,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kv = new CfKvClient({
|
||||
accountId: config.cloudflare_account_id,
|
||||
namespaceId,
|
||||
apiToken: config.cf_api_token,
|
||||
});
|
||||
|
||||
const kvSpinner = ora('寫入 workflow 至 CF KV').start();
|
||||
try {
|
||||
const workflowDef = {
|
||||
name: workflow.name,
|
||||
description: workflow.description ?? '',
|
||||
graph,
|
||||
config: workflow.config ?? {},
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
await kv.put(`workflow:${workflow.name}`, JSON.stringify(workflowDef));
|
||||
kvSpinner.succeed(chalk.green(`✓ workflow "${workflow.name}" 已寫入你的 CF KV`));
|
||||
} catch (e) {
|
||||
kvSpinner.fail(chalk.red(`KV 寫入失敗:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const executorBase = getCypherExecutorUrl(config);
|
||||
const webhookUrl = `${executorBase}/webhooks/${workflow.name}`;
|
||||
console.log(chalk.bold(`\n Webhook URL:${chalk.cyan(webhookUrl)}`));
|
||||
if (config.api_key) {
|
||||
console.log(chalk.gray(` (使用時需帶 X-Arcrun-API-Key: ${config.api_key.slice(0, 8)}...)\n`));
|
||||
}
|
||||
console.log(` 執行:${chalk.cyan(`acr run ${workflow.name}`)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* acr run <workflow_name> [--input key=value...]
|
||||
* 觸發 cypher-executor 執行指定 workflow
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||
|
||||
interface RunOptions {
|
||||
input?: string[];
|
||||
}
|
||||
|
||||
export async function cmdRun(workflowName: string, options: RunOptions): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
|
||||
// 解析 --input key=value 為 JSON object
|
||||
const inputContext: Record<string, string> = {};
|
||||
for (const pair of (options.input ?? [])) {
|
||||
const idx = pair.indexOf('=');
|
||||
if (idx < 0) {
|
||||
console.error(chalk.red(`--input 格式錯誤:${pair}(應為 key=value)`));
|
||||
process.exit(1);
|
||||
}
|
||||
inputContext[pair.slice(0, idx)] = pair.slice(idx + 1);
|
||||
}
|
||||
|
||||
const spinner = ora(`執行 workflow "${workflowName}"`).start();
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
|
||||
|
||||
const webhookUrl = `${executorUrl}/webhooks/${workflowName}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(inputContext),
|
||||
});
|
||||
|
||||
const data = await res.json() as {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
trace?: Array<{ node: string; status: string; error?: string }>;
|
||||
duration_ms: number;
|
||||
failed_node?: string;
|
||||
};
|
||||
|
||||
if (data.success) {
|
||||
spinner.succeed(chalk.green(`✓ 執行成功(${data.duration_ms}ms)`));
|
||||
console.log('\n 結果:');
|
||||
console.log(JSON.stringify(data.data, null, 2).split('\n').map(l => ` ${l}`).join('\n'));
|
||||
} else {
|
||||
spinner.fail(chalk.red(`✗ 執行失敗(${data.duration_ms}ms)`));
|
||||
if (data.failed_node) {
|
||||
console.log(chalk.red(`\n 失敗節點:${data.failed_node}`));
|
||||
}
|
||||
if (data.error) {
|
||||
console.log(chalk.red(` 錯誤:${data.error}`));
|
||||
}
|
||||
if (data.trace) {
|
||||
console.log('\n 執行追蹤:');
|
||||
for (const step of data.trace) {
|
||||
const icon = step.status === 'failed' ? chalk.red('✗') : chalk.green('✓');
|
||||
console.log(` ${icon} ${step.node}${step.error ? ` — ${step.error}` : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* acr validate <workflow.yaml>
|
||||
* 在執行前驗證 YAML 格式、關係詞合法性、零件是否存在、credentials 是否已上傳
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||
import { loadWorkflowYaml, parseTriplets, validateRelations, getNodeNames } from '../lib/yaml-parser.js';
|
||||
import { CfKvClient } from '../lib/cf-api.js';
|
||||
|
||||
export async function cmdValidate(filePath: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
let allPassed = true;
|
||||
|
||||
const check = (label: string, ok: boolean, detail?: string) => {
|
||||
const icon = ok ? chalk.green('✓') : chalk.red('✗');
|
||||
console.log(` ${icon} ${label}${detail ? ` ${chalk.gray(detail)}` : ''}`);
|
||||
if (!ok) allPassed = false;
|
||||
};
|
||||
|
||||
console.log(chalk.bold(`\n 驗證 ${filePath}\n`));
|
||||
|
||||
// 1. YAML 格式
|
||||
let workflow;
|
||||
try {
|
||||
workflow = loadWorkflowYaml(filePath);
|
||||
check('YAML 格式正確', true, `name=${workflow.name}`);
|
||||
} catch (e) {
|
||||
check('YAML 格式', false, e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. 三元組解析
|
||||
let triplets;
|
||||
try {
|
||||
triplets = parseTriplets(workflow.flow);
|
||||
check('三元組解析', true, `${triplets.length} 條`);
|
||||
} catch (e) {
|
||||
check('三元組解析', false, e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. 關係詞驗證(不允許 PIPE)
|
||||
try {
|
||||
validateRelations(triplets);
|
||||
check('關係詞合法性', true);
|
||||
} catch (e) {
|
||||
check('關係詞合法性', false, e instanceof Error ? e.message : String(e));
|
||||
allPassed = false;
|
||||
}
|
||||
|
||||
// 4. 所有節點在 config 中有對應(Input/Output 節點除外)
|
||||
const nodeNames = getNodeNames(triplets);
|
||||
const inputNodes = new Set(triplets.map(t => t.subject).filter(s =>
|
||||
!triplets.some(t => t.object === s)
|
||||
));
|
||||
const outputNodes = new Set(triplets.map(t => t.object).filter(o =>
|
||||
!triplets.some(t => t.subject === o)
|
||||
));
|
||||
const componentNodes = nodeNames.filter(n => !inputNodes.has(n) && !outputNodes.has(n));
|
||||
|
||||
const missingConfigs = componentNodes.filter(n => !(workflow.config ?? {})[n]);
|
||||
if (missingConfigs.length > 0) {
|
||||
check('config 完整性', false, `缺少 config:${missingConfigs.join(', ')}`);
|
||||
allPassed = false;
|
||||
} else {
|
||||
check('config 完整性', true, `${componentNodes.length} 個節點均有 config`);
|
||||
}
|
||||
|
||||
// 5. 零件存在於 WASM_BUCKET(透過 cypher/search 確認)
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
|
||||
|
||||
const res = await fetch(`${executorUrl}/cypher/search`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ triplets: workflow.flow }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { missing: string[] };
|
||||
if (data.missing.length > 0) {
|
||||
check('零件存在性', false, `WASM_BUCKET 中找不到:${data.missing.join(', ')}`);
|
||||
allPassed = false;
|
||||
} else {
|
||||
check('零件存在性', true, '所有零件均已在 WASM_BUCKET');
|
||||
}
|
||||
} else {
|
||||
check('零件存在性', false, `無法連線 ${executorUrl},跳過驗證`);
|
||||
}
|
||||
} catch {
|
||||
check('零件存在性', false, `無法連線 ${executorUrl},跳過驗證`);
|
||||
}
|
||||
|
||||
// 6. 所需 credentials 已上傳至 CREDENTIALS_KV(僅在有 KV 設定時執行)
|
||||
if (config.cloudflare_account_id && config.cf_api_token) {
|
||||
const namespaceId = config.mode === 'standard'
|
||||
? config.user_kv_namespace_id
|
||||
: config.credentials_kv_namespace_id;
|
||||
|
||||
if (namespaceId) {
|
||||
const kv = new CfKvClient({
|
||||
accountId: config.cloudflare_account_id,
|
||||
namespaceId,
|
||||
apiToken: config.cf_api_token,
|
||||
});
|
||||
|
||||
// 查詢已上傳的 credentials
|
||||
try {
|
||||
const kvKeys = await kv.list('cred:');
|
||||
const uploadedCreds = new Set(kvKeys.map(k => k.name.replace('cred:', '')));
|
||||
|
||||
// 比對 workflow config 中引用的 credential key
|
||||
const usedCreds = extractCredentialRefs(workflow.config ?? {});
|
||||
const missingCreds = usedCreds.filter(k => !uploadedCreds.has(k));
|
||||
|
||||
if (missingCreds.length > 0) {
|
||||
check('Credentials 上傳', false, `未上傳:${missingCreds.join(', ')}(執行 acr creds push)`);
|
||||
allPassed = false;
|
||||
} else {
|
||||
check('Credentials 上傳', true, `已上傳 ${uploadedCreds.size} 個 credential`);
|
||||
}
|
||||
} catch {
|
||||
check('Credentials 上傳', false, 'KV 查詢失敗,跳過驗證');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (allPassed) {
|
||||
console.log(chalk.green.bold(' ✓ 驗證通過\n'));
|
||||
} else {
|
||||
console.log(chalk.red.bold(' ✗ 驗證未通過,請修正上方錯誤\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/** 從 workflow config 中提取可能的 credential key 引用(模板 {{creds.xxx}})*/
|
||||
function extractCredentialRefs(config: Record<string, Record<string, unknown>>): string[] {
|
||||
const refs = new Set<string>();
|
||||
const jsonStr = JSON.stringify(config);
|
||||
const matches = jsonStr.matchAll(/\{\{creds\.([^}]+)\}\}/g);
|
||||
for (const m of matches) {
|
||||
refs.add(m[1]);
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* arcrun CLI — acr
|
||||
* AI Workflow CLI for Cloudflare Workers + WASM
|
||||
*
|
||||
* 安裝:npm i -g arcrun
|
||||
* 使用:acr <指令>
|
||||
*/
|
||||
import { Command } from 'commander';
|
||||
import { cmdInit } from './commands/init.js';
|
||||
import { cmdCredsPush } from './commands/creds.js';
|
||||
import { cmdPush } from './commands/push.js';
|
||||
import { cmdRun } from './commands/run.js';
|
||||
import { cmdValidate } from './commands/validate.js';
|
||||
import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js';
|
||||
import { cmdList } from './commands/list.js';
|
||||
import { cmdLogs } from './commands/logs.js';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('acr')
|
||||
.description('arcrun — AI Workflow CLI for Cloudflare Workers + WASM')
|
||||
.version('1.0.0');
|
||||
|
||||
// acr init [--self-hosted]
|
||||
program
|
||||
.command('init')
|
||||
.description('互動式初始化設定(建立 ~/.arcrun/config.yaml)')
|
||||
.option('--self-hosted', '使用 Self-hosted 模式(自行部署所有 Worker)')
|
||||
.action((options: { selfHosted?: boolean }) => cmdInit(options));
|
||||
|
||||
// acr creds push [credentials.yaml]
|
||||
const credsCmd = program.command('creds').description('Credential 管理');
|
||||
credsCmd
|
||||
.command('push [file]')
|
||||
.description('加密上傳 credentials.yaml 至你的 CF KV(不經過 arcrun.dev)')
|
||||
.action((file: string) => cmdCredsPush(file ?? 'credentials.yaml'));
|
||||
|
||||
// acr push <workflow.yaml>
|
||||
program
|
||||
.command('push <file>')
|
||||
.description('解析 workflow.yaml 並部署至你的 CF KV')
|
||||
.action((file: string) => cmdPush(file));
|
||||
|
||||
// acr run <workflow_name> [--input key=value...]
|
||||
program
|
||||
.command('run <workflow>')
|
||||
.description('執行指定 workflow')
|
||||
.option('-i, --input <pairs...>', 'input 參數(格式:key=value)')
|
||||
.action((workflow: string, options: { input?: string[] }) => cmdRun(workflow, options));
|
||||
|
||||
// acr validate <workflow.yaml>
|
||||
program
|
||||
.command('validate <file>')
|
||||
.description('執行前驗證 workflow.yaml(格式、關係詞、零件存在性、credentials)')
|
||||
.action((file: string) => cmdValidate(file));
|
||||
|
||||
// acr parts
|
||||
// acr parts scaffold <component>
|
||||
// acr parts publish <component> [--status <submission_id>]
|
||||
const partsCmd = program.command('parts').description('零件庫管理');
|
||||
partsCmd
|
||||
.action(() => cmdParts());
|
||||
|
||||
partsCmd
|
||||
.command('scaffold <component>')
|
||||
.description('輸出零件的 config 範本(可直接貼入 workflow.yaml)')
|
||||
.action((component: string) => cmdPartsScaffold(component));
|
||||
|
||||
partsCmd
|
||||
.command('publish <component-dir>')
|
||||
.description('提交零件至 arcrun.dev 公眾 registry')
|
||||
.option('--status <submission_id>', '查詢提交審核進度')
|
||||
.action((dir: string, options: { status?: string }) => cmdPartsPublish(dir, options));
|
||||
|
||||
// acr list
|
||||
program
|
||||
.command('list')
|
||||
.description('列出 CF KV 中所有已部署的 workflow')
|
||||
.action(() => cmdList());
|
||||
|
||||
// acr logs <workflow_name>
|
||||
program
|
||||
.command('logs <workflow>')
|
||||
.description('顯示 workflow 最近執行記錄')
|
||||
.action((workflow: string) => cmdLogs(workflow));
|
||||
|
||||
program.parse(process.argv);
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Cloudflare KV REST API wrapper
|
||||
* 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI
|
||||
*/
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
export interface CfKvClientOptions {
|
||||
accountId: string;
|
||||
namespaceId: string;
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
export class CfKvClient {
|
||||
private base: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor({ accountId, namespaceId, apiToken }: CfKvClientOptions) {
|
||||
this.base = `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async put(key: string, value: string): Promise<void> {
|
||||
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...this.headers, 'Content-Type': 'text/plain' },
|
||||
body: value,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`KV PUT 失敗(${res.status}):${err.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`KV GET 失敗(${res.status}):${err.slice(0, 200)}`);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async list(prefix?: string): Promise<Array<{ name: string; expiration?: number; metadata?: unknown }>> {
|
||||
const url = new URL(`${this.base}/keys`);
|
||||
if (prefix) url.searchParams.set('prefix', prefix);
|
||||
url.searchParams.set('limit', '1000');
|
||||
|
||||
const res = await fetch(url.toString(), { headers: this.headers });
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`KV LIST 失敗(${res.status}):${err.slice(0, 200)}`);
|
||||
}
|
||||
const data = await res.json() as {
|
||||
result: Array<{ name: string; expiration?: number; metadata?: unknown }>;
|
||||
};
|
||||
return data.result ?? [];
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`KV DELETE 失敗(${res.status}):${err.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
|
||||
export async function encryptCredential(value: string, encryptionKey: string): Promise<string> {
|
||||
// 若沒有設定 encryption key,使用 base64 作為 fallback(dev 模式)
|
||||
if (!encryptionKey || encryptionKey.length < 32) {
|
||||
const b64 = Buffer.from(value).toString('base64');
|
||||
return JSON.stringify({ encrypted: b64, iv: 'dev-mode', mode: 'base64' });
|
||||
}
|
||||
|
||||
const keyBytes = hexToUint8Array(encryptionKey);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt'],
|
||||
);
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(value);
|
||||
const cipherBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, encoded);
|
||||
|
||||
return JSON.stringify({
|
||||
encrypted: uint8ArrayToBase64(new Uint8Array(cipherBuffer)),
|
||||
iv: uint8ArrayToBase64(iv),
|
||||
});
|
||||
}
|
||||
|
||||
function hexToUint8Array(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function uint8ArrayToBase64(arr: Uint8Array): string {
|
||||
return Buffer.from(arr).toString('base64');
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* CLI 設定檔管理(~/.arcrun/config.yaml)
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
export interface ArcrunConfig {
|
||||
mode: 'standard' | 'self-hosted';
|
||||
// Standard 模式
|
||||
cloudflare_account_id?: string;
|
||||
user_kv_namespace_id?: string;
|
||||
cf_api_token?: string;
|
||||
api_key?: string; // arcrun.dev API Key(ak_前綴)
|
||||
// Self-hosted 模式
|
||||
cypher_executor_url?: string;
|
||||
credentials_kv_namespace_id?: string;
|
||||
webhooks_kv_namespace_id?: string;
|
||||
wasm_bucket?: string;
|
||||
// 共用
|
||||
multi_tenant?: boolean;
|
||||
}
|
||||
|
||||
const CONFIG_DIR = join(homedir(), '.arcrun');
|
||||
const CONFIG_PATH = join(CONFIG_DIR, 'config.yaml');
|
||||
|
||||
export function configExists(): boolean {
|
||||
return existsSync(CONFIG_PATH);
|
||||
}
|
||||
|
||||
export function loadConfig(): ArcrunConfig {
|
||||
if (!existsSync(CONFIG_PATH)) {
|
||||
throw new Error(
|
||||
'找不到 ~/.arcrun/config.yaml\n' +
|
||||
'請先執行:acr init'
|
||||
);
|
||||
}
|
||||
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
||||
return yaml.load(raw) as ArcrunConfig;
|
||||
}
|
||||
|
||||
export function saveConfig(config: ArcrunConfig): void {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
writeFileSync(CONFIG_PATH, yaml.dump(config), 'utf8');
|
||||
}
|
||||
|
||||
export function getCypherExecutorUrl(config: ArcrunConfig): string {
|
||||
if (config.mode === 'self-hosted' && config.cypher_executor_url) {
|
||||
return config.cypher_executor_url;
|
||||
}
|
||||
return 'https://cypher.arcrun.dev';
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* workflow.yaml 解析與三元組驗證
|
||||
*/
|
||||
import yaml from 'js-yaml';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export interface WorkflowYaml {
|
||||
name: string;
|
||||
description?: string;
|
||||
flow: string[];
|
||||
config?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface ParsedTriplet {
|
||||
subject: string;
|
||||
relation: string;
|
||||
object: string;
|
||||
}
|
||||
|
||||
/** 合法關係詞(拒絕 PIPE)*/
|
||||
const VALID_RELATIONS = new Set([
|
||||
'完成後', '失敗時', '對每個', '條件滿足時',
|
||||
'ON_SUCCESS', 'ON_FAIL', 'FOREACH', 'IF', 'ON_CLICK', 'CALLS_SUBFLOW',
|
||||
]);
|
||||
|
||||
const BANNED_RELATIONS = new Set(['PIPE']);
|
||||
|
||||
export function loadWorkflowYaml(filePath: string): WorkflowYaml {
|
||||
const raw = readFileSync(filePath, 'utf8');
|
||||
const doc = yaml.load(raw) as WorkflowYaml;
|
||||
|
||||
if (!doc.name) throw new Error('workflow.yaml 缺少 name 欄位');
|
||||
if (!Array.isArray(doc.flow) || doc.flow.length === 0) {
|
||||
throw new Error('workflow.yaml 的 flow 欄位必須為非空陣列');
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
export function parseTriplets(flow: string[]): ParsedTriplet[] {
|
||||
const triplets: ParsedTriplet[] = [];
|
||||
|
||||
for (const line of flow) {
|
||||
const parts = line.split('>>').map(s => s.trim());
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(
|
||||
`三元組格式錯誤:「${line}」\n` +
|
||||
`正確格式:「A >> 關係詞 >> B」`
|
||||
);
|
||||
}
|
||||
const [subject, relation, object] = parts;
|
||||
triplets.push({ subject, relation, object });
|
||||
}
|
||||
|
||||
return triplets;
|
||||
}
|
||||
|
||||
export function validateRelations(triplets: ParsedTriplet[]): void {
|
||||
for (const t of triplets) {
|
||||
if (BANNED_RELATIONS.has(t.relation)) {
|
||||
throw new Error(
|
||||
`不允許使用關係詞「${t.relation}」。\n` +
|
||||
`「PIPE」已棄用,請改用「完成後」或「ON_SUCCESS」。`
|
||||
);
|
||||
}
|
||||
if (!VALID_RELATIONS.has(t.relation)) {
|
||||
throw new Error(
|
||||
`未知關係詞「${t.relation}」。\n` +
|
||||
`合法關係詞:${[...VALID_RELATIONS].join('、')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeNames(triplets: ParsedTriplet[]): string[] {
|
||||
const nodes = new Set<string>();
|
||||
for (const t of triplets) {
|
||||
nodes.add(t.subject);
|
||||
nodes.add(t.object);
|
||||
}
|
||||
return [...nodes];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user