diff --git a/.gitignore b/.gitignore index 2a1e03c..e79be5a 100644 --- a/.gitignore +++ b/.gitignore @@ -422,3 +422,4 @@ backend/cmd/desktop/rsrc_windows_*.syso # Bun .build/ *.bun-build +dist/release/ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0375a2b..4fc33ea 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -950,7 +950,9 @@ bun run build #### 构建流程 -`scripts/build.ts` 执行三步流水线: +构建逻辑拆分为两个文件:`scripts/build-common.ts`(共享函数)和 `scripts/build.ts`(编排逻辑)。 + +`scripts/build.ts` 执行三步流水线(函数来自 `build-common.ts`): ``` 1. Vite build → dist/web/ (前端静态资源,含 code splitting) @@ -992,10 +994,64 @@ bun run build ```bash bun run clean -# 清理 dist/ 构建产物和 .build/ 临时文件 +# 清理 dist/ 构建产物、dist/release/ 发布产物和 .build/ 临时文件 ``` -### 3.4 开发工作流 +### 3.4 跨平台发布 + +#### 发布命令 + +```bash +bun run release # 编译全部 7 个目标平台 +bun run release --target linux-x64 # 编译指定平台 +bun run release --target linux-x64,windows-x64,darwin-arm64 # 多平台 +``` + +#### 发布流程 + +`scripts/release.ts` 复用 `build-common.ts` 的前端构建和代码生成,然后执行多目标交叉编译和打包: + +``` +1. Vite build → dist/web/ (前端静态资源,只执行一次) +2. Code generation → .build/ (资源嵌入代码) +3. 多目标 Bun compile → dist/release/binaries/ (7 个目标平台二进制) +4. tar.gz 打包 → dist/release/packages/ (压缩包 + SHA256 校验和) +``` + +#### 支持的目标平台 + +| CLI 参数 | Bun CompileTarget | 说明 | +| ------------------ | ---------------------- | ------------------- | +| `linux-x64` | `bun-linux-x64` | Linux x64 glibc | +| `linux-arm64` | `bun-linux-arm64` | Linux ARM64 glibc | +| `linux-x64-musl` | `bun-linux-x64-musl` | Linux x64 musl | +| `linux-arm64-musl` | `bun-linux-arm64-musl` | Linux ARM64 musl | +| `windows-x64` | `bun-windows-x64` | Windows x64 | +| `darwin-x64` | `bun-darwin-x64` | macOS Intel | +| `darwin-arm64` | `bun-darwin-arm64` | macOS Apple Silicon | + +#### 产出物 + +``` +dist/release/ +├── binaries/ ← 裸二进制 +│ ├── dial-server-{version}-{os}-{arch}[.exe] +│ └── ... +└── packages/ ← 压缩包 + 校验和 + ├── dial-server_{version}_{os}_{arch}.tar.gz + ├── dial-server_{version}_{os}_{arch}.tar.gz.sha256 + └── ... +``` + +压缩包内含可执行文件(`dial-server` 或 `dial-server.exe`)、`probes.example.yaml` 和 `LICENSE`。 + +#### 命名规范 + +- 裸二进制:`dial-server-{version}-{os}-{arch}[.exe]`,如 `dial-server-0.1.0-linux-x64` +- 压缩包:`dial-server_{version}_{os}_{arch}.tar.gz`,如 `dial-server_0.1.0_linux_x64.tar.gz` +- 校验和:`<压缩包文件名>.sha256`,格式兼容 `sha256sum -c` + +### 3.5 开发工作流 #### 日常开发循环 @@ -1015,29 +1071,30 @@ bun run verify `verify` 适合 CI 或正式提交前,会完整验证类型检查、lint、格式、单元测试和生产构建。 -### 3.5 Executable/E2E 验证 +### 3.6 Executable/E2E 验证 原 `scripts/smoke.ts` 覆盖过薄,已从当前工作流移除。后续如需验证 production executable 的 API、静态资源服务、SPA fallback 行为,应重新设计独立的 executable/E2E 测试。 -### 3.6 脚本说明 +### 3.7 脚本说明 -| 脚本 | 文件 | 说明 | -| ---------------------- | ----------------------------------- | ---------------------------------------- | -| `bun run dev` | `scripts/dev.ts` | 双进程开发服务(Vite :5173 + API :3000) | -| `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server | -| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server | -| `bun run build` | `scripts/build.ts` | Vite → codegen → Bun compile 三步构建 | -| `bun run schema` | `scripts/generate-config-schema.ts` | 生成 `probe-config.schema.json` | -| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 schema 导出物是否同步 | -| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 | +| 脚本 | 文件 | 说明 | +| ---------------------- | ----------------------------------- | -------------------------------------------------- | +| `bun run dev` | `scripts/dev.ts` | 双进程开发服务(Vite :5173 + API :3000) | +| `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server | +| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server | +| `bun run build` | `scripts/build.ts` | Vite → codegen → Bun compile 三步构建 | +| `bun run release` | `scripts/release.ts` | 跨平台发布打包(多目标交叉编译 + tar.gz + SHA256) | +| `bun run schema` | `scripts/generate-config-schema.ts` | 生成 `probe-config.schema.json` | +| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 schema 导出物是否同步 | +| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 | -### 3.7 环境变量 +### 3.8 环境变量 | 变量 | 用途 | 默认值 | | --------------------------- | ----------------------------------------------- | -------- | | `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 | -### 3.8 项目配置文件 +### 3.9 项目配置文件 | 文件 | 用途 | | ---------------------- | ---------------------------------------------- | @@ -1050,14 +1107,14 @@ bun run verify | `probes.example.yaml` | 配置文件示例 | | `opencode.json` | OpenCode 工具配置(TDesign MCP server) | -### 3.9 依赖管理 +### 3.10 依赖管理 - **包管理器**:仅使用 `bun`,禁止使用 npm、pnpm、yarn - **安装依赖**:`bun install` - **运行工具**:使用 `bunx`,禁止使用 `npx`、`pnpx` - **锁文件**:`bun.lock` -### 3.10 目录约定 +### 3.11 目录约定 | 目录 | 约定 | | ------------- | ---------------------------------------------------- | diff --git a/README.md b/README.md index 7f6f7f2..837e040 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,35 @@ bun run build 构建产物为独立可执行文件,只需一个 YAML 配置文件即可运行。 +### 跨平台发布打包 + +```bash +# 编译全部 7 个目标平台 +bun run release + +# 编译指定平台 +bun run release --target linux-x64 +bun run release --target linux-x64,windows-x64,darwin-arm64 +``` + +支持的目标平台:`linux-x64`、`linux-arm64`、`linux-x64-musl`、`linux-arm64-musl`、`windows-x64`、`darwin-x64`、`darwin-arm64` + +**产出物结构:** + +``` +dist/release/ +├── binaries/ ← 裸二进制(带版本号和平台标识) +│ ├── dial-server-0.1.0-linux-x64 +│ ├── dial-server-0.1.0-windows-x64.exe +│ └── ... +└── packages/ ← tar.gz 压缩包 + SHA256 校验和 + ├── dial-server_0.1.0_linux_x64.tar.gz + ├── dial-server_0.1.0_linux_x64.tar.gz.sha256 + └── ... +``` + +压缩包内含可执行文件、`probes.example.yaml` 和 `LICENSE`,解压后可直接使用。 + ## 配置文件 程序通过 YAML 配置文件定义所有运行参数,完整示例参见 [`probes.example.yaml`](probes.example.yaml)。 diff --git a/bun.lock b/bun.lock index 707db9c..779460b 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "@types/jsdom": "^28.0.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/tar-stream": "^3.1.4", "@vitejs/plugin-react": "^6.0.2", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", @@ -45,6 +46,7 @@ "jsdom": "^29.1.1", "lint-staged": "^17.0.4", "prettier": "^3.8.3", + "tar-stream": "^3.2.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", "vite": "^8.0.13", @@ -310,6 +312,8 @@ "@types/sortablejs": ["@types/sortablejs@1.15.9", "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz", {}, "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="], + "@types/tar-stream": ["@types/tar-stream@3.1.4", "https://registry.npmmirror.com/@types/tar-stream/-/tar-stream-3.1.4.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg=="], + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "https://registry.npmmirror.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], @@ -416,8 +420,22 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "b4a": ["b4a@1.8.1", "https://registry.npmmirror.com/b4a/-/b4a-1.8.1.tgz", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="], + "balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "bare-events": ["bare-events@2.8.3", "https://registry.npmmirror.com/bare-events/-/bare-events-2.8.3.tgz", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw=="], + + "bare-fs": ["bare-fs@4.7.1", "https://registry.npmmirror.com/bare-fs/-/bare-fs-4.7.1.tgz", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw=="], + + "bare-os": ["bare-os@3.9.1", "https://registry.npmmirror.com/bare-os/-/bare-os-3.9.1.tgz", {}, "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ=="], + + "bare-path": ["bare-path@3.0.0", "https://registry.npmmirror.com/bare-path/-/bare-path-3.0.0.tgz", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], + + "bare-stream": ["bare-stream@2.13.1", "https://registry.npmmirror.com/bare-stream/-/bare-stream-2.13.1.tgz", { "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow=="], + + "bare-url": ["bare-url@2.4.3", "https://registry.npmmirror.com/bare-url/-/bare-url-2.4.3.tgz", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.28", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA=="], "bidi-js": ["bidi-js@1.0.3", "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], @@ -620,12 +638,16 @@ "eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "events-universal": ["events-universal@1.0.1", "https://registry.npmmirror.com/events-universal/-/events-universal-1.0.1.tgz", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + "eventsource-parser": ["eventsource-parser@3.0.8", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.8.tgz", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], + "fast-fifo": ["fast-fifo@1.3.2", "https://registry.npmmirror.com/fast-fifo/-/fast-fifo-1.3.2.tgz", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], @@ -1032,6 +1054,8 @@ "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + "streamx": ["streamx@2.25.0", "https://registry.npmmirror.com/streamx/-/streamx-2.25.0.tgz", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], + "string-argv": ["string-argv@0.3.2", "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -1052,10 +1076,16 @@ "synckit": ["synckit@0.11.12", "https://registry.npmmirror.com/synckit/-/synckit-0.11.12.tgz", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], + "tar-stream": ["tar-stream@3.2.0", "https://registry.npmmirror.com/tar-stream/-/tar-stream-3.2.0.tgz", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="], + "tdesign-icons-react": ["tdesign-icons-react@0.6.4", "https://registry.npmmirror.com/tdesign-icons-react/-/tdesign-icons-react-0.6.4.tgz", { "dependencies": { "@babel/runtime": "^7.16.5", "classnames": "^2.2.6" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-USAoi9vBWcwcJT45VqR3dRqX1MeAsn/RhHVx4bLwplhrlvE80ZQ1N9V+6F3HqE1Qe9mMDbtRM8Ul80+lALScww=="], "tdesign-react": ["tdesign-react@1.16.9", "https://registry.npmmirror.com/tdesign-react/-/tdesign-react-1.16.9.tgz", { "dependencies": { "@babel/runtime": "~7.26.7", "@popperjs/core": "~2.11.2", "@types/sortablejs": "^1.10.7", "@types/validator": "^13.1.3", "classnames": "~2.5.1", "dayjs": "1.11.10", "hoist-non-react-statics": "~3.3.2", "lodash-es": "^4.17.21", "mitt": "^3.0.0", "raf": "~3.4.1", "react-fast-compare": "^3.2.2", "react-is": "^18.2.0", "react-transition-group": "~4.4.1", "sortablejs": "^1.15.0", "tdesign-icons-react": "^0.6.4", "tslib": "~2.3.1", "validator": "~13.15.0" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-C3uZRTkJ1iQ62BrMkuvqvBK+4HEuhl82rABxa6kAHGHL3eBI4DPfzAJGF0T3b+DKCBeJxb0x10elumT6NkQEaw=="], + "teex": ["teex@1.0.1", "https://registry.npmmirror.com/teex/-/teex-1.0.1.tgz", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], + + "text-decoder": ["text-decoder@1.2.7", "https://registry.npmmirror.com/text-decoder/-/text-decoder-1.2.7.tgz", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyexec": ["tinyexec@1.1.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.2.tgz", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], diff --git a/openspec/specs/cross-platform-release/spec.md b/openspec/specs/cross-platform-release/spec.md new file mode 100644 index 0000000..96ac3c4 --- /dev/null +++ b/openspec/specs/cross-platform-release/spec.md @@ -0,0 +1,132 @@ +## Purpose + +定义多平台交叉编译发布流程,包括目标平台矩阵、可执行文件命名规范、压缩包打包、SHA256 校验和生成、CLI 接口。 + +## Requirements + +### Requirement: Release 目标平台矩阵 +Release 流程 SHALL 支持以下 7 个编译目标,覆盖所有指定平台: + +| Bun CompileTarget | OS | Arch | +|---|---|---| +| bun-linux-x64 | linux | x64 | +| bun-linux-arm64 | linux | arm64 | +| bun-linux-x64-musl | linux | x64-musl | +| bun-linux-arm64-musl | linux | arm64-musl | +| bun-windows-x64 | windows | x64 | +| bun-darwin-x64 | darwin | x64 | +| bun-darwin-arm64 | darwin | arm64 | + +#### Scenario: 默认全平台编译 +- **WHEN** 开发者运行 `bun run release` 不带额外参数 +- **THEN** 系统 SHALL 依次编译上述 7 个目标 + +#### Scenario: 指定单一目标编译 +- **WHEN** 开发者运行 `bun run release --target linux-x64` +- **THEN** 系统 SHALL 只编译 `bun-linux-x64` 目标 + +#### Scenario: 指定多个目标编译 +- **WHEN** 开发者运行 `bun run release --target linux-x64,darwin-arm64,windows-x64` +- **THEN** 系统 SHALL 编译指定的 3 个目标 + +#### Scenario: 无效 target 参数 +- **WHEN** 开发者传入不存在的 `--target` 值 +- **THEN** 系统 MUST 报错退出,并列出所有可用的 target 值 + +### Requirement: Release 构建流水线 +Release 流程 SHALL 执行四步流水线:Vite 前端构建 → code generation → 多目标交叉编译 → 打包与校验和。 + +#### Scenario: Release 构建顺序 +- **WHEN** 开发者运行 `bun run release` +- **THEN** 系统 MUST 依次执行 Vite build、code generation、多目标 Bun compile、tar.gz 打包和 SHA256 校验和生成 + +#### Scenario: Vite 构建失败 +- **WHEN** Vite build 步骤失败 +- **THEN** 系统 MUST 停止后续步骤,不执行 code generation 或编译 + +#### Scenario: 单目标编译失败 +- **WHEN** 某个目标的 Bun compile 失败 +- **THEN** 系统 MUST 停止后续打包,报告失败的目标 + +#### Scenario: 前端构建只执行一次 +- **WHEN** release 流程编译多个目标 +- **THEN** Vite build 和 code generation MUST 只执行一次,所有目标共用同一份前端产出 + +#### Scenario: 构建完成后清理 +- **WHEN** release 流程完成(无论成功或失败) +- **THEN** 系统 SHALL 清理 `.build/` 临时目录 + +### Requirement: 可执行文件命名规范 +Release 产出的裸二进制 SHALL 使用 `dial-server-{version}-{os}-{arch}` 命名,Windows 平台附加 `.exe` 后缀。 + +#### Scenario: Linux x64 可执行文件命名 +- **WHEN** 编译目标为 bun-linux-x64 且版本为 0.1.0 +- **THEN** 裸二进制文件名 SHALL 为 `dial-server-0.1.0-linux-x64` + +#### Scenario: Windows 可执行文件命名 +- **WHEN** 编译目标为 bun-windows-x64 且版本为 0.1.0 +- **THEN** 裸二进制文件名 SHALL 为 `dial-server-0.1.0-windows-x64.exe` + +#### Scenario: musl 变体命名 +- **WHEN** 编译目标为 bun-linux-x64-musl 且版本为 0.1.0 +- **THEN** 裸二进制文件名 SHALL 为 `dial-server-0.1.0-linux-x64-musl` + +### Requirement: 压缩包打包 +每个目标 SHALL 生成一个 tar.gz 压缩包,内含可执行文件、示例配置和许可证。 + +#### Scenario: 压缩包命名 +- **WHEN** 编译 linux-x64 目标且版本为 0.1.0 +- **THEN** 压缩包文件名 SHALL 为 `dial-server_0.1.0_linux_x64.tar.gz` + +#### Scenario: 压缩包内部目录结构 +- **WHEN** 解压 `dial-server_0.1.0_linux_x64.tar.gz` +- **THEN** 产出目录 SHALL 为 `dial-server_0.1.0_linux_x64/`,内含 `dial-server`(可执行文件)、`probes.example.yaml`(示例配置)、`LICENSE`(许可证) + +#### Scenario: Windows 目标压缩包 +- **WHEN** 编译 windows-x64 目标且版本为 0.1.0 +- **THEN** 压缩包 SHALL 使用 `.tar.gz` 格式,内部可执行文件名 SHALL 为 `dial-server.exe` + +#### Scenario: 压缩包内可执行文件权限 +- **WHEN** 在 Linux/macOS 上解压非 Windows 目标的压缩包 +- **THEN** 可执行文件 SHALL 具有 0o755 权限(可执行),配置文件和许可证 SHALL 具有 0o644 权限 + +#### Scenario: 版本号来源 +- **WHEN** release 流程执行 +- **THEN** 版本号 SHALL 从 `package.json.version` 读取,与 build 命令使用同一来源 + +### Requirement: SHA256 校验和 +每个压缩包 SHALL 附带一个 SHA256 校验和文件,格式兼容 `sha256sum -c`。 + +#### Scenario: 校验和文件命名 +- **WHEN** 压缩包为 `dial-server_0.1.0_linux_x64.tar.gz` +- **THEN** 校验和文件名 SHALL 为 `dial-server_0.1.0_linux_x64.tar.gz.sha256` + +#### Scenario: 校验和文件内容格式 +- **WHEN** 读取 `.sha256` 文件 +- **THEN** 内容 SHALL 为 ` \n` 格式,其中 `` 为 64 位小写十六进制字符串,`` 为对应压缩包文件名,中间以两个空格分隔 + +#### Scenario: 校验和正确性 +- **WHEN** 使用 `sha256sum -c` 命令验证 `.sha256` 文件 +- **THEN** 校验 MUST 通过 + +### Requirement: 产出物目录结构 +Release 产出物 SHALL 按类型分目录存放在 `dist/release/` 下。 + +#### Scenario: 产出物目录布局 +- **WHEN** release 流程成功完成 +- **THEN** `dist/release/binaries/` SHALL 包含所有裸二进制文件,`dist/release/packages/` SHALL 包含所有压缩包和校验和文件 + +#### Scenario: 清理命令覆盖 +- **WHEN** 开发者运行 `bun run clean` +- **THEN** 系统 SHALL 清理 `dist/release/` 目录 + +### Requirement: Release 报告 +Release 流程完成时 SHALL 输出构建报告,包含各产出物的文件大小。 + +#### Scenario: 成功构建报告 +- **WHEN** release 流程成功完成 +- **THEN** 系统 SHALL 输出每个裸二进制和压缩包的文件路径和大小 + +#### Scenario: 失败构建报告 +- **WHEN** release 流程失败 +- **THEN** 系统 SHALL 输出失败的具体步骤和目标平台 diff --git a/openspec/specs/single-executable-packaging/spec.md b/openspec/specs/single-executable-packaging/spec.md index 69ea171..7273699 100644 --- a/openspec/specs/single-executable-packaging/spec.md +++ b/openspec/specs/single-executable-packaging/spec.md @@ -7,6 +7,8 @@ ### Requirement: 生产构建顺序 生产构建 MUST 通过三步流水线完成:Vite 前端构建 → code generation → Bun compile。 +构建步骤 1-2(Vite build、code generation)的逻辑 SHALL 从 `scripts/build-common.ts` 导入,`scripts/build.ts` 只保留当前平台编译和编排逻辑。 + #### Scenario: 运行生产构建 - **WHEN** 开发者运行生产构建命令 - **THEN** 系统 MUST 依次执行 Vite build、资源导入 code generation、Bun.build compile,最终输出单可执行文件 @@ -19,6 +21,10 @@ - **WHEN** Bun.build compile 步骤失败 - **THEN** 系统 MUST 清理 `.build/` 临时目录,不保留 stale executable +#### Scenario: 重构后 build 行为不变 +- **WHEN** `scripts/build.ts` 改为从 `scripts/build-common.ts` 导入共享构建函数 +- **THEN** `bun run build` 的产出文件路径(`dist/dial-server`)、构建步骤顺序和错误处理行为 SHALL 与重构前完全一致 + ### Requirement: 单 executable 输出 生产构建 SHALL 输出一个 standalone executable,其中包含 Bun 后端和通过 `import with { type: "file" }` 嵌入的 Vite 前端产出。 @@ -37,13 +43,15 @@ ### Requirement: 构建中间产物管理 构建流程 SHALL 使用 `.build/` 临时目录存放 code generation 产物,构建完成后清理。 +清理逻辑 SHALL 定义在 `scripts/build-common.ts` 中,供 `build.ts` 和 `release.ts` 共用。 + #### Scenario: 构建成功后清理中间产物 - **WHEN** 生产构建成功完成并输出 executable -- **THEN** 系统 SHALL 删除 `.build/` 临时目录 +- **THEN** 系统 SHALL 通过 `build-common.ts` 中的 `cleanup()` 函数删除 `.build/` 临时目录 #### Scenario: 构建失败时清理中间产物 - **WHEN** 生产构建在 Bun compile 步骤失败 -- **THEN** 系统 SHALL 删除 `.build/` 临时目录和 stale executable +- **THEN** 系统 SHALL 通过 `build-common.ts` 中的 `cleanup()` 函数删除 `.build/` 临时目录和 stale executable ### Requirement: 外部运行时配置 executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。 diff --git a/package.json b/package.json index ee80d7c..7c379c3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "verify": "bun run check && bun run build", "test": "bun test", "clean": "bun run scripts/clean.ts", + "release": "bun run scripts/release.ts", "typecheck": "tsc --noEmit", "prepare": "husky", "version:patch": "bun run scripts/bump-version.ts patch", @@ -33,6 +34,7 @@ "@types/jsdom": "^28.0.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/tar-stream": "^3.1.4", "@vitejs/plugin-react": "^6.0.2", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", @@ -46,6 +48,7 @@ "jsdom": "^29.1.1", "lint-staged": "^17.0.4", "prettier": "^3.8.3", + "tar-stream": "^3.2.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", "vite": "^8.0.13" diff --git a/scripts/build-common.ts b/scripts/build-common.ts new file mode 100644 index 0000000..e312592 --- /dev/null +++ b/scripts/build-common.ts @@ -0,0 +1,123 @@ +import { readdir, rm, writeFile } from "node:fs/promises"; +import { join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { validateVersion } from "./bump-version-logic"; + +export const projectRoot = fileURLToPath(new URL("..", import.meta.url)); +export const distWebDir = join(projectRoot, "dist/web"); +export const buildDir = join(projectRoot, ".build"); +export const packageJsonPath = join(projectRoot, "package.json"); + +export async function cleanup() { + await rm(buildDir, { force: true, recursive: true }); +} + +export async function codeGeneration() { + console.log("Step 2/3: Code generation..."); + await rm(buildDir, { force: true, recursive: true }); + await Bun.write(join(buildDir, ".gitkeep"), ""); + + const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string }; + const version = packageJson.version; + if (typeof version !== "string") { + console.error("package.json does not have a valid version field"); + process.exit(1); + } + validateVersion(version); + + const allFiles = await scanDir(distWebDir, "/"); + const importLines: string[] = []; + const fileEntries: string[] = []; + let indexHtmlVar = ""; + + for (let i = 0; i < allFiles.length; i++) { + const urlPath = allFiles[i]!; + const varName = `f${i}`; + const filePath = toImportSpecifier(buildDir, join(distWebDir, urlPath.slice(1))); + importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`); + + if (urlPath === "/index.html") { + indexHtmlVar = varName; + } else { + fileEntries.push(` "${urlPath}": Bun.file(${varName}),`); + } + } + + if (!indexHtmlVar) { + console.error("index.html not found in dist/web/"); + process.exit(1); + } + + const staticAssetsTs = [ + `import type { StaticAssets } from "../src/server/static";`, + "", + ...importLines, + "", + `export const staticAssets: StaticAssets = {`, + ` files: {`, + ...fileEntries, + ` },`, + ` indexHtml: Bun.file(${indexHtmlVar}),`, + `};`, + "", + ].join("\n"); + + await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs); + + const serverEntryTs = [ + `import { bootstrap } from "../src/server/bootstrap";`, + `import { readRuntimeConfig } from "../src/server/config";`, + `import { staticAssets } from "./static-assets";`, + "", + `const APP_VERSION = "${version}" as const;`, + "", + `async function main() {`, + ` const { configPath } = readRuntimeConfig();`, + ` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`, + `}`, + "", + `void main().catch((error) => {`, + ` console.error("启动失败:", error instanceof Error ? error.message : error);`, + ` process.exit(1);`, + `});`, + "", + ].join("\n"); + + await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs); + + return version; +} + +export async function scanDir(dir: string, prefix: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const paths: string[] = []; + for (const entry of entries) { + const fullPath = join(dir, entry.name); + const urlPath = `${prefix}${entry.name}`; + if (entry.isDirectory()) { + paths.push(...(await scanDir(fullPath, `${urlPath}/`))); + } else { + paths.push(urlPath); + } + } + return paths; +} + +export function toImportSpecifier(fromDir: string, targetPath: string) { + return relative(fromDir, targetPath).split(sep).join("/"); +} + +export async function viteBuild() { + console.log("Step 1/3: Vite build..."); + const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], { + cwd: projectRoot, + stderr: "inherit", + stdout: "inherit", + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + console.error("Vite build failed"); + process.exit(1); + } +} diff --git a/scripts/build.ts b/scripts/build.ts index 66e197c..9ea67e2 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,14 +1,9 @@ -import { readdir, rm, writeFile } from "node:fs/promises"; -import { join, relative, sep } from "node:path"; -import { fileURLToPath } from "node:url"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; -import { validateVersion } from "./bump-version-logic"; +import { buildDir, cleanup, codeGeneration, projectRoot, viteBuild } from "./build-common"; -const projectRoot = fileURLToPath(new URL("..", import.meta.url)); -const distWebDir = join(projectRoot, "dist/web"); -const buildDir = join(projectRoot, ".build"); const executablePath = join(projectRoot, "dist/dial-server"); -const packageJsonPath = join(projectRoot, "package.json"); async function build() { try { @@ -54,115 +49,4 @@ async function bunCompile() { } } -async function cleanup() { - await rm(buildDir, { force: true, recursive: true }); -} - -async function codeGeneration() { - console.log("Step 2/3: Code generation..."); - await rm(buildDir, { force: true, recursive: true }); - await Bun.write(join(buildDir, ".gitkeep"), ""); - - const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string }; - const version = packageJson.version; - if (typeof version !== "string") { - console.error("package.json does not have a valid version field"); - process.exit(1); - } - validateVersion(version); - - const allFiles = await scanDir(distWebDir, "/"); - const importLines: string[] = []; - const fileEntries: string[] = []; - let indexHtmlVar = ""; - - for (let i = 0; i < allFiles.length; i++) { - const urlPath = allFiles[i]!; - const varName = `f${i}`; - const filePath = toImportSpecifier(buildDir, join(distWebDir, urlPath.slice(1))); - importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`); - - if (urlPath === "/index.html") { - indexHtmlVar = varName; - } else { - fileEntries.push(` "${urlPath}": Bun.file(${varName}),`); - } - } - - if (!indexHtmlVar) { - console.error("index.html not found in dist/web/"); - process.exit(1); - } - - const staticAssetsTs = [ - `import type { StaticAssets } from "../src/server/static";`, - "", - ...importLines, - "", - `export const staticAssets: StaticAssets = {`, - ` files: {`, - ...fileEntries, - ` },`, - ` indexHtml: Bun.file(${indexHtmlVar}),`, - `};`, - "", - ].join("\n"); - - await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs); - - const serverEntryTs = [ - `import { bootstrap } from "../src/server/bootstrap";`, - `import { readRuntimeConfig } from "../src/server/config";`, - `import { staticAssets } from "./static-assets";`, - "", - `const APP_VERSION = "${version}" as const;`, - "", - `async function main() {`, - ` const { configPath } = readRuntimeConfig();`, - ` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`, - `}`, - "", - `void main().catch((error) => {`, - ` console.error("启动失败:", error instanceof Error ? error.message : error);`, - ` process.exit(1);`, - `});`, - "", - ].join("\n"); - - await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs); -} - -async function scanDir(dir: string, prefix: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }); - const paths: string[] = []; - for (const entry of entries) { - const fullPath = join(dir, entry.name); - const urlPath = `${prefix}${entry.name}`; - if (entry.isDirectory()) { - paths.push(...(await scanDir(fullPath, `${urlPath}/`))); - } else { - paths.push(urlPath); - } - } - return paths; -} - -function toImportSpecifier(fromDir: string, targetPath: string) { - return relative(fromDir, targetPath).split(sep).join("/"); -} - -async function viteBuild() { - console.log("Step 1/3: Vite build..."); - const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], { - cwd: projectRoot, - stderr: "inherit", - stdout: "inherit", - }); - const exitCode = await proc.exited; - if (exitCode !== 0) { - console.error("Vite build failed"); - process.exit(1); - } -} - await build(); diff --git a/scripts/clean.ts b/scripts/clean.ts index 59e30ec..a06afda 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -8,6 +8,7 @@ const dirs: Array<{ desc: string; path: string }> = [ { desc: "Bun 构建缓存", path: ".build" }, { desc: "Playwright 测试报告", path: "playwright-report" }, { desc: "测试结果", path: "test-results" }, + { desc: "发布产物", path: "dist/release" }, ]; const filePatterns: Array<{ desc: string; glob: string }> = [{ desc: "Bun 构建临时文件", glob: ".*.bun-build" }]; diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..4048e38 --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,196 @@ +import { createHash } from "node:crypto"; +import { mkdir, rm, stat } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { createGzip } from "node:zlib"; +import tar from "tar-stream"; + +import { buildDir, cleanup, codeGeneration, projectRoot, viteBuild } from "./build-common"; + +const releaseDir = join(projectRoot, "dist/release"); +const binariesDir = join(releaseDir, "binaries"); +const packagesDir = join(releaseDir, "packages"); + +export interface ReleaseTarget { + arch: string; + bunTarget: string; + displayName: string; + os: string; +} + +export const ALL_TARGETS: ReleaseTarget[] = [ + { arch: "x64", bunTarget: "bun-linux-x64", displayName: "Linux x64 (glibc)", os: "linux" }, + { arch: "arm64", bunTarget: "bun-linux-arm64", displayName: "Linux ARM64 (glibc)", os: "linux" }, + { arch: "x64-musl", bunTarget: "bun-linux-x64-musl", displayName: "Linux x64 (musl)", os: "linux" }, + { arch: "arm64-musl", bunTarget: "bun-linux-arm64-musl", displayName: "Linux ARM64 (musl)", os: "linux" }, + { arch: "x64", bunTarget: "bun-windows-x64", displayName: "Windows x64", os: "windows" }, + { arch: "x64", bunTarget: "bun-darwin-x64", displayName: "macOS x64 (Intel)", os: "darwin" }, + { arch: "arm64", bunTarget: "bun-darwin-arm64", displayName: "macOS ARM64 (Apple Silicon)", os: "darwin" }, +]; + +export function archiveName(target: ReleaseTarget, version: string): string { + return `dial-server_${version}_${target.os}_${target.arch}.tar.gz`; +} + +export function checksumName(target: ReleaseTarget, version: string): string { + return `${archiveName(target, version)}.sha256`; +} + +export async function compileTarget(target: ReleaseTarget, version: string): Promise { + const outfile = join(binariesDir, execName(target, version)); + console.log(` 编译 ${target.displayName}...`); + + const result = await Bun.build({ + compile: { + autoloadBunfig: true, + autoloadDotenv: true, + outfile, + target: target.bunTarget as Bun.Build.CompileTarget, + }, + entrypoints: [join(buildDir, "server-entry.ts")], + minify: true, + }); + + if (!result.success) { + console.error(` 编译失败 (${target.displayName}):`, result.logs); + process.exit(1); + } + + return outfile; +} + +export async function computeChecksum(archivePath: string): Promise { + const content = await Bun.file(archivePath).arrayBuffer(); + const hash = createHash("sha256").update(Buffer.from(content)).digest("hex"); + const filename = relative(packagesDir, archivePath); + const checksumPath = `${archivePath}.sha256`; + const checksumContent = `${hash} ${filename}\n`; + await Bun.write(checksumPath, checksumContent); + return checksumPath; +} + +export function execName(target: ReleaseTarget, version: string): string { + const suffix = target.os === "windows" ? ".exe" : ""; + return `dial-server-${version}-${target.os}-${target.arch}${suffix}`; +} + +export async function packageTarget(target: ReleaseTarget, version: string, binaryPath: string): Promise { + const archivePath = join(packagesDir, archiveName(target, version)); + const prefix = `dial-server_${version}_${target.os}_${target.arch}`; + const binaryName = target.os === "windows" ? "dial-server.exe" : "dial-server"; + + const binaryContent = await Bun.file(binaryPath).arrayBuffer(); + const probesContent = await Bun.file(join(projectRoot, "probes.example.yaml")).arrayBuffer(); + const licenseContent = await Bun.file(join(projectRoot, "LICENSE")).arrayBuffer(); + + const pack = tar.pack(); + pack.entry( + { mode: 0o755, name: `${prefix}/${binaryName}`, size: binaryContent.byteLength }, + Buffer.from(binaryContent), + ); + pack.entry( + { mode: 0o644, name: `${prefix}/probes.example.yaml`, size: probesContent.byteLength }, + Buffer.from(probesContent), + ); + pack.entry({ mode: 0o644, name: `${prefix}/LICENSE`, size: licenseContent.byteLength }, Buffer.from(licenseContent)); + pack.finalize(); + + const gzip = createGzip(); + const chunks: Buffer[] = []; + + await new Promise((resolve, reject) => { + pack.pipe(gzip); + gzip.on("data", (chunk: Buffer) => chunks.push(chunk)); + gzip.on("end", resolve); + gzip.on("error", reject); + }); + + await Bun.write(archivePath, Buffer.concat(chunks)); + return archivePath; +} + +export function parseTargets(args: string[]): ReleaseTarget[] { + const targetIndex = args.indexOf("--target"); + if (targetIndex === -1 || targetIndex === args.length - 1) { + return ALL_TARGETS; + } + + const targetValues = args[targetIndex + 1]!.split(","); + const targets: ReleaseTarget[] = []; + + for (const value of targetValues) { + const bunTarget = `bun-${value.trim()}`; + const found = ALL_TARGETS.find((t) => t.bunTarget === bunTarget); + if (!found) { + const available = ALL_TARGETS.map((t) => t.bunTarget.replace(/^bun-/, "")).join(", "); + console.error(`无效的 target: ${value.trim()}`); + console.error(`可用的 target 值: ${available}`); + process.exit(1); + } + targets.push(found); + } + + return targets; +} + +export async function printReport(binaries: string[], archives: string[]): Promise { + console.log("\n=== Release 报告 ===\n"); + + console.log("裸二进制:"); + for (const binary of binaries) { + const size = (await stat(binary)).size; + const mb = (size / 1024 / 1024).toFixed(1); + console.log(` ${relative(projectRoot, binary)} (${mb} MB)`); + } + + console.log("\n压缩包:"); + for (const archive of archives) { + const size = (await stat(archive)).size; + const mb = (size / 1024 / 1024).toFixed(1); + console.log(` ${relative(projectRoot, archive)} (${mb} MB)`); + } +} + +async function release() { + const targets = parseTargets(process.argv); + console.log(`Release 目标: ${targets.map((t) => t.displayName).join(", ")}\n`); + + try { + await viteBuild(); + const version = await codeGeneration(); + + console.log(`\n版本: ${version}`); + console.log(`编译 ${targets.length} 个目标...\n`); + + await rm(releaseDir, { force: true, recursive: true }); + await mkdir(binariesDir, { recursive: true }); + await mkdir(packagesDir, { recursive: true }); + + const binaries: string[] = []; + for (const target of targets) { + const binaryPath = await compileTarget(target, version); + binaries.push(binaryPath); + } + + const archives: string[] = []; + for (let i = 0; i < targets.length; i++) { + const target = targets[i]!; + const binaryPath = binaries[i]!; + console.log(` 打包 ${target.displayName}...`); + const archivePath = await packageTarget(target, version, binaryPath); + await computeChecksum(archivePath); + archives.push(archivePath); + } + + await cleanup(); + await printReport(binaries, archives); + console.log("\nRelease 完成!"); + } catch (error) { + await cleanup(); + console.error("Release 失败:", error); + process.exit(1); + } +} + +if (import.meta.main) { + await release(); +} diff --git a/tests/scripts/build-common.test.ts b/tests/scripts/build-common.test.ts new file mode 100644 index 0000000..9d947d9 --- /dev/null +++ b/tests/scripts/build-common.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; + +import { scanDir, toImportSpecifier } from "../../scripts/build-common"; + +describe("scanDir", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = join(tmpdir(), `build-common-test-${Date.now()}`); + await mkdir(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tempDir, { force: true, recursive: true }); + }); + + test("扫描空目录", async () => { + const files = await scanDir(tempDir, "/"); + expect(files).toEqual([]); + }); + + test("扫描单文件", async () => { + await writeFile(join(tempDir, "index.html"), ""); + const files = await scanDir(tempDir, "/"); + expect(files).toEqual(["/index.html"]); + }); + + test("扫描嵌套目录", async () => { + await mkdir(join(tempDir, "assets"), { recursive: true }); + await writeFile(join(tempDir, "index.html"), ""); + await writeFile(join(tempDir, "assets", "style.css"), ""); + await writeFile(join(tempDir, "assets", "app.js"), ""); + + const files = await scanDir(tempDir, "/"); + expect(files.sort()).toEqual(["/assets/app.js", "/assets/style.css", "/index.html"]); + }); + + test("扫描多层嵌套", async () => { + await mkdir(join(tempDir, "a", "b"), { recursive: true }); + await writeFile(join(tempDir, "a", "b", "deep.txt"), ""); + + const files = await scanDir(tempDir, "/"); + expect(files).toEqual(["/a/b/deep.txt"]); + }); + + test("自定义前缀", async () => { + await writeFile(join(tempDir, "file.txt"), ""); + const files = await scanDir(tempDir, "/static/"); + expect(files).toEqual(["/static/file.txt"]); + }); +}); + +describe("toImportSpecifier", () => { + test("同目录文件生成正确的相对路径", () => { + const result = toImportSpecifier("/project/.build", "/project/.build/file.txt"); + expect(result).toBe("file.txt"); + }); + + test("子目录文件生成正确的相对路径", () => { + const result = toImportSpecifier("/project/.build", "/project/.build/sub/file.txt"); + expect(result).toBe("sub/file.txt"); + }); + + test("Windows 路径分隔符转换为正斜杠", () => { + const fromDir = ["C:", "project", ".build"].join(sep); + const targetPath = ["C:", "project", "dist", "web", "assets", "app.js"].join(sep); + const result = toImportSpecifier(fromDir, targetPath); + expect(result).toBe("../dist/web/assets/app.js"); + expect(result).not.toContain(sep); + }); +}); diff --git a/tests/scripts/release.test.ts b/tests/scripts/release.test.ts new file mode 100644 index 0000000..37753a7 --- /dev/null +++ b/tests/scripts/release.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + ALL_TARGETS, + archiveName, + checksumName, + computeChecksum, + execName, + packageTarget, + parseTargets, +} from "../../scripts/release"; + +describe("parseTargets", () => { + test("默认返回全部目标", () => { + const targets = parseTargets([]); + expect(targets).toEqual(ALL_TARGETS); + }); + + test("解析单一目标", () => { + const targets = parseTargets(["--target", "linux-x64"]); + expect(targets).toHaveLength(1); + expect(targets[0]!.bunTarget).toBe("bun-linux-x64"); + expect(targets[0]!.os).toBe("linux"); + expect(targets[0]!.arch).toBe("x64"); + }); + + test("解析多个逗号分隔目标", () => { + const targets = parseTargets(["--target", "linux-x64,darwin-arm64,windows-x64"]); + expect(targets).toHaveLength(3); + expect(targets[0]!.bunTarget).toBe("bun-linux-x64"); + expect(targets[1]!.bunTarget).toBe("bun-darwin-arm64"); + expect(targets[2]!.bunTarget).toBe("bun-windows-x64"); + }); + + test("解析 musl 变体", () => { + const targets = parseTargets(["--target", "linux-x64-musl"]); + expect(targets).toHaveLength(1); + expect(targets[0]!.bunTarget).toBe("bun-linux-x64-musl"); + expect(targets[0]!.os).toBe("linux"); + expect(targets[0]!.arch).toBe("x64-musl"); + }); + + test("无效 target 导致进程退出", () => { + const exitCalls: number[] = []; + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalExit = process.exit; + process.exit = ((code: number) => { + exitCalls.push(code); + }) as never; + + try { + parseTargets(["--target", "invalid-target"]); + } finally { + process.exit = originalExit; + } + + expect(exitCalls).toEqual([1]); + }); +}); + +describe("execName", () => { + const linuxTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64")!; + const windowsTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-windows-x64")!; + const muslTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64-musl")!; + + test("Linux x64 可执行文件命名", () => { + expect(execName(linuxTarget, "0.1.0")).toBe("dial-server-0.1.0-linux-x64"); + }); + + test("Windows 可执行文件命名带 .exe 后缀", () => { + expect(execName(windowsTarget, "0.1.0")).toBe("dial-server-0.1.0-windows-x64.exe"); + }); + + test("musl 变体命名", () => { + expect(execName(muslTarget, "0.1.0")).toBe("dial-server-0.1.0-linux-x64-musl"); + }); + + test("版本号正确嵌入", () => { + expect(execName(linuxTarget, "1.2.3")).toBe("dial-server-1.2.3-linux-x64"); + }); +}); + +describe("archiveName", () => { + const linuxTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64")!; + const windowsTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-windows-x64")!; + + test("Linux 压缩包命名", () => { + expect(archiveName(linuxTarget, "0.1.0")).toBe("dial-server_0.1.0_linux_x64.tar.gz"); + }); + + test("Windows 压缩包命名", () => { + expect(archiveName(windowsTarget, "0.1.0")).toBe("dial-server_0.1.0_windows_x64.tar.gz"); + }); +}); + +describe("checksumName", () => { + const linuxTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64")!; + + test("校验和文件命名", () => { + expect(checksumName(linuxTarget, "0.1.0")).toBe("dial-server_0.1.0_linux_x64.tar.gz.sha256"); + }); +}); + +describe("computeChecksum", () => { + const projectRoot = fileURLToPath(new URL("../../", import.meta.url)); + const packagesDir = join(projectRoot, "dist/release/packages"); + + beforeEach(async () => { + await mkdir(packagesDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(packagesDir, { force: true, recursive: true }); + }); + + test("生成正确格式的 sha256 文件", async () => { + const archivePath = join(packagesDir, "dial-server_0.1.0_linux_x64.tar.gz"); + const content = Buffer.from("test archive content"); + await Bun.write(archivePath, content); + + const checksumPath = await computeChecksum(archivePath); + const checksumContent = await Bun.file(checksumPath).text(); + + const parts = checksumContent.split(" "); + expect(parts).toHaveLength(2); + expect(parts[0]).toMatch(/^[0-9a-f]{64}$/); + expect(parts[1]).toBe("dial-server_0.1.0_linux_x64.tar.gz\n"); + }); + + test("校验和与文件内容一致", async () => { + const archivePath = join(packagesDir, "test.tar.gz"); + const content = Buffer.from("hello world"); + await Bun.write(archivePath, content); + + const checksumPath = await computeChecksum(archivePath); + const checksumContent = await Bun.file(checksumPath).text(); + const hash = checksumContent.split(" ")[0]!; + + const expectedHash = Bun.CryptoHasher.hash("sha256", content, "hex"); + expect(hash).toBe(expectedHash); + }); +}); + +describe("packageTarget", () => { + const projectRoot = fileURLToPath(new URL("../../", import.meta.url)); + const packagesDir = join(projectRoot, "dist/release/packages"); + + beforeEach(async () => { + await mkdir(packagesDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(packagesDir, { force: true, recursive: true }); + }); + + test("Linux 压缩包包含正确文件、目录前缀和权限位", async () => { + const linuxTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64")!; + const binaryPath = join(packagesDir, "test-binary"); + await Bun.write(binaryPath, "binary content"); + + const archivePath = await packageTarget(linuxTarget, "0.1.0", binaryPath); + const archiveContent = await Bun.file(archivePath).arrayBuffer(); + + const tar = await import("tar-stream"); + const zlib = await import("node:zlib"); + const extract = tar.extract(); + + const entries: Array<{ mode: number; name: string }> = []; + await new Promise((resolve, reject) => { + const gunzip = zlib.createGunzip(); + gunzip.write(Buffer.from(archiveContent)); + gunzip.end(); + gunzip.pipe(extract); + extract.on("entry", (header: { mode?: number; name: string }, _stream: unknown, next: () => void) => { + entries.push({ mode: header.mode ?? 0, name: header.name }); + next(); + }); + extract.on("finish", resolve); + extract.on("error", reject); + }); + + const names = entries.map((e) => e.name); + expect(names).toContain("dial-server_0.1.0_linux_x64/dial-server"); + expect(names).toContain("dial-server_0.1.0_linux_x64/probes.example.yaml"); + expect(names).toContain("dial-server_0.1.0_linux_x64/LICENSE"); + + const binaryEntry = entries.find((e) => e.name.endsWith("/dial-server"))!; + expect(binaryEntry.mode).toBe(0o755); + + const probesEntry = entries.find((e) => e.name.endsWith("probes.example.yaml"))!; + expect(probesEntry.mode).toBe(0o644); + }); + + test("Windows 压缩包内可执行文件名为 dial-server.exe", async () => { + const windowsTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-windows-x64")!; + const binaryPath = join(packagesDir, "test-binary.exe"); + await Bun.write(binaryPath, "binary content"); + + const archivePath = await packageTarget(windowsTarget, "0.1.0", binaryPath); + const archiveContent = await Bun.file(archivePath).arrayBuffer(); + + const tar = await import("tar-stream"); + const zlib = await import("node:zlib"); + const extract = tar.extract(); + + const names: string[] = []; + await new Promise((resolve, reject) => { + const gunzip = zlib.createGunzip(); + gunzip.write(Buffer.from(archiveContent)); + gunzip.end(); + gunzip.pipe(extract); + extract.on("entry", (header: { name: string }, _stream: unknown, next: () => void) => { + names.push(header.name); + next(); + }); + extract.on("finish", resolve); + extract.on("error", reject); + }); + + expect(names).toContain("dial-server_0.1.0_windows_x64/dial-server.exe"); + }); +});