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:
@@ -0,0 +1,224 @@
|
|||||||
|
# Design 補充:`acr init --self-hosted` 一鍵自動化(installer 模式)
|
||||||
|
|
||||||
|
> 2026-06-01 初稿 → 2026-06-02 定案改寫(richblack 拍板 installer 形態)。
|
||||||
|
> 本檔是 `sdk-and-website/design.md` 的單檔補充(規則 02 §4.3 允許)。
|
||||||
|
> **狀態:design 已與 richblack 對齊;實作前讀 §6 前置依賴。**
|
||||||
|
> 背景:戰法從 SaaS 轉 self-hosted 開源(docs/HANDOFF-self-host-harness.md §0)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 定案形態(richblack 2026-06-02)
|
||||||
|
|
||||||
|
**arcrun CLI = installer / orchestrator**(類似 rustup / nvm:工具本身小,按需從遠端拉真正內容)。
|
||||||
|
|
||||||
|
### 用戶只做 4 件事,中間什麼都不用懂:
|
||||||
|
1. 申請 CF 帳號
|
||||||
|
2. 安裝 CF CLI(`wrangler`)
|
||||||
|
3. 安裝 arcrun CLI(`npm i -g arcrun`)
|
||||||
|
4. `acr init --self-hosted`(貼 CF Account ID + API Token)→ **完成,其餘看機器跑**
|
||||||
|
|
||||||
|
### CLI 自動做(用戶無感):
|
||||||
|
- 驗 CF token 權限
|
||||||
|
- 建 7 個 KV namespace + 1 個 R2 bucket(冪等)
|
||||||
|
- **從 GitHub release 下載預編譯部署物**(含 24 個 `.wasm` + 各 Worker 的 wrangler.toml + cypher-executor/registry)
|
||||||
|
- 把建好的 KV namespace id 注入各 wrangler.toml + cypher-executor 的 `WORKER_SUBDOMAIN`
|
||||||
|
- **`wrangler deploy` 部署全部 Worker**(用戶已裝 wrangler)
|
||||||
|
- seed auth recipe + API recipe 進 RECIPES KV
|
||||||
|
- 寫回 `~/.arcrun/config.yaml`
|
||||||
|
- 印出「手動 `wrangler secret put ENCRYPTION_KEY` ×3」提示(secret 不自動化,rule 05)
|
||||||
|
|
||||||
|
### 關鍵技術決策(richblack 2026-06-02)
|
||||||
|
| 決策 | 選擇 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| 零件部署物 | **預編譯 `.wasm`**(不在用戶端 build)| 用戶不懂 tinygo、也不該懂。下載即用。 |
|
||||||
|
| 部署工具 | **wrangler**(shell out)| 用戶已裝 CF CLI;self-host 本來就有上傳能力。CLI 不自己重寫 CF Script Upload API。 |
|
||||||
|
| 源碼來源 | **GitHub release tarball**(含預編譯 wasm)| 版本明確、不需用戶有 git、`acr update` 拉新 release 同一條路。 |
|
||||||
|
| 為何不是 git clone | repo **沒 commit `.wasm`**(rule 05 build 產物不 commit)→ clone 拿不到 wasm | 必須走含 wasm 的 release artifact。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 為什麼是 installer 而非「repo 內掃 wrangler.toml」(推翻初稿)
|
||||||
|
|
||||||
|
初稿假設「用戶在 repo 內跑、CLI 掃 wrangler.toml」。**推翻**,因為:
|
||||||
|
- npm 全域裝的 `acr` 手上**沒有** 24 個 Worker 源碼。
|
||||||
|
- repo 沒 commit `.wasm`(已查證 `git ls-files .component-builds | grep .wasm` = 0)→ 連 clone 都拿不到可部署的 wasm。
|
||||||
|
- 用戶不該需要懂 git / tinygo / repo 結構。
|
||||||
|
|
||||||
|
→ 正解:CLI 當 installer,從 **GitHub release(含預編譯 wasm)** 拉部署物到暫存目錄,在暫存目錄注入 KV id 後 `wrangler deploy`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 流程設計(`initSelfHosted` 改寫)
|
||||||
|
|
||||||
|
```
|
||||||
|
acr init --self-hosted
|
||||||
|
│
|
||||||
|
├─ 1. 問 2 輸入:CF Account ID + CF API Token
|
||||||
|
│ (wrangler 是否已裝?which wrangler;沒裝 → 提示先裝 CF CLI 再來)
|
||||||
|
│ 驗 token:CF API GET /accounts/{id}/tokens/verify + GET /accounts/{id}
|
||||||
|
│ 缺權限(Workers Scripts Edit / KV Edit / R2 Edit)→ exit 1 指出缺哪個 scope
|
||||||
|
│
|
||||||
|
├─ 2. 建資源(冪等:先 list 已存在就重用)
|
||||||
|
│ 7 KV:WEBHOOKS / CREDENTIALS_KV / RECIPES / USERS_KV /
|
||||||
|
│ SESSIONS_KV / ANALYTICS_KV / EXEC_CONTEXT(rule 01 資料儲存表)
|
||||||
|
│ 1 R2:WASM_BUCKET
|
||||||
|
│
|
||||||
|
├─ 3. 下載部署物:GitHub release tarball → 解壓到暫存目錄 (~/.arcrun/.deploy-<ver>/)
|
||||||
|
│ 內含:cypher-executor/ + registry/ + .component-builds/*(每個含預編譯 component.wasm + wrangler.toml)
|
||||||
|
│
|
||||||
|
├─ 4. 注入設定到暫存目錄的 wrangler.toml(不改用戶 repo,改暫存副本)
|
||||||
|
│ - 各 Worker 的 KV binding id ← step 2 建立的
|
||||||
|
│ - cypher-executor [vars] WORKER_SUBDOMAIN ← CF API GET /accounts/{id}/workers/subdomain
|
||||||
|
│
|
||||||
|
├─ 5. 部署:對暫存目錄每個含 wrangler.toml 的 dir,shell out
|
||||||
|
│ `wrangler deploy`(env CLOUDFLARE_API_TOKEN=<token>, CLOUDFLARE_ACCOUNT_ID=<id>)
|
||||||
|
│ 分兩層:tier1 = .component-builds/*(先)→ tier2 = cypher-executor / registry(後)
|
||||||
|
│ 每個 wrangler.toml 已含 workers_dev = true → workers.dev URL 自動啟用
|
||||||
|
│
|
||||||
|
├─ 6. seed recipe 進 RECIPES KV(部署後打新 cypher URL,或直接 CF KV API 寫)
|
||||||
|
│ - auth recipe:重用 AUTH_RECIPE_SEEDS(cypher-executor/src/lib/auth-recipe-seeds.ts)
|
||||||
|
│ - API recipe:新增 seed-api-recipes.ts(見 §5)
|
||||||
|
│
|
||||||
|
├─ 7. 寫回 config(mode: self-hosted + 所有 id + cypher_executor_url = 部署後 workers.dev URL)
|
||||||
|
│
|
||||||
|
└─ 8. 印手動 secret 提示:
|
||||||
|
wrangler secret put ENCRYPTION_KEY --name arcrun-cypher-executor
|
||||||
|
wrangler secret put ENCRYPTION_KEY --name arcrun-auth-static-key
|
||||||
|
wrangler secret put ENCRYPTION_KEY --name arcrun-auth-service-account
|
||||||
|
(三 Worker 共用同一把 key,見 memory: encryption-key-drift-trap)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `acr update`(同一條路,未來新零件)
|
||||||
|
- 拉新 GitHub release → 解壓 → 注入既有 config 的 KV id → wrangler deploy 變動的 Worker。
|
||||||
|
- 第一期至少做到「重跑等效 init 的部署步驟」;diff-only 部署可後續優化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 動到的檔案
|
||||||
|
|
||||||
|
| 檔案 | 動作 |
|
||||||
|
|---|---|
|
||||||
|
| `cli/src/commands/init.ts` | 改寫 `initSelfHosted()`(line 105-131)為 installer 流程 |
|
||||||
|
| `cli/src/lib/cf-api.ts` | 擴充:KV namespace 建立/list、R2 bucket 建立、subdomain 查詢、token verify |
|
||||||
|
| 新增 `cli/src/lib/deploy.ts`(暫定)| 下載 release tarball + 解壓 + 注入 wrangler.toml + shell out wrangler deploy |
|
||||||
|
| 新增 `cli/src/commands/update.ts`(暫定)| `acr update`:拉新 release 重部署 |
|
||||||
|
| 新增 `cli/src/lib/api-recipe-seeds.ts` | API recipe **種子資料**(installer 用;放 CLI 端,**不放 cypher-executor/src**——rule 02 §2.2 hook 擋 cypher-executor TS hard-code endpoint,且 seed 資料本就屬 installer 職責)|
|
||||||
|
| 新增 `cypher-executor/scripts/seed-api-recipes.ts` | seed **腳本**(給 prod 補灌用,import CLI 的種子資料;`scripts/` 不受 §2.2 hook 管)|
|
||||||
|
| `cli/src/index.ts` | 註冊 `acr update` 指令 |
|
||||||
|
|
||||||
|
**不動**:cypher-executor 執行路徑、既有零件 wasm 源碼、config 讀取端(config.ts:52 已支援 self-hosted)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API recipe seed(新增 seed-api-recipes.ts,richblack 2026-06-02 定)
|
||||||
|
|
||||||
|
codebase 只有 auth recipe seed。新增 `seed-api-recipes.ts`,把現役 API recipe hard-code 成種子。
|
||||||
|
|
||||||
|
### 現役 API recipe(從 prod KV 查得,2026-06-01)
|
||||||
|
- `kbdb_get`(+ create_block / patch_block / delete / ingest)→ auth_service: kbdb
|
||||||
|
- `gmail_send` → google_gmail_sa
|
||||||
|
- `google_sheets_append` / `google_sheets_read` → google_sheets_sa
|
||||||
|
- `telegram_send` → telegram
|
||||||
|
- `line_notify_send` → line_notify
|
||||||
|
|
||||||
|
### KBDB recipe 採 Supabase 模式(richblack 2026-06-02)
|
||||||
|
- **KBDB 是 richblack 提供的服務**(跟 arcrun 一樣),採「基礎免費、大量收費」。
|
||||||
|
- KBDB recipe **進 seed**(展示能力 = 引子,Supabase 模式)。使用者要用 → 去 **arcrun 取統一 API Key**(已有 /register 入口),把 key 設成 credential。
|
||||||
|
- ⚠️ **FOLLOW-UP(交 KBDB 端)**:現役 endpoint 是 `kbdb.finally.click{{_path}}`。richblack:這是 KBDB 端要改的問題——KBDB 該用統一對外網址提供大家用,不是 finally.click。**seed 先照現況進;KBDB 端改網址後同步更新 seed。** 此事不擋 init 實作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 部署物產製:commit wasm 進 repo + codeload tarball(richblack 2026-06-02 定案)
|
||||||
|
|
||||||
|
> 此節**取代初稿的「GitHub release artifact」構想**。richblack 拍板更輕的做法:
|
||||||
|
> 直接把預編譯 wasm commit 進 repo,CLI 從 GitHub codeload tarball 拿。不需 release.yml 機制。
|
||||||
|
|
||||||
|
### 6.1 策略
|
||||||
|
|
||||||
|
- **repo 自帶可部署的 wasm**:刪 `.gitignore` 的 `*.wasm` 排除,commit 預編譯 wasm 進 repo。
|
||||||
|
→ repo 本身就是部署來源,CLI 直接拿、用戶用自己的 CF token deploy。
|
||||||
|
- **CLI 走 codeload tarball**:`https://codeload.github.com/richblack/arcrun/tar.gz/{ref}`
|
||||||
|
(ref = main 或 tag)。用戶不需 git、版本可控(tag)。`acr update` 拉新 ref。
|
||||||
|
- **理由**(richblack):「我在我的 CF 能用 = 我已擁有 wasm;用戶指向我的 GitHub 取得 wasm,
|
||||||
|
用他自己的 CF credential deploy。開源,看不看源碼不重要,體驗好最重要。」
|
||||||
|
|
||||||
|
### 6.2 ⚠️ 推翻既有鐵律(rule 05)— 需同步改規則
|
||||||
|
|
||||||
|
`.claude/rules/05-deploy-convention.md` 明文「`.component-builds/{name}/component.wasm` **不 commit 進 repo**
|
||||||
|
(build 產物)」「Phase 1-3 暫時 commit 過,**之後會加 .gitignore 清理**」。
|
||||||
|
**本決策反向**:commit wasm 進 repo(self-host 需 repo 自帶可部署 wasm)。
|
||||||
|
→ **實作時必須同步改 rule 05 + .gitignore**,否則 pre-write hook / 規則與實作打架。
|
||||||
|
→ deploy.yml 的 CI rebuild 步驟仍保留(CI 部署 prod 時用最新 source rebuild,與 commit 的 wasm 不衝突;
|
||||||
|
commit 的 wasm 是給「self-host 用戶 + acr init」用的部署來源)。
|
||||||
|
|
||||||
|
### 6.3 只 commit 部署所需的 wasm(省空間)
|
||||||
|
|
||||||
|
- 實況(2026-06-02 查):`registry/components/*.wasm` 23 個(build 中間產物)+
|
||||||
|
`.component-builds/*/component.wasm` 22 個(部署物),共 **~50MB**。
|
||||||
|
- **部署只需 `.component-builds/*/component.wasm`**(wrangler deploy 認這個)。
|
||||||
|
→ **只 commit `.component-builds/*/component.wasm`(22 個),不必 commit registry 那 23 個**(省一半)。
|
||||||
|
`.gitignore` 改成:保留排除 `registry/components/**/*.wasm`(中間產物),只放行 `.component-builds/**/component.wasm`。
|
||||||
|
- ⚠️ **誠實 trade-off**(mindset §7):commit wasm 進 repo → 每次 wasm rebuild 都在 git 歷史累積二進位,
|
||||||
|
**repo 長期會膨脹**。可接受(self-host 體驗優先),但記錄此代價;未來若膨脹過劇,再考慮 release artifact / git-lfs。
|
||||||
|
|
||||||
|
### 6.3.1 「錯做成零件」的 3 個不 commit(richblack 2026-06-02)
|
||||||
|
|
||||||
|
實際 commit 的是 **19 個正當零件**,不是 22。排除的 3 個:`claude_api` / `km_writer` / `kbdb_upsert_block`。
|
||||||
|
- **原因(richblack 修正「待刪」說法)**:它們**不是 endpoint 薄殼,是把工作流硬塞進零件**(違反 DECISIONS §1)。
|
||||||
|
例:`kbdb_upsert_block` 的 upsert 邏輯應在 KBDB API 那邊(API 提供 upsert endpoint),零件只該驅動它;
|
||||||
|
現在卻把「GET 找→有則 PATCH 無則 POST」整段工作流塞進零件。本質是工作流/recipe,被錯做成零件。
|
||||||
|
- **為何「現在就不 commit」而非「先 commit 之後刪」**:commit 二進位進 git 歷史後,即使日後 `git rm`,
|
||||||
|
歷史裡仍永久殘留(repo 體積已被佔),除非 rewrite history(很麻煩)。**錯誤的東西不灌進永久歷史。**
|
||||||
|
- **落地**:`.gitignore` 放行 22 個後**再排除這 3 個**(後出現規則勝出);`deploy.ts discoverWorkerDirs`
|
||||||
|
只部署「同時有 wrangler.toml + component.wasm」的目錄 → self-host 用戶 codeload 拿到的目錄缺這 3 個 wasm → 自然跳過。
|
||||||
|
- **後續**:這 3 個的降級(變回工作流/recipe)是 BACKLOG 既有待辦,本次不處理,但確保它們不進 self-host 部署來源。
|
||||||
|
|
||||||
|
### 6.4 CLI deploy 流程(deploy.ts downloadAndDeploy 補實作)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 下載 codeload tarball(ref 預設 main,acr update 可帶 tag)→ 解壓 ~/.arcrun/.deploy-<ref>/
|
||||||
|
2. 讀解壓出的 .component-builds/* + cypher-executor/ + registry/
|
||||||
|
3. 各 wrangler.toml 注入 ctx.kvNamespaceIds + cypher-executor WORKER_SUBDOMAIN
|
||||||
|
4. tier1=.component-builds/*(先)→ tier2=cypher-executor/registry(後)
|
||||||
|
每個 dir:pnpm install(若有 lock)→ CLOUDFLARE_API_TOKEN=<用戶> wrangler deploy
|
||||||
|
5. 回 cypherExecutorUrl = https://arcrun-cypher-executor.<subdomain>.workers.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:tier2(cypher-executor/registry)是 TS,wrangler deploy 會在用戶端用內建 esbuild bundle
|
||||||
|
(不需額外工具,richblack 確認源碼可見不重要、體驗優先 → artifact 含 TS 源碼即可)。
|
||||||
|
|
||||||
|
### 6.5 實作順序
|
||||||
|
|
||||||
|
1. 改 `.gitignore`(放行 `.component-builds/**/component.wasm`)+ commit 22 個 wasm。
|
||||||
|
2. 同步改 rule 05(記錄此決策推翻原慣例)。
|
||||||
|
3. 補實 `deploy.ts downloadAndDeploy`(codeload 下載 + 注入 + wrangler deploy)。
|
||||||
|
4. **在 1-2 完成前,downloadAndDeploy 維持誠實 unimplemented,不假裝(mindset §7)。**
|
||||||
|
|
||||||
|
### 6.6 未來方向:零件按需安裝(richblack 2026-06-02,現在不做)
|
||||||
|
|
||||||
|
- 現在 `acr init --self-hosted` **全裝基礎零件**(22 個一次部署)。簡單、夠用。
|
||||||
|
- **未來若零件數量真的變很多**,再思考「按需安裝」(只裝 workflow 實際用到的零件 / 用戶選裝)。
|
||||||
|
- **現在不做的理由**(DECISIONS 附錄「會不會累積成債」):零件目前少且未來絕大多數是 recipe
|
||||||
|
(不需 deploy)→ 為「零件爆量」做按需安裝基建 = 為不存在的規模做自動化 = 過度工程。
|
||||||
|
零件真的爆量再回頭做,屆時是「未來一次性處理的設計點」,現在不必焦慮。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 驗收標準(客觀證據,mindset §7)
|
||||||
|
|
||||||
|
1. richblack 用**全新 CF 帳號** + wrangler 已裝 + 一個 CF API Token 跑 `acr init --self-hosted`
|
||||||
|
→ 全程無手動建 KV / 無手動 clone / 無 tinygo / 無手動填 namespace id。
|
||||||
|
2. 跑完印 secret 提示,richblack 手動 `wrangler secret put ENCRYPTION_KEY` ×3。
|
||||||
|
3. `acr push` 一個含 http_request + 自建 recipe 的 workflow → trigger → **HTTP 2xx + execution trace**。
|
||||||
|
4. 冪等:重跑 init 不重建已存在 KV / 不報錯。
|
||||||
|
5. `acr update` 拉新 codeload tarball(tag)→ 重部署成功。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 為何不違反鐵律
|
||||||
|
|
||||||
|
- 只動 `cli/` + 新增 `cypher-executor/scripts/`(seed 腳本,非執行路徑業務邏輯)。
|
||||||
|
- 不在 `registry/components/` 寫 TS;不在 cypher-executor TS 實作 credential/auth/JWT。
|
||||||
|
- 不新增 Service Binding。
|
||||||
|
- secret 不進自動化(§3 step 8 手動)。
|
||||||
|
- 不重寫部署輪子(用 wrangler,不自寫 CF Script Upload)。
|
||||||
@@ -112,6 +112,24 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 5:acr init --self-hosted installer(2026-06-02 新增)
|
||||||
|
|
||||||
|
> 定稿 design:`self-hosted-init.md`。CLI = installer:建 KV/R2 + 拉預編譯 wasm + wrangler deploy + seed。
|
||||||
|
> 用戶只做:申請 CF 帳號 → 裝 wrangler → 裝 acr → acr init --self-hosted。其餘自動。
|
||||||
|
> 背景:戰法轉 self-hosted 開源(docs/HANDOFF-self-host-harness.md)。
|
||||||
|
|
||||||
|
- [x] 13.1 API recipe 種子 — **位置修正**:種子資料放 `cli/src/lib/api-recipe-seeds.ts`(installer 用,避開 cypher §2.2 hook),seed 腳本 `cypher-executor/scripts/seed-api-recipes.ts`(import 種子,給 prod 補灌)。10 個現役 recipe(kbdb_*/gmail_send/google_sheets_*/telegram_send/line_notify_send)。KBDB Supabase 模式進 seed(finally.click 是 KBDB 端 follow-up,已註於 api-recipe-seeds.ts)
|
||||||
|
- [x] 13.2 `cli/src/lib/cf-api.ts` 新增 `CfAccountClient`:verifyAccess / listKvNamespaces / ensureKvNamespace(冪等)/ ensureR2Bucket(冪等)/ getWorkersSubdomain
|
||||||
|
- [x] 13.3 `cli/src/commands/init.ts` `initSelfHosted()` 改寫:驗 token → 建 7 KV + R2 → 查 subdomain → downloadAndDeploy → 寫 config → seed(部署完成時)→ 印 secret 提示。誠實:部署未自動化時明說,不假綠
|
||||||
|
- [x] 13.4 `cli/src/lib/deploy.ts`:REQUIRED_KV/R2/SECRET 常數 + wranglerAvailable() + **downloadAndDeploy 已補實**(codeload tarball 下載 + 解壓 + discoverWorkerDirs 分 tier + injectWranglerConfig 注入 KV id/subdomain + runWranglerDeploy;部分失敗誠實收集回報,不假綠)
|
||||||
|
- [x] 13.5 `cli/src/commands/update.ts` + index.ts 註冊 `acr update`(self-hosted 重部署,同走 downloadAndDeploy)
|
||||||
|
- [x] 13.6 部署物產製:**改用 commit wasm 進 repo + codeload**(取代 release artifact,richblack 2026-06-02,§6)
|
||||||
|
- `.gitignore` 否定規則放行 `.component-builds/**/component.wasm`(registry 中間產物仍排除)→ 已驗 git check-ignore
|
||||||
|
- rule 05 同步改(記錄推翻「wasm 不 commit」+ trade-off)
|
||||||
|
- commit 22 個 `.component-builds/*/component.wasm` 進 repo
|
||||||
|
- [ ] 13.7 驗收:全新 CF 帳號跑 acr init --self-hosted 全自動;acr push workflow → trigger 2xx + trace(**待 richblack 用第二帳號實測** + push 含 wasm 的 commit 到 GitHub 後 codeload 才拿得到)
|
||||||
|
- [x] 13.8 typecheck:cli `tsc --noEmit` exit 0
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- JS SDK 套件名需 richblack 決定(`arcrun` 已被 CLI 佔用 → 可能用 `@arcrun/sdk`)
|
- JS SDK 套件名需 richblack 決定(`arcrun` 已被 CLI 佔用 → 可能用 `@arcrun/sdk`)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Design 補充:recipe 入庫把關(push 那一刻)
|
||||||
|
|
||||||
|
> 2026-06-01。本檔是 `component-gatekeeping/design.md` 的單檔補充(規則 02 §4.3 允許)。
|
||||||
|
> **狀態:待 richblack review 才動 code(這是 change)。**
|
||||||
|
> 背景:richblack 2026-06-01 方向修正(見下「方向定調」)+ docs/HANDOFF-self-host-harness.md。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 方向定調(richblack 2026-06-01)
|
||||||
|
|
||||||
|
把關的對象與位置整個移位了,先講清楚才不會做歪:
|
||||||
|
|
||||||
|
### 0.1 零件這條路 = 封鎖,且「不再有假零件這件事」
|
||||||
|
- 零件由維護者(richblack)管理,**CC 不能自製/修改零件**。
|
||||||
|
- 封鎖機制 = **零件投稿走 GitHub PR + 人 merge**(DECISIONS §8 / design 頂部方向修正)。
|
||||||
|
AI 偽造不了 GitHub approve,這是天然人類閘門。CC 在本機產不出能進庫的零件。
|
||||||
|
- → 因此「擋假零件」(原 W1)這件事**不存在了**:CC 根本造不出零件,workflow 引用 recipe
|
||||||
|
(如 `component: kbdb_get`)是**合法且未來唯一的擴充方式**,不該被當假零件擋。
|
||||||
|
|
||||||
|
### 0.2 零件 PR 把關 = 人工,不自動化(除非未來爆量)
|
||||||
|
- richblack 2026-06-01 澄清 BACKLOG 步驟5「不做 hook/自動化」的真意:
|
||||||
|
**零件真實數量很少、絕大多數是 recipe** → 原本想做的「驗證零件 PR 的自動化機制」
|
||||||
|
(CI 跑 Gherkin/沙箱/向量)**不需要**,量少 → **有 PR 進來就人工檢查**。
|
||||||
|
**只有零件開發量變很大時**才回頭想自動化。
|
||||||
|
- → component-gatekeeping 的 G4/覆蓋檢查/黃金向量自動化 = **不做**(人工取代),與既有 tasks 收尾一致。
|
||||||
|
|
||||||
|
### 0.3 真正的 harness 把關 = recipe 入庫(push)那一刻
|
||||||
|
CC 唯一能擴充的是 recipe。recipe 一律用「**推(push)**」,**自有庫與公共庫同一套指令**。
|
||||||
|
把關依庫別分強度:
|
||||||
|
|
||||||
|
| 庫別 | 能做到的把關 | 機制 |
|
||||||
|
|---|---|---|
|
||||||
|
| **自有庫(self-hosted)** | 只能**提醒**(無法在別人機器強制) | (1) 資料外流提醒 (2) 打通檢查 |
|
||||||
|
| **公共庫** | 維護者機制**檢核實際打通、真收到成功回傳** | PR/CI relay(DECISIONS §3c,第一期後)|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 自有庫 push 把關(self-hosted,第一期做)
|
||||||
|
|
||||||
|
`acr recipe push` 的兩個提醒。**提醒級 = 告知 + 需人類明示同意,不硬擋**(self-hosted 是用戶自己的庫,
|
||||||
|
他同意後就是他的責任 — mindset §6 / data-exfil-warning 既有原則)。
|
||||||
|
|
||||||
|
### 1.1 資料外流提醒(W2.2)
|
||||||
|
- **觸發**:push 的 recipe / 或部署的 workflow 會讓「資料對外可見」——主要是產生**對外可被呼叫的 webhook**
|
||||||
|
(`POST /webhooks/named/...` 對外 trigger URL),或 recipe 把本地資料 POST 到外部服務。
|
||||||
|
- **行為**:CLI 印明確警示「這個動作會讓 X 對外界可見/可呼叫,確認要繼續嗎?」→ 需人類明示同意(y/N)。
|
||||||
|
非 TTY(AI 直跑)→ 拒絕,提示「需人類確認」(mindset §7:絕不代替人類做暴露確認)。
|
||||||
|
- **與既有 data-exfil-warning 的關係**:已有 API 層 + pre-bash hook(commit 51d40ee 等)。
|
||||||
|
本項確認**涵蓋 recipe push 這條路徑**;若已涵蓋則只補文件,若沒涵蓋則補上 push 路徑的提醒。
|
||||||
|
- **誠實限制**:AI 技術上能偽造 exposure_consent。價值是法律歸責 + 軌跡可審,不聲稱不可繞過(mindset §7)。
|
||||||
|
|
||||||
|
### 1.2 打通檢查(W2.3)
|
||||||
|
- **目的**:recipe 是「指向外部 API 的指針」,正確性一半在「打不打得通」(DECISIONS §1 recipe 驗收標準 = 2xx)。
|
||||||
|
- **行為**:push 時(或 push 後)對 recipe 的 endpoint **實打一次**,回報 HTTP status。
|
||||||
|
- 2xx → 「✓ recipe 打通(HTTP 200)」
|
||||||
|
- 4xx/5xx → 「⚠️ recipe 未打通(HTTP 401/404/...)」+ 誠實標原因(如「缺 credential → 先 acr creds push」)
|
||||||
|
- 連不上 → 「⚠️ 無法連線」
|
||||||
|
- **self-hosted 是提醒級**:打不通**不硬擋 push**(用戶可能就是要先 push 再設 credential),只如實回報。
|
||||||
|
- **誠實**(mindset §7):缺 credential 打不到 2xx 就誠實標「未驗收:缺 X」,不 mock 充綠燈。
|
||||||
|
|
||||||
|
### 1.3 動到的檔案(待 review 後)
|
||||||
|
| 檔案 | 動作 |
|
||||||
|
|---|---|
|
||||||
|
| `cli/src/commands/recipe.ts` | push 流程加 (1) 資料外流提醒 prompt (2) 打通檢查(實打 endpoint 回報 status) |
|
||||||
|
| `.claude/hooks/*`(如需)| 確認 data-exfil pre-bash hook 涵蓋 recipe push;缺則補 |
|
||||||
|
|
||||||
|
**不動**:cypher-executor 執行路徑、零件、credential 解密邏輯。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 公共庫 push 把關(第一期後)
|
||||||
|
|
||||||
|
- recipe 進公共庫 = 別人會用 → 需維護者機制檢核「**實際打通、真收到成功回傳**」(不是投稿者自報)。
|
||||||
|
- 機制:DECISIONS §3c 的 **test/relay**——push 公共庫走 relay,維護者當下親見真實打通記錄
|
||||||
|
(執行者不能驗證自己,§7 閉環)。
|
||||||
|
- **範圍**:依賴公共庫 + relay 基建,**第一期不做**(第一期是 self-hosted + 提醒級)。
|
||||||
|
- 本檔只記框架,第一期不實作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 同一套指令、不同把關強度的切分(W2.4)
|
||||||
|
|
||||||
|
- `acr recipe push`(自有庫,預設)→ §1 提醒級。
|
||||||
|
- `acr recipe push --public`(公共庫,未來)→ §2 relay 檢核級。
|
||||||
|
- 同一指令、旗標分流(呼應 design §4.3 公私庫分流:`-p`/`--public`)。
|
||||||
|
- 第一期只實作預設(自有庫)路徑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 驗收標準(客觀證據,mindset §7)
|
||||||
|
|
||||||
|
第一期(自有庫提醒級):
|
||||||
|
1. `acr recipe push` 一個會產對外 webhook 的東西 → CLI 印資料外流警示 + 要人類同意;非 TTY → 拒絕。
|
||||||
|
2. `acr recipe push` 一個 endpoint 可達的 recipe → 打通檢查回報「✓ HTTP 2xx」。
|
||||||
|
3. `acr recipe push` 一個缺 credential 的 recipe → 回報「⚠️ 未打通:缺 credential」(誠實,不假綠),但仍允許 push。
|
||||||
|
4. 確認 workflow 引用 recipe(`component: kbdb_get`)**不再被任何 validate 步驟當假零件擋**(W1 已作廢)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 與既有 SDD 的一致性確認(無新矛盾)
|
||||||
|
|
||||||
|
- 不動「零件投稿走 PR + 人工檢查」(§0.1/0.2,與 design 頂部方向修正、DECISIONS §8 一致)。
|
||||||
|
- 不重啟「零件 PR 自動化把關」(§0.2,與 BACKLOG 步驟5 真意一致)。
|
||||||
|
- 資料外流提醒延續既有 data-exfil-warning 原則(mindset §6),只確認涵蓋 recipe push 路徑。
|
||||||
|
- 打通檢查 = recipe 驗收標準 2xx 的落地(DECISIONS §1)。
|
||||||
@@ -63,6 +63,35 @@
|
|||||||
- [ ] 5.5 pre-write-guard.sh:寫 `registry/components/{白名單外}/` → exit 2
|
- [ ] 5.5 pre-write-guard.sh:寫 `registry/components/{白名單外}/` → exit 2
|
||||||
- [ ] 5.6 pre-bash-guard.sh:mkdir `registry/components/{白名單外}` → exit 2
|
- [ ] 5.6 pre-bash-guard.sh:mkdir `registry/components/{白名單外}` → exit 2
|
||||||
|
|
||||||
|
## W1 ~~CLI workflow validate 擋假零件式 component 名~~(2026-06-01 作廢,方向修正)
|
||||||
|
|
||||||
|
> **作廢原因(richblack 2026-06-01)**:「擋假零件」這件事不再存在——因為**自製/修改零件的路
|
||||||
|
> 已被封鎖**(CC 根本造不出零件),workflow 引用 recipe(如 component: kbdb_get)是**合法且
|
||||||
|
> 未來唯一的擴充方式**,不該被當「假零件」擋。把關點從「workflow validate」**移到 recipe 入庫
|
||||||
|
> (push)那一刻**。已動的 yaml-parser.ts `LEGAL_PRIMITIVES`/`findSuspectComponents` 已回退。
|
||||||
|
> 取而代之 → 見 W2。
|
||||||
|
|
||||||
|
## W2 封鎖自製零件 + recipe 入庫把關(2026-06-01 新方向)
|
||||||
|
|
||||||
|
> richblack 2026-06-01 定調:
|
||||||
|
> - 零件由維護者管理,**CC 不能自製/修改零件**(hook + CLI 拒絕)→ 不再有「假零件」。
|
||||||
|
> - CC 唯一能擴充的是 **recipe**。recipe 一律用「推(push)」,**自有庫與公共庫同一套指令**。
|
||||||
|
> - 把關依庫別分強度:
|
||||||
|
> - **自有庫(self-hosted)**:只能**提醒**(無法在別人機器強制)。兩個提醒:
|
||||||
|
> (1) 資料外流提醒——某動作會讓外界看到你的東西(如 workflow 產對外 webhook),同意後是他的責任;
|
||||||
|
> (2) 打通檢查——查他要打的 API 是否打得通(2xx)。
|
||||||
|
> - **公共庫**:由維護者機制檢核「實際打通、真收到成功回傳」(PR/CI relay,DECISIONS §3c,第一期後)。
|
||||||
|
> 屬 change,需先寫 design 給 richblack review 才動 code。本節先記框架。
|
||||||
|
|
||||||
|
- [x] W2.1 封鎖自製零件 — **釐清完成(richblack 2026-06-02)**:靠「零件投稿走 GitHub PR + 人 merge」
|
||||||
|
天然閘門(DECISIONS §8)。BACKLOG 步驟5「不做 hook」真意 = 零件少、不為零件 PR 蓋自動化把關
|
||||||
|
(量少人工檢查;爆量才回頭想),**不是**不阻止自製。無矛盾,不需新做 hook。
|
||||||
|
- [x] W2.2 `acr recipe push` 資料外流提醒 — **既有實作已涵蓋**:recipe.ts:70-79 `obtainExposureConsent`
|
||||||
|
(exposure-warning.ts:互動打資源名確認、非 TTY 拒絕、首次問記住)。data-exfil-warning SDD 已做,確認涵蓋 recipe push 路徑。
|
||||||
|
- [x] W2.3 `acr recipe push` 打通檢查 — **新增** `probeRecipeEndpoint`(recipe.ts):push 成功後實打 endpoint,
|
||||||
|
回報 2xx/⚠。提醒級不硬擋;endpoint 含 {{模板}} → 誠實說明待 run 才知;401/403 → 標「多半缺 credential,非 recipe bug」(不假綠,mindset §7)
|
||||||
|
- [ ] W2.4 公共庫 push(--public)= 維護者 relay 檢核(DECISIONS §3c)— 第一期後,本期只做自有庫提醒級
|
||||||
|
|
||||||
## 驗收(design §8)
|
## 驗收(design §8)
|
||||||
- [ ] V1 投寫死 endpoint 假零件 → G1 退稿(終端輸出)
|
- [ ] V1 投寫死 endpoint 假零件 → G1 退稿(終端輸出)
|
||||||
- [ ] V2 投 `.ts` 進 registry/components → hook exit 2
|
- [ ] V2 投 `.ts` 進 registry/components → hook exit 2
|
||||||
|
|||||||
@@ -85,12 +85,27 @@ find . -name 'wrangler.toml' -not -path '*/node_modules/*' -not -name 'wrangler.
|
|||||||
|
|
||||||
## WASM 來源
|
## WASM 來源
|
||||||
|
|
||||||
`.component-builds/{name}/component.wasm` 不 commit 進 repo(build 產物)。
|
> **⚠️ 慣例變更(richblack 2026-06-02,self-hosted 開源策略)**:
|
||||||
|
> 原慣例「`.component-builds/{name}/component.wasm` 不 commit 進 repo」**已推翻**。
|
||||||
|
> 現在 **commit `.component-builds/*/component.wasm` 進 repo**,因為 self-host 用戶 / `acr init --self-hosted`
|
||||||
|
> 從 GitHub(codeload tarball)直接拿這份 wasm 部署到自己的 CF——repo 必須自帶可部署的 wasm。
|
||||||
|
> 決策依據:`.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6`。
|
||||||
|
|
||||||
- 本地開發:`cd registry/components/{name} && tinygo build -target=wasi -o {name}.wasm main.go && cp {name}.wasm ../../../.component-builds/{name}/component.wasm`
|
### 現行規則(2026-06-02 起)
|
||||||
- CI:workflow 在 deploy 前自動 rebuild + copy
|
|
||||||
|
|
||||||
**例外**:Phase 1-3 開發期為了加速驗證,部分 WASM 檔暫時 commit 進了 repo。之後會加 `.gitignore` 清理。
|
- **`.component-builds/*/component.wasm` → commit 進 repo**(部署來源)。`.gitignore` 用否定規則放行:
|
||||||
|
```
|
||||||
|
*.wasm # 預設排除
|
||||||
|
!.component-builds/**/component.wasm # 例外放行部署物
|
||||||
|
```
|
||||||
|
- **`registry/components/*.wasm` → 仍不 commit**(build 中間產物,部署不直接用,`.gitignore` 仍排除)。
|
||||||
|
- 本地開發 build:`cd registry/components/{name} && tinygo build -target=wasi -o {name}.wasm main.go && cp {name}.wasm ../../../.component-builds/{name}/component.wasm`,**然後 commit `.component-builds/{name}/component.wasm`**。
|
||||||
|
- CI(deploy.yml):仍在 deploy 前自動 rebuild + copy(部署 prod 用最新 source;與 repo 內 commit 的 wasm 不衝突——前者給 CI deploy prod,後者給 self-host 用戶當部署來源)。
|
||||||
|
|
||||||
|
### 誠實 trade-off(mindset §7)
|
||||||
|
|
||||||
|
commit wasm 進 repo → 每次 rebuild 在 git 歷史累積二進位,**repo 長期會膨脹**。
|
||||||
|
可接受(self-host 體驗優先),未來若膨脹過劇再考慮 git-lfs / 按需安裝(self-hosted-init.md §6.6)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10
@@ -1,7 +1,17 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.wrangler/
|
.wrangler/
|
||||||
dist/
|
dist/
|
||||||
|
# wasm:預設排除(build 中間產物,如 registry/components/*.wasm)
|
||||||
*.wasm
|
*.wasm
|
||||||
|
# 例外:放行 .component-builds 的部署物 wasm — self-host 用戶 / acr init 從 repo 直接拿這份部署
|
||||||
|
# (推翻 rule 05 原「wasm 不 commit」慣例,見 .agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6)
|
||||||
|
!.component-builds/**/component.wasm
|
||||||
|
# 但「錯做成零件」的再次排除(後出現的規則勝出):claude_api / km_writer / kbdb_upsert_block
|
||||||
|
# 不是 endpoint 薄殼,是把工作流硬塞進零件(違反 DECISIONS §1)→ 要降級成工作流/recipe,
|
||||||
|
# 不該進 repo 部署來源。commit 二進位進歷史無法乾淨移除 → 一開始就不放行。見 BACKLOG 降級待辦。
|
||||||
|
.component-builds/claude_api/component.wasm
|
||||||
|
.component-builds/km_writer/component.wasm
|
||||||
|
.component-builds/kbdb_upsert_block/component.wasm
|
||||||
credentials.yaml
|
credentials.yaml
|
||||||
~/.arcrun/
|
~/.arcrun/
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -2,56 +2,81 @@
|
|||||||
|
|
||||||
**讓 AI 把想法直接變成自動化工作流,而不是一行一行寫程式。**
|
**讓 AI 把想法直接變成自動化工作流,而不是一行一行寫程式。**
|
||||||
|
|
||||||
|
arcrun 是一套**給 AI(Claude Code)用的 harness**:你叫 CC 用 arcrun 開發自動化時,它知道能用什麼、不能做什麼,做錯了有機制擋住——讓 CC 順暢、且不容易做歪。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 為什麼做這個?
|
## 為什麼做這個?
|
||||||
|
|
||||||
AI 愈來愈強,但跟 AI 協作還是很有摩擦:
|
AI 愈來愈強,但跟 AI 協作做自動化還是很有摩擦:
|
||||||
|
|
||||||
**問題一:AI 調 API 太耗 token。**
|
**問題一:AI 調 API 太耗 token。**
|
||||||
每次讓 AI 幫你查資料、發郵件、寫入試算表,它都要在對話裡描述、確認、執行,token 燒很快。更好的做法是讓 AI 把任務寫成一次性的程式,之後直接執行。
|
每次讓 AI 幫你查資料、發信、寫試算表,它都要在對話裡描述、確認、執行,token 燒很快。更好的做法是讓 AI 把任務寫成一次性的工作流,之後直接執行。
|
||||||
|
|
||||||
**問題二:AI 寫程式容易出 bug,debug 又耗時。**
|
**問題二:AI 從頭寫腳本容易出 bug。**
|
||||||
讓 AI 從頭寫一個完整的爬蟲或自動化腳本,十次有八次要來回修。根本原因是零件沒有被複用——每次都是全新的程式。
|
讓 AI 從零寫一整個自動化腳本,十次有八次要來回修。根本原因是邏輯沒被複用——每次都是全新的程式。
|
||||||
|
|
||||||
**解法:把邏輯寫成工作流,零件由社群反覆驗證。**
|
**問題三:第三方依賴是單點故障。**
|
||||||
想到工作流,你可能想到 n8n。很好,arcrun 就是這個方向,但為 AI 優先設計:
|
每多依賴一個不可掌控的第三方(含 SaaS 平台本身),就多一個會掛掉、會漲價、會關服務的風險。
|
||||||
|
|
||||||
|
**解法:把邏輯寫成純文字工作流,跑在你自己的 Cloudflare 上。**
|
||||||
|
想到工作流你可能想到 n8n。方向一樣,但 arcrun 為 AI 優先設計、且**開源自架**:
|
||||||
|
|
||||||
| | n8n | arcrun |
|
| | n8n | arcrun |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 語法 | JSON / 拖拉介面,檔案動輒數千行 | 三行 Cypher,AI 和人都能直接讀 |
|
| 語法 | JSON / 拖拉介面,動輒數千行 | 三行 Cypher,AI 和人都能直接讀 |
|
||||||
| context 消耗 | 大,容易超出 window | 極小,整個 workflow 幾十個 token |
|
| context 消耗 | 大,容易超出 window | 極小,整個 workflow 幾十個 token |
|
||||||
| 零件擴充 | GUI 拖拉,人類操作 | CLI 一行,AI 自己寫零件、自己提交 |
|
| 擴充方式 | GUI 拖拉,人類操作 | 寫一段 recipe(純文字),AI 自己會寫 |
|
||||||
| 執行環境 | 需要自架或付費訂閱 | 從本機一行指令跑起來 |
|
| 執行環境 | 需自架或付費訂閱 | 部署到**你自己的** Cloudflare,你說了算 |
|
||||||
|
|
||||||
arcrun 的核心假設:**最好的 AI 協作工具,應該讓 AI 自己能讀、能寫、能執行、能除錯。**
|
核心假設:**最好的 AI 協作工具,應該讓 AI 自己能讀、能寫、能執行、能除錯——而且整套跑在你掌控的環境裡。**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 專案定位
|
## 核心概念:零件 vs recipe(最重要,先讀這個)
|
||||||
|
|
||||||
| 層級 | 內容 | 授權 |
|
arcrun 只有兩種東西,分清楚就不會做歪:
|
||||||
|------|------|------|
|
|
||||||
| **開源核心** | cypher-executor、21 個 WASM 零件、credentials Worker、CLI(`acr`) | MIT |
|
|
||||||
| **Hosted** | 一行取得 API Key,workflow 和 credential 永遠在你自己的 Cloudflare KV | 免費 |
|
|
||||||
| **Self-hosted** | Fork 後自行部署,完全掌控 | MIT |
|
|
||||||
|
|
||||||
人用也完全沒問題。但每一個設計決定都優先問:「AI 用起來方不方便?」
|
### 零件(primitive)— 固定的一小套,由維護者管理,**你不自製**
|
||||||
|
- 是 WASM 程式,要部署,幾乎不變。**只有四類**:
|
||||||
|
1. **流程控制**:`if_control` / `switch` / `filter` / `foreach_control` / `try_catch` / `wait`
|
||||||
|
2. **資料處理**:`string_ops` / `number_ops` / `array_ops` / `date_ops` / `set` / `merge` / `validate_json`
|
||||||
|
3. **`http_request`**:打任意 HTTP
|
||||||
|
4. **credential(auth)**:`auth_static_key` / `auth_service_account` / `auth_oauth2` / `auth_mtls`(背後自動注入,你不直接擺)
|
||||||
|
- **零件不對外連特定服務。** 任何「打某個固定 API endpoint」的東西**都不是零件**——那是 recipe。
|
||||||
|
- 新增零件走 **GitHub PR**(人 merge 把關),不是 self-service。日常開發你**不會也不該**自製零件。
|
||||||
|
|
||||||
|
### recipe — 你(或 AI)能自由擴充的東西,純文字,不用部署
|
||||||
|
- recipe = `http_request` + 一組固定設定(endpoint / method / headers / body 模板)。
|
||||||
|
- 要打 Notion / Slack / 你自己的 API?**寫一個 recipe**,不是做一個零件。
|
||||||
|
- recipe 是純文字、不用 deploy、改一次零成本。AI 讀得懂 API 文件就能幫你組出 recipe。
|
||||||
|
|
||||||
|
> **一句話判準**:要打一個固定的外部 endpoint → **recipe**。要做流程控制 / 資料處理 / 通用 HTTP → 用既有**零件**。其他大部分情況 → 直接寫**工作流**把它們串起來。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速開始
|
## 快速開始(Self-hosted)
|
||||||
|
|
||||||
### 玩法一:從你的電腦直接執行(最快,不需要 Cloudflare)
|
arcrun 是 self-hosted 開源:你用自己的 Cloudflare 帳號跑整套。一次裝好,之後就是你的。
|
||||||
|
|
||||||
安裝 CLI,立刻寫一個 workflow 在本機跑:
|
### 1. 取得 Cloudflare 憑證
|
||||||
|
|
||||||
|
你需要一個 CF 帳號,並在 dashboard 建一個 **API Token**(權限:Workers Scripts Edit + KV Storage Edit + Workers R2 Storage Edit)。CLI 不代管你的憑證——你自己建、自己持有。
|
||||||
|
|
||||||
|
### 2. 一鍵初始化
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm i -g arcrun
|
npm i -g arcrun
|
||||||
acr init --local
|
acr init --self-hosted
|
||||||
```
|
```
|
||||||
|
|
||||||
建立 `hello.yaml`:
|
貼上 CF Account ID + API Token,CLI 自動:建立所需 KV、部署所有 Worker(含執行引擎 cypher-executor 與全部零件)、啟用 workers.dev、寫回設定。跑完會提示你手動設定 runtime secret(如 `ENCRYPTION_KEY`),這一步刻意不自動化(secret 不進工具流程)。
|
||||||
|
|
||||||
|
> 想先不碰 Cloudflare、純在本機感受語法?`acr init --local` 然後 `acr run`。
|
||||||
|
|
||||||
|
### 3. 寫一個工作流
|
||||||
|
|
||||||
|
`hello.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: hello
|
name: hello
|
||||||
@@ -63,95 +88,35 @@ config:
|
|||||||
operation: upper
|
operation: upper
|
||||||
```
|
```
|
||||||
|
|
||||||
執行:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
acr run hello --input input="Hello, world"
|
acr run hello --input input="Hello, world" # → HELLO WORLD
|
||||||
```
|
```
|
||||||
|
|
||||||
結果:`HELLO WORLD`
|
### 4. 要打外部 API?寫一個 recipe(不是做零件)
|
||||||
|
|
||||||
不需要帳號、不需要部署、不需要 API Key。直接感受 workflow 跑起來是什麼感覺。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 玩法二:把 Workflow 推到 Cloudflare 雲端執行
|
|
||||||
|
|
||||||
workflow 部署到 arcrun.dev,任何地方都能觸發(Webhook、cron、前端按鈕)。
|
|
||||||
|
|
||||||
**你只需要一個 email。** 不需要 Cloudflare 帳號、不需要 API Token。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
acr init
|
# 上傳一個打外部 API 的 recipe(push 時會幫你檢查打不打得通)
|
||||||
|
acr recipe push my_api_recipe.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
互動式設定,輸入 email 自動取得 API Key,workflow 和 credential 以 API Key 隔離,多租戶安全。
|
recipe 裡寫好 endpoint / method / body 模板,credential 由 auth 自動注入。workflow 裡用 `component: <recipe 名>` 或 `component: rec_xxxx` 引用它。
|
||||||
|
|
||||||
上傳 credential(加密後才送出,arcrun.dev 只存密文):
|
### 5. 上傳 credential(client 端加密,明文不離開你的機器)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 查看某服務需要哪些 credential(以 Notion 為例)
|
acr auth-recipe list # 看支援哪些服務的認證
|
||||||
acr auth-recipe scaffold notion
|
acr auth-recipe scaffold notion # 取得 credentials.yaml 範本
|
||||||
|
acr creds push credentials.yaml # AES-GCM 加密後上傳到你自己的 KV
|
||||||
# 建立 credentials.yaml,填入取得的 token
|
|
||||||
# 查看所有支援的服務
|
|
||||||
acr auth-recipe list
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
### 6. 部署 + 觸發
|
||||||
acr creds push credentials.yaml # AES-GCM 加密後上傳,token 不離開你的機器
|
|
||||||
```
|
|
||||||
|
|
||||||
加密金鑰在 `acr init` 時已自動取得並存入 config,不需要手動設定。
|
|
||||||
|
|
||||||
部署並執行:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
acr push newsletter.yaml
|
acr push newsletter.yaml # 部署,取得 Webhook URL
|
||||||
acr run newsletter --input email=user@example.com
|
acr run newsletter --input email=user@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
範例 workflow(訂閱電子報,發感謝信 + 記錄到 Google Sheets):
|
> ⚠️ **部署對外 webhook = 把東西暴露給外界。** `acr push` 產生的 trigger URL 任何拿到的人都能打。CLI 會在這類動作前提醒你——確認後就是你的責任(你可以選擇加上 API Key 限制 / 權限)。
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: newsletter_subscribe
|
|
||||||
|
|
||||||
flow:
|
|
||||||
- "input >> 完成後 >> send_thanks"
|
|
||||||
- "input >> 完成後 >> save_to_sheet"
|
|
||||||
- "send_thanks >> 失敗時 >> notify_error"
|
|
||||||
|
|
||||||
config:
|
|
||||||
send_thanks:
|
|
||||||
to: "{{input.email}}"
|
|
||||||
subject: "感謝訂閱!"
|
|
||||||
body: "歡迎加入,我們很高興你在這裡。"
|
|
||||||
# gmail_token 從 credentials.yaml 自動注入,不需要手動填
|
|
||||||
|
|
||||||
save_to_sheet:
|
|
||||||
spreadsheet_id: "your-sheet-id"
|
|
||||||
range: "訂閱者!A:B"
|
|
||||||
values: [["{{input.email}}", "{{input.timestamp}}"]]
|
|
||||||
|
|
||||||
notify_error:
|
|
||||||
chat_id: "your-telegram-chat-id"
|
|
||||||
text: "發信失敗:{{input.email}}"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 玩法三:完全 Self-hosted
|
|
||||||
|
|
||||||
Fork 後把全部 Worker 部署到你自己的 Cloudflare 帳號。你的零件庫、你的執行引擎、你說了算。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd cypher-executor && wrangler deploy
|
|
||||||
cd ../credentials && wrangler deploy
|
|
||||||
cd ../registry && wrangler deploy
|
|
||||||
acr init --self-hosted
|
|
||||||
```
|
|
||||||
|
|
||||||
做好零件後可以推回 arcrun 公眾庫,讓所有人受益(見[貢獻零件](#貢獻零件))。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -161,147 +126,68 @@ acr init --self-hosted
|
|||||||
"A >> 關係詞 >> B"
|
"A >> 關係詞 >> B"
|
||||||
```
|
```
|
||||||
|
|
||||||
這就是全部。每一行是一條邊,描述 A 之後發生什麼事。
|
這就是全部。每一行是一條邊,描述 A 之後發生什麼。
|
||||||
|
|
||||||
| 關係詞 | 別名 | 說明 |
|
| 關係詞 | 別名 | 說明 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| `完成後` | `ON_SUCCESS` | A 成功後執行 B |
|
| `完成後` | `ON_SUCCESS` | A 成功後執行 B |
|
||||||
| `失敗時` | `ON_FAIL` | A 失敗時執行 B |
|
| `失敗時` | `ON_FAIL` | A 失敗時執行 B |
|
||||||
| `對每個` | `FOREACH` | 對 A 的每個元素執行 B |
|
| `對每個` | `FOREACH` | 對 A 的每個元素執行 B(支援 `對每個 X` 命名)|
|
||||||
| `條件滿足時` | `IF` | 條件為真時執行 B |
|
| `條件滿足時` | `IF` | 條件為真時執行 B |
|
||||||
| `CALLS_SUBFLOW` | — | 呼叫另一個 workflow |
|
| `CALLS_SUBFLOW` | — | 呼叫另一個 workflow |
|
||||||
|
|
||||||
整個 workflow 通常不超過十行,AI 一眼掃完不需要捲動。
|
整個 workflow 通常不超過十行,AI 一眼掃完不需捲動。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 零件
|
## 為什麼零件這麼少?(這是特性,不是缺陷)
|
||||||
|
|
||||||
### 關於零件數量
|
你可能覺得「才這幾個零件,n8n 有幾百個」。但 n8n 那幾百個零件,本質多半是「把一個 HTTP request 包成某服務的格式」。在 arcrun,那些**全部是 recipe**——純文字、AI 幫你寫、不用等誰先做好封裝。
|
||||||
|
|
||||||
arcrun 目前有 21 個核心零件,你可能覺得不夠用——畢竟 n8n 有幾百個。
|
對 AI 來說,一個彈性的 `http_request` + recipe,比一百個固定零件封裝更好用:AI 讀得懂 API 文件,直接組出正確的 recipe,不必等有人做「Notion 零件」。
|
||||||
|
|
||||||
但大多數 n8n 零件的本質都是:把一個 HTTP request 包裝成特定服務的格式。arcrun 的 `http_request` 零件本身就能做同樣的事,差別是:**你讓 AI 幫你配置它,而不是等人工寫好一個現成的封裝。**
|
零件少 = 攻擊面小、要驗證的東西少、整套能塞進 AI 的 context。**長尾交給 recipe,核心保持精簡**,是刻意的設計。
|
||||||
|
|
||||||
```bash
|
---
|
||||||
# 讓 AI 幫你設定一個 Notion 整合
|
|
||||||
acr parts scaffold http_request
|
|
||||||
# AI 填入 Notion API endpoint、headers、body 格式,你貼上 API Key,搞定
|
|
||||||
```
|
|
||||||
|
|
||||||
對 AI 來說,一個彈性的 `http_request` 零件比一百個固定封裝更好用——因為 AI 能讀懂 API 文件,直接組出正確的配置,不需要等有人做 Notion 零件。
|
## recipe 入庫把關(誠實說明)
|
||||||
|
|
||||||
### 21 個核心零件
|
- **自有庫(你的 self-hosted)**:`acr recipe push` 時 CLI 會 (1) 提醒會不會把資料/服務暴露給外界、(2) 幫你實打一次看 API 通不通(2xx)。這是**提醒級**——arcrun 不替你做授權判斷,打不打得通最終由發 API key 的服務裁決。打不通會誠實標原因(例:缺 credential),不會假裝成功。
|
||||||
|
- **公共庫(未來)**:recipe 要貢獻給別人用、進公共庫時,由維護者機制檢核「實際打通、真收到成功回傳」(投稿者不能自己驗證自己)。此機制在第一期之後啟用。
|
||||||
**整合類**(需要 Credential,credential 自動從加密 KV 注入,workflow yaml 裡看不到明文)
|
|
||||||
|
|
||||||
| 零件 | 說明 | 所需 Credential |
|
|
||||||
|------|------|-----------------|
|
|
||||||
| `gmail` | Gmail 發信 | `gmail_token` |
|
|
||||||
| `google_sheets` | Google Sheets 讀寫 | `google_oauth` |
|
|
||||||
| `telegram` | Telegram Bot 發訊息 | `telegram_bot_token` |
|
|
||||||
| `line_notify` | LINE Notify 發訊息 | `line_token` |
|
|
||||||
| `http_request` | 任意 HTTP 請求 | — |
|
|
||||||
|
|
||||||
**第三方服務整合(Auth Recipe,20 個)**
|
|
||||||
|
|
||||||
除了以上核心零件,arcrun 平台預建了 20 個常用服務的認證 recipe,使用方式與一般零件相同,只需上傳對應 credential:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
acr auth-recipe list # 列出所有支援服務
|
|
||||||
acr auth-recipe scaffold notion # 取得 credentials.yaml 範本 + workflow 範例
|
|
||||||
```
|
|
||||||
|
|
||||||
支援:Notion、Slack、GitHub、OpenAI、Anthropic、Airtable、Discord、Stripe、Twilio、SendGrid、HubSpot、Linear、Shopify、Resend、Supabase、Typeform、Jira、Google Sheets SA、Gmail SA、Google Drive SA
|
|
||||||
|
|
||||||
**控制流**
|
|
||||||
|
|
||||||
| 零件 | 說明 |
|
|
||||||
|------|------|
|
|
||||||
| `if_control` | 條件判斷 |
|
|
||||||
| `foreach_control` | 迴圈執行 |
|
|
||||||
| `try_catch` | 錯誤處理 |
|
|
||||||
| `switch` | 多路路由 |
|
|
||||||
| `wait` | 延遲等待 |
|
|
||||||
|
|
||||||
**資料處理**
|
|
||||||
|
|
||||||
| 零件 | 說明 |
|
|
||||||
|------|------|
|
|
||||||
| `set` | 設定/賦值變數 |
|
|
||||||
| `filter` | 陣列過濾 |
|
|
||||||
| `merge` | 合併物件 |
|
|
||||||
| `string_ops` | 字串操作 |
|
|
||||||
| `number_ops` | 數字運算 |
|
|
||||||
| `array_ops` | 陣列操作 |
|
|
||||||
| `date_ops` | 日期操作 |
|
|
||||||
|
|
||||||
**AI 類**
|
|
||||||
|
|
||||||
| 零件 | 說明 |
|
|
||||||
|------|------|
|
|
||||||
| `ai_transform_compile` | 自然語言描述 → 轉換規則(Workers AI) |
|
|
||||||
| `ai_transform_run` | 執行編譯好的 AI 轉換規則 |
|
|
||||||
|
|
||||||
**其他**
|
|
||||||
|
|
||||||
| 零件 | 說明 |
|
|
||||||
|------|------|
|
|
||||||
| `validate_json` | JSON Schema 驗證 |
|
|
||||||
| `cron` | Cron 排程觸發 |
|
|
||||||
|
|
||||||
取得任一零件的 workflow 配置範本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
acr parts scaffold gmail
|
|
||||||
```
|
|
||||||
|
|
||||||
### 貢獻零件
|
|
||||||
|
|
||||||
零件是 `.wasm` 檔案,stdin 進 JSON、stdout 出 JSON。用什麼語言編譯不重要,只要輸出符合 WASI preview1 的 `.wasm` 即可。
|
|
||||||
|
|
||||||
**arcrun 的零件主要由 AI 撰寫。** 這個設計決定影響了語言選擇:
|
|
||||||
|
|
||||||
| 語言 | 輸出大小 | AI 撰寫品質 | 學習曲線 | 備註 |
|
|
||||||
|------|---------|------------|---------|------|
|
|
||||||
| **TinyGo** | 極小(10–80KB) | 優秀 | 低(Go 語法簡單) | 官方零件首選;語法與 TS 差異夠大,AI 不易混淆 |
|
|
||||||
| **AssemblyScript** | 小(20–150KB) | 良好 | 低(TypeScript 語法) | 社群貢獻首選;TS 開發者上手最快 |
|
|
||||||
| **Rust** | 小–中(30–300KB) | 良好 | 高(Rust 所有權) | 效能最強;適合複雜演算法零件 |
|
|
||||||
| C / C++ | 小 | 尚可 | 高 | 不建議,現代語言更好 |
|
|
||||||
|
|
||||||
**注意:** AssemblyScript 與 TypeScript 語法高度相似,AI 有時會把純 TS 邏輯直接搬過來造成編譯錯誤。TinyGo 語法差異夠大,AI 出錯率較低。初期如果不確定選哪個,TinyGo 是最穩的選擇。
|
|
||||||
|
|
||||||
**AI 可以直接幫你寫零件。** 把 API 文件和 [CONTRIBUTING.md](CONTRIBUTING.md) 一起貼給它,指定語言(TinyGo 或 AssemblyScript),它生成源碼和 `component.contract.yaml`,你編譯、測試、提交:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
acr parts publish ./my-component/
|
|
||||||
# 沙盒自動驗收(體積、syscall 掃描、Gherkin 測試)
|
|
||||||
# 通過後立即可用,等人工審核後對所有人開放
|
|
||||||
```
|
|
||||||
|
|
||||||
每個零件都帶統計數字(執行次數 × 成功率),真實使用數據說話。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CLI 指令
|
## CLI 指令
|
||||||
|
|
||||||
```
|
```
|
||||||
acr init 互動式初始化(local / cloud / self-hosted)
|
acr init --self-hosted 一鍵部署到你的 Cloudflare(貼 token,其餘自動)
|
||||||
acr creds push [file] 加密並上傳 credentials
|
acr init --local 純本機體驗語法,不需 Cloudflare
|
||||||
acr push <workflow.yaml> 部署 workflow
|
acr creds push [file] client 端加密並上傳 credentials
|
||||||
|
acr recipe push <file> 上傳 API recipe(push 時提醒暴露 + 檢查打通)
|
||||||
|
acr push <workflow.yaml> 部署 workflow,取得 Webhook URL
|
||||||
acr run <name> [--input k=v] 執行 workflow
|
acr run <name> [--input k=v] 執行 workflow
|
||||||
acr validate <workflow.yaml> 執行前驗證(零件存在、credential 已上傳)
|
acr validate <workflow.yaml> 執行前驗證格式 / 關係詞 / credential
|
||||||
acr parts 列出所有內建零件
|
acr parts 列出可用的核心零件
|
||||||
acr parts scaffold <comp> 取得零件的 workflow config 範本
|
acr parts scaffold <comp> 取得零件的 workflow config 範本
|
||||||
acr parts publish <dir> 提交零件至公眾庫
|
acr auth-recipe list 列出支援的第三方服務認證
|
||||||
acr auth-recipe list 列出所有第三方服務整合(Notion、Slack 等)
|
|
||||||
acr auth-recipe info <service> 查看服務需要哪些 credential
|
|
||||||
acr auth-recipe scaffold <service> 取得 credentials.yaml 範本 + workflow 範例
|
acr auth-recipe scaffold <service> 取得 credentials.yaml 範本 + workflow 範例
|
||||||
acr recipe push <file> 上傳自訂 API recipe
|
|
||||||
acr list 列出已部署的 workflow
|
acr list 列出已部署的 workflow
|
||||||
acr logs <name> 查看執行記錄
|
acr logs <name> 查看執行記錄
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 給 AI 操盤手:開始前讀 `.claude/rules/06-mindset.md`(或 arcrun-mindset Skill)——它說明 arcrun 的世界觀(工作流是 default、零件稀有且不自製、一切外部 API 皆 recipe),讓你一開始就走在正路上。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 設計原則(節錄)
|
||||||
|
|
||||||
|
- **減少對不可控第三方的依賴**:核心 MIT 開源、self-hosted、workflow 是你擁有的純文字。
|
||||||
|
- **解耦 / 原子化**:零件、recipe、執行引擎彼此可獨立替換。
|
||||||
|
- **arcrun 是 AI 用的工具**:需要 AI 判斷時是操盤的 CC 自己做,工作流裡**不內嵌**回頭呼叫 LLM 的節點。
|
||||||
|
- **誠實**:stub / 未驗證就如實標明,不假裝成功;完成以客觀證據(HTTP status / trace)為準。
|
||||||
|
|
||||||
|
完整決策見 [`DECISIONS.md`](DECISIONS.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
@@ -312,9 +198,9 @@ MIT
|
|||||||
|
|
||||||
## 致謝
|
## 致謝
|
||||||
|
|
||||||
arcrun 的核心架構、21 個 WASM 零件、CLI 工具鏈與這份文件,由以下貢獻者共同打造:
|
arcrun 的核心架構、WASM 零件、CLI 工具鏈與這份文件,由以下貢獻者共同打造:
|
||||||
|
|
||||||
- **[@richblack](https://github.com/richblack)** — 創始人,產品設計與架構決策
|
- **[@richblack](https://github.com/richblack)** — 創始人,產品設計與架構決策
|
||||||
- **[Claude Sonnet](https://claude.ai)(Anthropic)** — 核心實作夥伴,負責零件開發、executor 架構、CLI 實作與程式碼審查
|
- **Claude(Anthropic)** — 實作夥伴:零件開發、executor 架構、CLI 實作與程式碼審查
|
||||||
|
|
||||||
歡迎加入:[CONTRIBUTING.md](CONTRIBUTING.md)
|
歡迎加入:[CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|||||||
+130
-13
@@ -8,6 +8,16 @@ import { writeFileSync, existsSync, readFileSync, appendFileSync } from 'node:fs
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { saveConfig, type ArcrunConfig } from '../lib/config.js';
|
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';
|
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');
|
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> {
|
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 accountId = await prompt(rl, '你的 Cloudflare Account ID');
|
||||||
const cypherUrl = await prompt(rl, 'Cypher Executor URL(部署後的 workers.dev URL)');
|
const cfApiToken = await prompt(rl, 'CF API Token(需 Workers Scripts Edit + KV Edit + R2 Edit)');
|
||||||
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 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 = {
|
const config: ArcrunConfig = {
|
||||||
mode: 'self-hosted',
|
mode: 'self-hosted',
|
||||||
cloudflare_account_id: accountId,
|
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,
|
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,
|
multi_tenant: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
createCredentialsYamlIfMissing();
|
createCredentialsYamlIfMissing();
|
||||||
|
|
||||||
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml'));
|
// 6. seed API recipe(部署成功 + 有 cypher URL 才打;否則提示稍後 acr update 後再 seed)
|
||||||
console.log(chalk.green(' ✓ 建立 credentials.yaml\n'));
|
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 {
|
function createHelloYamlIfMissing(): void {
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ export async function cmdRecipePush(filePath: string): Promise<void> {
|
|||||||
spinner.succeed(chalk.green(`✓ recipe "${data.recipe.canonical_id}" 上傳成功`));
|
spinner.succeed(chalk.green(`✓ recipe "${data.recipe.canonical_id}" 上傳成功`));
|
||||||
console.log(`\n Hash ID:${chalk.cyan(data.recipe.hash_id)} (穩定引用,不受改名影響)`);
|
console.log(`\n Hash ID:${chalk.cyan(data.recipe.hash_id)} (穩定引用,不受改名影響)`);
|
||||||
console.log(` Endpoint:${chalk.gray(data.recipe.endpoint)}`);
|
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.bold('\n 在 workflow config 中使用:\n'));
|
||||||
console.log(chalk.cyan(` config:`));
|
console.log(chalk.cyan(` config:`));
|
||||||
console.log(chalk.cyan(` my_node:`));
|
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> {
|
export async function cmdRecipeList(): Promise<void> {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const executorUrl = getCypherExecutorUrl(config);
|
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 { 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 { cmdUpdate } from './commands/update.js';
|
||||||
import { cmdAuthRecipeList, cmdAuthRecipeInfo, cmdAuthRecipeScaffold } from './commands/auth-recipe.js';
|
import { cmdAuthRecipeList, cmdAuthRecipeInfo, cmdAuthRecipeScaffold } from './commands/auth-recipe.js';
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -120,4 +121,10 @@ program
|
|||||||
.description('顯示 workflow 最近執行記錄')
|
.description('顯示 workflow 最近執行記錄')
|
||||||
.action((workflow: string) => cmdLogs(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);
|
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 解密邏輯對應)*/
|
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
|
||||||
export async function encryptCredential(value: string, encryptionKey: string): Promise<string> {
|
export async function encryptCredential(value: string, encryptionKey: string): Promise<string> {
|
||||||
if (!encryptionKey || encryptionKey.length < 64) {
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* seed-api-recipes.ts
|
||||||
|
*
|
||||||
|
* 將現役 API recipe 種子上傳至目標 cypher-executor(prod 或 self-host)。
|
||||||
|
* 種子資料的單一來源在 CLI 端(cli/src/lib/api-recipe-seeds.ts),此腳本 import 它,
|
||||||
|
* 避免兩份種子定義漂移。
|
||||||
|
*
|
||||||
|
* 執行:
|
||||||
|
* npx tsx scripts/seed-api-recipes.ts
|
||||||
|
*
|
||||||
|
* 環境變數:
|
||||||
|
* ARCRUN_API_URL - 目標 cypher-executor,預設 https://cypher.arcrun.dev
|
||||||
|
* ARCRUN_API_KEY - X-Arcrun-API-Key(POST /recipes 需要)
|
||||||
|
*
|
||||||
|
* 注意:API recipe 帶 endpoint(資料去向)→ POST /recipes 會要 exposure_consent
|
||||||
|
* (data-exfil-warning)。seed 是平台預建、非用戶 push,腳本帶種子層級的 consent。
|
||||||
|
*
|
||||||
|
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { API_RECIPE_SEEDS } from '../../cli/src/lib/api-recipe-seeds.js';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.ARCRUN_API_URL ?? 'https://cypher.arcrun.dev';
|
||||||
|
const API_KEY = process.env.ARCRUN_API_KEY ?? '';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n Seeding ${API_RECIPE_SEEDS.length} API recipes → ${BASE_URL}\n`);
|
||||||
|
|
||||||
|
let ok = 0;
|
||||||
|
let fail = 0;
|
||||||
|
|
||||||
|
for (const recipe of API_RECIPE_SEEDS) {
|
||||||
|
process.stdout.write(` ${recipe.canonical_id.padEnd(24)} `);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/recipes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(API_KEY ? { 'X-Arcrun-API-Key': API_KEY } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
canonical_id: recipe.canonical_id,
|
||||||
|
display_name: recipe.display_name,
|
||||||
|
description: recipe.description,
|
||||||
|
endpoint: recipe.endpoint,
|
||||||
|
method: recipe.method,
|
||||||
|
auth_service: recipe.auth_service,
|
||||||
|
// 種子層級的暴露同意:平台預建 recipe,非用戶互動 push。
|
||||||
|
// 格式須符合 cypher-executor ExposureConsent(confirmed_by_human + understood + confirmed_at)。
|
||||||
|
// 誠實標明來源是 seed,軌跡可審(mindset §7:機制價值是歸責+可審,非防偽)。
|
||||||
|
exposure_consent: {
|
||||||
|
confirmed_by_human: true,
|
||||||
|
understood: `platform seed recipe (api-recipe-seeds.ts): ${recipe.canonical_id} → ${recipe.endpoint}`,
|
||||||
|
confirmed_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
console.log('✓');
|
||||||
|
ok++;
|
||||||
|
} else {
|
||||||
|
const err = await res.text().catch(() => '');
|
||||||
|
console.log(`✗ HTTP ${res.status}: ${err.slice(0, 120)}`);
|
||||||
|
fail++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`✗ ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n 完成:${ok} 成功,${fail} 失敗\n`);
|
||||||
|
if (fail > 0) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# 交辦文件:完成 arcrun self-hosted harness(給接手的 CC)
|
||||||
|
|
||||||
|
> 建立:2026-06-01(由前一個 CC 調查後撰寫)
|
||||||
|
> 對象:接手的外部 CC
|
||||||
|
> 目的:把 arcrun 補到「任何 CC 在自己的 CF 帳號上 self-host 後就能順暢開發、且不可能重蹈 mira 的錯」的程度。
|
||||||
|
>
|
||||||
|
> **先讀**:`DECISIONS.md`(穩定決策)、`.claude/rules/06-mindset.md`(mindset)、`BACKLOG.md`(流動待辦)。
|
||||||
|
> 本文件不取代它們,只是把「今天要做的三件事」連同已查證的實況整理好,讓你不用重跑調查。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 戰法已轉變(最重要的背景)
|
||||||
|
|
||||||
|
richblack 2026-06-01 決定:**從 SaaS 改成 self-hosted 開源策略。**
|
||||||
|
|
||||||
|
這直接改變 harness 的成功定義:
|
||||||
|
|
||||||
|
- **舊定義**:在 richblack 的 prod 帳號(`cypher.arcrun.dev`)上能跑。
|
||||||
|
- **新定義**:**任何 CC 在自己的 CF 帳號上 `acr init --self-hosted` 後就能跑通一個含 recipe 的 workflow,而且寫錯時會被程式擋住。**
|
||||||
|
|
||||||
|
richblack 會用另一個 CF 帳號實測 self-host。所以「self-hosted 一鍵起得來」從「第一期重要但非阻擋項」**升為今日第一優先**。
|
||||||
|
|
||||||
|
### arcrun 現在的核心心智(richblack 2026-06-01 校準,比 DECISIONS §1 更硬)
|
||||||
|
|
||||||
|
- 核心**零件數量少、由 richblack 維護、不接受 CC 自製**(可投稿 PR,人 merge = 閘門)。
|
||||||
|
- 其他人做的一律是 **recipe**(= http_request + 一組 YAML 設定,不用 deploy)。
|
||||||
|
- arcrun 是**一套給 CC 的 harness**:事前提醒 CC 能用什麼 / 不能做什麼,事後用程式擋住讓它**無法犯錯**。
|
||||||
|
- **你不用管 mira。** mira 是錯誤做法的源頭(見 §1),它自己會修。你的目標是讓**任何** CC 都能用,且絕不會發生 mira 的錯。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. mira 故障 = 症狀樣本(已定位,不用你修)
|
||||||
|
|
||||||
|
mira(`/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/polaris/mira/arcrun/*.yaml`)的 workflow 寫 `component: kbdb_get` / `claude_api` / `telegram` 等。這些是 **mira 當初自己錯做的「假零件」**(DECISIONS §1 判準:打固定 endpoint 的東西是假零件,該是 recipe)。
|
||||||
|
|
||||||
|
本次整修(BACKLOG 步驟3)已把這些假零件**降級成 recipe + 刪掉零件目錄**(registry/components 從 33 → 22)。所以 mira 斷了。
|
||||||
|
|
||||||
|
**這證明的事**:mira 的錯,正是當時 harness 沒擋住的漏洞。零件刪了,但 harness 還缺「**事前告訴 CC 別這樣做 + 事後擋住 CC 這樣做**」的完整機制 → 下一個 CC 還會犯同樣的錯。**這就是你要補的(§3 task 2)。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 已查證的實況(你不用重查,2026-06-01 實打 prod)
|
||||||
|
|
||||||
|
### 2.1 降級後的 recipe 鏈路是「活的」✅
|
||||||
|
|
||||||
|
實打 `https://cypher.arcrun.dev/recipes`(richblack prod)確認以下 recipe 都在 KV:
|
||||||
|
|
||||||
|
| canonical_id | hash | endpoint | auth_service |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `kbdb_get` | rec_4c7dcf9b | `https://kbdb.finally.click{{_path}}` | kbdb |
|
||||||
|
| `gmail_send` | rec_cd426129 | gmail.googleapis.com/.../send | google_gmail_sa |
|
||||||
|
| `google_sheets_append` | rec_9fd1b662 | sheets.googleapis.com{{_path}} | google_sheets_sa |
|
||||||
|
|
||||||
|
→ **「對的用法」(`component: kbdb_get` 走解析鏈 step 6 查 `recipe:kbdb_get`)本身是通的。** 不需要重建 recipe。
|
||||||
|
|
||||||
|
> 注意:這是 richblack 的 prod KV。**self-host 的新帳號 KV 是空的**,需要 seed 這些 recipe(見 §3 task 1 的 seed 步驟)。
|
||||||
|
|
||||||
|
### 2.2 component-loader 解析鏈(`cypher-executor/src/lib/component-loader.ts`)
|
||||||
|
|
||||||
|
`resolveComponent` 依序嘗試 8 層(行號近似,以實際檔案為準):
|
||||||
|
|
||||||
|
```
|
||||||
|
0. 平台 orchestration 零件(trigger_workflow) line ~88
|
||||||
|
1. 內建零件(純 JS) line ~96
|
||||||
|
2. 外部 URL(http(s)://...) line ~100
|
||||||
|
3. cmp_hash → WEBHOOKS KV idx → 邏輯 Worker line ~105
|
||||||
|
4. rec_hash → RECIPES KV idx → recipe 執行 line ~115
|
||||||
|
5. 邏輯零件 canonical_id → Service Binding (SVC_*) line ~122
|
||||||
|
5.5 auth recipe (auth_recipe:{service}) line ~127
|
||||||
|
6. KV recipe canonical_id → RECIPES KV → fetch 外部 API line ~130 ← kbdb_get 等降級 recipe 命中這層
|
||||||
|
7. WASM HTTP runner(白名單 WASM_HTTP_RUNNER_IDS) line ~134
|
||||||
|
8. 找不到 → 報錯 line ~142
|
||||||
|
```
|
||||||
|
|
||||||
|
`WASM_HTTP_RUNNER_IDS` 白名單(line ~36)現只剩:`http_request` / `cron` / 4 個 `auth_*` primitive。
|
||||||
|
→ `claude_api`、`kbdb_upsert_block`(BACKLOG 標 deferred、源碼暫留)**不在白名單也沒 recipe** → 用到它們的 workflow 會落到 step 8 報錯。這是 mira 自己的問題,不在你範圍。
|
||||||
|
|
||||||
|
### 2.3 `acr init --self-hosted` 現況:純手動問答,差很遠
|
||||||
|
|
||||||
|
`cli/src/commands/init.ts` 的 `initSelfHosted()`(line 105-131)**只是問 6 個問題後寫進 config**:
|
||||||
|
要求 CC 自己**事先**部署好 Worker、建好 KV、再手填 Account ID / cypher URL / 兩個 KV namespace ID / WASM bucket / CF token。
|
||||||
|
|
||||||
|
BACKLOG 步驟7 要的是「**貼 CF token → 自動建 KV、部署 Worker、自動 workers.dev、寫回 config**」。**這是最大缺口,task 1 的主體。**
|
||||||
|
|
||||||
|
config 讀取端已支援 self-hosted(`cli/src/lib/config.ts:52` 已能用 `cypher_executor_url`),所以你只要把「自動部署」這段補上,config/執行端不用動。
|
||||||
|
|
||||||
|
### 2.4 CI/CD 已是通用掃描式(可重用於 self-host 部署)
|
||||||
|
|
||||||
|
`.github/workflows/deploy.yml` 掃所有含 `wrangler.toml` 的目錄自動部署(見 `.claude/rules/05-deploy-convention.md`)。
|
||||||
|
self-host 自動部署可以參考同一套掃描邏輯(`find . -name wrangler.toml`),對每個目錄跑 `wrangler deploy`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 今天要做的三件事(按序,全在 harness 主線)
|
||||||
|
|
||||||
|
> richblack 指示:「全部要做」(含 `acr init --self-hosted`)。
|
||||||
|
> 三件都做完 = 今天可交付:外部 CC 能 self-host 起來、用對的方式開發、犯錯被擋。
|
||||||
|
|
||||||
|
### 🔴 Task 1:完成 `acr init --self-hosted` 一鍵自動化(第一優先)
|
||||||
|
|
||||||
|
> ✅ **實作狀態(2026-06-02,已大致完成)**:定稿形態為 **installer 模式**(richblack 拍板)——
|
||||||
|
> 用戶只做:申請 CF 帳號 → 裝 wrangler → 裝 acr → `acr init --self-hosted`(貼 token),其餘自動。
|
||||||
|
> 已實作(typecheck 過):`cli/src/lib/api-recipe-seeds.ts`(10 recipe 種子)、`cf-api.ts` 的
|
||||||
|
> `CfAccountClient`(建 KV/R2/查 subdomain/驗 token)、`deploy.ts`(常數 + downloadAndDeploy)、
|
||||||
|
> `initSelfHosted()` 改寫、`acr update`、`cypher-executor/scripts/seed-api-recipes.ts`。
|
||||||
|
> **唯一剩餘前置(13.6)**:repo 沒有含預編譯 wasm 的 GitHub release(.wasm 不 commit,rule 05)→
|
||||||
|
> `downloadAndDeploy()` 目前**誠實回 implemented:false 不假裝部署**(mindset §7)。建 KV/R2/seed/config
|
||||||
|
> 已可跑;release 產製管道補上後部署即自動化。定稿設計見 SDD `self-hosted-init.md`(含 §6 前置依賴)。
|
||||||
|
> **以下原始子步驟保留供對照**;KBDB recipe 採 Supabase 模式進 seed(richblack 2026-06-02)。
|
||||||
|
|
||||||
|
**目標**:CC 只需提供「CF Account ID + CF API Token」,CLI 自動完成其餘一切。
|
||||||
|
|
||||||
|
**SDD**:定稿 `.agents/specs/arcrun/sdk-and-website/self-hosted-init.md`(installer 模式,已與 richblack 對齊)。
|
||||||
|
|
||||||
|
**子步驟**:
|
||||||
|
1. 改 `cli/src/commands/init.ts` 的 `initSelfHosted()`:
|
||||||
|
- 收 CF Account ID + CF API Token(要 KV Edit + Workers Scripts Edit + R2 權限)。
|
||||||
|
- 用 CF API(或 shell out `wrangler`)**自動建 7 個 KV namespace**:WEBHOOKS / CREDENTIALS_KV / RECIPES / USERS_KV / SESSIONS_KV / ANALYTICS_KV / EXEC_CONTEXT(清單見 `.claude/rules/01-tech-stack.md` 資料儲存表)+ R2 WASM_BUCKET。
|
||||||
|
- **自動部署所有 Worker**:cypher-executor + registry + 22 個 `.component-builds/*`。可重用 §2.4 的 `wrangler.toml` 掃描。每個 worker 的 `wrangler.toml` 已含 `workers_dev = true`(BACKLOG 步驟 P1#2 已做),部署後 workers.dev URL 自動啟用。
|
||||||
|
- **把 cypher-executor 的 `[vars] WORKER_SUBDOMAIN` 改成 CC 自己的帳號 subdomain**(self-host 關鍵,見 P0 #9:cypher-executor 走 `arcrun-{name}.{subdomain}.workers.dev` 對內 URL)。
|
||||||
|
- **seed 降級 recipe + auth recipe 進 RECIPES KV**:新帳號 KV 是空的。把 §2.1 那些 recipe(kbdb_get/gmail_send/...)+ auth recipe seed 寫進去。auth recipe seed 已有 `cypher-executor/scripts/seed-auth-recipes.ts`,API recipe 需確認有對應 seed 機制(routes/recipes.ts 是動態 push,可能要寫一份 seed 腳本或用 `acr recipe push`)。
|
||||||
|
- 寫回 config(現有欄位已足夠)。
|
||||||
|
2. **runtime secret 不進 CLI 自動化**:`ENCRYPTION_KEY` 等由 CC 自己 `wrangler secret put`(rule 05 禁止 secret 進自動化流程)。CLI 應在最後**印出提示**告訴 CC 要手動 put 哪些 secret 到哪些 worker。
|
||||||
|
|
||||||
|
**驗收(客觀證據,不是口頭宣布 — mindset §7)**:
|
||||||
|
- richblack 用全新 CF 帳號跑 `acr init --self-hosted` → 全程無手動建 KV / 部署。
|
||||||
|
- 跑完後 `acr push` 一個含 `component: kbdb_get`(或 http_request + 自建 recipe)的 workflow → trigger → HTTP 2xx + execution trace 證明跑通。
|
||||||
|
|
||||||
|
### 🔴 Task 2(已重定義 2026-06-01):封鎖自製零件 + recipe 入庫把關
|
||||||
|
|
||||||
|
> ✅ **實作狀態(2026-06-02,第一期部分完成)**:(1) 封鎖自製零件 = 靠 GitHub PR 人 merge,無需新做
|
||||||
|
> (矛盾已釐清)。(2a) 資料外流提醒 = **既有實作已涵蓋**(recipe.ts `obtainExposureConsent` + exposure-warning.ts,
|
||||||
|
> 非 TTY 拒絕)。(2b) 打通檢查 = **新增** `probeRecipeEndpoint`(recipe.ts,typecheck 過):push 後實打
|
||||||
|
> endpoint,提醒級不硬擋,含 {{模板}} 誠實說明待 run 才知,401/403 標「多半缺 credential 非 bug」。
|
||||||
|
> 公共庫 relay 檢核(--public)= 第一期後。SDD `recipe-push-gatekeeping.md` + tasks.md W2。
|
||||||
|
|
||||||
|
> ⚠️ **方向修正(richblack 2026-06-01)**:原 Task 2「acr validate 擋假零件名」**作廢**。
|
||||||
|
> 理由:自製/修改零件的路已封鎖(CC 造不出零件)→「擋假零件」這件事不存在;workflow 引用
|
||||||
|
> recipe(`component: kbdb_get`)是合法且未來唯一的擴充方式,不該被當假零件擋。
|
||||||
|
> 把關點**從 workflow validate 移到 recipe 入庫(push)那一刻**。
|
||||||
|
> 已動的 yaml-parser.ts(LEGAL_PRIMITIVES / findSuspectComponents)**已回退**。
|
||||||
|
|
||||||
|
**新目標**:
|
||||||
|
1. **封鎖自製零件**:靠「零件投稿走 GitHub PR + 人 merge」天然閘門(DECISIONS §8)。零件數量少、
|
||||||
|
絕大多數是 recipe → 不為零件 PR 蓋自動化把關(量少,人工檢查即可;爆量才回頭想自動化)。
|
||||||
|
2. **recipe 入庫把關**(CC 唯一能擴充的是 recipe,一律用 push,自有庫/公共庫同一套指令):
|
||||||
|
- **自有庫(self-hosted)= 提醒級**:(a) 資料外流提醒——會讓資料/服務對外可見的動作需人類明示同意;
|
||||||
|
(b) 打通檢查——push 時實打 endpoint 回報 2xx 與否(誠實標原因,不假綠,不硬擋)。
|
||||||
|
- **公共庫 = 維護者 relay 檢核**(實際打通、真收到成功回傳)— 第一期後。
|
||||||
|
|
||||||
|
**SDD**:已寫 design 給 richblack review →
|
||||||
|
`.agents/specs/component-gatekeeping/recipe-push-gatekeeping.md`(+ tasks.md W2 節)。**review 通過才動 code。**
|
||||||
|
|
||||||
|
**動到的檔案(待 review)**:`cli/src/commands/recipe.ts`(push 加提醒 + 打通檢查)、確認 data-exfil hook 涵蓋 recipe push 路徑。
|
||||||
|
|
||||||
|
**驗收**:
|
||||||
|
- `acr recipe push` 會產對外 webhook 的東西 → 印資料外流警示 + 要人類同意;非 TTY → 拒絕。
|
||||||
|
- `acr recipe push` endpoint 可達的 recipe → 回報「✓ HTTP 2xx」。
|
||||||
|
- `acr recipe push` 缺 credential → 回報「⚠️ 未打通:缺 credential」(誠實),仍允許 push。
|
||||||
|
- workflow 引用 recipe(`component: kbdb_get`)**不被任何 validate 步驟當假零件擋**。
|
||||||
|
|
||||||
|
### 🔴 Task 3:README 重寫成單一路徑 — harness「事前提醒」
|
||||||
|
|
||||||
|
**目標**:self-hosted 開源後,README 是外界 CC 唯一入口。砍掉「玩法一/二/三」三選一,講清楚單一正確路徑。
|
||||||
|
|
||||||
|
**子步驟**(改根 `README.md`):
|
||||||
|
1. 砍三選一玩法,留**一條路**:`acr init --self-hosted` → 寫 workflow(primitive 串 + recipe)→ `acr push` → trigger。
|
||||||
|
2. 明示心智(呼應 mindset §1):「零件就這固定幾個由我們維護、不接受自製;要打外部 API 就寫 recipe;要編排就寫工作流。」
|
||||||
|
3. 連到 `.claude/rules/06-mindset.md` / arcrun-mindset Skill,讓 CC 一開始就有正確世界觀。
|
||||||
|
|
||||||
|
**驗收**:README 讀完,一個沒看過 arcrun 的 CC 知道:能用什麼、不能自製零件、打外部 API 要寫 recipe、怎麼 self-host 起來。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 今天「不要做」的(避免你走偏)
|
||||||
|
|
||||||
|
| 項目 | 為何不做 |
|
||||||
|
|---|---|
|
||||||
|
| 修 mira | richblack 明示不用管,mira 自己修 |
|
||||||
|
| 步驟2 `acr recipe test` / relay / credits | DECISIONS §3c 明確劃為服務側、非第一期 |
|
||||||
|
| 步驟6 搬家拆 matrix | 純 repo 整理,不影響 CC 能否用 |
|
||||||
|
| 砍 `injectCredentials` 舊路 / `BUILTIN_CREDENTIALS_MAP` | 獨立清理,不擋交付(DECISIONS §3b / BACKLOG「第一期之後」)|
|
||||||
|
| 新 primitive / Gherkin 真跑 / 入站認證 | richblack 已標「不要現在做」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 鐵律提醒(違反會被 hook block)
|
||||||
|
|
||||||
|
- 任何 code 變動前先讀對應 SDD + 在回覆開頭宣告(`.claude/rules/00-sdd-protocol.md`)。
|
||||||
|
- `registry/components/` 下禁止 TS;cypher-executor TS 禁止 credential/auth/JWT 業務邏輯(`.claude/rules/02-forbidden.md`,hook 強制)。
|
||||||
|
- 每完成一個 task 立刻更新對應 tasks.md / BACKLOG.md 的 `[x]`,不批次。
|
||||||
|
- 誠實限制(mindset §7):stub / 未完成就標 unimplemented,**不假綠**;完成 = 客觀證據(exit code / HTTP status + trace),不是口頭宣布。
|
||||||
Reference in New Issue
Block a user