feat: 跨平台发布打包,支持 7 个目标平台交叉编译和 tar.gz 分发
- 新增 scripts/release.ts,支持 7 个编译目标(linux/darwin/windows + musl 变体) - 从 build.ts 提取共享构建逻辑到 build-common.ts,现有 build 行为不变 - 使用 tar-stream + node:zlib 创建 tar.gz,精确控制 Unix 权限位 - SHA256 校验和文件格式兼容 sha256sum -c - 支持 --target 参数选择特定平台编译 - 新增 devDependency: tar-stream、@types/tar-stream - 更新 README.md 和 DEVELOPMENT.md 文档 - 同步 openspec specs
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -422,3 +422,4 @@ backend/cmd/desktop/rsrc_windows_*.syso
|
||||
# Bun
|
||||
.build/
|
||||
*.bun-build
|
||||
dist/release/
|
||||
|
||||
@@ -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 目录约定
|
||||
|
||||
| 目录 | 约定 |
|
||||
| ------------- | ---------------------------------------------------- |
|
||||
|
||||
29
README.md
29
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)。
|
||||
|
||||
30
bun.lock
30
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=="],
|
||||
|
||||
132
openspec/specs/cross-platform-release/spec.md
Normal file
132
openspec/specs/cross-platform-release/spec.md
Normal file
@@ -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 为 `<hash> <filename>\n` 格式,其中 `<hash>` 为 64 位小写十六进制字符串,`<filename>` 为对应压缩包文件名,中间以两个空格分隔
|
||||
|
||||
#### 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 输出失败的具体步骤和目标平台
|
||||
@@ -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 之外。
|
||||
|
||||
@@ -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"
|
||||
|
||||
123
scripts/build-common.ts
Normal file
123
scripts/build-common.ts
Normal file
@@ -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<string[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
122
scripts/build.ts
122
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<string[]> {
|
||||
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();
|
||||
|
||||
@@ -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" }];
|
||||
|
||||
196
scripts/release.ts
Normal file
196
scripts/release.ts
Normal file
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<void>((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<void> {
|
||||
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();
|
||||
}
|
||||
74
tests/scripts/build-common.test.ts
Normal file
74
tests/scripts/build-common.test.ts
Normal file
@@ -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"), "<html></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);
|
||||
});
|
||||
});
|
||||
225
tests/scripts/release.test.ts
Normal file
225
tests/scripts/release.test.ts
Normal file
@@ -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<void>((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<void>((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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user