diff --git a/cli/package.json b/cli/package.json index 73a9692..761bea8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "arcrun", - "version": "1.0.2", + "version": "1.0.3", "description": "AI Workflow CLI for arcrun — deploy and run WASM-based AI workflows on Cloudflare", "bin": { "acr": "dist/index.js" diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index c15b1d0..0cd1881 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -9,7 +9,7 @@ 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'; +const ARCRUN_REGISTER_URL = 'https://cypher.arcrun.dev/register'; async function prompt(rl: ReturnType, question: string): Promise { const answer = await rl.question(chalk.cyan(`? ${question}: `)); @@ -35,21 +35,22 @@ export async function cmdInit(options: { local?: boolean; selfHosted?: boolean } } async function initLocal(): Promise { - console.log(chalk.gray(' Local 模式:不需要 Cloudflare 帳號,直接在本機跑 workflow\n')); + console.log(chalk.gray(' Local 模式:不需要 Cloudflare 帳號,workflow 由 arcrun.dev 雲端引擎執行\n')); const config: ArcrunConfig = { mode: 'local', }; saveConfig(config); - createCredentialsYamlIfMissing(); + createHelloYamlIfMissing(); - console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml(local 模式)\n')); + console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml(local 模式)')); + console.log(chalk.green(' ✓ 建立 hello.yaml 範例 workflow\n')); console.log(' 你可以立刻開始:'); - console.log(chalk.cyan(' acr validate workflow.yaml --offline') + ' # 驗證 workflow 格式'); - console.log(chalk.cyan(' acr run ') + ' # 本機執行\n'); - console.log(chalk.gray(' Local 模式不連線 arcrun.dev,所有 workflow 在本機執行。')); - console.log(chalk.gray(' 需要 Cloudflare 部署?執行 acr init(Standard 模式)。\n')); + console.log(chalk.cyan(' acr validate hello.yaml --offline') + ' # 驗證 workflow 格式'); + console.log(chalk.cyan(' acr run hello') + ' # 執行 hello workflow\n'); + console.log(chalk.gray(' Local 模式:YAML 留在本機,workflow 由 arcrun.dev 引擎執行。')); + console.log(chalk.gray(' 需要用自己的 CF 帳號存放 credentials?執行 acr init(Standard 模式)。\n')); } async function initStandard(rl: ReturnType): Promise { @@ -79,7 +80,7 @@ async function initStandard(rl: ReturnType): Promise): Promise> ON_SUCCESS >> ai_reply"\n' + + ' - "ai_reply >> ON_SUCCESS >> log_output"\n\n' + + 'config:\n' + + ' ai_reply:\n' + + ' component: "component://cmp_openai_chat"\n' + + ' model: "gpt-4o-mini"\n' + + ' prompt: "請用繁體中文說 Hello World,並解釋 arcrun 是什麼"\n' + + ' log_output:\n' + + ' component: "component://cmp_log_stdout"\n', + 'utf8' + ); + } +} + function createCredentialsYamlIfMissing(): void { const credPath = join(process.cwd(), 'credentials.yaml'); if (!existsSync(credPath)) { diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index ff38908..db77fb0 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -1,10 +1,19 @@ /** * acr run [--input key=value...] - * 觸發 cypher-executor 執行指定 workflow + * + * 玩法一(Standard / Local): + * 在本機找 .yaml,解析 triplets + config, + * 直接 POST /cypher/execute 給 cypher.arcrun.dev 執行。 + * YAML 不存在 KV,每次執行都帶著走。 + * + * 玩法二(Self-hosted,workflow 已 push 到 KV): + * POST /webhooks/,executor 從 WEBHOOKS KV 讀取定義執行。 */ import chalk from 'chalk'; import ora from 'ora'; +import { existsSync, readFileSync } from 'node:fs'; import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; +import { loadWorkflowYaml, parseTriplets } from '../lib/yaml-parser.js'; interface RunOptions { input?: string[]; @@ -25,15 +34,63 @@ export async function cmdRun(workflowName: string, options: RunOptions): Promise inputContext[pair.slice(0, idx)] = pair.slice(idx + 1); } - const spinner = ora(`執行 workflow "${workflowName}"`).start(); - const headers: Record = { 'Content-Type': 'application/json' }; if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key; - const webhookUrl = `${executorUrl}/webhooks/${workflowName}`; + // ── 玩法一:Standard 模式,YAML 在本機,帶著打 /cypher/execute ────────────── + if (config.mode === 'standard' || config.mode === 'local') { + const yamlPath = findWorkflowYaml(workflowName); + if (!yamlPath) { + console.error(chalk.red(`找不到 ${workflowName}.yaml(在目前目錄或子目錄尋找)`)); + console.error(chalk.gray('玩法二(已 push 到 KV)請改用 Self-hosted 模式')); + process.exit(1); + } + let workflow; + try { + workflow = loadWorkflowYaml(yamlPath); + } catch (e) { + console.error(chalk.red(`YAML 解析失敗:${e instanceof Error ? e.message : e}`)); + process.exit(1); + } + + const triplets = parseTriplets(workflow.flow); + + const spinner = ora(`執行 workflow "${workflow.name}"`).start(); + + try { + const res = await fetch(`${executorUrl}/cypher/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + triplets: workflow.flow, + context: { ...inputContext, ...(workflow.config ?? {}) }, + graph_id: workflow.name, + graph_name: workflow.name, + }), + }); + + 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; + }; + + printResult(spinner, data, triplets.length); + } catch (e) { + spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`)); + process.exit(1); + } + return; + } + + // ── 玩法二:Self-hosted,workflow 已存在 KV,打 /webhooks/{name} ───────────── + const spinner = ora(`執行 workflow "${workflowName}"`).start(); try { - const res = await fetch(webhookUrl, { + const res = await fetch(`${executorUrl}/webhooks/${workflowName}`, { method: 'POST', headers, body: JSON.stringify(inputContext), @@ -48,28 +105,49 @@ export async function cmdRun(workflowName: string, options: RunOptions): Promise 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}` : ''}`); - } - } - } + printResult(spinner, data, 0); } catch (e) { spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`)); process.exit(1); } } + +// ── helpers ────────────────────────────────────────────────────────────────── + +function findWorkflowYaml(name: string): string | null { + const candidates = [ + `${name}.yaml`, + `${name}.yml`, + `workflows/${name}.yaml`, + `workflows/${name}.yml`, + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + return null; +} + +function printResult( + spinner: ReturnType, + data: { success: boolean; data?: unknown; error?: string; trace?: Array<{ node: string; status: string; error?: string }>; duration_ms: number; failed_node?: string }, + _nodeCount: number, +): void { + if (data.success) { + spinner.succeed(chalk.green(`✓ 執行成功(${data.duration_ms}ms)`)); + if (data.data !== undefined && data.data !== null) { + 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}` : ''}`); + } + } + } +} diff --git a/cypher-executor/src/index.ts b/cypher-executor/src/index.ts index 75ca2b8..bfd7bdc 100644 --- a/cypher-executor/src/index.ts +++ b/cypher-executor/src/index.ts @@ -10,6 +10,7 @@ import { docsRouter } from './routes/docs'; import { webhooksRouter } from './routes/webhooks'; import { webhooksCrudRouter } from './routes/webhooks-crud'; import { webhooksListRouter } from './routes/webhooks-list'; +import { registerRouter } from './routes/register'; const app = new Hono<{ Bindings: Bindings }>(); @@ -25,6 +26,7 @@ app.route('/', validateRouter); app.route('/', webhooksRouter); app.route('/', webhooksCrudRouter); app.route('/', webhooksListRouter); +app.route('/', registerRouter); // Worker 導出 export default app; diff --git a/cypher-executor/src/routes/register.ts b/cypher-executor/src/routes/register.ts new file mode 100644 index 0000000..42b9497 --- /dev/null +++ b/cypher-executor/src/routes/register.ts @@ -0,0 +1,45 @@ +// POST /register — API Key 發放 +// email → HMAC-SHA256(email, ENCRYPTION_KEY) → api_key (ak_ 前綴) +// 同一個 email 永遠得到相同的 Key,無需資料庫 + +import { Hono } from 'hono'; +import type { Bindings } from '../types'; + +export const registerRouter = new Hono<{ Bindings: Bindings }>(); + +registerRouter.post('/register', async (c) => { + let email: string; + try { + const body = await c.req.json() as { email?: string }; + email = (body.email ?? '').trim().toLowerCase(); + } catch { + return c.json({ success: false, error: 'request body 必須為 JSON' }, 400); + } + + if (!email || !email.includes('@')) { + return c.json({ success: false, error: 'email 格式不正確' }, 400); + } + + const encryptionKey = c.env.ENCRYPTION_KEY; + if (!encryptionKey || encryptionKey.length < 32) { + return c.json({ success: false, error: 'server configuration error' }, 500); + } + + // HMAC-SHA256(email, ENCRYPTION_KEY) → hex → 取前 32 字元 → ak_ 前綴 + const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32)); + const msgData = new TextEncoder().encode(email); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] + ); + const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData); + const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join(''); + const apiKey = 'ak_' + hex.slice(0, 32); + + return c.json({ + success: true, + api_key: apiKey, + email, + message: 'API Key 已發放,請妥善保存。相同 email 永遠得到相同的 Key。', + }); +});