diff --git a/.gitignore b/.gitignore index 8266d88..e3cb101 100644 --- a/.gitignore +++ b/.gitignore @@ -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 降級待辦。 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/parts.ts b/cli/src/commands/parts.ts index 3416ead..65a3606 100644 --- a/cli/src/commands/parts.ts +++ b/cli/src/commands/parts.ts @@ -109,6 +109,23 @@ export const BUILTIN_COMPONENTS: ComponentDef[] = [ component: wait ms: 1000`, }, + { + // code 是自足 Worker(quickjs 沙箱,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', diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index eedb181..76195c1 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -97,6 +97,24 @@ export const SECRET_TARGET_WORKERS = [ 'arcrun-auth-service-account', ] as const; +/** 共享部署依賴(downloadAndDeploy 2.5:tarball root 裝一次,各 worker 往上 resolve)。 + * 含全部 worker 的 runtime deps:tier1 component 只要 hono;tier2 cypher/registry/mcp/kbdb + * 另需 zod / @hono/zod-openapi / @modelcontextprotocol/sdk / js-yaml / yaml; + * code 自足 Worker(registry/components/code)另需 quickjs-emscripten-core + wasmfile variant + * (版本對齊該零件 package.json,drift 由 cli/tests/deploy-code-component.test.ts 看守)。 + * 漏一個會讓該 worker deploy 失敗,故寧可多列。export 供離線測試驗清單完整。*/ +export const SHARED_DEPLOY_DEPS: Record = { + 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; @@ -176,17 +194,11 @@ export async function downloadAndDeploy( let sharedBin = ''; try { process.stdout.write(chalk.gray(' → 安裝共享部署依賴(一次,取代每個 worker 各裝)...')); - // 含全部 worker 的 runtime deps(tier1 component 只要 hono;tier2 cypher/registry/mcp/kbdb - // 另需 zod / @hono/zod-openapi / @modelcontextprotocol/sdk / js-yaml / yaml)→ 全裝 root, - // 各 worker 往上 resolve,esbuild bundle 找得到。漏一個會讓該 worker deploy 失敗,故寧可多列。 + // 依賴清單抽出成 SHARED_DEPLOY_DEPS(export 供離線測試看守,見常數 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'] }); @@ -432,8 +444,17 @@ async function downloadRepoTarball(ref: string): Promise { return join(dir, top); } -/** 掃解壓出的部署物,回傳 tier1(.component-builds/*)與 tier2(cypher-executor/registry)目錄清單。*/ -function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } { +/** 自足 Worker 零件(非 TinyGo-wasm 家族):目錄相對 root + 部署 gate 必要產物(相對該目錄)。 + * 目前只有 code(quickjs 沙箱,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 零件)與 + * tier2(cypher-executor/registry/kbdb/mcp 引擎)目錄清單。export 供離線測試。*/ +export function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } { const tier1: string[] = []; const tier2: string[] = []; @@ -450,6 +471,16 @@ function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } } } } + // 自足 Worker 零件(如 code):與 TinyGo 家族不同(自帶 index.ts + 相依 npm 套件 + + // vendored quickjs.wasm),但同屬 tier1「零件」語義 → 一起先於引擎部署。 + // deps(quickjs-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 worker(mcp-account-source §5c:codeload 主庫即得 MCP, // .mcp.json 指自己的 mcp 而非官方 mcp.arcrun.dev)。 // kbdb:MCP 的 partnerAuthMiddleware 透過 KBDB service binding 打 arcrun-kbdb worker(mcp/wrangler.toml)。 diff --git a/cli/tests/deploy-code-component.test.ts b/cli/tests/deploy-code-component.test.ts new file mode 100644 index 0000000..4b25774 --- /dev/null +++ b/cli/tests/deploy-code-component.test.ts @@ -0,0 +1,122 @@ +/** + * deploy-code-component.test.ts — 離線驗「code 零件接進部署流程」(Arcrun#4 後續)。 + * + * 驗三件事(不打網路、不部署): + * 1. 部署清單含 code:discoverWorkerDirs 在產物齊全時把 registry/components/code 收進 tier1; + * 缺 vendored quickjs.wasm 時誠實跳過(gate 精神比照 tier1 component.wasm)。 + * 2. 產物完整:repo 內 code 零件的部署必要檔存在且 quickjs.wasm 已被 git 追蹤 + * (= 會進 Gitea archive tarball,acr update 才拿得到);共享依賴 SHARED_DEPLOY_DEPS + * 涵蓋 code 零件 package.json 的全部 runtime deps(drift 守門)。 + * 3. 注入正確:stripOfficialOnlyBindings 對 code 的 wrangler.toml 剝掉官方 route + * (code.arcrun.dev),保留 workers_dev / COMPONENT_ID → self-hosted 落自己帳號的 workers.dev。 + * + * 用 Node 內建 test runner(node --test,零額外依賴;Node ≥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 { 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('discoverWorkerDirs:code 產物齊全 → 進 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)'); +}); + +test('SHARED_DEPLOY_DEPS 涵蓋 code 零件全部 runtime deps(drift 守門)', () => { + const pkg = JSON.parse(readFileSync(join(CODE_DIR, 'package.json'), 'utf8')) as + { dependencies?: Record }; + 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}」版本 drift:SHARED_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('injectMultiTenant:code 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_COMPONENTS(acr 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_example(component: code)'); +}); diff --git a/registry/components/code/vendor/quickjs.wasm b/registry/components/code/vendor/quickjs.wasm new file mode 100644 index 0000000..1815757 Binary files /dev/null and b/registry/components/code/vendor/quickjs.wasm differ