From 794e3eba3a7e406080de26482f045e68013d7657 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 7 Jul 2026 08:13:05 +0000 Subject: [PATCH] =?UTF-8?q?fix(cli):=20acr=20update/deploy=20=E4=B8=8B?= =?UTF-8?q?=E8=BC=89=E6=BA=90=E7=94=B1=20GitHub=20codeload=20=E6=94=B9?= =?UTF-8?q?=E6=8C=87=20Gitea=20archive=EF=BC=88Arcrun#4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 動機:D20 防 flag 鐵律下 self-hosted 用戶(如 Mira)不能碰 GitHub, 而「init 之後才新增的零件」(如 code 零件)唯一重裝管道 acr update 綁死 GitHub codeload → 沒有乾淨重裝路徑。 改動(只碰 CLI 下載邏輯,不動 README/cypher/mcp/tools): - deploy.ts:ARCRUN_REPO 預設 uncle6me-web/Arcrun → Leo/Arcrun; 新增 ARCRUN_GITEA_BASE(預設 https://git.uncle6.me)與 giteaToken() (ARCRUN_GITEA_TOKEN > GITEA_TOKEN,不寫死)。 - 抽出純函式 buildArchiveUrl / buildDownloadHeaders 走 Gitea archive API GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz,保留 #13 P2 cache-buster + no-cache 防 stale;private repo 帶 Authorization: token。 - downloadRepoTarball 改用上述;401/403 給「設 GITEA_TOKEN」提示。 - update.ts docstring 對齊(GitHub release → Gitea archive;標註 install≈update)。 install≈update:init 與 update 共用 downloadAndDeploy,其內容指紋 manifest 天然「新零件補、內容未變者略過」,故用戶跑 acr update 即補裝新零件。 測試:cli/tests/deploy-url.test.ts(Node 內建 test runner,零新依賴)8 顆全綠, 涵蓋 URL 組裝、repo/base 覆蓋、token→header、public 無 token、env 優先序。 真正打 Gitea 下載/部署是 leo 的閘,不在本 PR。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk --- cli/package.json | 1 + cli/src/commands/update.ts | 14 +++--- cli/src/lib/deploy.ts | 87 +++++++++++++++++++++++++++--------- cli/tests/deploy-url.test.ts | 72 +++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 26 deletions(-) create mode 100644 cli/tests/deploy-url.test.ts diff --git a/cli/package.json b/cli/package.json index 1630ab3..682c800 100644 --- a/cli/package.json +++ b/cli/package.json @@ -10,6 +10,7 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", + "test": "node --test \"tests/**/*.test.ts\"", "prepublishOnly": "npm run build && chmod +x dist/index.js" }, "dependencies": { diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index cb93b6b..8865250 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -1,13 +1,15 @@ /** - * acr update — 拉新 GitHub release,重新部署零件/引擎到用戶自己的 Cloudflare。 + * acr update — 從 Gitea 拉最新 archive,重新部署零件/引擎到用戶自己的 Cloudflare。 * - * 與 acr init --self-hosted 走同一條「下載 release → 注入 KV id → wrangler deploy」的路, - * 差別只在:init 是首次(建 KV/R2 + 寫 config),update 是沿用既有 config 重部署變動的 Worker。 + * 與 acr init --self-hosted 走同一條「下載 archive → 注入 KV id → wrangler deploy」的路 + *(同一支 downloadAndDeploy),差別只在:init 是首次(建 KV/D1 + 寫 config), + * update 是沿用既有 config 重部署變動的 Worker。因共用 downloadAndDeploy 的內容指紋 manifest, + * 「新裝零件補上、內容未變者略過」對 update 天然成立 → install ≈ update(Arcrun#4)。 + * + * 下載源(Arcrun#4,2026-07-07):由 GitHub codeload 改指 Gitea archive(git.uncle6.me), + * 讓 D20 防 flag 下不能碰 GitHub 的 self-hosted 用戶也能 acr update 補裝新零件(如 code 零件)。 * * 對應 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'; diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index eedb181..5ee15ca 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -1,11 +1,17 @@ /** * 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) + * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6(commit wasm + archive) * * 策略(richblack 2026-06-02):repo 自帶預編譯 wasm(.component-builds 下各 component.wasm, - * 見 rule 05 慣例變更)→ CLI 從 GitHub codeload tarball 拿完整部署物 → 注入用戶的 KV id + * 見 rule 05 慣例變更)→ CLI 從 repo archive tarball 拿完整部署物 → 注入用戶的 KV id * → 用用戶自己的 CF token wrangler deploy。用戶不需 git / tinygo,只需 wrangler。 + * + * 下載源(Arcrun#4,2026-07-07):從 GitHub codeload 改指 Gitea(git.uncle6.me)archive API。 + * 動機:D20 防 flag 鐵律下 self-hosted 用戶(如 Mira)不能走 GitHub;且「init 之後才新增的 + * 零件」(如 code 零件)唯一重裝管道就是 acr update,綁死 GitHub codeload = 壞。改指 Gitea 後, + * 用戶跑 acr update 就會把新零件補上、已裝的(內容指紋未變)略過(downloadAndDeploy 冪等)。 + * fork/自架者可用 ARCRUN_REPO / ARCRUN_GITEA_BASE / GITEA_TOKEN env 覆蓋(見下方常數)。 */ import { execFileSync } from 'node:child_process'; @@ -63,9 +69,42 @@ function dirContentHash(dir: string, accountId: string): string { return h.digest('hex'); } -/** GitHub repo(codeload tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。 - * 注意:repo 名大小寫敏感(codeload 路徑需完全一致)。*/ -const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'uncle6me-web/Arcrun'; +/** repo 路徑 owner/name(archive tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。 + * 注意:repo 名大小寫敏感(archive 路徑需完全一致)。 + * Arcrun#4:真身在 Gitea(Leo/Arcrun),故預設從 GitHub 的 uncle6me-web/Arcrun 改為 Gitea 路徑。*/ +const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'Leo/Arcrun'; + +/** Gitea 站台 base URL(archive API host)。fork/自架不同站台用 ARCRUN_GITEA_BASE env 覆蓋。 + * 末尾斜線會被正規化掉,避免組出 `//api`。*/ +const ARCRUN_GITEA_BASE = (process.env.ARCRUN_GITEA_BASE ?? 'https://git.uncle6.me').replace(/\/+$/, ''); + +/** Gitea access token(private repo 下載用)。走既有 env 機制,不寫死。 + * ARCRUN_GITEA_TOKEN 優先(專用),否則沿用 bootstrap 既有的 GITEA_TOKEN。 + * public repo 可不設(回傳 undefined → 不帶 Authorization header 也能下載)。*/ +function giteaToken(): string | undefined { + return process.env.ARCRUN_GITEA_TOKEN || process.env.GITEA_TOKEN || undefined; +} + +/** + * 組 Gitea archive 下載 URL(純函式,好離線測 URL 組裝)。 + * Gitea archive API:`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。 + * bust:唯一 cache-buster query param(Gitea 對不同 query 視為不同請求 → 繞過任何中間快取; + * 對齊原 codeload #13 P2 假綠防護,行為保留)。 + */ +export function buildArchiveUrl(ref: string, bust: string, repo = ARCRUN_REPO, base = ARCRUN_GITEA_BASE): string { + return `${base}/api/v1/repos/${repo}/archive/${ref}.tar.gz?_cb=${encodeURIComponent(bust)}`; +} + +/** + * 組下載用的 request headers(純函式,好離線測 token → header 對映)。 + * 帶 no-cache(保留 #13 P2 stale 防護);有 token 才加 Gitea 慣用的 `Authorization: token ` + *(private repo 需要;public repo 省略也可下載)。token 不寫死,來自 giteaToken()。 + */ +export function buildDownloadHeaders(token = giteaToken()): Record { + const headers: Record = { 'Cache-Control': 'no-cache', Pragma: 'no-cache' }; + if (token) headers.Authorization = `token ${token}`; + return headers; +} /** * init 要建立的 KV namespace(title)。 @@ -134,10 +173,10 @@ export function wranglerAvailable(): boolean { } /** - * 下載 repo codeload tarball(含預編譯 wasm)→ 注入用戶 KV id → wrangler deploy 全部 Worker。 + * 下載 repo archive tarball(含預編譯 wasm)→ 注入用戶 KV id → wrangler deploy 全部 Worker。 * * SDD self-hosted-init.md §6.4: - * 1. 下載 codeload tarball(ref 預設 main)→ 解壓到暫存目錄 + * 1. 下載 Gitea archive 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..workers.dev @@ -152,7 +191,7 @@ export async function downloadAndDeploy( ref = 'main', opts: { force?: boolean } = {}, ): Promise { - // 1. 下載 + 解壓 codeload tarball + // 1. 下載 + 解壓 Gitea archive tarball let root: string; try { root = await downloadRepoTarball(ref); @@ -397,25 +436,33 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise } } -/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。 +/** 下載 Gitea archive tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。 * - * ⚠️ Arcrun#13 P2 根因修復:codeload 的 branch tarball(tar.gz/main)由 GitHub CDN 快取, + * ⚠️ Arcrun#13 P2 根因防護(沿用):branch archive(archive/main.tar.gz)可能被中間層快取, * push 後該 ref 的 tarball 可能 stale 數分鐘。「push → 立刻 acr update」會抓到舊 tarball → - * wrangler deploy 仍回 ✓(部署成功)但 ship 的是**舊 code** → seed 還是舊數量(假綠: - * 「deploy 成功」≠「部到修好的版本」)。這正是 telegram seed 灌不進 leo21c 的真因。 - * 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過 CDN 快取,每次抓到 ref 的最新內容。*/ + * wrangler deploy 仍回 ✓(部署成功)但 ship 的是**舊 code**(假綠:「deploy 成功」≠「部到修好的版本」)。 + * 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過快取,每次抓到 ref 的最新內容。 + * + * Arcrun#4:來源由 GitHub codeload 改為 Gitea archive API(走 GITEA_TOKEN,不寫死)。*/ async function downloadRepoTarball(ref: string): Promise { - // 唯一 cache-buster query param:codeload 對不同 query 視為不同資源 → 繞過 stale CDN entry。 + // 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。 const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}?_cb=${bust}`; - console.log(chalk.gray(` → 從 GitHub 下載最新版本(${ARCRUN_REPO}@${ref},約 10–30 秒,視網速)...`)); + const url = buildArchiveUrl(ref, bust); + console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${ref},約 10–30 秒,視網速)...`)); const res = await fetch(url, { signal: AbortSignal.timeout(120_000), // 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball(#13 P2 假綠根因)。 - headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' }, + // 帶 Gitea token(若有,private repo 需要;public 省略也可)。 + headers: buildDownloadHeaders(), cache: 'no-store', }); - if (!res.ok) throw new Error(`codeload HTTP ${res.status}(${url})`); + if (!res.ok) { + // 401/403 多半是 private repo 缺 token(或 token 無此 repo 讀權限)→ 給可行動的提示。 + const hint = (res.status === 401 || res.status === 403) + ? '(private repo?請設 GITEA_TOKEN 環境變數,需對此 repo 有讀取權限)' + : ''; + throw new Error(`Gitea archive HTTP ${res.status}${hint}(${url})`); + } const buf = Buffer.from(await res.arrayBuffer()); const sizeMB = (buf.length / 1024 / 1024).toFixed(1); @@ -443,14 +490,14 @@ function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } const dir = join(cbRoot, name); // 需同時有 wrangler.toml 且有 component.wasm 才部署。 // 「錯做成零件」的(claude_api / km_writer / kbdb_upsert_block)wasm 沒 commit 進 repo - // (.gitignore 排除,待降級成工作流/recipe)→ codeload 拿到的目錄缺 wasm → 自然跳過, + // (.gitignore 排除,待降級成工作流/recipe)→ archive 拿到的目錄缺 wasm → 自然跳過, // 不讓 wrangler deploy 因缺檔失敗。 if (existsSync(join(dir, 'wrangler.toml')) && existsSync(join(dir, 'component.wasm'))) { tier1.push(dir); } } } - // self-hosted 也部署自己的 MCP worker(mcp-account-source §5c:codeload 主庫即得 MCP, + // self-hosted 也部署自己的 MCP worker(mcp-account-source §5c:archive 主庫即得 MCP, // .mcp.json 指自己的 mcp 而非官方 mcp.arcrun.dev)。 // kbdb:MCP 的 partnerAuthMiddleware 透過 KBDB service binding 打 arcrun-kbdb worker(mcp/wrangler.toml)。 // D1 arcrun-kbdb 已由 init/update 建好,但 worker 本體要一併部署,否則 binding 指向不存在的 service diff --git a/cli/tests/deploy-url.test.ts b/cli/tests/deploy-url.test.ts new file mode 100644 index 0000000..ea109bc --- /dev/null +++ b/cli/tests/deploy-url.test.ts @@ -0,0 +1,72 @@ +/** + * deploy-url.test.ts — 離線驗證下載源組裝(Arcrun#4:codeload → Gitea archive)。 + * + * 涵蓋純函式:buildArchiveUrl(URL 組裝)、buildDownloadHeaders(token → header)。 + * 打真 Gitea 下載/部署是 leo 的閘,不在單元測試做。 + * + * 用 Node 內建 test runner(node --test,零額外依賴;Node ≥22.18 自帶 TS type-stripping)。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildArchiveUrl, buildDownloadHeaders } from '../src/lib/deploy.ts'; + +test('buildArchiveUrl 指向 Gitea archive API,不再是 GitHub codeload', () => { + const url = buildArchiveUrl('main', 'bust123'); + assert.ok(url.startsWith('https://git.uncle6.me/api/v1/repos/Leo/Arcrun/archive/main.tar.gz'), + `預期 Gitea archive API,實得:${url}`); + assert.ok(!/codeload|github\.com/.test(url), `不應含 GitHub 下載源:${url}`); +}); + +test('buildArchiveUrl 帶 cache-buster query(#13 P2 stale 防護沿用)', () => { + const url = buildArchiveUrl('main', 'abc def'); // 含空白 → 應被 encode + assert.match(url, /[?&]_cb=abc%20def$/); +}); + +test('buildArchiveUrl ref 進 archive 檔名(tag/branch 皆走同路徑)', () => { + const url = buildArchiveUrl('v1.2.3', 'x'); + assert.ok(url.includes('/archive/v1.2.3.tar.gz?'), url); +}); + +test('buildArchiveUrl 可用 repo / base 覆蓋(fork/自架站台)', () => { + const url = buildArchiveUrl('main', 'x', 'Mira/Arcrun', 'https://gitea.example.org'); + assert.ok(url.startsWith('https://gitea.example.org/api/v1/repos/Mira/Arcrun/archive/main.tar.gz'), url); +}); + +test('buildDownloadHeaders:有 token → 帶 Gitea 慣用 Authorization: token ', () => { + const h = buildDownloadHeaders('secrettok'); + assert.equal(h.Authorization, 'token secrettok'); + assert.equal(h['Cache-Control'], 'no-cache'); + assert.equal(h.Pragma, 'no-cache'); +}); + +test('buildDownloadHeaders:無 token(public repo)→ 不帶 Authorization,仍 no-cache', () => { + // 顯式傳 undefined 會觸發預設參數 giteaToken()(讀 env);要驗 public 路徑需清掉 env token。 + const saved = { a: process.env.ARCRUN_GITEA_TOKEN, g: process.env.GITEA_TOKEN }; + delete process.env.ARCRUN_GITEA_TOKEN; + delete process.env.GITEA_TOKEN; + try { + const h = buildDownloadHeaders(); // 走預設 → giteaToken() → undefined + assert.equal(h.Authorization, undefined); + assert.equal(h['Cache-Control'], 'no-cache'); + } finally { + if (saved.a !== undefined) process.env.ARCRUN_GITEA_TOKEN = saved.a; + if (saved.g !== undefined) process.env.GITEA_TOKEN = saved.g; + } +}); + +test('buildDownloadHeaders:ARCRUN_GITEA_TOKEN 優先於 GITEA_TOKEN(env 機制,不寫死)', () => { + const saved = { a: process.env.ARCRUN_GITEA_TOKEN, g: process.env.GITEA_TOKEN }; + process.env.ARCRUN_GITEA_TOKEN = 'preferred'; + process.env.GITEA_TOKEN = 'fallback'; + try { + assert.equal(buildDownloadHeaders().Authorization, 'token preferred'); + } finally { + if (saved.a === undefined) delete process.env.ARCRUN_GITEA_TOKEN; else process.env.ARCRUN_GITEA_TOKEN = saved.a; + if (saved.g === undefined) delete process.env.GITEA_TOKEN; else process.env.GITEA_TOKEN = saved.g; + } +}); + +test('buildDownloadHeaders:空字串 token 視為無(避免送出 "token ")', () => { + const h = buildDownloadHeaders(''); + assert.equal(h.Authorization, undefined); +});