Compare commits

...

3 Commits

Author SHA1 Message Date
Leo 621cb8d948 feat(cli): code 零件接進 acr init/update 部署流程(自足 Worker 進部署清單 + vendored wasm 進 repo)
動機(Arcrun#4 後續,leo 批准):code 零件只有原始碼(registry/components/code),
downloadAndDeploy 完全沒涵蓋它——tier1 只掃 .component-builds/*(TinyGo 家族,要求
component.wasm),tier2 寫死四個引擎。merge 後用戶跑 acr update 應真的裝上 code。

接法(實查後裁定):
- code 是自足 Worker(quickjs-emscripten wasmfile variant,非 TinyGo;見其 index.ts 頭註),
  「缺 wasm」的真相是「這一類根本不在部署清單」+「vendored quickjs.wasm 是 gitignored
  build 產物、不進 archive」。
- deploy.ts 新增 SELF_CONTAINED_COMPONENT_WORKERS(目錄 + 必要產物 gate,比照 tier1
  component.wasm gate 的誠實跳過精神),discoverWorkerDirs 將其排進 tier1(零件先於引擎)。
- vendored quickjs.wasm(491KB)commit 進 repo:.gitignore 放行(完全比照
  !.component-builds/**/component.wasm 的「部署物 wasm 例外」先例)→ acr update 從
  Gitea archive 直接拿到,更新不需 npm build 工具鏈。
- 共享依賴抽成 SHARED_DEPLOY_DEPS 並補 quickjs-emscripten-core + wasmfile variant
  (版本對齊零件 package.json,測試看守 drift)→ root 裝一次、esbuild 往上 resolve。
- 注入零改動:既有 stripOfficialOnlyBindings 剝掉 code.arcrun.dev 官方 route、
  workers_dev=true 保留 → self-hosted 自動落 arcrun-code.<sub>.workers.dev。
- parts.ts BUILTIN_COMPONENTS 加 code 條目(issue #13 W3:零件=靜態清單)→ acr parts 可見。
  實查:init 本來就不對 registry 註冊任何零件(registry index 是官方 backfill 腳本的事),
  故「比照其他零件」=進 BUILTIN_COMPONENTS 即對齊。

測試:cli/tests/deploy-code-component.test.ts 9 顆全綠(node --test,零新依賴):
部署清單含 code / 缺產物誠實跳過 / wasm 已 git 追蹤(會進 archive)/ SHARED_DEPLOY_DEPS
涵蓋零件全部 runtime deps + 版本一致 / route 剝除與 [vars] 保留 / parts 清單含 code。
tsc --noEmit 綠;零件自身 12 顆 vitest 綠(沙箱行為未動)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
2026-07-07 09:01:11 +00:00
Leo 020ccdf085 Merge pull request 'fix(cli): acr update/deploy 下載源改指 Gitea archive(Arcrun#4)' (#26) from fix/acr-update-gitea-source into main 2026-07-07 08:40:02 +00:00
Leo 794e3eba3a fix(cli): acr update/deploy 下載源由 GitHub codeload 改指 Gitea archive(Arcrun#4)
動機: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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
2026-07-07 08:13:05 +00:00
8 changed files with 348 additions and 36 deletions
+5
View File
@@ -6,6 +6,11 @@ dist/
# 例外:放行 .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
# 例外:code 零件(自足 Worker)的 vendored quickjs.wasm 同屬部署物 —— acr init/update 從
# repo archive 直接部署(同上 .component-builds 放行邏輯)。來源=npm 套件
# @jitl/quickjs-wasmfile-release-sync 的 emscripten-module.wasm,由 postinstall vendor-wasm.mjs
# 產出、跟套件版本走(升版時重跑 npm install 再 commit 覆蓋)。約 491KB。
!registry/components/code/vendor/quickjs.wasm
# 但「錯做成零件」的再次排除(後出現的規則勝出):claude_api / km_writer / kbdb_upsert_block
# 不是 endpoint 薄殼,是把工作流硬塞進零件(違反 DECISIONS §1)→ 要降級成工作流/recipe,
# 不該進 repo 部署來源。commit 二進位進歷史無法乾淨移除 → 一開始就不放行。見 BACKLOG 降級待辦。
+1
View File
@@ -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": {
+17
View File
@@ -109,6 +109,23 @@ export const BUILTIN_COMPONENTS: ComponentDef[] = [
component: wait
ms: 1000`,
},
{
// code 是自足 Workerquickjs 沙箱,registry/components/code),非 TinyGo-wasm 家族,
// 但同屬靜態零件(走 PR + 人類閘門 merge 新增)→ 進本清單正確(issue #13 W3 原則)。
// 部署:acr init/update 的 downloadAndDeploy 自動部署(deploy.ts SELF_CONTAINED_COMPONENT_WORKERS)。
canonical_id: 'code',
display_name: '程式碼(沙箱 inline JS',
category: 'logic',
description: 'QuickJS-wasm 沙箱執行 inline JS:讀 input、return JSON-able 值;碰不到網路/檔案/env',
config_example:
` my_code:
component: code
code: |
const doubled = input.items.map(x => x * 2);
return { doubled, count: doubled.length };
input:
items: [1, 2, 3]`,
},
// ── 資料類(Data) ─────────────────────────────────────────────────────────
{
canonical_id: 'set',
+8 -6
View File
@@ -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 ≈ updateArcrun#4)。
*
* 下載源(Arcrun#42026-07-07):由 GitHub codeload 改指 Gitea archivegit.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';
+108 -30
View File
@@ -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 §6commit wasm + codeload
* 對應 SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6commit 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#42026-07-07):從 GitHub codeload 改指 Giteagit.uncle6.mearchive 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 repocodeload tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。
* 注意:repo 名大小寫敏感(codeload 路徑需完全一致)。*/
const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'uncle6me-web/Arcrun';
/** repo 路徑 owner/namearchive tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。
* 注意:repo 名大小寫敏感(archive 路徑需完全一致)。
* Arcrun#4:真身在 GiteaLeo/Arcrun),故預設從 GitHub 的 uncle6me-web/Arcrun 改為 Gitea 路徑。*/
const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'Leo/Arcrun';
/** Gitea 站台 base URLarchive API host)。fork/自架不同站台用 ARCRUN_GITEA_BASE env 覆蓋。
* 末尾斜線會被正規化掉,避免組出 `//api`。*/
const ARCRUN_GITEA_BASE = (process.env.ARCRUN_GITEA_BASE ?? 'https://git.uncle6.me').replace(/\/+$/, '');
/** Gitea access tokenprivate 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 paramGitea 對不同 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 <TOKEN>`
*private repo 需要;public repo 省略也可下載)。token 不寫死,來自 giteaToken()。
*/
export function buildDownloadHeaders(token = giteaToken()): Record<string, string> {
const headers: Record<string, string> = { 'Cache-Control': 'no-cache', Pragma: 'no-cache' };
if (token) headers.Authorization = `token ${token}`;
return headers;
}
/**
* init 要建立的 KV namespacetitle)。
@@ -97,6 +136,24 @@ export const SECRET_TARGET_WORKERS = [
'arcrun-auth-service-account',
] as const;
/** 共享部署依賴(downloadAndDeploy 2.5tarball root 裝一次,各 worker 往上 resolve)。
* 含全部 worker 的 runtime depstier1 component 只要 honotier2 cypher/registry/mcp/kbdb
* 另需 zod / @hono/zod-openapi / @modelcontextprotocol/sdk / js-yaml / yaml
* code 自足 Workerregistry/components/code)另需 quickjs-emscripten-core + wasmfile variant
* (版本對齊該零件 package.jsondrift 由 cli/tests/deploy-code-component.test.ts 看守)。
* 漏一個會讓該 worker deploy 失敗,故寧可多列。export 供離線測試驗清單完整。*/
export const SHARED_DEPLOY_DEPS: Record<string, string> = {
hono: '^4.7.0',
wrangler: '^4.0.0',
zod: '^3.23.0',
'@hono/zod-openapi': '^0.18.0',
'@modelcontextprotocol/sdk': '^1.0.0',
'js-yaml': '^4.1.0',
yaml: '^2.4.0',
'quickjs-emscripten-core': '^0.31.0',
'@jitl/quickjs-wasmfile-release-sync': '^0.32.0',
};
export interface DeployContext {
accountId: string;
apiToken: string;
@@ -134,10 +191,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 tarballref 預設 main)→ 解壓到暫存目錄
* 1. 下載 Gitea archive tarballref 預設 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
@@ -152,7 +209,7 @@ export async function downloadAndDeploy(
ref = 'main',
opts: { force?: boolean } = {},
): Promise<DeployResult> {
// 1. 下載 + 解壓 codeload tarball
// 1. 下載 + 解壓 Gitea archive tarball
let root: string;
try {
root = await downloadRepoTarball(ref);
@@ -176,17 +233,11 @@ export async function downloadAndDeploy(
let sharedBin = '';
try {
process.stdout.write(chalk.gray(' → 安裝共享部署依賴(一次,取代每個 worker 各裝)...'));
// 含全部 worker 的 runtime depstier1 component 只要 honotier2 cypher/registry/mcp/kbdb
// 另需 zod / @hono/zod-openapi / @modelcontextprotocol/sdk / js-yaml / yaml)→ 全裝 root
// 各 worker 往上 resolveesbuild bundle 找得到。漏一個會讓該 worker deploy 失敗,故寧可多列。
// 依賴清單抽出成 SHARED_DEPLOY_DEPSexport 供離線測試看守,見常數 doc)。
writeFileSync(
join(root, 'package.json'),
JSON.stringify({ name: 'arcrun-deploy-shared', private: true, type: 'module',
dependencies: {
hono: '^4.7.0', wrangler: '^4.0.0', zod: '^3.23.0',
'@hono/zod-openapi': '^0.18.0', '@modelcontextprotocol/sdk': '^1.0.0',
'js-yaml': '^4.1.0', yaml: '^2.4.0',
} }),
dependencies: SHARED_DEPLOY_DEPS }),
);
execFileSync('npm', ['install', '--no-audit', '--no-fund'],
{ cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
@@ -397,25 +448,33 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void>
}
}
/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。
/** 下載 Gitea archive tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。
*
* ⚠️ Arcrun#13 P2 根因修復:codeload 的 branch tarballtar.gz/main)由 GitHub CDN 快取,
* ⚠️ Arcrun#13 P2 根因防護(沿用):branch archivearchive/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<string> {
// 唯一 cache-buster query paramcodeload 對不同 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},約 1030 秒,視網速)...`));
const url = buildArchiveUrl(ref, bust);
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${ref},約 1030 秒,視網速)...`));
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);
@@ -432,8 +491,17 @@ async function downloadRepoTarball(ref: string): Promise<string> {
return join(dir, top);
}
/** 掃解壓出的部署物,回傳 tier1.component-builds/*)與 tier2cypher-executor/registry)目錄清單。*/
function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } {
/** 自足 Worker 零件(非 TinyGo-wasm 家族):目錄相對 root + 部署 gate 必要產物(相對該目錄)。
* 目前只有 codequickjs 沙箱,registry/components/code)。gate 精神比照 tier1 的 component.wasm
* 必要產物(vendored quickjs.wasm,需 commit 進 repo)缺 → 誠實跳過,不讓 wrangler deploy 因缺檔失敗。
* export 供離線測試驗「部署清單含 code + 產物 gate 正確」。*/
export const SELF_CONTAINED_COMPONENT_WORKERS: ReadonlyArray<{ dir: string[]; requires: string[][] }> = [
{ dir: ['registry', 'components', 'code'], requires: [['vendor', 'quickjs.wasm']] },
];
/** 掃解壓出的部署物,回傳 tier1.component-builds/* + 自足 Worker 零件)與
* tier2cypher-executor/registry/kbdb/mcp 引擎)目錄清單。export 供離線測試。*/
export function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } {
const tier1: string[] = [];
const tier2: string[] = [];
@@ -443,14 +511,24 @@ function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] }
const dir = join(cbRoot, name);
// 需同時有 wrangler.toml 且有 component.wasm 才部署。
// 「錯做成零件」的(claude_api / km_writer / kbdb_upsert_blockwasm 沒 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 workermcp-account-source §5ccodeload 主庫即得 MCP
// 自足 Worker 零件(如 code):與 TinyGo 家族不同(自帶 index.ts + 相依 npm 套件 +
// vendored quickjs.wasm),但同屬 tier1「零件」語義 → 一起先於引擎部署。
// depsquickjs-emscripten-core / wasmfile variant)由 root 共享安裝提供(SHARED_DEPLOY_DEPS),
// wrangler 對相對路徑 .wasm import 自動綁 CompiledWasm(見該零件 index.ts 頭註)。
for (const { dir: rel, requires } of SELF_CONTAINED_COMPONENT_WORKERS) {
const dir = join(root, ...rel);
const complete = existsSync(join(dir, 'wrangler.toml'))
&& requires.every(r => existsSync(join(dir, ...r)));
if (complete) tier1.push(dir);
}
// self-hosted 也部署自己的 MCP workermcp-account-source §5carchive 主庫即得 MCP
// .mcp.json 指自己的 mcp 而非官方 mcp.arcrun.dev)。
// kbdbMCP 的 partnerAuthMiddleware 透過 KBDB service binding 打 arcrun-kbdb workermcp/wrangler.toml)。
// D1 arcrun-kbdb 已由 init/update 建好,但 worker 本體要一併部署,否則 binding 指向不存在的 service
+137
View File
@@ -0,0 +1,137 @@
/**
* deploy-code-component.test.ts code Arcrun#4
*
*
* 1. codediscoverWorkerDirs registry/components/code tier1
* vendored quickjs.wasm gate tier1 component.wasm
* 2. repo code quickjs.wasm git
* = Gitea archive tarballacr update SHARED_DEPLOY_DEPS
* code package.json runtime depsdrift
* 3. stripOfficialOnlyBindings code wrangler.toml route
* code.arcrun.dev workers_dev / COMPONENT_ID self-hosted workers.dev
*
* Node test runnernode --testNode 22.18 TS type-stripping
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, statSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
discoverWorkerDirs,
SELF_CONTAINED_COMPONENT_WORKERS,
SHARED_DEPLOY_DEPS,
stripOfficialOnlyBindings,
injectMultiTenant,
} from '../src/lib/deploy.ts';
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const CODE_DIR = join(REPO_ROOT, 'registry', 'components', 'code');
// ── 1. 部署清單 ───────────────────────────────────────────────────────────────
function makeFixtureRoot(opts: { withWasm: boolean; withToml?: boolean }): string {
const root = mkdtempSync(join(tmpdir(), 'arcrun-test-root-'));
const dir = join(root, 'registry', 'components', 'code');
mkdirSync(join(dir, 'vendor'), { recursive: true });
if (opts.withToml !== false) writeFileSync(join(dir, 'wrangler.toml'), 'name = "arcrun-code"\n');
if (opts.withWasm) writeFileSync(join(dir, 'vendor', 'quickjs.wasm'), 'fake-wasm');
return root;
}
test('discoverWorkerDirscode 產物齊全 → 進 tier1(部署清單含 code', () => {
const root = makeFixtureRoot({ withWasm: true });
const { tier1 } = discoverWorkerDirs(root);
assert.ok(tier1.some(d => d.endsWith(join('registry', 'components', 'code'))),
`tier1 應含 code 目錄,實得:${JSON.stringify(tier1)}`);
});
test('discoverWorkerDirs:缺 vendor/quickjs.wasm → 誠實跳過(不讓 wrangler 因缺檔失敗)', () => {
const root = makeFixtureRoot({ withWasm: false });
const { tier1, tier2 } = discoverWorkerDirs(root);
assert.ok(!tier1.concat(tier2).some(d => d.includes('code')),
'wasm 缺席時不應把 code 排進部署清單');
});
test('discoverWorkerDirs:缺 wrangler.toml → 跳過', () => {
const root = makeFixtureRoot({ withWasm: true, withToml: false });
const { tier1 } = discoverWorkerDirs(root);
assert.equal(tier1.length, 0);
});
test('SELF_CONTAINED_COMPONENT_WORKERS 宣告 code 目錄與必要產物', () => {
const code = SELF_CONTAINED_COMPONENT_WORKERS.find(w => w.dir.join('/') === 'registry/components/code');
assert.ok(code, '清單應含 registry/components/code');
assert.ok(code!.requires.some(r => r.join('/') === 'vendor/quickjs.wasm'));
});
// ── 2. 產物完整(對 repo 實體檢查) ──────────────────────────────────────────
test('repo 內 code 零件部署必要檔齊全,quickjs.wasm 已 commit(會進 Gitea archive', () => {
for (const f of ['wrangler.toml', 'index.ts', 'sandbox.mjs', join('vendor', 'quickjs.wasm')]) {
assert.ok(statSync(join(CODE_DIR, f)).isFile(), `${f}`);
}
assert.ok(statSync(join(CODE_DIR, 'vendor', 'quickjs.wasm')).size > 100_000,
'quickjs.wasm 尺寸異常(應約 491KB');
// git 追蹤 = 會被 git archive 打包進 Gitea 下載物(.gitignore 放行是否生效的最終證據)。
const tracked = execFileSync('git', ['ls-files', '--', 'registry/components/code/vendor/quickjs.wasm'],
{ cwd: REPO_ROOT, encoding: 'utf8' }).trim();
assert.equal(tracked, 'registry/components/code/vendor/quickjs.wasm',
'vendor/quickjs.wasm 未被 git 追蹤(.gitignore 放行失效或忘了 git add');
});
// vendored quickjs.wasm 的內容指紋(@jitl/quickjs-wasmfile-release-sync ^0.32.0 的
// emscripten-module.wasm)。升套件版本卻沒重跑 vendornpm install → postinstall)再 commit
// → 這裡紅燈,防「package.json 升了、部署物 wasm 還是舊版」的 drift(審查小記 2026-07-07)。
// 升版 SOPcd registry/components/code && npm install → commit vendor/quickjs.wasm → 更新此常數。
const QUICKJS_WASM_SHA256 = '105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232';
test('vendored quickjs.wasm 內容指紋一致(升版沒重 vendor 就紅燈)', () => {
const buf = readFileSync(join(CODE_DIR, 'vendor', 'quickjs.wasm'));
const sha = createHash('sha256').update(buf).digest('hex');
assert.equal(sha, QUICKJS_WASM_SHA256,
'vendor/quickjs.wasm 指紋與紀錄不符:若剛升 quickjs 套件版本,重跑 npm install 讓 postinstall ' +
'重 vendor、commit 新 wasm,並同步更新 QUICKJS_WASM_SHA256');
});
test('SHARED_DEPLOY_DEPS 涵蓋 code 零件全部 runtime depsdrift 守門)', () => {
const pkg = JSON.parse(readFileSync(join(CODE_DIR, 'package.json'), 'utf8')) as
{ dependencies?: Record<string, string> };
for (const [dep, ver] of Object.entries(pkg.dependencies ?? {})) {
assert.ok(dep in SHARED_DEPLOY_DEPS,
`code 零件 runtime dep「${dep}」不在 SHARED_DEPLOY_DEPS → 共享安裝路徑下 esbuild 會解析失敗`);
assert.equal(SHARED_DEPLOY_DEPS[dep], ver,
`${dep}」版本 driftSHARED_DEPLOY_DEPS=${SHARED_DEPLOY_DEPS[dep]} vs 零件 package.json=${ver}`);
}
});
// ── 3. 注入正確(用 repo 內真 wrangler.toml) ────────────────────────────────
test('stripOfficialOnlyBindings:剝掉 code.arcrun.dev route,保留 workers_dev + COMPONENT_ID', () => {
const toml = readFileSync(join(CODE_DIR, 'wrangler.toml'), 'utf8');
assert.match(toml, /code\.arcrun\.dev/, '前提:repo toml 應含官方 route(官方 CI 部署用)');
const out = stripOfficialOnlyBindings(toml);
assert.ok(!/\[\[routes\]\]|zone_name|code\.arcrun\.dev/.test(out), `官方 route 應被剝除:\n${out}`);
assert.match(out, /workers_dev\s*=\s*true/, 'workers_dev 須保留(self-hosted 靠它對外)');
assert.match(out, /COMPONENT_ID\s*=\s*"code"/, '[vars] COMPONENT_ID 須保留');
assert.match(out, /name\s*=\s*"arcrun-code"/, 'worker 名須保留(cypher wasmWorkerUrl 慣例 arcrun-{kebab}');
});
test('injectMultiTenantcode toml 有 [vars] → 插入 MULTI_TENANT="false"(無害,與其他 worker 一致)', () => {
const toml = readFileSync(join(CODE_DIR, 'wrangler.toml'), 'utf8');
const out = injectMultiTenant(toml);
assert.match(out, /MULTI_TENANT\s*=\s*"false"/);
});
// ── 4. acr parts 清單含 code ─────────────────────────────────────────────────
// parts.ts 內部以 .js 副檔名 import 相鄰模組,node --test 的 type-stripping 不重寫
// 副檔名 → 無法直接 import;以原始碼文字驗證清單含 code 條目。tsc 另保證型別正確。)
test('BUILTIN_COMPONENTSacr parts 靜態清單)含 code 條目', () => {
const src = readFileSync(join(REPO_ROOT, 'cli', 'src', 'commands', 'parts.ts'), 'utf8');
assert.match(src, /canonical_id: 'code'/, 'parts.ts BUILTIN_COMPONENTS 應含 code');
assert.match(src, /component: code/, 'code 條目應含 config_examplecomponent: code');
});
+72
View File
@@ -0,0 +1,72 @@
/**
* deploy-url.test.ts Arcrun#4codeload Gitea archive
*
* buildArchiveUrlURL buildDownloadHeaderstoken header
* Gitea / leo
*
* Node test runnernode --testNode 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 <T>', () => {
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:無 tokenpublic 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('buildDownloadHeadersARCRUN_GITEA_TOKEN 優先於 GITEA_TOKENenv 機制,不寫死)', () => {
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);
});
Binary file not shown.