feat(cli): add recipe / auth-recipe commands + update push/creds/init
- New: acr recipe (push/list/get a user recipe to RECIPES KV) - New: acr auth-recipe (inspect platform-seeded auth recipes) - push/creds/init/parts/config updated to match the new cypher-executor routing (/auth, /credentials, webhooks-named). Co-Authored-By: Claude Opus 4.7 <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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "arcrun",
|
"name": "arcrun",
|
||||||
"version": "1.0.3",
|
"version": "1.1.0",
|
||||||
"description": "AI Workflow CLI for arcrun — deploy and run WASM-based AI workflows on Cloudflare",
|
"description": "AI Workflow CLI for arcrun — deploy and run WASM-based AI workflows on Cloudflare",
|
||||||
"bin": {
|
"bin": {
|
||||||
"acr": "dist/index.js"
|
"acr": "dist/index.js"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-27
@@ -1,20 +1,54 @@
|
|||||||
/**
|
/**
|
||||||
* acr creds push [credentials.yaml]
|
* acr creds push [credentials.yaml]
|
||||||
* 讀取 credentials.yaml,加密後上傳至用戶自己的 CF KV(key 格式:cred:{name})
|
*
|
||||||
* 不經過 arcrun.dev
|
* 讀取 credentials.yaml,以 ENCRYPTION_KEY 加密後 POST 至 cypher.arcrun.dev/credentials。
|
||||||
|
* Server 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV(多租戶隔離)。
|
||||||
|
*
|
||||||
|
* 不再需要用戶提供 CF API Token 或 KV Namespace ID。
|
||||||
*/
|
*/
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import ora from 'ora';
|
import ora from 'ora';
|
||||||
import { loadConfig } from '../lib/config.js';
|
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||||
import { CfKvClient, encryptCredential } from '../lib/cf-api.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> {
|
export async function cmdCredsPush(filePath: string): Promise<void> {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
if (!config.cloudflare_account_id || !config.cf_api_token) {
|
if (config.mode === 'local') {
|
||||||
console.error(chalk.red('缺少 Cloudflare 設定,請執行 acr init'));
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,37 +68,42 @@ export async function cmdCredsPush(filePath: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 決定要寫入哪個 KV namespace
|
// 加密金鑰:優先從 config 讀(acr init 時自動寫入),其次從環境變數
|
||||||
const namespaceId = config.mode === 'standard'
|
const encryptionKey = config.encryption_key ?? process.env.ARCRUN_ENCRYPTION_KEY ?? '';
|
||||||
? config.user_kv_namespace_id!
|
if (!encryptionKey || encryptionKey.length < 64) {
|
||||||
: config.credentials_kv_namespace_id!;
|
console.error(chalk.red(
|
||||||
|
'缺少 encryption_key。請重新執行 acr init 取得設定。',
|
||||||
if (!namespaceId) {
|
));
|
||||||
console.error(chalk.red('缺少 KV Namespace ID,請執行 acr init'));
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const kv = new CfKvClient({
|
const baseUrl = getCypherExecutorUrl(config);
|
||||||
accountId: config.cloudflare_account_id,
|
console.log(chalk.bold(`\n 上傳 ${entries.length} 個 credentials 至 ${baseUrl}\n`));
|
||||||
namespaceId,
|
|
||||||
apiToken: config.cf_api_token,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 加密金鑰(若無則用 dev 模式 base64)
|
|
||||||
const encryptionKey = process.env.ARCRUN_ENCRYPTION_KEY ?? '';
|
|
||||||
|
|
||||||
console.log(chalk.bold(`\n 上傳 ${entries.length} 個 credentials 至你的 CF KV\n`));
|
|
||||||
|
|
||||||
for (const [name, value] of entries) {
|
for (const [name, value] of entries) {
|
||||||
const spinner = ora(` ${name}`).start();
|
const spinner = ora(` ${name}`).start();
|
||||||
try {
|
try {
|
||||||
const encrypted = await encryptCredential(String(value), encryptionKey);
|
const { encrypted, iv } = await encryptValue(String(value), encryptionKey);
|
||||||
await kv.put(`cred:${name}`, encrypted);
|
|
||||||
spinner.succeed(chalk.green(` ✓ ${name} 已加密上傳至你的 CF KV`));
|
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) {
|
} catch (e) {
|
||||||
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
|
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(chalk.gray('\n 你的 credential 存在你自己的 CF KV,arcrun 不會儲存它們。\n'));
|
console.log(chalk.gray('\n Credential 已加密儲存。執行 workflow 時會自動注入,無需在 --input 手動帶 token。\n'));
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-38
@@ -47,70 +47,59 @@ async function initLocal(): Promise<void> {
|
|||||||
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml(local 模式)'));
|
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml(local 模式)'));
|
||||||
console.log(chalk.green(' ✓ 建立 hello.yaml 範例 workflow\n'));
|
console.log(chalk.green(' ✓ 建立 hello.yaml 範例 workflow\n'));
|
||||||
console.log(' 你可以立刻開始:');
|
console.log(' 你可以立刻開始:');
|
||||||
console.log(chalk.cyan(' acr validate hello.yaml --offline') + ' # 驗證 workflow 格式');
|
console.log(chalk.cyan(' acr validate hello.yaml --offline') + ' # 驗證 workflow 格式');
|
||||||
console.log(chalk.cyan(' acr run hello') + ' # 執行 hello workflow\n');
|
console.log(chalk.cyan(' acr run hello --input input="Hello, arcrun!"') + ' # 執行,輸出大寫字串\n');
|
||||||
console.log(chalk.gray(' Local 模式:YAML 留在本機,workflow 由 arcrun.dev 引擎執行。'));
|
console.log(chalk.gray(' Local 模式:YAML 留在本機,workflow 由 arcrun.dev 引擎執行。'));
|
||||||
console.log(chalk.gray(' 需要用自己的 CF 帳號存放 credentials?執行 acr init(Standard 模式)。\n'));
|
console.log(chalk.gray(' 需要用自己的 CF 帳號存放 credentials?執行 acr init(Standard 模式)。\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initStandard(rl: ReturnType<typeof createInterface>): Promise<void> {
|
async function initStandard(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||||
console.log(chalk.gray(' Standard 模式:使用 arcrun.dev 的執行引擎,credential 存在你自己的 CF KV\n'));
|
console.log(chalk.gray(' Standard 模式:只需要 email,不需要 Cloudflare 帳號\n'));
|
||||||
|
|
||||||
const accountId = await prompt(rl, '你的 Cloudflare Account ID');
|
const email = await prompt(rl, 'Email(用來取得 API Key)');
|
||||||
const kvNamespaceId = await prompt(rl,
|
|
||||||
'USER_KV Namespace ID(先至 CF Dashboard 建立一個 KV 後貼上)'
|
|
||||||
);
|
|
||||||
const cfApiToken = await prompt(rl,
|
|
||||||
'CF API Token(只需 KV Edit 權限,供 acr 讀寫你的 KV)'
|
|
||||||
);
|
|
||||||
const email = await prompt(rl, 'Email(取得 arcrun.dev API Key)');
|
|
||||||
|
|
||||||
process.stdout.write(chalk.gray('\n → 向 arcrun.dev 取得 API Key...'));
|
process.stdout.write(chalk.gray('\n → 向 arcrun.dev 取得 API Key...'));
|
||||||
|
|
||||||
let apiKey: string;
|
let apiKey = '';
|
||||||
|
let encryptionKey = '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(ARCRUN_REGISTER_URL, {
|
const res = await fetch(ARCRUN_REGISTER_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email }), // CF API Token 永遠不離開本機
|
body: JSON.stringify({ email }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.text();
|
const err = await res.text();
|
||||||
throw new Error(`API Key 取得失敗(${res.status}):${err}`);
|
throw new Error(`取得失敗(${res.status}):${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json() as { api_key: string };
|
const data = await res.json() as { api_key: string; encryption_key: string };
|
||||||
apiKey = data.api_key;
|
apiKey = data.api_key;
|
||||||
|
encryptionKey = data.encryption_key;
|
||||||
console.log(chalk.green(' ✓'));
|
console.log(chalk.green(' ✓'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(chalk.yellow(' ✗(離線模式,請稍後執行 acr init 重試)'));
|
console.log(chalk.yellow(` ✗ ${e instanceof Error ? e.message : e}`));
|
||||||
apiKey = '';
|
console.log(chalk.yellow(' 請確認網路連線後重新執行 acr init\n'));
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: ArcrunConfig = {
|
const config: ArcrunConfig = {
|
||||||
mode: 'standard',
|
mode: 'standard',
|
||||||
cloudflare_account_id: accountId,
|
|
||||||
user_kv_namespace_id: kvNamespaceId,
|
|
||||||
cf_api_token: cfApiToken,
|
|
||||||
api_key: apiKey,
|
api_key: apiKey,
|
||||||
|
encryption_key: encryptionKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
|
|
||||||
// 建立空白 credentials.yaml
|
|
||||||
createCredentialsYamlIfMissing();
|
createCredentialsYamlIfMissing();
|
||||||
|
|
||||||
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml'));
|
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml'));
|
||||||
if (apiKey) {
|
console.log(chalk.green(` ✓ API Key:${apiKey.slice(0, 8)}...`));
|
||||||
console.log(chalk.green(` ✓ API Key:${apiKey.slice(0, 8)}...(已安全儲存)`));
|
|
||||||
}
|
|
||||||
console.log(chalk.green(' ✓ 建立 credentials.yaml(已加入 .gitignore)\n'));
|
console.log(chalk.green(' ✓ 建立 credentials.yaml(已加入 .gitignore)\n'));
|
||||||
console.log(chalk.gray(' 你的 credential 與 workflow 存在你自己的 CF KV,arcrun 不會儲存它們。\n'));
|
|
||||||
console.log(' 下一步:');
|
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 creds push credentials.yaml') + ' # 上傳加密 credentials');
|
||||||
console.log(chalk.cyan(' acr push workflow.yaml') + ' # 部署 workflow');
|
console.log(chalk.cyan(' acr push workflow.yaml') + ' # 部署 workflow 並取得 Webhook URL\n');
|
||||||
console.log(chalk.cyan(' acr run <workflow_name>') + ' # 執行 workflow\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initSelfHosted(rl: ReturnType<typeof createInterface>): Promise<void> {
|
async function initSelfHosted(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||||
@@ -146,19 +135,15 @@ function createHelloYamlIfMissing(): void {
|
|||||||
if (!existsSync(helloPath)) {
|
if (!existsSync(helloPath)) {
|
||||||
writeFileSync(helloPath,
|
writeFileSync(helloPath,
|
||||||
'# arcrun hello world workflow\n' +
|
'# arcrun hello world workflow\n' +
|
||||||
'# 執行:acr run hello\n\n' +
|
'# 執行:acr run hello --input input="Hello, arcrun!"\n\n' +
|
||||||
'name: hello\n' +
|
'name: hello\n' +
|
||||||
'description: "Hello world — 示範如何讓 AI 處理訊息後傳送通知"\n\n' +
|
'description: "Hello world — 示範字串轉大寫"\n\n' +
|
||||||
'flow:\n' +
|
'flow:\n' +
|
||||||
' - "input >> ON_SUCCESS >> ai_reply"\n' +
|
' - "input >> ON_SUCCESS >> transform"\n\n' +
|
||||||
' - "ai_reply >> ON_SUCCESS >> log_output"\n\n' +
|
|
||||||
'config:\n' +
|
'config:\n' +
|
||||||
' ai_reply:\n' +
|
' transform:\n' +
|
||||||
' component: "component://cmp_openai_chat"\n' +
|
' component: string_ops\n' +
|
||||||
' model: "gpt-4o-mini"\n' +
|
' operation: upper\n',
|
||||||
' prompt: "請用繁體中文說 Hello World,並解釋 arcrun 是什麼"\n' +
|
|
||||||
' log_output:\n' +
|
|
||||||
' component: "component://cmp_log_stdout"\n',
|
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+356
-175
@@ -1,149 +1,397 @@
|
|||||||
/**
|
/**
|
||||||
* acr parts — 列出所有可用零件(按類型分組,含統計與 author)
|
* acr parts — 列出所有可用零件(內建清單,不依賴 registry.arcrun.dev)
|
||||||
* acr parts scaffold <component> — 輸出 config 範本
|
* acr parts scaffold <component> — 輸出 config 範本(可直接貼入 workflow.yaml)
|
||||||
* acr parts publish <component> — 提交零件至公眾 registry
|
* acr parts publish <component> — 提交零件至公眾 registry(Phase 5,封測後)
|
||||||
*/
|
*/
|
||||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import ora from 'ora';
|
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||||
import { loadConfig } from '../lib/config.js';
|
|
||||||
|
|
||||||
const REGISTRY_URL = 'https://registry.arcrun.dev';
|
// ── 內建零件定義 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ComponentInfo {
|
interface CredentialRequirement {
|
||||||
canonical_id: string;
|
key: string;
|
||||||
display_name: string;
|
type: string;
|
||||||
category: string;
|
inject_as: string;
|
||||||
description: string;
|
|
||||||
author?: string;
|
|
||||||
total_runs?: number;
|
|
||||||
success_rate?: number;
|
|
||||||
avg_duration_ms?: number;
|
|
||||||
visibility?: 'public' | 'author_only';
|
|
||||||
credentials_required?: Array<{ key: string; type: string; inject_as: string }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface 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> {
|
export async function cmdParts(): Promise<void> {
|
||||||
const spinner = ora('從 registry.arcrun.dev 取得零件清單').start();
|
|
||||||
|
|
||||||
let components: ComponentInfo[] = [];
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${REGISTRY_URL}/components`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json() as { components: ComponentInfo[] };
|
|
||||||
components = data.components ?? [];
|
|
||||||
}
|
|
||||||
spinner.stop();
|
|
||||||
} catch {
|
|
||||||
spinner.stop();
|
|
||||||
console.log(chalk.yellow(' 無法連線 registry.arcrun.dev,顯示本地零件清單\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (components.length === 0) {
|
|
||||||
// fallback:顯示本地 registry 目錄中的零件
|
|
||||||
components = loadLocalComponents();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 依 category 分組
|
|
||||||
const grouped: Record<string, ComponentInfo[]> = {};
|
|
||||||
for (const comp of components) {
|
|
||||||
const cat = comp.category ?? 'other';
|
|
||||||
if (!grouped[cat]) grouped[cat] = [];
|
|
||||||
grouped[cat].push(comp);
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
const categoryLabels: Record<string, string> = {
|
||||||
api: '整合類(Integration)',
|
|
||||||
logic: '控制類(Control Flow)',
|
logic: '控制類(Control Flow)',
|
||||||
data: '資料類(Data)',
|
data: '資料類(Data)',
|
||||||
|
api: '整合類(API / Integration)',
|
||||||
ai: 'AI 類',
|
ai: 'AI 類',
|
||||||
other: '其他',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(chalk.bold('\n arcrun 零件庫\n'));
|
const grouped: Record<string, ComponentDef[]> = {};
|
||||||
|
for (const comp of BUILTIN_COMPONENTS) {
|
||||||
|
if (!grouped[comp.category]) grouped[comp.category] = [];
|
||||||
|
grouped[comp.category].push(comp);
|
||||||
|
}
|
||||||
|
|
||||||
for (const [cat, comps] of Object.entries(grouped)) {
|
console.log(chalk.bold(`\n arcrun 零件庫(${BUILTIN_COMPONENTS.length} 個內建零件)\n`));
|
||||||
console.log(chalk.bold.underline(` ${categoryLabels[cat] ?? cat}`));
|
|
||||||
|
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) {
|
for (const comp of comps) {
|
||||||
const isAuthorOnly = comp.visibility === 'author_only';
|
|
||||||
const tag = isAuthorOnly ? chalk.yellow(' [待審核]') : '';
|
|
||||||
|
|
||||||
let statsLine = '';
|
|
||||||
if (!isAuthorOnly && comp.total_runs !== undefined) {
|
|
||||||
const rate = ((comp.success_rate ?? 1) * 100).toFixed(1);
|
|
||||||
const runs = comp.total_runs.toLocaleString();
|
|
||||||
const ms = Math.round(comp.avg_duration_ms ?? 0);
|
|
||||||
statsLine = chalk.gray(` ★ ${rate}% 成功 | ${runs} 次執行 | 平均 ${ms}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authorStr = comp.author ? chalk.gray(` by ${comp.author}`) : '';
|
|
||||||
const credStr = comp.credentials_required?.length
|
const credStr = comp.credentials_required?.length
|
||||||
? chalk.yellow(` 🔑 需要 ${comp.credentials_required.map(c => c.key).join(', ')}`)
|
? chalk.yellow(` (需要 ${comp.credentials_required.map(c => c.key).join(', ')})`)
|
||||||
: '';
|
: '';
|
||||||
|
console.log(` • ${chalk.cyan(comp.canonical_id.padEnd(22))}${comp.display_name}${credStr}`);
|
||||||
console.log(` • ${chalk.cyan(comp.canonical_id.padEnd(20))}${comp.display_name}${tag}${authorStr}${credStr}`);
|
console.log(chalk.gray(` ${comp.description}`));
|
||||||
if (statsLine) console.log(statsLine);
|
|
||||||
}
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(chalk.gray(' 使用 acr parts scaffold <component> 取得 config 範本'));
|
console.log(chalk.gray(' 使用 acr parts scaffold <component> 取得 config 範本'));
|
||||||
console.log(chalk.gray(' 使用 acr parts publish <component> 提交零件至公眾庫\n'));
|
console.log(chalk.gray(' 第三方服務整合(Notion/Slack/GitHub 等):acr auth-recipe list'));
|
||||||
|
console.log(chalk.gray(' API 整合類若需打自訂服務,請用 acr recipe push 建立 recipe\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdPartsScaffold(componentId: string): Promise<void> {
|
export async function cmdPartsScaffold(componentId: string): Promise<void> {
|
||||||
// 優先從本地 registry 讀取 contract.yaml
|
const comp = BUILTIN_COMPONENTS.find(c => c.canonical_id === componentId);
|
||||||
const localContract = loadLocalContract(componentId);
|
|
||||||
|
|
||||||
if (!localContract) {
|
if (!comp) {
|
||||||
// 嘗試從 registry.arcrun.dev 取得
|
// 找不到內建零件 → 嘗試 auth recipe
|
||||||
|
const config = loadConfig();
|
||||||
|
const baseUrl = getCypherExecutorUrl(config);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${REGISTRY_URL}/components/${componentId}/contract`);
|
const res = await fetch(`${baseUrl}/auth-recipes/${componentId}`);
|
||||||
if (!res.ok) {
|
if (res.ok) {
|
||||||
console.error(chalk.red(`零件 "${componentId}" 不存在,執行 acr parts 查看可用清單`));
|
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 }> } };
|
||||||
process.exit(1);
|
const recipe = data.recipe;
|
||||||
|
console.log(chalk.bold(`\n ${componentId} — ${recipe.display_name ?? componentId}\n`));
|
||||||
|
if (recipe.description) console.log(chalk.gray(` ${recipe.description}\n`));
|
||||||
|
console.log(chalk.cyan(' # credentials.yaml 範本(填入後執行 acr creds push)\n'));
|
||||||
|
for (const s of recipe.required_secrets) {
|
||||||
|
if (s.help) console.log(chalk.gray(` # ${s.label}`));
|
||||||
|
if (s.help) console.log(chalk.gray(` # ${s.help}`));
|
||||||
|
if (s.help_url) console.log(chalk.gray(` # 說明文件:${s.help_url}`));
|
||||||
|
if (s.type === 'json_blob') {
|
||||||
|
console.log(` ${s.key}: |`);
|
||||||
|
console.log(` {`);
|
||||||
|
console.log(` "type": "service_account",`);
|
||||||
|
console.log(` ...`);
|
||||||
|
console.log(` }`);
|
||||||
|
} else {
|
||||||
|
console.log(` ${s.key}: ""`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
console.log(chalk.cyan(' # workflow.yaml config 範例\n'));
|
||||||
|
console.log(` ${componentId}_node:`);
|
||||||
|
console.log(` component: ${componentId}`);
|
||||||
|
console.log(` method: POST`);
|
||||||
|
console.log(` _path: /your-endpoint-path`);
|
||||||
|
console.log('');
|
||||||
|
console.log(chalk.gray(` 完整說明:acr auth-recipe info ${componentId}\n`));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json() as { config_example?: string; credentials_required?: unknown[] };
|
|
||||||
printScaffold(componentId, data.config_example, data.credentials_required as ComponentInfo['credentials_required']);
|
|
||||||
} catch {
|
} catch {
|
||||||
console.error(chalk.red(`無法取得 "${componentId}" 的 contract,請確認零件名稱`));
|
// 離線或服務不可用,繼續顯示錯誤
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
|
console.error(chalk.red(`找不到零件 "${componentId}"。`));
|
||||||
|
console.log(chalk.gray('執行 acr parts 查看內建零件。'));
|
||||||
|
console.log(chalk.gray('執行 acr auth-recipe list 查看第三方服務整合。'));
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const configExample = extractYamlField(localContract, 'config_example');
|
console.log(chalk.bold(`\n ${comp.canonical_id} — ${comp.display_name}\n`));
|
||||||
const credsRequired = extractCredentialsRequired(localContract);
|
console.log(chalk.gray(` ${comp.description}\n`));
|
||||||
printScaffold(componentId, configExample, credsRequired);
|
|
||||||
}
|
|
||||||
|
|
||||||
function printScaffold(
|
console.log(chalk.cyan(' # 貼入 workflow.yaml 的 config: 區塊'));
|
||||||
componentId: string,
|
console.log(comp.config_example.split('\n').map(l => ` ${l}`).join('\n'));
|
||||||
configExample?: string,
|
|
||||||
credsRequired?: ComponentInfo['credentials_required'],
|
|
||||||
): void {
|
|
||||||
console.log(chalk.bold(`\n ${componentId} — workflow.yaml config 範本\n`));
|
|
||||||
|
|
||||||
if (configExample) {
|
if (comp.credentials_required?.length) {
|
||||||
console.log(chalk.cyan(' # 貼入 workflow.yaml 的 config: 區塊'));
|
console.log(chalk.bold('\n credentials.yaml 範本(填入後執行 acr creds push)\n'));
|
||||||
console.log(configExample.split('\n').map(l => ` ${l}`).join('\n'));
|
for (const cred of comp.credentials_required) {
|
||||||
} else {
|
console.log(chalk.cyan(` # ${cred.type}(執行時自動注入為 ${cred.inject_as} 欄位)`));
|
||||||
console.log(chalk.yellow(' (無 config_example,請參考文檔)'));
|
console.log(` ${cred.key}: "your-token-here"\n`);
|
||||||
}
|
|
||||||
|
|
||||||
if (credsRequired && credsRequired.length > 0) {
|
|
||||||
console.log(chalk.bold('\n credentials.yaml 範本(加入後執行 acr creds push)\n'));
|
|
||||||
for (const cred of credsRequired) {
|
|
||||||
console.log(chalk.cyan(` # ${cred.type}(${cred.inject_as} 欄位自動注入)`));
|
|
||||||
console.log(` ${cred.key}: "your-${cred.type}-token"\n`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdPartsPublish(componentDir: string, options: { status?: string }): Promise<void> {
|
export async function cmdPartsPublish(componentDir: string, options: { status?: string }): Promise<void> {
|
||||||
|
const REGISTRY_URL = 'https://registry.arcrun.dev';
|
||||||
|
|
||||||
if (options.status) {
|
if (options.status) {
|
||||||
// 查詢審核進度
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${REGISTRY_URL}/submit/status/${options.status}`);
|
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 };
|
const data = await res.json() as { status: string; visibility?: string; failed_step?: string; reason?: string; approved_at?: string };
|
||||||
@@ -165,7 +413,6 @@ export async function cmdPartsPublish(componentDir: string, options: { status?:
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 讀取零件目錄
|
|
||||||
const contractPath = join(componentDir, 'component.contract.yaml');
|
const contractPath = join(componentDir, 'component.contract.yaml');
|
||||||
const mainGoPath = join(componentDir, 'main.go');
|
const mainGoPath = join(componentDir, 'main.go');
|
||||||
const wasmName = componentDir.split('/').pop() ?? componentDir;
|
const wasmName = componentDir.split('/').pop() ?? componentDir;
|
||||||
@@ -180,7 +427,7 @@ export async function cmdPartsPublish(componentDir: string, options: { status?:
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const spinner = ora('提交零件至 registry.arcrun.dev').start();
|
console.log(chalk.bold('\n 提交零件至 registry.arcrun.dev...\n'));
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('contract', new Blob([readFileSync(contractPath)], { type: 'application/yaml' }), 'component.contract.yaml');
|
formData.append('contract', new Blob([readFileSync(contractPath)], { type: 'application/yaml' }), 'component.contract.yaml');
|
||||||
@@ -198,84 +445,18 @@ export async function cmdPartsPublish(componentDir: string, options: { status?:
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.text();
|
const err = await res.text();
|
||||||
spinner.fail(chalk.red(`提交失敗(${res.status}):${err.slice(0, 200)}`));
|
console.error(chalk.red(`提交失敗(${res.status}):${err.slice(0, 200)}`));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json() as { submission_id: string; status: string; visibility?: string };
|
const data = await res.json() as { submission_id: string; status: string; visibility?: string };
|
||||||
spinner.succeed(chalk.green(`✓ 提交成功`));
|
console.log(chalk.green(`✓ 提交成功`));
|
||||||
console.log(`\n Submission ID:${chalk.cyan(data.submission_id)}`);
|
console.log(`\n Submission ID:${chalk.cyan(data.submission_id)}`);
|
||||||
console.log(` 狀態:${data.status}`);
|
console.log(` 狀態:${data.status}`);
|
||||||
if (data.visibility) console.log(` Visibility:${data.visibility}`);
|
if (data.visibility) console.log(` Visibility:${data.visibility}`);
|
||||||
console.log(chalk.gray(`\n 查詢進度:acr parts publish --status ${data.submission_id}\n`));
|
console.log(chalk.gray(`\n 查詢進度:acr parts publish --status ${data.submission_id}\n`));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
spinner.fail(chalk.red(`提交失敗:${e instanceof Error ? e.message : e}`));
|
console.error(chalk.red(`提交失敗:${e instanceof Error ? e.message : e}`));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function loadLocalComponents(): ComponentInfo[] {
|
|
||||||
// 嘗試從相對路徑尋找 registry/components
|
|
||||||
const dirs = [
|
|
||||||
join(process.cwd(), 'registry/components'),
|
|
||||||
join(process.cwd(), '../registry/components'),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const dir of dirs) {
|
|
||||||
if (existsSync(dir)) {
|
|
||||||
const components: ComponentInfo[] = [];
|
|
||||||
for (const name of readdirSync(dir)) {
|
|
||||||
const contractPath = join(dir, name, 'component.contract.yaml');
|
|
||||||
if (existsSync(contractPath)) {
|
|
||||||
const raw = readFileSync(contractPath, 'utf8');
|
|
||||||
const canonical_id = extractYamlScalar(raw, 'canonical_id') ?? name;
|
|
||||||
const display_name = extractYamlScalar(raw, 'display_name') ?? name;
|
|
||||||
const category = extractYamlScalar(raw, 'category') ?? 'other';
|
|
||||||
const description = extractYamlScalar(raw, 'description') ?? '';
|
|
||||||
components.push({ canonical_id, display_name, category, description });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return components;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadLocalContract(componentId: string): string | null {
|
|
||||||
const dirs = [
|
|
||||||
join(process.cwd(), `registry/components/${componentId}/component.contract.yaml`),
|
|
||||||
join(process.cwd(), `../registry/components/${componentId}/component.contract.yaml`),
|
|
||||||
];
|
|
||||||
for (const p of dirs) {
|
|
||||||
if (existsSync(p)) return readFileSync(p, 'utf8');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractYamlScalar(yaml: string, key: string): string | undefined {
|
|
||||||
const m = yaml.match(new RegExp(`^${key}:\\s*["']?([^"'\\n]+)["']?`, 'm'));
|
|
||||||
return m?.[1]?.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractYamlField(yaml: string, field: string): string | undefined {
|
|
||||||
const m = yaml.match(new RegExp(`^${field}:\\s*\\|\\n((?:[ \\t]+[^\\n]*\\n?)*)`, 'm'));
|
|
||||||
return m?.[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractCredentialsRequired(yaml: string): ComponentInfo['credentials_required'] {
|
|
||||||
const section = yaml.match(/credentials_required:\s*([\s\S]*?)(?=\n\w|\n#|$)/);
|
|
||||||
if (!section) return [];
|
|
||||||
const items: ComponentInfo['credentials_required'] = [];
|
|
||||||
const blocks = section[1].split(/\n - /).slice(1);
|
|
||||||
for (const block of blocks) {
|
|
||||||
const key = block.match(/key:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
|
|
||||||
const type = block.match(/type:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
|
|
||||||
const inject_as = block.match(/inject_as:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
|
|
||||||
if (key && type && inject_as) {
|
|
||||||
items!.push({ key, type, inject_as });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|||||||
+77
-47
@@ -1,21 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* acr push <workflow.yaml>
|
* acr push <workflow.yaml>
|
||||||
* 解析三元組,轉成執行圖,直接寫入用戶的 USER_KV(key = workflow:{name})
|
*
|
||||||
|
* 解析 workflow.yaml,透過 /cypher/search 取得執行圖,
|
||||||
|
* 然後 POST 至 cypher.arcrun.dev/webhooks/named(帶 X-Arcrun-API-Key)。
|
||||||
|
* Server 以 {api_key}:wf:{name} 為 KV key 存入 WEBHOOKS KV。
|
||||||
|
*
|
||||||
|
* 不再需要用戶的 CF API Token 或 KV Namespace ID。
|
||||||
*/
|
*/
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import ora from 'ora';
|
import ora from 'ora';
|
||||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||||
import { CfKvClient } from '../lib/cf-api.js';
|
|
||||||
import { loadWorkflowYaml, parseTriplets, validateRelations } from '../lib/yaml-parser.js';
|
import { loadWorkflowYaml, parseTriplets, validateRelations } from '../lib/yaml-parser.js';
|
||||||
|
|
||||||
export async function cmdPush(filePath: string): Promise<void> {
|
export async function cmdPush(filePath: string): Promise<void> {
|
||||||
const config = loadConfig();
|
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();
|
const spinner = ora('解析 workflow.yaml').start();
|
||||||
let workflow;
|
let workflow;
|
||||||
try {
|
try {
|
||||||
workflow = loadWorkflowYaml(filePath);
|
workflow = loadWorkflowYaml(filePath);
|
||||||
spinner.text = `驗證三元組(${workflow.flow.length} 條)`;
|
|
||||||
const triplets = parseTriplets(workflow.flow);
|
const triplets = parseTriplets(workflow.flow);
|
||||||
validateRelations(triplets);
|
validateRelations(triplets);
|
||||||
spinner.succeed(`解析完成:${workflow.name}(${triplets.length} 條三元組)`);
|
spinner.succeed(`解析完成:${workflow.name}(${triplets.length} 條三元組)`);
|
||||||
@@ -24,16 +39,16 @@ export async function cmdPush(filePath: string): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST 到 cypher-executor 取得執行圖
|
|
||||||
const executorUrl = getCypherExecutorUrl(config);
|
const executorUrl = getCypherExecutorUrl(config);
|
||||||
const triplets = parseTriplets(workflow.flow);
|
const headers: Record<string, string> = {
|
||||||
const searchSpinner = ora(`向 ${executorUrl} 解析執行圖`).start();
|
'Content-Type': 'application/json',
|
||||||
|
'X-Arcrun-API-Key': config.api_key,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 向 /cypher/search 取得執行圖
|
||||||
|
const searchSpinner = ora('取得執行圖').start();
|
||||||
let graph: unknown;
|
let graph: unknown;
|
||||||
try {
|
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`, {
|
const res = await fetch(`${executorUrl}/cypher/search`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -46,56 +61,71 @@ export async function cmdPush(filePath: string): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json() as { cypher: unknown; missing: string[] };
|
const data = await res.json() as { cypher: { nodes: unknown[]; edges: unknown[] }; missing: string[] };
|
||||||
if (data.missing.length > 0) {
|
if (data.missing?.length > 0) {
|
||||||
searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`));
|
searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
graph = data.cypher;
|
// 附上 id / name,並將 workflow.config 套入節點(componentId + data)
|
||||||
|
const rawGraph = data.cypher as { nodes: Array<{ id: string; componentId?: string; data?: Record<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('執行圖解析完成');
|
searchSpinner.succeed('執行圖解析完成');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
searchSpinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
searchSpinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 寫入 USER_KV(key = workflow:{name})
|
// POST 至 /webhooks/named
|
||||||
const namespaceId = config.mode === 'standard'
|
const deploySpinner = ora(`部署 "${workflow.name}" 至 ${executorUrl}`).start();
|
||||||
? config.user_kv_namespace_id!
|
|
||||||
: config.webhooks_kv_namespace_id!;
|
|
||||||
|
|
||||||
if (!namespaceId || !config.cloudflare_account_id || !config.cf_api_token) {
|
|
||||||
console.error(chalk.red('缺少 KV 設定,請執行 acr init'));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const kv = new CfKvClient({
|
|
||||||
accountId: config.cloudflare_account_id,
|
|
||||||
namespaceId,
|
|
||||||
apiToken: config.cf_api_token,
|
|
||||||
});
|
|
||||||
|
|
||||||
const kvSpinner = ora('寫入 workflow 至 CF KV').start();
|
|
||||||
try {
|
try {
|
||||||
const workflowDef = {
|
const res = await fetch(`${executorUrl}/webhooks/named`, {
|
||||||
name: workflow.name,
|
method: 'POST',
|
||||||
description: workflow.description ?? '',
|
headers,
|
||||||
graph,
|
body: JSON.stringify({
|
||||||
config: workflow.config ?? {},
|
name: workflow.name,
|
||||||
created_at: new Date().toISOString(),
|
graph,
|
||||||
};
|
config: workflow.config ?? {},
|
||||||
await kv.put(`workflow:${workflow.name}`, JSON.stringify(workflowDef));
|
description: workflow.description ?? '',
|
||||||
kvSpinner.succeed(chalk.green(`✓ workflow "${workflow.name}" 已寫入你的 CF KV`));
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
} catch (e) {
|
||||||
kvSpinner.fail(chalk.red(`KV 寫入失敗:${e instanceof Error ? e.message : e}`));
|
deploySpinner.fail(chalk.red(`部署失敗:${e instanceof Error ? e.message : e}`));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const executorBase = getCypherExecutorUrl(config);
|
|
||||||
const webhookUrl = `${executorBase}/webhooks/${workflow.name}`;
|
|
||||||
console.log(chalk.bold(`\n Webhook URL:${chalk.cyan(webhookUrl)}`));
|
|
||||||
if (config.api_key) {
|
|
||||||
console.log(chalk.gray(` (使用時需帶 X-Arcrun-API-Key: ${config.api_key.slice(0, 8)}...)\n`));
|
|
||||||
}
|
|
||||||
console.log(` 執行:${chalk.cyan(`acr run ${workflow.name}`)}\n`);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* 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 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);
|
||||||
|
const spinner = ora(`上傳 recipe "${recipe.canonical_id}"`).start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${executorUrl}/recipes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Arcrun-API-Key': config.api_key,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(recipe),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
|
||||||
|
|
||||||
|
if (!data.success || !data.recipe) {
|
||||||
|
spinner.fail(chalk.red(`上傳失敗:${data.error ?? '未知錯誤'}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner.succeed(chalk.green(`✓ recipe "${data.recipe.canonical_id}" 上傳成功`));
|
||||||
|
console.log(`\n Hash ID:${chalk.cyan(data.recipe.hash_id)} (穩定引用,不受改名影響)`);
|
||||||
|
console.log(` Endpoint:${chalk.gray(data.recipe.endpoint)}`);
|
||||||
|
console.log(chalk.bold('\n 在 workflow config 中使用:\n'));
|
||||||
|
console.log(chalk.cyan(` config:`));
|
||||||
|
console.log(chalk.cyan(` my_node:`));
|
||||||
|
console.log(chalk.cyan(` component: ${data.recipe.canonical_id} # 或用 hash: ${data.recipe.hash_id}`));
|
||||||
|
if (data.recipe.credentials_required?.length) {
|
||||||
|
console.log(chalk.yellow(`\n 此 recipe 需要 credentials:${data.recipe.credentials_required.map(c => c.key).join(', ')}`));
|
||||||
|
console.log(chalk.gray(' 執行 acr creds push 上傳 token'));
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
} catch (e) {
|
||||||
|
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdRecipeList(): Promise<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-1
@@ -13,15 +13,17 @@ import { cmdPush } from './commands/push.js';
|
|||||||
import { cmdRun } from './commands/run.js';
|
import { cmdRun } from './commands/run.js';
|
||||||
import { cmdValidate } from './commands/validate.js';
|
import { cmdValidate } from './commands/validate.js';
|
||||||
import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js';
|
import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js';
|
||||||
|
import { cmdRecipePush, cmdRecipeList, cmdRecipeDelete } from './commands/recipe.js';
|
||||||
import { cmdList } from './commands/list.js';
|
import { cmdList } from './commands/list.js';
|
||||||
import { cmdLogs } from './commands/logs.js';
|
import { cmdLogs } from './commands/logs.js';
|
||||||
|
import { cmdAuthRecipeList, cmdAuthRecipeInfo, cmdAuthRecipeScaffold } from './commands/auth-recipe.js';
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name('acr')
|
.name('acr')
|
||||||
.description('arcrun — AI Workflow CLI for Cloudflare Workers + WASM')
|
.description('arcrun — AI Workflow CLI for Cloudflare Workers + WASM')
|
||||||
.version('1.0.0');
|
.version('1.1.0');
|
||||||
|
|
||||||
// acr init [--self-hosted]
|
// acr init [--self-hosted]
|
||||||
program
|
program
|
||||||
@@ -76,6 +78,36 @@ partsCmd
|
|||||||
.option('--status <submission_id>', '查詢提交審核進度')
|
.option('--status <submission_id>', '查詢提交審核進度')
|
||||||
.action((dir: string, options: { status?: string }) => cmdPartsPublish(dir, options));
|
.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
|
// acr list
|
||||||
program
|
program
|
||||||
.command('list')
|
.command('list')
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import yaml from 'js-yaml';
|
|||||||
export interface ArcrunConfig {
|
export interface ArcrunConfig {
|
||||||
mode: 'local' | 'standard' | 'self-hosted';
|
mode: 'local' | 'standard' | 'self-hosted';
|
||||||
// Standard 模式
|
// 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;
|
cloudflare_account_id?: string;
|
||||||
user_kv_namespace_id?: string;
|
user_kv_namespace_id?: string;
|
||||||
cf_api_token?: string;
|
cf_api_token?: string;
|
||||||
api_key?: string; // arcrun.dev API Key(ak_前綴)
|
|
||||||
// Self-hosted 模式
|
|
||||||
cypher_executor_url?: string;
|
cypher_executor_url?: string;
|
||||||
credentials_kv_namespace_id?: string;
|
credentials_kv_namespace_id?: string;
|
||||||
webhooks_kv_namespace_id?: string;
|
webhooks_kv_namespace_id?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user