diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..d287621 --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,338 @@ +{ + "name": "arcrun", + "version": "1.0.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "arcrun", + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^12.0.0", + "js-yaml": "^4.1.0", + "ora": "^8.0.1" + }, + "bin": { + "acr": "dist/index.js" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.0.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/cli/package.json b/cli/package.json index 761bea8..345dfe8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "arcrun", - "version": "1.0.3", + "version": "1.1.0", "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/auth-recipe.ts b/cli/src/commands/auth-recipe.ts new file mode 100644 index 0000000..2212a42 --- /dev/null +++ b/cli/src/commands/auth-recipe.ts @@ -0,0 +1,219 @@ +/** + * acr auth-recipe — 第三方服務認證 Recipe 管理 + * + * acr auth-recipe list 列出所有平台預建的 auth recipe(服務清單) + * acr auth-recipe info 顯示某服務的 recipe 詳情 + * acr auth-recipe scaffold 輸出 credentials.yaml 範本 + workflow 使用範例 + */ + +import chalk from 'chalk'; +import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; + +interface SecretRequirement { + key: string; + label: string; + type?: 'string' | 'json_blob'; + help?: string; + help_url?: string; + optional?: boolean; +} + +interface AuthInjectSpec { + header?: Record; + query?: Record; + body?: Record; +} + +interface AuthRecipe { + kind: 'auth_recipe'; + service: string; + version: number; + primitive: string; + base_url: string; + display_name?: string; + description?: string; + service_account_kind?: string; + token_exchange?: { endpoint: string; scopes: string[] }; + required_secrets: SecretRequirement[]; + inject: AuthInjectSpec; +} + +async function fetchAuthRecipes(baseUrl: string): Promise { + const res = await fetch(`${baseUrl}/auth-recipes`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json() as { recipes: AuthRecipe[] }; + return data.recipes ?? []; +} + +async function fetchAuthRecipe(baseUrl: string, service: string): Promise { + const res = await fetch(`${baseUrl}/auth-recipes/${service}`); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json() as { recipe: AuthRecipe }; + return data.recipe ?? null; +} + +/** 輸出 credentials.yaml 範本 */ +function buildCredentialsYaml(recipe: AuthRecipe): string { + const lines: string[] = [ + `# credentials.yaml — ${recipe.display_name ?? recipe.service}`, + `# 填入後執行:acr creds push`, + ``, + ]; + for (const s of recipe.required_secrets) { + if (s.help) lines.push(`# ${s.label}`); + if (s.help) lines.push(`# ${s.help}`); + if (s.help_url) lines.push(`# 說明文件:${s.help_url}`); + if (s.type === 'json_blob') { + lines.push(`${s.key}: |`); + lines.push(` {`); + lines.push(` "type": "service_account",`); + lines.push(` ...`); + lines.push(` }`); + } else { + lines.push(`${s.key}: ""`); + } + lines.push(``); + } + return lines.join('\n'); +} + +/** 輸出 workflow.yaml config 範例 */ +function buildWorkflowExample(recipe: AuthRecipe): string { + const lines: string[] = [ + `# workflow.yaml — ${recipe.display_name ?? recipe.service} 使用範例`, + ``, + `name: my_workflow`, + `triplets:`, + ` - "input >> PIPE >> ${recipe.service}_call"`, + ` - "${recipe.service}_call >> PIPE >> output"`, + ``, + `config:`, + ` ${recipe.service}_call:`, + ` component: ${recipe.service}`, + ` method: POST`, + ` _path: /your-endpoint-path`, + ` # 其他參數依 API 文件填入`, + ]; + return lines.join('\n'); +} + +export async function cmdAuthRecipeList(): Promise { + const config = loadConfig(); + const baseUrl = getCypherExecutorUrl(config); + + try { + const recipes = await fetchAuthRecipes(baseUrl); + + if (recipes.length === 0) { + console.log(chalk.yellow(' 尚無 auth recipe。')); + return; + } + + // 依 primitive 分組 + const staticKey = recipes.filter(r => r.primitive === 'static_key'); + const serviceAccount = recipes.filter(r => r.primitive === 'service_account'); + const others = recipes.filter(r => r.primitive !== 'static_key' && r.primitive !== 'service_account'); + + console.log(chalk.bold(`\n 第三方服務整合(${recipes.length} 個)\n`)); + + if (staticKey.length) { + console.log(chalk.gray(' API Key / Token 類:')); + for (const r of staticKey) { + const name = (r.display_name ?? r.service).padEnd(30); + const secrets = r.required_secrets.map(s => s.key).join(', '); + console.log(` ${chalk.cyan(r.service.padEnd(22))} ${name} ${chalk.gray(`需要: ${secrets}`)}`); + } + console.log(); + } + + if (serviceAccount.length) { + console.log(chalk.gray(' Service Account / JWT 類:')); + for (const r of serviceAccount) { + const name = (r.display_name ?? r.service).padEnd(30); + const secrets = r.required_secrets.map(s => s.key).join(', '); + console.log(` ${chalk.cyan(r.service.padEnd(22))} ${name} ${chalk.gray(`需要: ${secrets}`)}`); + } + console.log(); + } + + if (others.length) { + console.log(chalk.gray(' 其他:')); + for (const r of others) { + console.log(` ${chalk.cyan(r.service.padEnd(22))} ${r.display_name ?? r.service}`); + } + console.log(); + } + + console.log(chalk.gray(` 使用 acr auth-recipe scaffold 查看設定範本\n`)); + } catch (e) { + console.error(chalk.red(` 無法取得 auth recipe 清單:${e instanceof Error ? e.message : e}`)); + process.exit(1); + } +} + +export async function cmdAuthRecipeInfo(service: string): Promise { + const config = loadConfig(); + const baseUrl = getCypherExecutorUrl(config); + + try { + const recipe = await fetchAuthRecipe(baseUrl, service); + if (!recipe) { + console.error(chalk.red(` 找不到 auth recipe:${service}`)); + console.log(chalk.gray(` 執行 acr auth-recipe list 查看所有可用服務`)); + process.exit(1); + } + + console.log(chalk.bold(`\n ${recipe.display_name ?? recipe.service}\n`)); + if (recipe.description) console.log(` ${recipe.description}\n`); + console.log(` service: ${recipe.service}`); + console.log(` primitive: ${recipe.primitive}`); + console.log(` base_url: ${recipe.base_url}`); + if (recipe.token_exchange) { + console.log(` scopes: ${recipe.token_exchange.scopes.join(', ')}`); + } + console.log(`\n ${chalk.bold('需要的 credentials:')}`); + for (const s of recipe.required_secrets) { + console.log(` ${chalk.cyan(s.key)}${s.optional ? chalk.gray(' (optional)') : ''}`); + console.log(` ${s.label}`); + if (s.help) console.log(chalk.gray(` ${s.help}`)); + if (s.help_url) console.log(chalk.gray(` → ${s.help_url}`)); + } + console.log(); + } catch (e) { + console.error(chalk.red(` 錯誤:${e instanceof Error ? e.message : e}`)); + process.exit(1); + } +} + +export async function cmdAuthRecipeScaffold(service: string): Promise { + const config = loadConfig(); + const baseUrl = getCypherExecutorUrl(config); + + try { + const recipe = await fetchAuthRecipe(baseUrl, service); + if (!recipe) { + console.error(chalk.red(` 找不到 auth recipe:${service}`)); + console.log(chalk.gray(` 執行 acr auth-recipe list 查看所有可用服務`)); + process.exit(1); + } + + console.log(chalk.bold(`\n ── credentials.yaml 範本 ────────────────────────────────\n`)); + console.log(buildCredentialsYaml(recipe)); + + console.log(chalk.bold(`\n ── workflow.yaml 範例 ───────────────────────────────────\n`)); + console.log(buildWorkflowExample(recipe)); + console.log(); + + if (recipe.primitive === 'service_account') { + console.log(chalk.yellow( + ` 注意(Service Account):\n` + + ` 將整份 Service Account JSON 貼入 credentials.yaml 的 ${recipe.required_secrets[0].key} 欄位。\n` + + ` 格式:多行 YAML literal block(| 後換行縮排)。\n` + )); + } + } catch (e) { + console.error(chalk.red(` 錯誤:${e instanceof Error ? e.message : e}`)); + process.exit(1); + } +} diff --git a/cli/src/commands/creds.ts b/cli/src/commands/creds.ts index 5068220..4b68f17 100644 --- a/cli/src/commands/creds.ts +++ b/cli/src/commands/creds.ts @@ -1,20 +1,54 @@ /** * acr creds push [credentials.yaml] - * 讀取 credentials.yaml,加密後上傳至用戶自己的 CF KV(key 格式:cred:{name}) - * 不經過 arcrun.dev + * + * 讀取 credentials.yaml,以 ENCRYPTION_KEY 加密後 POST 至 cypher.arcrun.dev/credentials。 + * Server 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV(多租戶隔離)。 + * + * 不再需要用戶提供 CF API Token 或 KV Namespace ID。 */ 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'; +import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; + +async function encryptValue(value: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> { + const keyBytes = hexToUint8Array(encryptionKey); + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes.buffer as ArrayBuffer, + { name: 'AES-GCM' }, + false, + ['encrypt'], + ); + + const ivBytes = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(value); + const cipherBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivBytes }, cryptoKey, encoded); + + return { + encrypted: Buffer.from(new Uint8Array(cipherBuffer)).toString('base64'), + iv: Buffer.from(ivBytes).toString('base64'), + }; +} + +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; +} export async function cmdCredsPush(filePath: string): Promise { const config = loadConfig(); - if (!config.cloudflare_account_id || !config.cf_api_token) { - console.error(chalk.red('缺少 Cloudflare 設定,請執行 acr init')); + if (config.mode === 'local') { + console.error(chalk.red('Local 模式不支援 acr creds push。')); + console.log(chalk.gray('請先執行 acr init 設定 Standard 模式,取得 API Key。')); + process.exit(1); + } + + if (!config.api_key) { + console.error(chalk.red('缺少 api_key,請重新執行 acr init。')); process.exit(1); } @@ -34,37 +68,42 @@ export async function cmdCredsPush(filePath: string): Promise { 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')); + // 加密金鑰:優先從 config 讀(acr init 時自動寫入),其次從環境變數 + const encryptionKey = config.encryption_key ?? process.env.ARCRUN_ENCRYPTION_KEY ?? ''; + if (!encryptionKey || encryptionKey.length < 64) { + console.error(chalk.red( + '缺少 encryption_key。請重新執行 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`)); + const baseUrl = getCypherExecutorUrl(config); + console.log(chalk.bold(`\n 上傳 ${entries.length} 個 credentials 至 ${baseUrl}\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`)); + const { encrypted, iv } = await encryptValue(String(value), encryptionKey); + + const res = await fetch(`${baseUrl}/credentials`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Arcrun-API-Key': config.api_key!, + }, + body: JSON.stringify({ name, encrypted, iv }), + }); + + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new Error(`HTTP ${res.status}: ${errBody.slice(0, 200)}`); + } + + spinner.succeed(chalk.green(` ✓ ${name}`)); } catch (e) { spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`)); } } - console.log(chalk.gray('\n 你的 credential 存在你自己的 CF KV,arcrun 不會儲存它們。\n')); + console.log(chalk.gray('\n Credential 已加密儲存。執行 workflow 時會自動注入,無需在 --input 手動帶 token。\n')); } diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 0cd1881..7160fed 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -47,70 +47,59 @@ async function initLocal(): Promise { 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 hello.yaml --offline') + ' # 驗證 workflow 格式'); - console.log(chalk.cyan(' acr run hello') + ' # 執行 hello workflow\n'); + console.log(chalk.cyan(' acr validate hello.yaml --offline') + ' # 驗證 workflow 格式'); + console.log(chalk.cyan(' acr run hello --input input="Hello, arcrun!"') + ' # 執行,輸出大寫字串\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 { - console.log(chalk.gray(' Standard 模式:使用 arcrun.dev 的執行引擎,credential 存在你自己的 CF KV\n')); + console.log(chalk.gray(' Standard 模式:只需要 email,不需要 Cloudflare 帳號\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)'); + const email = await prompt(rl, 'Email(用來取得 API Key)'); process.stdout.write(chalk.gray('\n → 向 arcrun.dev 取得 API Key...')); - let apiKey: string; + let apiKey = ''; + let encryptionKey = ''; try { const res = await fetch(ARCRUN_REGISTER_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), // CF API Token 永遠不離開本機 + body: JSON.stringify({ email }), }); if (!res.ok) { const err = await res.text(); - throw new Error(`API Key 取得失敗(${res.status}):${err}`); + throw new Error(`取得失敗(${res.status}):${err}`); } - const data = await res.json() as { api_key: string }; + const data = await res.json() as { api_key: string; encryption_key: string }; apiKey = data.api_key; + encryptionKey = data.encryption_key; console.log(chalk.green(' ✓')); } catch (e) { - console.log(chalk.yellow(' ✗(離線模式,請稍後執行 acr init 重試)')); - apiKey = ''; + console.log(chalk.yellow(` ✗ ${e instanceof Error ? e.message : e}`)); + console.log(chalk.yellow(' 請確認網路連線後重新執行 acr init\n')); + process.exit(1); } const config: ArcrunConfig = { mode: 'standard', - cloudflare_account_id: accountId, - user_kv_namespace_id: kvNamespaceId, - cf_api_token: cfApiToken, api_key: apiKey, + encryption_key: encryptionKey, }; 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(` ✓ 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 parts scaffold ') + ' # 查看零件 config 範本'); 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\n'); + console.log(chalk.cyan(' acr push workflow.yaml') + ' # 部署 workflow 並取得 Webhook URL\n'); } async function initSelfHosted(rl: ReturnType): Promise { @@ -146,19 +135,15 @@ function createHelloYamlIfMissing(): void { if (!existsSync(helloPath)) { writeFileSync(helloPath, '# arcrun hello world workflow\n' + - '# 執行:acr run hello\n\n' + + '# 執行:acr run hello --input input="Hello, arcrun!"\n\n' + 'name: hello\n' + - 'description: "Hello world — 示範如何讓 AI 處理訊息後傳送通知"\n\n' + + 'description: "Hello world — 示範字串轉大寫"\n\n' + 'flow:\n' + - ' - "input >> ON_SUCCESS >> ai_reply"\n' + - ' - "ai_reply >> ON_SUCCESS >> log_output"\n\n' + + ' - "input >> ON_SUCCESS >> transform"\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', + ' transform:\n' + + ' component: string_ops\n' + + ' operation: upper\n', 'utf8' ); } diff --git a/cli/src/commands/parts.ts b/cli/src/commands/parts.ts index 31ef90d..6bdf54d 100644 --- a/cli/src/commands/parts.ts +++ b/cli/src/commands/parts.ts @@ -1,149 +1,397 @@ /** - * acr parts — 列出所有可用零件(按類型分組,含統計與 author) - * acr parts scaffold — 輸出 config 範本 - * acr parts publish — 提交零件至公眾 registry + * acr parts — 列出所有可用零件(內建清單,不依賴 registry.arcrun.dev) + * acr parts scaffold — 輸出 config 範本(可直接貼入 workflow.yaml) + * acr parts publish — 提交零件至公眾 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 { - 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 = {}; - for (const comp of components) { - const cat = comp.category ?? 'other'; - if (!grouped[cat]) grouped[cat] = []; - grouped[cat].push(comp); - } - const categoryLabels: Record = { - api: '整合類(Integration)', logic: '控制類(Control Flow)', data: '資料類(Data)', + api: '整合類(API / Integration)', ai: 'AI 類', - other: '其他', }; - console.log(chalk.bold('\n arcrun 零件庫\n')); + const grouped: Record = {}; + 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 取得 config 範本')); - console.log(chalk.gray(' 使用 acr parts publish 提交零件至公眾庫\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 { - // 優先從本地 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 { + 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; -} diff --git a/cli/src/commands/push.ts b/cli/src/commands/push.ts index 535444d..de64458 100644 --- a/cli/src/commands/push.ts +++ b/cli/src/commands/push.ts @@ -1,21 +1,36 @@ /** * acr push - * 解析三元組,轉成執行圖,直接寫入用戶的 USER_KV(key = workflow:{name}) + * + * 解析 workflow.yaml,透過 /cypher/search 取得執行圖, + * 然後 POST 至 cypher.arcrun.dev/webhooks/named(帶 X-Arcrun-API-Key)。 + * Server 以 {api_key}:wf:{name} 為 KV key 存入 WEBHOOKS KV。 + * + * 不再需要用戶的 CF API Token 或 KV Namespace ID。 */ 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 { const config = loadConfig(); + if (config.mode === 'local') { + console.error(chalk.red('Local 模式不支援 acr push(Webhook 部署需要 Standard 模式)。')); + console.log(chalk.gray('請執行 acr init 取得 API Key,或直接用 acr run 本機測試。')); + process.exit(1); + } + + if (!config.api_key) { + console.error(chalk.red('缺少 api_key,請重新執行 acr init。')); + process.exit(1); + } + + // 解析 YAML 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} 條三元組)`); @@ -24,16 +39,16 @@ export async function cmdPush(filePath: string): Promise { process.exit(1); } - // POST 到 cypher-executor 取得執行圖 const executorUrl = getCypherExecutorUrl(config); - const triplets = parseTriplets(workflow.flow); - const searchSpinner = ora(`向 ${executorUrl} 解析執行圖`).start(); + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Arcrun-API-Key': config.api_key, + }; + // 向 /cypher/search 取得執行圖 + const searchSpinner = ora('取得執行圖').start(); let graph: unknown; try { - const headers: Record = { '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, @@ -46,56 +61,71 @@ export async function cmdPush(filePath: string): Promise { process.exit(1); } - const data = await res.json() as { cypher: unknown; missing: string[] }; - if (data.missing.length > 0) { + const data = await res.json() as { cypher: { nodes: unknown[]; edges: unknown[] }; missing: string[] }; + if (data.missing?.length > 0) { searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`)); process.exit(1); } - graph = data.cypher; + // 附上 id / name,並將 workflow.config 套入節點(componentId + data) + const rawGraph = data.cypher as { nodes: Array<{ id: string; componentId?: string; data?: Record }>; edges: unknown[] }; + const cfg = (workflow.config ?? {}) as Record>; + + const nodes = rawGraph.nodes.map(node => { + const nodeCfg = cfg[node.id]; + if (!nodeCfg) return node; + const { component, ...params } = nodeCfg; + return { + ...node, + componentId: typeof component === 'string' ? component : node.componentId, + data: Object.keys(params).length > 0 ? { ...(node.data ?? {}), ...params } : node.data, + }; + }); + + graph = { id: workflow.name, name: workflow.name, nodes, edges: rawGraph.edges }; 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(); + // POST 至 /webhooks/named + const deploySpinner = ora(`部署 "${workflow.name}" 至 ${executorUrl}`).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`)); + const res = await fetch(`${executorUrl}/webhooks/named`, { + method: 'POST', + headers, + body: JSON.stringify({ + name: workflow.name, + graph, + config: workflow.config ?? {}, + description: workflow.description ?? '', + }), + }); + + if (!res.ok) { + const err = await res.text(); + deploySpinner.fail(chalk.red(`部署失敗(${res.status}):${err.slice(0, 200)}`)); + process.exit(1); + } + + const data = await res.json() as { name: string; webhook_url: string; created_at: string }; + deploySpinner.succeed(chalk.green(`✓ "${workflow.name}" 已部署`)); + + console.log(chalk.bold(`\n Webhook URL:${chalk.cyan(data.webhook_url)}`)); + console.log(chalk.gray(` 需帶 Header:X-Arcrun-API-Key: ${config.api_key.slice(0, 8)}...`)); + console.log(''); + console.log(chalk.gray(' 測試執行:')); + console.log(` ${chalk.cyan(`acr run ${workflow.name}`)}`); + console.log(''); + console.log(chalk.gray(' curl 觸發範例:')); + console.log(` ${chalk.cyan(`curl -X POST ${data.webhook_url} \\`)}`); + console.log(` ${chalk.cyan(` -H 'X-Arcrun-API-Key: ${config.api_key}' \\`)}`); + console.log(` ${chalk.cyan(` -H 'Content-Type: application/json' \\`)}`); + console.log(` ${chalk.cyan(` -d '{"key": "value"}'`)}`); + console.log(''); } catch (e) { - kvSpinner.fail(chalk.red(`KV 寫入失敗:${e instanceof Error ? e.message : e}`)); + deploySpinner.fail(chalk.red(`部署失敗:${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`); } diff --git a/cli/src/commands/recipe.ts b/cli/src/commands/recipe.ts new file mode 100644 index 0000000..2e8d9ea --- /dev/null +++ b/cli/src/commands/recipe.ts @@ -0,0 +1,174 @@ +/** + * acr recipe push — 上傳 recipe YAML 到 arcrun.dev + * acr recipe list — 列出已上傳的 recipe + * acr recipe delete — 刪除 recipe(canonical_id 或 rec_hash) + */ +import chalk from 'chalk'; +import ora from 'ora'; +import { readFileSync, existsSync } from 'node:fs'; +import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; +import yaml from 'js-yaml'; + +interface RecipeYaml { + canonical_id?: string; + display_name?: string; + description?: string; + endpoint?: string; + method?: string; + headers?: Record; + body?: Record; + credentials_required?: Array<{ key: string; inject_as: string }>; +} + +interface RecipeDefinition { + canonical_id: string; + hash_id: string; + display_name?: string; + description?: string; + endpoint: string; + method?: string; + credentials_required?: Array<{ key: string; inject_as: string }>; + created_at: number; + updated_at: number; +} + +export async function cmdRecipePush(filePath: string): Promise { + const config = loadConfig(); + + if (!config.api_key) { + console.error(chalk.red('缺少 API Key,請先執行 acr init 取得 API Key')); + process.exit(1); + } + + if (!existsSync(filePath)) { + console.error(chalk.red(`找不到檔案:${filePath}`)); + process.exit(1); + } + + // 讀取並解析 YAML + let recipe: RecipeYaml; + try { + const raw = readFileSync(filePath, 'utf8'); + recipe = yaml.load(raw) as RecipeYaml; + } catch (e) { + console.error(chalk.red(`YAML 解析失敗:${e instanceof Error ? e.message : e}`)); + process.exit(1); + } + + if (!recipe.canonical_id) { + console.error(chalk.red('recipe YAML 缺少 canonical_id 欄位')); + process.exit(1); + } + if (!recipe.endpoint) { + console.error(chalk.red('recipe YAML 缺少 endpoint 欄位')); + process.exit(1); + } + + const executorUrl = getCypherExecutorUrl(config); + const spinner = ora(`上傳 recipe "${recipe.canonical_id}"`).start(); + + try { + const res = await fetch(`${executorUrl}/recipes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Arcrun-API-Key': config.api_key, + }, + body: JSON.stringify(recipe), + }); + + const data = await res.json() as { success: boolean; recipe?: RecipeDefinition; error?: string }; + + if (!data.success || !data.recipe) { + spinner.fail(chalk.red(`上傳失敗:${data.error ?? '未知錯誤'}`)); + process.exit(1); + } + + spinner.succeed(chalk.green(`✓ recipe "${data.recipe.canonical_id}" 上傳成功`)); + console.log(`\n Hash ID:${chalk.cyan(data.recipe.hash_id)} (穩定引用,不受改名影響)`); + console.log(` Endpoint:${chalk.gray(data.recipe.endpoint)}`); + console.log(chalk.bold('\n 在 workflow config 中使用:\n')); + console.log(chalk.cyan(` config:`)); + console.log(chalk.cyan(` my_node:`)); + console.log(chalk.cyan(` component: ${data.recipe.canonical_id} # 或用 hash: ${data.recipe.hash_id}`)); + if (data.recipe.credentials_required?.length) { + console.log(chalk.yellow(`\n 此 recipe 需要 credentials:${data.recipe.credentials_required.map(c => c.key).join(', ')}`)); + console.log(chalk.gray(' 執行 acr creds push 上傳 token')); + } + console.log(''); + } catch (e) { + spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`)); + process.exit(1); + } +} + +export async function cmdRecipeList(): Promise { + const config = loadConfig(); + const executorUrl = getCypherExecutorUrl(config); + const spinner = ora('取得 recipe 清單').start(); + + try { + const headers: Record = {}; + if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key; + + const res = await fetch(`${executorUrl}/recipes`, { headers }); + const data = await res.json() as { success: boolean; recipes?: RecipeDefinition[]; error?: string }; + + spinner.stop(); + + if (!data.success) { + console.error(chalk.red(`錯誤:${data.error}`)); + process.exit(1); + } + + const recipes = data.recipes ?? []; + if (recipes.length === 0) { + console.log(chalk.gray('\n 尚無 recipe。執行 acr recipe push 上傳。\n')); + return; + } + + console.log(chalk.bold(`\n arcrun recipes(${recipes.length} 個)\n`)); + for (const r of recipes) { + console.log(` • ${chalk.cyan(r.canonical_id.padEnd(20))} ${chalk.gray(r.hash_id)} ${r.display_name ?? ''}`); + console.log(` ${chalk.gray(r.endpoint)}`); + if (r.credentials_required?.length) { + console.log(` ${chalk.yellow('🔑 需要:' + r.credentials_required.map(c => c.key).join(', '))}`); + } + } + console.log(''); + } catch (e) { + spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`)); + process.exit(1); + } +} + +export async function cmdRecipeDelete(id: string): Promise { + const config = loadConfig(); + + if (!config.api_key) { + console.error(chalk.red('缺少 API Key,請先執行 acr init')); + process.exit(1); + } + + const executorUrl = getCypherExecutorUrl(config); + const spinner = ora(`刪除 recipe "${id}"`).start(); + + try { + const res = await fetch(`${executorUrl}/recipes/${id}`, { + method: 'DELETE', + headers: { 'X-Arcrun-API-Key': config.api_key }, + }); + + const data = await res.json() as { success: boolean; deleted?: string; error?: string }; + + if (!data.success) { + spinner.fail(chalk.red(`刪除失敗:${data.error ?? '未知錯誤'}`)); + process.exit(1); + } + + spinner.succeed(chalk.green(`✓ recipe "${data.deleted}" 已刪除`)); + } catch (e) { + spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`)); + process.exit(1); + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts index df2d578..90d501e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -13,15 +13,17 @@ 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 { cmdRecipePush, cmdRecipeList, cmdRecipeDelete } from './commands/recipe.js'; import { cmdList } from './commands/list.js'; import { cmdLogs } from './commands/logs.js'; +import { cmdAuthRecipeList, cmdAuthRecipeInfo, cmdAuthRecipeScaffold } from './commands/auth-recipe.js'; const program = new Command(); program .name('acr') .description('arcrun — AI Workflow CLI for Cloudflare Workers + WASM') - .version('1.0.0'); + .version('1.1.0'); // acr init [--self-hosted] program @@ -76,6 +78,36 @@ partsCmd .option('--status ', '查詢提交審核進度') .action((dir: string, options: { status?: string }) => cmdPartsPublish(dir, options)); +// acr recipe push / list / delete +const recipeCmd = program.command('recipe').description('API Recipe 管理'); +recipeCmd + .command('push ') + .description('上傳 recipe YAML 到 arcrun.dev(不需要 deploy Worker)') + .action((file: string) => cmdRecipePush(file)); +recipeCmd + .command('list') + .description('列出已上傳的 recipe') + .action(() => cmdRecipeList()); +recipeCmd + .command('delete ') + .description('刪除 recipe(canonical_id 或 rec_hash)') + .action((id: string) => cmdRecipeDelete(id)); + +// acr auth-recipe list / info / scaffold +const authRecipeCmd = program.command('auth-recipe').description('第三方服務認證 Recipe(新增服務整合)'); +authRecipeCmd + .command('list') + .description('列出所有平台預建的服務整合(Notion、Slack、GitHub 等)') + .action(() => cmdAuthRecipeList()); +authRecipeCmd + .command('info ') + .description('顯示服務 recipe 詳情(需要哪些 credential)') + .action((service: string) => cmdAuthRecipeInfo(service)); +authRecipeCmd + .command('scaffold ') + .description('輸出 credentials.yaml 範本 + workflow.yaml 使用範例') + .action((service: string) => cmdAuthRecipeScaffold(service)); + // acr list program .command('list') diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 1095ef1..ba1bc59 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -9,11 +9,12 @@ import yaml from 'js-yaml'; export interface ArcrunConfig { mode: 'local' | 'standard' | 'self-hosted'; // Standard 模式 + api_key?: string; // arcrun.dev API Key(ak_前綴) + encryption_key?: string; // AES-GCM key,與 cypher-executor ENCRYPTION_KEY secret 一致 + // Self-hosted 模式 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;