arcrun — AI workflow execution engine (clean history)
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。 此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在 richblack/arcrun 與本地 backup 分支)。含: - acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe) - recipe push 把關(資料外流提醒 + 打通檢查) - 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1) - CLI / cypher-executor / registry / 完整 SDD Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Generated
+338
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "arcrun",
|
||||
"version": "1.1.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 && chmod +x dist/index.js"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"keywords": [
|
||||
"cloudflare",
|
||||
"workers",
|
||||
"wasm",
|
||||
"workflow",
|
||||
"ai",
|
||||
"arcrun"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/richblack/arcrun.git"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* acr auth-recipe — 第三方服務認證 Recipe 管理
|
||||
*
|
||||
* acr auth-recipe list 列出所有平台預建的 auth recipe(服務清單)
|
||||
* acr auth-recipe info <service> 顯示某服務的 recipe 詳情
|
||||
* acr auth-recipe scaffold <service> 輸出 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<string, string>;
|
||||
query?: Record<string, string>;
|
||||
body?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<AuthRecipe[]> {
|
||||
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<AuthRecipe | null> {
|
||||
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<void> {
|
||||
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 <service> 查看設定範本\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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* acr creds push [credentials.yaml]
|
||||
*
|
||||
* 讀取 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, 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<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 讀取 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;
|
||||
}
|
||||
|
||||
// 加密金鑰:優先從 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 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, 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 已加密儲存。執行 workflow 時會自動注入,無需在 --input 手動帶 token。\n'));
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 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, readFileSync, appendFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import chalk from 'chalk';
|
||||
import { saveConfig, type ArcrunConfig } from '../lib/config.js';
|
||||
import { CfAccountClient } from '../lib/cf-api.js';
|
||||
import {
|
||||
REQUIRED_KV_NAMESPACES,
|
||||
REQUIRED_R2_BUCKET,
|
||||
SECRET_TARGET_WORKERS,
|
||||
wranglerAvailable,
|
||||
downloadAndDeploy,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
import { API_RECIPE_SEEDS } from '../lib/api-recipe-seeds.js';
|
||||
|
||||
const ARCRUN_REGISTER_URL = 'https://cypher.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: { local?: boolean; selfHosted?: boolean }): Promise<void> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
console.log(chalk.bold('\n arcrun 初始化設定\n'));
|
||||
|
||||
try {
|
||||
if (options.local) {
|
||||
await initLocal();
|
||||
} else if (options.selfHosted) {
|
||||
await initSelfHosted(rl);
|
||||
} else {
|
||||
await initStandard(rl);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function initLocal(): Promise<void> {
|
||||
console.log(chalk.gray(' Local 模式:不需要 Cloudflare 帳號,workflow 由 arcrun.dev 雲端引擎執行\n'));
|
||||
|
||||
const config: ArcrunConfig = {
|
||||
mode: 'local',
|
||||
};
|
||||
|
||||
saveConfig(config);
|
||||
createHelloYamlIfMissing();
|
||||
|
||||
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 --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<typeof createInterface>): Promise<void> {
|
||||
console.log(chalk.gray(' Standard 模式:只需要 email,不需要 Cloudflare 帳號\n'));
|
||||
|
||||
const email = await prompt(rl, 'Email(用來取得 API Key)');
|
||||
|
||||
process.stdout.write(chalk.gray('\n → 向 arcrun.dev 取得 API Key...'));
|
||||
|
||||
let apiKey = '';
|
||||
let encryptionKey = '';
|
||||
try {
|
||||
const res = await fetch(ARCRUN_REGISTER_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`取得失敗(${res.status}):${err}`);
|
||||
}
|
||||
|
||||
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(` ✗ ${e instanceof Error ? e.message : e}`));
|
||||
console.log(chalk.yellow(' 請確認網路連線後重新執行 acr init\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config: ArcrunConfig = {
|
||||
mode: 'standard',
|
||||
api_key: apiKey,
|
||||
encryption_key: encryptionKey,
|
||||
};
|
||||
|
||||
saveConfig(config);
|
||||
createCredentialsYamlIfMissing();
|
||||
|
||||
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml'));
|
||||
console.log(chalk.green(` ✓ API Key:${apiKey.slice(0, 8)}...`));
|
||||
console.log(chalk.green(' ✓ 建立 credentials.yaml(已加入 .gitignore)\n'));
|
||||
console.log(' 下一步:');
|
||||
console.log(chalk.cyan(' acr parts scaffold <component>') + ' # 查看零件 config 範本');
|
||||
console.log(chalk.cyan(' acr creds push credentials.yaml') + ' # 上傳加密 credentials');
|
||||
console.log(chalk.cyan(' acr push workflow.yaml') + ' # 部署 workflow 並取得 Webhook URL\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-hosted installer:用戶只提供 CF Account ID + API Token,其餘自動。
|
||||
* 驗 token → 建 7 KV + R2(冪等)→ 查 subdomain → 下載 release 部署 Worker
|
||||
* → seed auth+api recipe → 寫 config → 印手動 secret 提示。
|
||||
* SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md
|
||||
*/
|
||||
async function initSelfHosted(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||
console.log(chalk.gray(' Self-hosted 模式:自動部署整套 arcrun 到你的 Cloudflare 帳號\n'));
|
||||
console.log(chalk.gray(' 你只需提供 CF Account ID + API Token,其餘 CLI 自動完成。\n'));
|
||||
|
||||
// 前置:wrangler(CF CLI)
|
||||
if (!wranglerAvailable()) {
|
||||
console.log(chalk.yellow(' ✗ 找不到 wrangler(Cloudflare CLI)。'));
|
||||
console.log(chalk.yellow(' 請先安裝:npm i -g wrangler,然後重新執行 acr init --self-hosted\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const accountId = await prompt(rl, '你的 Cloudflare Account ID');
|
||||
const cfApiToken = await prompt(rl, 'CF API Token(需 Workers Scripts Edit + KV Edit + R2 Edit)');
|
||||
|
||||
const cf = new CfAccountClient(accountId, cfApiToken);
|
||||
|
||||
// 1. 驗 token / account 可達
|
||||
process.stdout.write(chalk.gray('\n → 驗證 Cloudflare 憑證...'));
|
||||
try {
|
||||
await cf.verifyAccess();
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(` ✗ ${e instanceof Error ? e.message : e}`));
|
||||
console.log(chalk.yellow(' 請確認 Account ID 與 API Token(含權限)正確後重試\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. 建 KV namespace(冪等)+ R2 bucket
|
||||
const kvNamespaceIds: Record<string, string> = {};
|
||||
try {
|
||||
const existing = await cf.listKvNamespaces();
|
||||
for (const title of REQUIRED_KV_NAMESPACES) {
|
||||
process.stdout.write(chalk.gray(` → KV ${title}...`));
|
||||
const id = await cf.ensureKvNamespace(title, existing);
|
||||
kvNamespaceIds[title] = id;
|
||||
console.log(chalk.green(' ✓'));
|
||||
}
|
||||
process.stdout.write(chalk.gray(` → R2 ${REQUIRED_R2_BUCKET}...`));
|
||||
await cf.ensureR2Bucket(REQUIRED_R2_BUCKET);
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(`\n ✗ 建立資源失敗:${e instanceof Error ? e.message : e}\n`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用)
|
||||
let workerSubdomain = '';
|
||||
try {
|
||||
workerSubdomain = await cf.getWorkersSubdomain();
|
||||
console.log(chalk.gray(` → workers.dev subdomain: ${workerSubdomain}`));
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(` ⚠ 查 subdomain 失敗(${e instanceof Error ? e.message : e}),稍後可手動補`));
|
||||
}
|
||||
|
||||
// 4. 下載 repo 部署物(含預編譯 wasm)+ 注入 KV id + wrangler deploy 全部 Worker
|
||||
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
|
||||
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds };
|
||||
const deploy = await downloadAndDeploy(deployCtx);
|
||||
const cypherUrl = deploy.cypherExecutorUrl
|
||||
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
|
||||
const deployFullyOk = /全部成功/.test(deploy.message);
|
||||
console.log(deployFullyOk ? chalk.green(` ✓ ${deploy.message}`) : chalk.yellow(` ⚠ ${deploy.message}`));
|
||||
|
||||
// 5. 寫 config(資源資訊存好,供後續 acr push / update / seed)
|
||||
const config: ArcrunConfig = {
|
||||
mode: 'self-hosted',
|
||||
cloudflare_account_id: accountId,
|
||||
cf_api_token: cfApiToken,
|
||||
cypher_executor_url: cypherUrl,
|
||||
webhooks_kv_namespace_id: kvNamespaceIds['WEBHOOKS'],
|
||||
credentials_kv_namespace_id: kvNamespaceIds['CREDENTIALS_KV'],
|
||||
wasm_bucket: REQUIRED_R2_BUCKET,
|
||||
multi_tenant: false,
|
||||
};
|
||||
saveConfig(config);
|
||||
createCredentialsYamlIfMissing();
|
||||
|
||||
// 6. seed API recipe(部署成功 + 有 cypher URL 才打;否則提示稍後 acr update 後再 seed)
|
||||
if (deployFullyOk && cypherUrl) {
|
||||
await seedApiRecipes(cypherUrl);
|
||||
} else if (cypherUrl) {
|
||||
console.log(chalk.gray(` → recipe seed 待部署穩定後再執行(${API_RECIPE_SEEDS.length} 個;acr update 會重試)`));
|
||||
}
|
||||
|
||||
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
|
||||
console.log(chalk.green('\n ✓ Cloudflare 資源就緒(7 KV + R2)'));
|
||||
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
|
||||
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
|
||||
|
||||
// 手動 secret 提示(secret 不進自動化,rule 05)
|
||||
console.log(chalk.bold('\n 下一步(手動設定 runtime secret):'));
|
||||
for (const w of SECRET_TARGET_WORKERS) {
|
||||
console.log(chalk.cyan(` wrangler secret put ENCRYPTION_KEY --name ${w}`));
|
||||
}
|
||||
console.log(chalk.gray(' 三個 Worker 共用同一把 ENCRYPTION_KEY(256-bit hex)。'));
|
||||
console.log(chalk.gray(' 生成:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"\n'));
|
||||
}
|
||||
|
||||
/** seed API recipe 到目標 cypher-executor(部署完成後)。*/
|
||||
async function seedApiRecipes(cypherUrl: string): Promise<void> {
|
||||
process.stdout.write(chalk.gray(` → seed ${API_RECIPE_SEEDS.length} 個 API recipe...`));
|
||||
let ok = 0;
|
||||
for (const r of API_RECIPE_SEEDS) {
|
||||
try {
|
||||
const res = await fetch(`${cypherUrl}/recipes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
canonical_id: r.canonical_id,
|
||||
display_name: r.display_name,
|
||||
description: r.description,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method,
|
||||
auth_service: r.auth_service,
|
||||
exposure_consent: {
|
||||
confirmed_by_human: true,
|
||||
understood: `platform seed recipe: ${r.canonical_id} → ${r.endpoint}`,
|
||||
confirmed_at: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (res.ok) ok++;
|
||||
} catch {
|
||||
// 單筆失敗不中斷整個 init;最終回報數量
|
||||
}
|
||||
}
|
||||
console.log(ok === API_RECIPE_SEEDS.length ? chalk.green(' ✓') : chalk.yellow(` ${ok}/${API_RECIPE_SEEDS.length}`));
|
||||
}
|
||||
|
||||
function createHelloYamlIfMissing(): void {
|
||||
const helloPath = join(process.cwd(), 'hello.yaml');
|
||||
if (!existsSync(helloPath)) {
|
||||
writeFileSync(helloPath,
|
||||
'# arcrun hello world workflow\n' +
|
||||
'# 執行:acr run hello --input input="Hello, arcrun!"\n\n' +
|
||||
'name: hello\n' +
|
||||
'description: "Hello world — 示範字串轉大寫"\n\n' +
|
||||
'flow:\n' +
|
||||
' - "input >> ON_SUCCESS >> transform"\n\n' +
|
||||
'config:\n' +
|
||||
' transform:\n' +
|
||||
' component: string_ops\n' +
|
||||
' operation: upper\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 = readFileSync(gitignorePath, 'utf8');
|
||||
if (!content.includes('credentials.yaml')) {
|
||||
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,462 @@
|
||||
/**
|
||||
* acr parts — 列出所有可用零件(內建清單,不依賴 registry.arcrun.dev)
|
||||
* acr parts scaffold <component> — 輸出 config 範本(可直接貼入 workflow.yaml)
|
||||
* acr parts publish <component> — 提交零件至公眾 registry(Phase 5,封測後)
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import chalk from 'chalk';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||
|
||||
// ── 內建零件定義 ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CredentialRequirement {
|
||||
key: string;
|
||||
type: string;
|
||||
inject_as: string;
|
||||
}
|
||||
|
||||
interface ComponentDef {
|
||||
canonical_id: string;
|
||||
display_name: string;
|
||||
category: 'logic' | 'data' | 'api' | 'ai';
|
||||
description: string;
|
||||
config_example: string;
|
||||
credentials_required?: CredentialRequirement[];
|
||||
}
|
||||
|
||||
const BUILTIN_COMPONENTS: ComponentDef[] = [
|
||||
// ── 控制類(Logic) ────────────────────────────────────────────────────────
|
||||
{
|
||||
canonical_id: 'if_control',
|
||||
display_name: 'If Control',
|
||||
category: 'logic',
|
||||
description: '條件分支:condition 為 true 走 ON_SUCCESS,否則走 ON_FAIL',
|
||||
config_example:
|
||||
` if_node:
|
||||
component: if_control
|
||||
condition: "status === active"`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'switch',
|
||||
display_name: 'Switch',
|
||||
category: 'logic',
|
||||
description: '多分支條件:根據 value 欄位選擇對應分支',
|
||||
config_example:
|
||||
` switch_node:
|
||||
component: switch
|
||||
key: status
|
||||
cases:
|
||||
active: branch_a
|
||||
inactive: branch_b`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'foreach_control',
|
||||
display_name: 'Foreach',
|
||||
category: 'logic',
|
||||
description: '迭代:對陣列每個元素執行下游節點',
|
||||
config_example:
|
||||
` loop_node:
|
||||
component: foreach_control
|
||||
iterator: item`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'filter',
|
||||
display_name: 'Filter',
|
||||
category: 'logic',
|
||||
description: '過濾陣列:保留符合 condition 的元素',
|
||||
config_example:
|
||||
` filter_node:
|
||||
component: filter
|
||||
key: items
|
||||
condition: "status === active"`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'merge',
|
||||
display_name: 'Merge',
|
||||
category: 'logic',
|
||||
description: '合併多個上游節點的輸出(Fan-in)',
|
||||
config_example:
|
||||
` merge_node:
|
||||
component: merge`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'try_catch',
|
||||
display_name: 'Try Catch',
|
||||
category: 'logic',
|
||||
description: '錯誤捕捉:下游失敗時執行 catch 分支',
|
||||
config_example:
|
||||
` safe_node:
|
||||
component: try_catch`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'wait',
|
||||
display_name: 'Wait',
|
||||
category: 'logic',
|
||||
description: '延遲執行:等待指定毫秒後繼續',
|
||||
config_example:
|
||||
` delay_node:
|
||||
component: wait
|
||||
ms: 1000`,
|
||||
},
|
||||
// ── 資料類(Data) ─────────────────────────────────────────────────────────
|
||||
{
|
||||
canonical_id: 'set',
|
||||
display_name: 'Set',
|
||||
category: 'data',
|
||||
description: '設定欄位:將靜態值寫入 context',
|
||||
config_example:
|
||||
` set_node:
|
||||
component: set
|
||||
values:
|
||||
status: active
|
||||
source: webhook`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'array_ops',
|
||||
display_name: 'Array Ops',
|
||||
category: 'data',
|
||||
description: '陣列操作:push / pop / slice / length',
|
||||
config_example:
|
||||
` arr_node:
|
||||
component: array_ops
|
||||
operation: push
|
||||
key: items
|
||||
value: "{{new_item}}"`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'string_ops',
|
||||
display_name: 'String Ops',
|
||||
category: 'data',
|
||||
description: '字串操作:upper / lower / trim / replace / split / join / length',
|
||||
config_example:
|
||||
` str_node:
|
||||
component: string_ops
|
||||
operation: upper
|
||||
input: "{{text}}"`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'number_ops',
|
||||
display_name: 'Number Ops',
|
||||
category: 'data',
|
||||
description: '數字操作:add / sub / mul / div / round / floor / ceil / abs',
|
||||
config_example:
|
||||
` num_node:
|
||||
component: number_ops
|
||||
operation: add
|
||||
a: "{{price}}"
|
||||
b: 10`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'date_ops',
|
||||
display_name: 'Date Ops',
|
||||
category: 'data',
|
||||
description: '日期操作:now / format / diff / add_days',
|
||||
config_example:
|
||||
` date_node:
|
||||
component: date_ops
|
||||
operation: now
|
||||
format: "2006-01-02 15:04:05"`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'validate_json',
|
||||
display_name: 'Validate JSON',
|
||||
category: 'data',
|
||||
description: '驗證 context 欄位是否符合 JSON Schema',
|
||||
config_example:
|
||||
` validate_node:
|
||||
component: validate_json
|
||||
schema:
|
||||
type: object
|
||||
required: [email, name]
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email`,
|
||||
},
|
||||
// ── AI 類 ──────────────────────────────────────────────────────────────────
|
||||
{
|
||||
canonical_id: 'ai_transform_compile',
|
||||
display_name: 'AI Transform Compile',
|
||||
category: 'ai',
|
||||
description: '將自然語言規則編譯成可執行轉換程式',
|
||||
config_example:
|
||||
` compile_node:
|
||||
component: ai_transform_compile
|
||||
rule: "把 name 轉成大寫,並在前面加上 Hello "`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'ai_transform_run',
|
||||
display_name: 'AI Transform Run',
|
||||
category: 'ai',
|
||||
description: '執行 ai_transform_compile 產生的轉換程式',
|
||||
config_example:
|
||||
` run_node:
|
||||
component: ai_transform_run
|
||||
program: "{{compiled_program}}"`,
|
||||
},
|
||||
// ── API 整合類(Recipe 型,不需 deploy Worker) ────────────────────────────
|
||||
{
|
||||
canonical_id: 'http_request',
|
||||
display_name: 'HTTP Request',
|
||||
category: 'api',
|
||||
description: '通用 HTTP 請求:支援任意 method / headers / body',
|
||||
config_example:
|
||||
` api_node:
|
||||
component: http_request
|
||||
url: "https://api.example.com/data"
|
||||
method: POST
|
||||
headers:
|
||||
Content-Type: application/json
|
||||
body:
|
||||
key: "{{value}}"`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'gmail',
|
||||
display_name: 'Gmail',
|
||||
category: 'api',
|
||||
description: '寄送 Gmail(需要 gmail_token credential)',
|
||||
config_example:
|
||||
` mail_node:
|
||||
component: gmail
|
||||
to: "recipient@example.com"
|
||||
subject: "來自 arcrun 的通知"
|
||||
body: "{{message}}"`,
|
||||
credentials_required: [
|
||||
{ key: 'gmail_token', type: 'OAuth2 access token', inject_as: 'access_token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
canonical_id: 'google_sheets',
|
||||
display_name: 'Google Sheets',
|
||||
category: 'api',
|
||||
description: 'Google Sheets 讀寫(需要 google_oauth credential)',
|
||||
config_example:
|
||||
` sheet_node:
|
||||
component: google_sheets
|
||||
spreadsheet_id: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"
|
||||
range: "Sheet1!A:B"
|
||||
operation: append
|
||||
values:
|
||||
- ["{{name}}", "{{email}}"]`,
|
||||
credentials_required: [
|
||||
{ key: 'google_oauth', type: 'OAuth2 access token', inject_as: 'access_token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
canonical_id: 'telegram',
|
||||
display_name: 'Telegram',
|
||||
category: 'api',
|
||||
description: '發送 Telegram 訊息(需要 telegram_bot_token credential)',
|
||||
config_example:
|
||||
` tg_node:
|
||||
component: telegram
|
||||
chat_id: "123456789"
|
||||
text: "{{message}}"`,
|
||||
credentials_required: [
|
||||
{ key: 'telegram_bot_token', type: 'Bot token', inject_as: 'bot_token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
canonical_id: 'line_notify',
|
||||
display_name: 'LINE Notify',
|
||||
category: 'api',
|
||||
description: '發送 LINE Notify 通知(需要 line_token credential)',
|
||||
config_example:
|
||||
` line_node:
|
||||
component: line_notify
|
||||
message: "{{notification}}"`,
|
||||
credentials_required: [
|
||||
{ key: 'line_token', type: 'LINE Notify token', inject_as: 'token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
canonical_id: 'notion',
|
||||
display_name: 'Notion',
|
||||
category: 'api',
|
||||
description: '透過 recipe 操作 Notion API(需先 acr recipe push)',
|
||||
config_example:
|
||||
` # 先上傳 recipe:acr recipe push notion.yaml
|
||||
notion_node:
|
||||
component: rec_xxxxxxxx # acr recipe push 後得到的 hash
|
||||
database_id: "{{db_id}}"
|
||||
properties:
|
||||
Name: "{{title}}"`,
|
||||
},
|
||||
];
|
||||
|
||||
// ── 指令實作 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function cmdParts(): Promise<void> {
|
||||
const categoryLabels: Record<string, string> = {
|
||||
logic: '控制類(Control Flow)',
|
||||
data: '資料類(Data)',
|
||||
api: '整合類(API / Integration)',
|
||||
ai: 'AI 類',
|
||||
};
|
||||
|
||||
const grouped: Record<string, ComponentDef[]> = {};
|
||||
for (const comp of BUILTIN_COMPONENTS) {
|
||||
if (!grouped[comp.category]) grouped[comp.category] = [];
|
||||
grouped[comp.category].push(comp);
|
||||
}
|
||||
|
||||
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 credStr = comp.credentials_required?.length
|
||||
? chalk.yellow(` (需要 ${comp.credentials_required.map(c => c.key).join(', ')})`)
|
||||
: '';
|
||||
console.log(` • ${chalk.cyan(comp.canonical_id.padEnd(22))}${comp.display_name}${credStr}`);
|
||||
console.log(chalk.gray(` ${comp.description}`));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(chalk.gray(' 使用 acr parts scaffold <component> 取得 config 範本'));
|
||||
console.log(chalk.gray(' 第三方服務整合(Notion/Slack/GitHub 等):acr auth-recipe list'));
|
||||
console.log(chalk.gray(' API 整合類若需打自訂服務,請用 acr recipe push 建立 recipe\n'));
|
||||
}
|
||||
|
||||
export async function cmdPartsScaffold(componentId: string): Promise<void> {
|
||||
const comp = BUILTIN_COMPONENTS.find(c => c.canonical_id === componentId);
|
||||
|
||||
if (!comp) {
|
||||
// 找不到內建零件 → 嘗試 auth recipe
|
||||
const config = loadConfig();
|
||||
const baseUrl = getCypherExecutorUrl(config);
|
||||
try {
|
||||
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;
|
||||
}
|
||||
} catch {
|
||||
// 離線或服務不可用,繼續顯示錯誤
|
||||
}
|
||||
|
||||
console.error(chalk.red(`找不到零件 "${componentId}"。`));
|
||||
console.log(chalk.gray('執行 acr parts 查看內建零件。'));
|
||||
console.log(chalk.gray('執行 acr auth-recipe list 查看第三方服務整合。'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`\n ${comp.canonical_id} — ${comp.display_name}\n`));
|
||||
console.log(chalk.gray(` ${comp.description}\n`));
|
||||
|
||||
console.log(chalk.cyan(' # 貼入 workflow.yaml 的 config: 區塊'));
|
||||
console.log(comp.config_example.split('\n').map(l => ` ${l}`).join('\n'));
|
||||
|
||||
if (comp.credentials_required?.length) {
|
||||
console.log(chalk.bold('\n credentials.yaml 範本(填入後執行 acr creds push)\n'));
|
||||
for (const cred of comp.credentials_required) {
|
||||
console.log(chalk.cyan(` # ${cred.type}(執行時自動注入為 ${cred.inject_as} 欄位)`));
|
||||
console.log(` ${cred.key}: "your-token-here"\n`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export async function cmdPartsPublish(componentDir: string, options: { status?: string }): Promise<void> {
|
||||
const REGISTRY_URL = 'https://registry.arcrun.dev';
|
||||
|
||||
if (options.status) {
|
||||
try {
|
||||
const res = await fetch(`${REGISTRY_URL}/submit/status/${options.status}`);
|
||||
const data = await res.json() as { status: string; visibility?: string; failed_step?: string; reason?: string; approved_at?: string };
|
||||
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);
|
||||
}
|
||||
|
||||
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');
|
||||
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();
|
||||
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 };
|
||||
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) {
|
||||
console.error(chalk.red(`提交失敗:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* acr push <workflow.yaml>
|
||||
*
|
||||
* 解析 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 { loadWorkflowYaml, parseTriplets, validateRelations } from '../lib/yaml-parser.js';
|
||||
import { obtainExposureConsent } from '../lib/exposure-warning.js';
|
||||
|
||||
export async function cmdPush(filePath: string): Promise<void> {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Arcrun-API-Key': config.api_key,
|
||||
};
|
||||
|
||||
// 向 /cypher/search 取得執行圖
|
||||
const searchSpinner = ora('取得執行圖').start();
|
||||
let graph: unknown;
|
||||
try {
|
||||
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: { nodes: unknown[]; edges: unknown[] }; missing: string[] };
|
||||
if (data.missing?.length > 0) {
|
||||
searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 附上 id / name,並將 workflow.config 套入節點(componentId + data)
|
||||
const rawGraph = data.cypher as { nodes: Array<{ id: string; componentId?: string; data?: Record<string, unknown> }>; edges: unknown[] };
|
||||
const cfg = (workflow.config ?? {}) as Record<string, Record<string, unknown>>;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
|
||||
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
|
||||
// server 端獨立存法律憑證並強制(防 CLI 被繞過)。
|
||||
const consent = await obtainExposureConsent({
|
||||
kind: 'workflow',
|
||||
resourceName: workflow.name,
|
||||
destination: `${executorUrl}/webhooks/named/${workflow.name}/trigger`,
|
||||
});
|
||||
if (!consent) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// POST 至 /webhooks/named
|
||||
const deploySpinner = ora(`部署 "${workflow.name}" 至 ${executorUrl}`).start();
|
||||
try {
|
||||
const res = await fetch(`${executorUrl}/webhooks/named`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
name: workflow.name,
|
||||
graph,
|
||||
config: workflow.config ?? {},
|
||||
description: workflow.description ?? '',
|
||||
exposure_consent: consent ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
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) {
|
||||
deploySpinner.fail(chalk.red(`部署失敗:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* acr recipe push <file> — 上傳 recipe YAML 到 arcrun.dev
|
||||
* acr recipe list — 列出已上傳的 recipe
|
||||
* acr recipe delete <id> — 刪除 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 { obtainExposureConsent } from '../lib/exposure-warning.js';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
interface RecipeYaml {
|
||||
canonical_id?: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
endpoint?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
// 資料外流警示:recipe 定義一個資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
|
||||
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
|
||||
const consent = await obtainExposureConsent({
|
||||
kind: 'recipe',
|
||||
resourceName: recipe.canonical_id,
|
||||
destination: recipe.endpoint,
|
||||
});
|
||||
if (!consent) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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, exposure_consent: consent ?? undefined }),
|
||||
});
|
||||
|
||||
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)}`);
|
||||
|
||||
// 打通檢查(SDD recipe-push-gatekeeping §1.2):recipe 是「指向外部 API 的指針」,
|
||||
// 正確性一半在「打不打得通」(DECISIONS §1 recipe 驗收 = 2xx)。
|
||||
// self-hosted 是提醒級:不硬擋、誠實標原因(缺 credential 打不到 2xx 就誠實說,不假綠 — mindset §7)。
|
||||
await probeRecipeEndpoint(recipe);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打通檢查:push 時對 recipe endpoint 實打一次,回報是否 2xx。
|
||||
*
|
||||
* 提醒級(self-hosted):只回報、不硬擋(用戶可能就是要先 push 再設 credential)。
|
||||
* 誠實(mindset §7):
|
||||
* - endpoint 含未填模板({{_path}} / {{auth.x}} 等)→ 執行期才有值,push 時無法驗,誠實說明。
|
||||
* - 打不到 2xx → 誠實標 HTTP status(如 401 多半是缺 credential),不假裝成功。
|
||||
* - arcrun 不做授權判斷:401/403 是對方服務裁決,不是 recipe 的 bug(DECISIONS / mindset §3)。
|
||||
*/
|
||||
async function probeRecipeEndpoint(recipe: RecipeYaml): Promise<void> {
|
||||
const endpoint = recipe.endpoint ?? '';
|
||||
if (/\{\{.*?\}\}/.test(endpoint)) {
|
||||
console.log(chalk.gray('\n 打通檢查:endpoint 含執行期變數({{...}}),push 時無法預打。'));
|
||||
console.log(chalk.gray(' 實際是否打通待 acr run 時才知(recipe 驗收標準 = 執行回 2xx)。'));
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(chalk.gray('\n 打通檢查(實打 endpoint)...'));
|
||||
try {
|
||||
const method = (recipe.method ?? 'POST').toUpperCase();
|
||||
const res = await fetch(endpoint, {
|
||||
method,
|
||||
headers: recipe.headers,
|
||||
// 不帶 credential(push 端沒有明文)→ 打不通多半是缺 auth,下面誠實標
|
||||
...(method !== 'GET' && method !== 'HEAD'
|
||||
? { body: JSON.stringify(recipe.body ?? {}) }
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (res.ok) {
|
||||
console.log(chalk.green(` ✓ HTTP ${res.status}(打通)`));
|
||||
} else if (res.status === 401 || res.status === 403) {
|
||||
console.log(chalk.yellow(` ⚠ HTTP ${res.status}`));
|
||||
console.log(chalk.gray(' 未驗收:多半是缺 credential(過認證後才會 2xx)。先 acr creds push 對應 token。'));
|
||||
console.log(chalk.gray(' 註:401/403 是對方服務在行使授權,不是 recipe 的 bug。'));
|
||||
} else {
|
||||
console.log(chalk.yellow(` ⚠ HTTP ${res.status}(未打通)`));
|
||||
console.log(chalk.gray(' recipe 已上傳,但 endpoint 目前未回 2xx。請確認 endpoint / method 正確。'));
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.log(chalk.yellow(` ⚠ 無法連線`));
|
||||
console.log(chalk.gray(` ${msg.slice(0, 120)}(recipe 已上傳;連線問題不擋 push)`));
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdRecipeList(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
const spinner = ora('取得 recipe 清單').start();
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
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 <file> 上傳。\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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* acr run <workflow_name> [--input key=value...]
|
||||
*
|
||||
* 玩法一(Standard / Local):
|
||||
* 在本機找 <workflow_name>.yaml,解析 triplets + config,
|
||||
* 直接 POST /cypher/execute 給 cypher.arcrun.dev 執行。
|
||||
* YAML 不存在 KV,每次執行都帶著走。
|
||||
*
|
||||
* 玩法二(Self-hosted,workflow 已 push 到 KV):
|
||||
* POST /webhooks/<workflow_name>,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[];
|
||||
}
|
||||
|
||||
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 headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
|
||||
|
||||
// ── 玩法一: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,
|
||||
config: workflow.config ?? {}, // node_name → {component, ...params}
|
||||
context: inputContext, // --input key=value 傳入的動態參數
|
||||
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(`${executorUrl}/webhooks/${workflowName}`, {
|
||||
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;
|
||||
};
|
||||
|
||||
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<typeof ora>,
|
||||
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}` : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* acr update — 拉新 GitHub release,重新部署零件/引擎到用戶自己的 Cloudflare。
|
||||
*
|
||||
* 與 acr init --self-hosted 走同一條「下載 release → 注入 KV id → wrangler deploy」的路,
|
||||
* 差別只在:init 是首次(建 KV/R2 + 寫 config),update 是沿用既有 config 重部署變動的 Worker。
|
||||
*
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3「acr update」
|
||||
*
|
||||
* 誠實限制(mindset §7 / SDD §6):部署依賴 GitHub release(含預編譯 wasm),
|
||||
* release 產製管道補上前,誠實回報未實作,不假裝更新成功。
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import {
|
||||
wranglerAvailable,
|
||||
downloadAndDeploy,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
|
||||
export async function cmdUpdate(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
if (config.mode !== 'self-hosted') {
|
||||
console.log(chalk.yellow('\n acr update 只用於 self-hosted 模式(部署在你自己的 Cloudflare)。'));
|
||||
console.log(chalk.gray(' 目前模式:' + config.mode + '。如要 self-host,先跑 acr init --self-hosted。\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!config.cloudflare_account_id || !config.cf_api_token) {
|
||||
console.log(chalk.yellow('\n config 缺 cloudflare_account_id / cf_api_token,無法部署。'));
|
||||
console.log(chalk.gray(' 請重新跑 acr init --self-hosted。\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!wranglerAvailable()) {
|
||||
console.log(chalk.yellow('\n ✗ 找不到 wrangler(Cloudflare CLI)。請先 npm i -g wrangler。\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\n acr update — 拉新 release 並重新部署\n'));
|
||||
|
||||
const ctx: DeployContext = {
|
||||
accountId: config.cloudflare_account_id,
|
||||
apiToken: config.cf_api_token,
|
||||
workerSubdomain: extractSubdomain(config.cypher_executor_url),
|
||||
kvNamespaceIds: {
|
||||
WEBHOOKS: config.webhooks_kv_namespace_id ?? '',
|
||||
CREDENTIALS_KV: config.credentials_kv_namespace_id ?? '',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await downloadAndDeploy(ctx);
|
||||
|
||||
if (result.implemented) {
|
||||
console.log(chalk.green('\n ✓ 更新完成\n'));
|
||||
} else {
|
||||
console.log(chalk.yellow(' ⚠ 更新尚未自動化:'));
|
||||
console.log(chalk.gray(' ' + result.message.split('\n').join('\n ')) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
/** 從 cypher_executor_url(https://arcrun-cypher-executor.<sub>.workers.dev)抽 subdomain。*/
|
||||
function extractSubdomain(url?: string): string {
|
||||
if (!url) return '';
|
||||
const m = url.match(/arcrun-cypher-executor\.([^.]+)\.workers\.dev/);
|
||||
return m?.[1] ?? '';
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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, options: { offline?: boolean } = {}): 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" 的保留字節點,其餘所有節點都必須有 config
|
||||
// 注意:不能用「只出現在 subject 或只出現在 object」來判斷是否需要 config,
|
||||
// 因為像 a >> b >> c 中 a、b、c 都可能需要 config
|
||||
const nodeNames = getNodeNames(triplets);
|
||||
const RESERVED_NODES = new Set(['input']); // 保留字節點不需要 config
|
||||
const componentNodes = nodeNames.filter(n => !RESERVED_NODES.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. 零件存在性(--offline 時跳過)
|
||||
if (options.offline || config.mode === 'local') {
|
||||
check('零件存在性', true, '離線模式,跳過遠端檢查');
|
||||
} else {
|
||||
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}(加 --offline 跳過此檢查)`);
|
||||
}
|
||||
} catch {
|
||||
check('零件存在性', false, `無法連線 ${executorUrl}(加 --offline 跳過此檢查)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Credentials 上傳檢查(--offline 或 local 模式時跳過)
|
||||
if (options.offline || config.mode === 'local') {
|
||||
// 離線模式略過
|
||||
} else 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,
|
||||
});
|
||||
|
||||
try {
|
||||
const kvKeys = await kv.list('cred:');
|
||||
const uploadedCreds = new Set(kvKeys.map(k => k.name.replace('cred:', '')));
|
||||
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,130 @@
|
||||
#!/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 { cmdRecipePush, cmdRecipeList, cmdRecipeDelete } from './commands/recipe.js';
|
||||
import { cmdList } from './commands/list.js';
|
||||
import { cmdLogs } from './commands/logs.js';
|
||||
import { cmdUpdate } from './commands/update.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.1.0');
|
||||
|
||||
// acr init [--self-hosted]
|
||||
program
|
||||
.command('init')
|
||||
.description('互動式初始化設定(建立 ~/.arcrun/config.yaml)')
|
||||
.option('--local', '本機模式:不需要 Cloudflare 帳號,直接在本機測試 workflow')
|
||||
.option('--self-hosted', '完全 Self-hosted 模式:自行部署所有 Cloudflare Worker')
|
||||
.action((options: { local?: boolean; 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)')
|
||||
.option('--offline', '離線模式:跳過零件存在性與 credentials 的遠端檢查')
|
||||
.action((file: string, options: { offline?: boolean }) => cmdValidate(file, options));
|
||||
|
||||
// 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 recipe push / list / delete
|
||||
const recipeCmd = program.command('recipe').description('API Recipe 管理');
|
||||
recipeCmd
|
||||
.command('push <file>')
|
||||
.description('上傳 recipe YAML 到 arcrun.dev(不需要 deploy Worker)')
|
||||
.action((file: string) => cmdRecipePush(file));
|
||||
recipeCmd
|
||||
.command('list')
|
||||
.description('列出已上傳的 recipe')
|
||||
.action(() => cmdRecipeList());
|
||||
recipeCmd
|
||||
.command('delete <id>')
|
||||
.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 <service>')
|
||||
.description('顯示服務 recipe 詳情(需要哪些 credential)')
|
||||
.action((service: string) => cmdAuthRecipeInfo(service));
|
||||
authRecipeCmd
|
||||
.command('scaffold <service>')
|
||||
.description('輸出 credentials.yaml 範本 + workflow.yaml 使用範例')
|
||||
.action((service: string) => cmdAuthRecipeScaffold(service));
|
||||
|
||||
// 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));
|
||||
|
||||
// acr update(self-hosted:拉新 release 重新部署零件/引擎)
|
||||
program
|
||||
.command('update')
|
||||
.description('self-hosted:拉新 release 並重新部署到你的 Cloudflare')
|
||||
.action(() => cmdUpdate());
|
||||
|
||||
program.parse(process.argv);
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* api-recipe-seeds.ts
|
||||
*
|
||||
* 現役 API recipe 的種子定義。self-host 新帳號 init 時把這些灌進空的 RECIPES KV
|
||||
* (透過 cypher-executor 的 POST /recipes,或 CF KV REST API 直接寫)。
|
||||
*
|
||||
* API recipe = http_request + 固定設定(endpoint/method 模板)。
|
||||
* 不需 deploy Worker,cypher-executor 執行時直接 fetch(見 cypher-executor/src/routes/recipes.ts)。
|
||||
*
|
||||
* 放在 CLI 端而非 cypher-executor/src:
|
||||
* - seed 資料是「installer 要灌進用戶 KV 的種子」,本就屬 CLI 職責(SDD self-hosted-init.md §4)。
|
||||
* - rule 02 §2.2 hook 擋 cypher-executor TS hard-code API endpoint;seed 的 endpoint 是資料欄位,
|
||||
* 放 CLI 端避開誤判,也更符合職責切分。
|
||||
*
|
||||
* 來源:2026-06-01 從 prod cypher.arcrun.dev/recipes 逐一查得的現役定義。
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5
|
||||
*
|
||||
* KBDB recipe(kbdb_*)採 Supabase 模式(richblack 2026-06-02):
|
||||
* 進 seed = 展示能力(引子)。使用者要用 → 去 arcrun 取統一 API Key 當 credential。
|
||||
* FOLLOW-UP(KBDB 端):endpoint 現為 kbdb.finally.click,KBDB 應改用統一對外網址;
|
||||
* KBDB 改網址後同步更新此處。seed 先照現況進。
|
||||
*/
|
||||
|
||||
export interface ApiRecipeSeed {
|
||||
canonical_id: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
auth_service?: string;
|
||||
}
|
||||
|
||||
export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
|
||||
// ── KBDB(Supabase 模式,auth_service=kbdb static_key)──
|
||||
{
|
||||
canonical_id: 'kbdb_get',
|
||||
display_name: 'KBDB Get',
|
||||
description: 'GET 讀取 block / 查詢。_path 帶查詢路徑。auth: kbdb static_key。',
|
||||
endpoint: 'https://kbdb.finally.click{{_path}}',
|
||||
method: 'GET',
|
||||
auth_service: 'kbdb',
|
||||
},
|
||||
{
|
||||
canonical_id: 'kbdb_create_block',
|
||||
display_name: 'KBDB Create Block',
|
||||
description: 'POST /blocks 建立 block。body 帶 block 欄位(content/type/page_name/source/user_id 等)。auth: kbdb static_key。',
|
||||
endpoint: 'https://kbdb.finally.click/blocks',
|
||||
method: 'POST',
|
||||
auth_service: 'kbdb',
|
||||
},
|
||||
{
|
||||
canonical_id: 'kbdb_patch_block',
|
||||
display_name: 'KBDB Patch Block',
|
||||
description: 'PATCH /blocks/:id 局部更新。_path 帶 /blocks/{id},body 帶要改的欄位。auth: kbdb static_key。',
|
||||
endpoint: 'https://kbdb.finally.click{{_path}}',
|
||||
method: 'PATCH',
|
||||
auth_service: 'kbdb',
|
||||
},
|
||||
{
|
||||
canonical_id: 'kbdb_delete',
|
||||
display_name: 'KBDB Delete',
|
||||
description: 'DELETE /blocks/:id 刪除 block。_path 帶 /blocks/{id}。auth: kbdb static_key。',
|
||||
endpoint: 'https://kbdb.finally.click{{_path}}',
|
||||
method: 'DELETE',
|
||||
auth_service: 'kbdb',
|
||||
},
|
||||
{
|
||||
canonical_id: 'kbdb_ingest',
|
||||
display_name: 'KBDB Ingest',
|
||||
description: 'POST /blocks/ingest 批次寫入。body 帶 input。auth: kbdb static_key。',
|
||||
endpoint: 'https://kbdb.finally.click/blocks/ingest',
|
||||
method: 'POST',
|
||||
auth_service: 'kbdb',
|
||||
},
|
||||
|
||||
// ── Google(service_account)──
|
||||
{
|
||||
canonical_id: 'gmail_send',
|
||||
display_name: 'Gmail Send',
|
||||
description: '寄 Gmail。POST messages/send,body 帶 raw(base64url MIME)。auth: google service_account。',
|
||||
endpoint: 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
|
||||
method: 'POST',
|
||||
auth_service: 'google_gmail_sa',
|
||||
},
|
||||
{
|
||||
canonical_id: 'google_sheets_append',
|
||||
display_name: 'Google Sheets Append',
|
||||
description: '寫 Sheets。PUT values?valueInputOption=RAW,body 帶 values。auth: google service_account。',
|
||||
endpoint: 'https://sheets.googleapis.com{{_path}}',
|
||||
method: 'PUT',
|
||||
auth_service: 'google_sheets_sa',
|
||||
},
|
||||
{
|
||||
canonical_id: 'google_sheets_read',
|
||||
display_name: 'Google Sheets Read',
|
||||
description: '讀 Sheets。GET values。_path 帶完整路徑。auth: google service_account。',
|
||||
endpoint: 'https://sheets.googleapis.com{{_path}}',
|
||||
method: 'GET',
|
||||
auth_service: 'google_sheets_sa',
|
||||
},
|
||||
|
||||
// ── 訊息(static_key)──
|
||||
{
|
||||
canonical_id: 'telegram_send',
|
||||
display_name: 'Telegram Send',
|
||||
description: 'Telegram sendMessage。token 在 URL path({{auth.bot_token}}),body 帶 chat_id+text。auth: static_key path 注入。',
|
||||
endpoint: 'https://api.telegram.org/bot{{auth.bot_token}}/sendMessage',
|
||||
method: 'POST',
|
||||
auth_service: 'telegram',
|
||||
},
|
||||
{
|
||||
canonical_id: 'line_notify_send',
|
||||
display_name: 'LINE Notify',
|
||||
description: 'LINE Notify 推訊息。POST notify,body 帶 message(form-urlencoded)。auth: static_key Bearer line token。',
|
||||
endpoint: 'https://notify-api.line.me/api/notify',
|
||||
method: 'POST',
|
||||
auth_service: 'line_notify',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare Account-level API wrapper(self-hosted installer 用)。
|
||||
*
|
||||
* 負責 acr init --self-hosted 的資源建立:驗 token、建/列 KV namespace、建 R2 bucket、查 workers.dev subdomain。
|
||||
* 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
|
||||
*/
|
||||
export class CfAccountClient {
|
||||
private accountBase: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor(accountId: string, apiToken: string) {
|
||||
this.accountBase = `${CF_API_BASE}/accounts/${accountId}`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.accountBase}${path}`, {
|
||||
...init,
|
||||
headers: { ...this.headers, ...(init?.headers ?? {}) },
|
||||
});
|
||||
const data = await res.json().catch(() => null) as
|
||||
| { success: boolean; result: T; errors?: Array<{ message: string }> }
|
||||
| null;
|
||||
if (!res.ok || !data?.success) {
|
||||
const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`;
|
||||
throw new Error(`CF API ${path} 失敗:${msg}`);
|
||||
}
|
||||
return data.result;
|
||||
}
|
||||
|
||||
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
|
||||
async verifyAccess(): Promise<void> {
|
||||
// GET /accounts/{id} 能通 = token 有此 account 的基本讀權限
|
||||
await this.cf<{ id: string; name: string }>('');
|
||||
}
|
||||
|
||||
/** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/
|
||||
async listKvNamespaces(): Promise<Map<string, string>> {
|
||||
const result = await this.cf<Array<{ id: string; title: string }>>(
|
||||
'/storage/kv/namespaces?per_page=100',
|
||||
);
|
||||
const map = new Map<string, string>();
|
||||
for (const ns of result) map.set(ns.title, ns.id);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 建立 KV namespace(若同名已存在則回傳既有 id,冪等)。*/
|
||||
async ensureKvNamespace(title: string, existing?: Map<string, string>): Promise<string> {
|
||||
const known = existing ?? (await this.listKvNamespaces());
|
||||
const found = known.get(title);
|
||||
if (found) return found;
|
||||
|
||||
const result = await this.cf<{ id: string; title: string }>(
|
||||
'/storage/kv/namespaces',
|
||||
{ method: 'POST', body: JSON.stringify({ title }) },
|
||||
);
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/** 建立 R2 bucket(已存在則略過,冪等)。*/
|
||||
async ensureR2Bucket(name: string): Promise<void> {
|
||||
try {
|
||||
await this.cf<{ name: string }>('/r2/buckets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
} catch (e) {
|
||||
// bucket 已存在 → CF 回 10004 之類;視為冪等成功
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (/already exists|10004/i.test(msg)) return;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
|
||||
async getWorkersSubdomain(): Promise<string> {
|
||||
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
|
||||
return result.subdomain;
|
||||
}
|
||||
}
|
||||
|
||||
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
|
||||
export async function encryptCredential(value: string, encryptionKey: string): Promise<string> {
|
||||
if (!encryptionKey || encryptionKey.length < 64) {
|
||||
throw new Error(
|
||||
'ARCRUN_ENCRYPTION_KEY 未設定或長度不足(需要 256-bit hex,即 64 個十六進位字元)\n' +
|
||||
'生成指令:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
|
||||
);
|
||||
}
|
||||
|
||||
const keyBytes = hexToUint8Array(encryptionKey);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ 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,56 @@
|
||||
/**
|
||||
* 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: '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;
|
||||
cypher_executor_url?: string;
|
||||
credentials_kv_namespace_id?: string;
|
||||
webhooks_kv_namespace_id?: string;
|
||||
wasm_bucket?: string;
|
||||
// 共用
|
||||
multi_tenant?: boolean;
|
||||
// 資料外流警示:本機記住「已同意暴露 / 選擇不再警示」的資源,避免每次 push 重問(§3 首次問記住)。
|
||||
// key 格式:`{kind}:{resourceName}`(如 "webhook:contacts_lookup" / "recipe:kbdb_get")。
|
||||
// 注意:這只是 CLI 端 UX(不重問);server 端獨立存法律憑證並強制(防 CLI 被繞過)。
|
||||
exposure_consented?: Record<string, { confirmed_at: string; suppress_future?: 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)) {
|
||||
// 未初始化時回傳 local 模式預設值,讓 validate --offline 等指令能在無設定下運作
|
||||
return { mode: 'local' };
|
||||
}
|
||||
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,220 @@
|
||||
/**
|
||||
* deploy.ts — self-hosted Worker 部署(installer 的「下載 repo tarball + wrangler deploy」段)
|
||||
*
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6(commit wasm + codeload)
|
||||
*
|
||||
* 策略(richblack 2026-06-02):repo 自帶預編譯 wasm(.component-builds 下各 component.wasm,
|
||||
* 見 rule 05 慣例變更)→ CLI 從 GitHub codeload tarball 拿完整部署物 → 注入用戶的 KV id
|
||||
* → 用用戶自己的 CF token wrangler deploy。用戶不需 git / tinygo,只需 wrangler。
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** GitHub repo(codeload tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。
|
||||
* 注意:repo 名大小寫敏感(codeload 路徑需完全一致)。*/
|
||||
const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'uncle6me-web/Arcrun';
|
||||
|
||||
/** init 要建立的 7 個 KV namespace(title)。權威來源:.claude/rules/01-tech-stack.md 資料儲存表。*/
|
||||
export const REQUIRED_KV_NAMESPACES = [
|
||||
'WEBHOOKS',
|
||||
'CREDENTIALS_KV',
|
||||
'RECIPES',
|
||||
'USERS_KV',
|
||||
'SESSIONS_KV',
|
||||
'ANALYTICS_KV',
|
||||
'EXEC_CONTEXT',
|
||||
] as const;
|
||||
|
||||
/** init 要建立的 R2 bucket。*/
|
||||
export const REQUIRED_R2_BUCKET = 'WASM_BUCKET';
|
||||
|
||||
/** 部署後要提示用戶手動 `wrangler secret put ENCRYPTION_KEY` 的 Worker。*/
|
||||
export const SECRET_TARGET_WORKERS = [
|
||||
'arcrun-cypher-executor',
|
||||
'arcrun-auth-static-key',
|
||||
'arcrun-auth-service-account',
|
||||
] as const;
|
||||
|
||||
export interface DeployContext {
|
||||
accountId: string;
|
||||
apiToken: string;
|
||||
workerSubdomain: string;
|
||||
kvNamespaceIds: Record<string, string>; // title → id
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
implemented: boolean;
|
||||
cypherExecutorUrl?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/
|
||||
export function wranglerAvailable(): boolean {
|
||||
try {
|
||||
execFileSync('wrangler', ['--version'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下載 repo codeload tarball(含預編譯 wasm)→ 注入用戶 KV id → wrangler deploy 全部 Worker。
|
||||
*
|
||||
* SDD self-hosted-init.md §6.4:
|
||||
* 1. 下載 codeload tarball(ref 預設 main)→ 解壓到暫存目錄
|
||||
* 2. 各 wrangler.toml 注入 ctx.kvNamespaceIds + cypher-executor WORKER_SUBDOMAIN
|
||||
* 3. tier1=.component-builds/* 先 → tier2=cypher-executor/registry 後,逐一 wrangler deploy
|
||||
* 4. 回 cypherExecutorUrl = https://arcrun-cypher-executor.<subdomain>.workers.dev
|
||||
*
|
||||
* 誠實(mindset §7):任一 worker deploy 失敗會收集進 message 回報,不假裝全綠。
|
||||
*
|
||||
* @param ctx 部署上下文
|
||||
* @param ref git ref(branch / tag),預設 main;acr update 可帶 tag
|
||||
*/
|
||||
export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promise<DeployResult> {
|
||||
// 1. 下載 + 解壓 codeload tarball
|
||||
let root: string;
|
||||
try {
|
||||
root = await downloadRepoTarball(ref);
|
||||
} catch (e) {
|
||||
return {
|
||||
implemented: true,
|
||||
message: `下載部署物失敗(${e instanceof Error ? e.message : e})。確認網路 + ARCRUN_REPO=${ARCRUN_REPO} 可達。`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 列出要部署的 worker 目錄(含 wrangler.toml),分 tier
|
||||
const { tier1, tier2 } = discoverWorkerDirs(root);
|
||||
if (tier1.length === 0 && tier2.length === 0) {
|
||||
return { implemented: true, message: `部署物中找不到任何 wrangler.toml(root=${root})。` };
|
||||
}
|
||||
|
||||
// 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
|
||||
const failures: string[] = [];
|
||||
let deployed = 0;
|
||||
for (const dir of [...tier1, ...tier2]) {
|
||||
const tomlPath = join(dir, 'wrangler.toml');
|
||||
try {
|
||||
injectWranglerConfig(tomlPath, ctx);
|
||||
runWranglerDeploy(dir, ctx);
|
||||
deployed++;
|
||||
} catch (e) {
|
||||
failures.push(`${dir}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const cypherExecutorUrl = ctx.workerSubdomain
|
||||
? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev`
|
||||
: undefined;
|
||||
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
implemented: true,
|
||||
cypherExecutorUrl,
|
||||
message:
|
||||
`部署 ${deployed}/${tier1.length + tier2.length} 成功,${failures.length} 失敗(誠實回報,未假綠):\n` +
|
||||
failures.map(f => ` ✗ ${f}`).join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
implemented: true,
|
||||
cypherExecutorUrl,
|
||||
message: `部署完成:${deployed} 個 Worker 全部成功。`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。*/
|
||||
async function downloadRepoTarball(ref: string): Promise<string> {
|
||||
const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
||||
if (!res.ok) throw new Error(`codeload HTTP ${res.status}(${url})`);
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const dir = mkdtempSync(join(tmpdir(), 'arcrun-deploy-'));
|
||||
const tarPath = join(dir, 'repo.tar.gz');
|
||||
writeFileSync(tarPath, buf);
|
||||
|
||||
// 用系統 tar 解壓(macOS/Linux 內建)。tarball 解出單一頂層目錄 {repo}-{ref}/。
|
||||
execFileSync('tar', ['-xzf', tarPath, '-C', dir], { stdio: 'ignore' });
|
||||
const entries = readdirSync(dir).filter(n => n !== 'repo.tar.gz');
|
||||
const top = entries.find(n => statSync(join(dir, n)).isDirectory());
|
||||
if (!top) throw new Error('tarball 解壓後找不到頂層目錄');
|
||||
return join(dir, top);
|
||||
}
|
||||
|
||||
/** 掃解壓出的部署物,回傳 tier1(.component-builds/*)與 tier2(cypher-executor/registry)目錄清單。*/
|
||||
function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } {
|
||||
const tier1: string[] = [];
|
||||
const tier2: string[] = [];
|
||||
|
||||
const cbRoot = join(root, '.component-builds');
|
||||
if (existsSync(cbRoot)) {
|
||||
for (const name of readdirSync(cbRoot)) {
|
||||
const dir = join(cbRoot, name);
|
||||
// 需同時有 wrangler.toml 且有 component.wasm 才部署。
|
||||
// 「錯做成零件」的(claude_api / km_writer / kbdb_upsert_block)wasm 沒 commit 進 repo
|
||||
// (.gitignore 排除,待降級成工作流/recipe)→ codeload 拿到的目錄缺 wasm → 自然跳過,
|
||||
// 不讓 wrangler deploy 因缺檔失敗。
|
||||
if (existsSync(join(dir, 'wrangler.toml')) && existsSync(join(dir, 'component.wasm'))) {
|
||||
tier1.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of ['cypher-executor', 'registry']) {
|
||||
const dir = join(root, name);
|
||||
if (existsSync(join(dir, 'wrangler.toml'))) tier2.push(dir);
|
||||
}
|
||||
return { tier1, tier2 };
|
||||
}
|
||||
|
||||
/** 注入用戶的 KV namespace id(取代 wrangler.toml 中各 binding 的 id)+ cypher WORKER_SUBDOMAIN。*/
|
||||
function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
if (!existsSync(tomlPath)) return;
|
||||
let toml = readFileSync(tomlPath, 'utf8');
|
||||
|
||||
// 對每個已建立的 KV namespace:把對應 binding 的 id 換成用戶的。
|
||||
// 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。
|
||||
for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) {
|
||||
if (!id) continue;
|
||||
const re = new RegExp(
|
||||
`(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`,
|
||||
'g',
|
||||
);
|
||||
toml = toml.replace(re, `$1${id}$2`);
|
||||
}
|
||||
|
||||
// cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain
|
||||
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
|
||||
toml = toml.replace(
|
||||
/(WORKER_SUBDOMAIN\s*=\s*")[^"]*(")/,
|
||||
`$1${ctx.workerSubdomain}$2`,
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(tomlPath, toml, 'utf8');
|
||||
}
|
||||
|
||||
/** 在 worker 目錄跑 wrangler deploy(用用戶的 CF token + account)。*/
|
||||
function runWranglerDeploy(dir: string, ctx: DeployContext): void {
|
||||
// 先裝依賴(cypher-executor/registry 是 TS,wrangler 內建 esbuild bundle 需 node_modules)
|
||||
if (existsSync(join(dir, 'package.json'))) {
|
||||
const installer = existsSync(join(dir, 'pnpm-lock.yaml'))
|
||||
? ['pnpm', 'install', '--frozen-lockfile']
|
||||
: ['npm', 'install', '--no-audit', '--no-fund'];
|
||||
execFileSync(installer[0], installer.slice(1), { cwd: dir, stdio: 'ignore' });
|
||||
}
|
||||
execFileSync('wrangler', ['deploy'], {
|
||||
cwd: dir,
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
CLOUDFLARE_API_TOKEN: ctx.apiToken,
|
||||
CLOUDFLARE_ACCOUNT_ID: ctx.accountId,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 資料外流警示 — CLI 互動(data-exfil-warning SDD §1a / B)
|
||||
*
|
||||
* 觸發策略:只在「資料變成可被外部呼叫」時警示(webhook 部署 / recipe push)。
|
||||
* 互動形式(richblack):仿 GCP 刪 project —— 要用戶打資源名證明讀了警示(比 y/n 硬,不用打一大串)。
|
||||
* 同意 = 法律憑證:回傳的 ExposureConsent 帶 understood(用戶打的內容)+ 時間,server 端 log。
|
||||
* 誠實限制:非 TTY(AI 直跑)無 --confirm-exposure → 拒絕(AI 不該替人類確認暴露)。
|
||||
*/
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import chalk from 'chalk';
|
||||
import { loadConfig, saveConfig } from './config.js';
|
||||
|
||||
export interface ExposureConsent {
|
||||
confirmed_by_human: true;
|
||||
understood: string;
|
||||
confirmed_at: string;
|
||||
suppress_future?: boolean;
|
||||
}
|
||||
|
||||
// 註(2026-05-30 信任修正):移除 --confirm-exposure / --suppress-warning 旗標。
|
||||
// 理由:arcrun 是 AI 的工具,AI 自己能加旗標 = 自己批准自己 = 閘門虛設(違 DECISIONS §7)。
|
||||
// 唯一通過 = 人類在 TTY 互動輸入資源名(AI 非互動環境生不出)。「以後不再問」改成互動中詢問。
|
||||
export interface ExposureWarningOptions {
|
||||
// 預留:未來 CI 用「人類預先簽的 token」(非 AI 能生的 flag)。第一期不做。
|
||||
_reserved?: never;
|
||||
}
|
||||
|
||||
export interface ExposureContext {
|
||||
/** 動作種類,顯示用:'webhook' | 'recipe' */
|
||||
kind: string;
|
||||
/** 資源名(用戶要打這個字確認)*/
|
||||
resourceName: string;
|
||||
/** 暴露後的 URL / 去向(顯示用,可選) */
|
||||
destination?: string;
|
||||
/** 這個資源讀取/送出什麼(盡力盤,盤不出傳 undefined) */
|
||||
dataSummary?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得暴露同意。回傳 ExposureConsent(放進 push 請求 body)。
|
||||
* 未取得同意 → 印訊息並 return null(呼叫端應中止)。
|
||||
*/
|
||||
export async function obtainExposureConsent(
|
||||
ctx: ExposureContext,
|
||||
opts: ExposureWarningOptions = {},
|
||||
): Promise<ExposureConsent | null> {
|
||||
const nowIso = new Date().toISOString();
|
||||
const memKey = `${ctx.kind}:${ctx.resourceName}`;
|
||||
|
||||
// §3 首次問記住:本機已記錄同意此資源 → 不重問(server 端仍存法律憑證並強制)。
|
||||
const cfg = loadConfig();
|
||||
const prior = cfg.exposure_consented?.[memKey];
|
||||
if (prior) {
|
||||
return {
|
||||
confirmed_by_human: true,
|
||||
understood: `先前已同意暴露 ${ctx.resourceName}(${prior.confirmed_at}${prior.suppress_future ? ',已選不再警示' : ''})`,
|
||||
confirmed_at: prior.confirmed_at,
|
||||
suppress_future: prior.suppress_future,
|
||||
};
|
||||
}
|
||||
|
||||
// 非 TTY(AI 直跑)→ 一律拒絕,無捷徑。AI 不該、也不能替人類確認暴露。
|
||||
// (移除了 --confirm-exposure 旗標:那是 AI 自己能加的後門,等於自己批准自己。)
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(chalk.red('\n⚠️ 此動作會把資源變成可被外部呼叫(暴露/送出資料),需人類明示同意。'));
|
||||
console.error(chalk.gray(' 你(AI)無法確認暴露——這必須由人類在終端機親自執行、輸入資源名確認。'));
|
||||
console.error(chalk.gray(' 請把這件事交給人類做。\n'));
|
||||
return null;
|
||||
}
|
||||
|
||||
// 互動式警示 + 打資源名確認(唯一通過路徑,AI 生不出這個輸入)
|
||||
printWarning(ctx);
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const answer = (await rl.question(
|
||||
chalk.bold(` 確認暴露?請輸入資源名 "${ctx.resourceName}" 以繼續(或 Ctrl-C 取消):`),
|
||||
)).trim();
|
||||
if (answer !== ctx.resourceName) {
|
||||
console.error(chalk.red(`\n 輸入不符(需輸入 "${ctx.resourceName}")。已取消,未暴露。\n`));
|
||||
return null;
|
||||
}
|
||||
// 互動中詢問「以後不再問」(人類選,不是 AI 加旗標)
|
||||
const suppressAns = (await rl.question(
|
||||
chalk.gray(` 以後此資源(${ctx.resourceName})的暴露不再提醒?(y/N):`),
|
||||
)).trim().toLowerCase();
|
||||
const suppress = suppressAns === 'y' || suppressAns === 'yes';
|
||||
rememberConsent(memKey, nowIso, suppress);
|
||||
return {
|
||||
confirmed_by_human: true,
|
||||
understood: `用戶輸入資源名 "${ctx.resourceName}" 確認暴露${ctx.destination ? `(去向:${ctx.destination})` : ''}${suppress ? ';並選擇以後不再提醒' : ''}`,
|
||||
confirmed_at: nowIso,
|
||||
suppress_future: suppress,
|
||||
};
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 本機記住此資源已同意(避免下次重問;server 端仍獨立存法律憑證並強制) */
|
||||
function rememberConsent(memKey: string, confirmedAt: string, suppressFuture: boolean): void {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
cfg.exposure_consented = cfg.exposure_consented ?? {};
|
||||
cfg.exposure_consented[memKey] = { confirmed_at: confirmedAt, suppress_future: suppressFuture };
|
||||
saveConfig(cfg);
|
||||
} catch {
|
||||
// 記不住不影響本次同意(server 端仍會擋首次)
|
||||
}
|
||||
}
|
||||
|
||||
function printWarning(ctx: ExposureContext): void {
|
||||
console.log(chalk.yellow.bold(`\n⚠️ 資料外流警示`));
|
||||
console.log(chalk.yellow(` 這個動作會把 ${ctx.kind} "${ctx.resourceName}" 變成可被外部呼叫。`));
|
||||
if (ctx.destination) {
|
||||
console.log(chalk.gray(` 去向:${ctx.destination}`));
|
||||
}
|
||||
if (ctx.dataSummary) {
|
||||
console.log(chalk.gray(` 涉及資料:${ctx.dataSummary}`));
|
||||
} else {
|
||||
console.log(chalk.gray(` 涉及資料:無法自動判斷,請自行確認此資源是否含敏感資料。`));
|
||||
}
|
||||
console.log(chalk.gray(` 任何能呼叫它的人都能取得它的輸出/能力。`));
|
||||
console.log('');
|
||||
console.log(chalk.cyan(` arcrun 可幫你保護它:要求呼叫者帶 API Key/設權限/限流(一個動作就能加)。`));
|
||||
console.log(chalk.gray(` 若這是要公開的資料(如公開 API),可直接確認。`));
|
||||
console.log('');
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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」。`
|
||||
);
|
||||
}
|
||||
// 容許 FOREACH iterator 命名變體:「對每個 paragraph」/「FOREACH item」
|
||||
// graph-builder.ts 已支援這個 regex(commit e8fca33 2026-05-07)
|
||||
const foreachMatch = t.relation.match(/^(?:對每個|FOREACH)\s+\w+$/i);
|
||||
if (foreachMatch) continue;
|
||||
|
||||
if (!VALID_RELATIONS.has(t.relation)) {
|
||||
throw new Error(
|
||||
`未知關係詞「${t.relation}」。\n` +
|
||||
`合法關係詞:${[...VALID_RELATIONS].join('、')}\n` +
|
||||
`(FOREACH 支援 iterator 命名:「對每個 X」/「FOREACH X」)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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