feat(self-hosted): acr init --self-hosted installer + recipe push 把關 + commit 部署 wasm
讓任何 CC 用自己的 CF 帳號一鍵 self-host arcrun(戰法轉 self-hosted 開源)。
Task 1 — acr init --self-hosted installer(用戶只給 CF Account ID + token,其餘自動):
- cli/src/lib/cf-api.ts: CfAccountClient(驗 token / 建 KV 冪等 / 建 R2 / 查 workers.dev subdomain)
- cli/src/lib/deploy.ts: 從 GitHub codeload tarball 拉部署物 → 注入用戶 KV id → wrangler deploy
(tier1 component-builds 先、tier2 cypher-executor/registry 後;部分失敗誠實回報不假綠)
- cli/src/lib/api-recipe-seeds.ts: 10 個現役 API recipe 種子(KBDB 採 Supabase 模式)
- cli/src/commands/init.ts: initSelfHosted() 改寫成 installer 流程
- cli/src/commands/update.ts: acr update(拉新 ref 重部署)
- cypher-executor/scripts/seed-api-recipes.ts: prod 補灌腳本
Task 2 — recipe 入庫把關(封鎖自製零件後,CC 唯一能擴充的是 recipe):
- cli/src/commands/recipe.ts: 新增 probeRecipeEndpoint 打通檢查(提醒級不硬擋,
含模板誠實說明待 run 才知,401/403 標多半缺 credential 非 bug)
- 資料外流提醒沿用既有 obtainExposureConsent(非 TTY 拒絕)
部署物產製:commit 預編譯 wasm 進 repo(推翻 rule 05「wasm 不 commit」):
- .gitignore: 放行 .component-builds/**/component.wasm(registry 中間產物仍排除)
- 只 commit 19 個正當零件 wasm;claude_api / km_writer / kbdb_upsert_block 排除
(非薄殼、是把工作流硬塞進零件,違反 DECISIONS §1,待降級)
- rule 05 同步記錄此慣例變更 + 膨脹 trade-off
SDD: sdk-and-website/self-hosted-init.md(installer 定案)、
component-gatekeeping/recipe-push-gatekeeping.md(recipe 把關)
README 重寫成單一 self-hosted 路徑。CLI typecheck exit 0。
未完(待 richblack):push 此 commit 到 GitHub 後 codeload 才拿得到 wasm;
用第二 CF 帳號端對端驗收 acr init --self-hosted。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+130
-13
@@ -8,6 +8,16 @@ 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';
|
||||
|
||||
@@ -102,32 +112,139 @@ async function initStandard(rl: ReturnType<typeof createInterface>): Promise<voi
|
||||
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 模式:自行部署所有 Worker 到你的 Cloudflare 帳號\n'));
|
||||
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 cypherUrl = await prompt(rl, 'Cypher Executor URL(部署後的 workers.dev URL)');
|
||||
const webhooksKvId = await prompt(rl, 'WEBHOOKS KV Namespace ID');
|
||||
const credentialsKvId = await prompt(rl, 'CREDENTIALS_KV Namespace ID');
|
||||
const wasmBucket = await prompt(rl, 'WASM_BUCKET 名稱');
|
||||
const cfApiToken = await prompt(rl, 'CF API Token(KV Edit 權限)');
|
||||
const 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,
|
||||
cypher_executor_url: cypherUrl,
|
||||
webhooks_kv_namespace_id: webhooksKvId,
|
||||
credentials_kv_namespace_id: credentialsKvId,
|
||||
wasm_bucket: wasmBucket,
|
||||
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();
|
||||
|
||||
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml'));
|
||||
console.log(chalk.green(' ✓ 建立 credentials.yaml\n'));
|
||||
// 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 {
|
||||
|
||||
@@ -100,6 +100,12 @@ export async function cmdRecipePush(filePath: string): Promise<void> {
|
||||
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:`));
|
||||
@@ -115,6 +121,52 @@ export async function cmdRecipePush(filePath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打通檢查: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);
|
||||
|
||||
@@ -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] ?? '';
|
||||
}
|
||||
@@ -16,6 +16,7 @@ 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();
|
||||
@@ -120,4 +121,10 @@ program
|
||||
.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',
|
||||
},
|
||||
];
|
||||
@@ -75,6 +75,91 @@ export class CfKvClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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。*/
|
||||
const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'richblack/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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user