diff --git a/.bun-version b/.bun-version new file mode 100644 index 0000000..9df886c --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.4.2 diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..6a4bc45 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,64 @@ +name: Check + +on: + pull_request: + push: + branches: + - '**' + workflow_call: + +permissions: + contents: read + +concurrency: + group: check-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + +jobs: + check: + name: ${{ matrix.os }} / JDK ${{ matrix.java }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + java: ['17', '21'] + env: + GRADLE_USER_HOME: ${{ github.workspace }}/.cache/gradle + steps: + - name: 检出代码 + uses: actions/checkout@v5 + - name: 安装 JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - name: 安装固定版本 Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: 安装固定版本 Gradle + uses: gradle/actions/setup-gradle@v5 + with: + gradle-version: '8.14' + - name: 缓存隔离的 Maven 仓库 + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/java-run-maven-repository + key: ${{ runner.os }}-jdk-${{ matrix.java }}-maven-${{ hashFiles('tests/fixtures/**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-jdk-${{ matrix.java }}-maven- + - name: 按锁文件安装依赖 + run: bun install --frozen-lockfile + - name: 类型检查 + run: bun run typecheck + - name: 快速回归测试 + run: bun test + - name: 编译本机二进制 + run: bun run compile + - name: 原生二进制 Maven / Gradle smoke + env: + JAVA_RUN_MAVEN_REPOSITORY: ${{ runner.temp }}/java-run-maven-repository + JAVA_RUN_GRADLE_HOME: ${{ env.GRADLE_USER_HOME }} + run: bun scripts/smoke.ts --suite=full diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1d6ed5f..ba8eb31 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,169 +1,221 @@ name: Release -permissions: - contents: write - on: push: tags: - - 'v*' # 监听以 v 开头的 tag - -jobs: - build: - name: 构建并发版 - runs-on: ubuntu-latest - - steps: - - name: 检出代码 - uses: actions/checkout@v4 + - 'v*' + branches: + - '**' + paths: + - '.github/workflows/release.yaml' + - '.github/workflows/check.yaml' + - 'scripts/compile.ts' + - 'scripts/checksum.ts' + - 'scripts/platforms.ts' + - 'scripts/release.ts' + - 'scripts/smoke.ts' + - 'docs/installation.md' + - 'LICENSE*' + - 'tests/compile.test.ts' + - 'tests/release.test.ts' + - 'package.json' + - 'bun.lockb' + - 'bump.config.ts' + - '.bun-version' + workflow_dispatch: - - name: 获取当前和上一个标签 - id: get_tags - run: | - git fetch --prune --unshallow - tags=($(git tag -l --sort=-version:refname)) - current_tag=${tags[0]} - previous_tag=${tags[1]} - echo "previous_tag=$previous_tag" >> $GITHUB_OUTPUT - echo "current_tag=$current_tag" >> $GITHUB_OUTPUT - - - name: 提取并分类提交消息 - id: extract_commit_messages - run: | - set -e - current_tag="${{ steps.get_tags.outputs.current_tag }}" - previous_tag="${{ steps.get_tags.outputs.previous_tag }}" - if [ -z "$previous_tag" ]; then - commit_messages=$(git log --pretty=format:"%s - by @%an (%h)" "$current_tag" | grep -E 'feat|fix|docs|perf' || true) - else - commit_messages=$(git log --pretty=format:"%s - by @%an (%h)" "$previous_tag".."$current_tag" | grep -E 'feat|fix|docs|perf' || true) - fi - - # 转义 ` 字符 - commit_messages=$(echo "$commit_messages" | sed 's/`/\\\`/g') - - { - echo 'feat_messages<> $GITHUB_OUTPUT - { - echo 'fix_messages<> $GITHUB_OUTPUT - { - echo 'docs_messages<> $GITHUB_OUTPUT - { - echo 'perf_messages<> $GITHUB_OUTPUT +permissions: + contents: read - - name: 获取当前分支名 - id: get_branch_name - run: | - branch_name=$(git rev-parse --abbrev-ref HEAD) - echo "branch_name=$branch_name" >> $GITHUB_OUTPUT +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} - - name: 发版详情 - id: generate_release_notes +jobs: + metadata: + name: 校验版本与发布目标 + runs-on: ubuntu-24.04 + outputs: + tag: ${{ steps.metadata.outputs.tag }} + prerelease: ${{ steps.metadata.outputs.prerelease }} + matrix: ${{ steps.metadata.outputs.matrix }} + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: 校验标签并生成产物清单 + id: metadata + run: bun scripts/release.ts metadata + - name: 检查发布状态 + if: github.event_name == 'push' && github.ref_type == 'tag' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.metadata.outputs.tag }} run: | - # 提取提交消息分类 - feat_messages=("${{ steps.extract_commit_messages.outputs.feat_messages }}") - fix_messages=("${{ steps.extract_commit_messages.outputs.fix_messages }}") - docs_messages=("${{ steps.extract_commit_messages.outputs.docs_messages }}") - perf_messages=("${{ steps.extract_commit_messages.outputs.perf_messages }}") - - release_notes="" - - if [[ -n "$feat_messages" ]]; then - release_notes="$release_notes\n### 🚀 Features 新功能: \n" - while IFS= read -r message; do - release_notes="$release_notes\n- $message" - done <<< "$feat_messages" + if state=$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft); then + if [ "$state" != 'true' ]; then + echo '该标签已经公开发布,请使用新版本标签。' + exit 1 + fi fi - if [[ -n "$fix_messages" ]]; then - release_notes="$release_notes\n### 🩹 Fixes 缺陷修复: \n" - while IFS= read -r message; do - release_notes="$release_notes\n- $message" - done <<< "$fix_messages" - fi + checks: + needs: metadata + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/check.yaml - if [[ -n "$docs_messages" ]]; then - release_notes="$release_notes\n### 📖 Documentation 文档: \n" - while IFS= read -r message; do - release_notes="$release_notes\n- $message" - done <<< "$docs_messages" - fi - - if [[ -n "$perf_messages" ]]; then - release_notes="$release_notes\n### 🔥 Performance 性能优化: \n" - while IFS= read -r message; do - release_notes="$release_notes\n- $message" - done <<< "$perf_messages" - fi - - # 转义 ` 字符 - release_notes=$(echo "$release_notes" | sed 's/`/\\\`/g') - echo "release_notes=$release_notes" >> $GITHUB_OUTPUT - - - name: 写入生成的发布说明到 changelog.md - run: | - echo -e "${{ steps.generate_release_notes.outputs.release_notes }}" > changelog.md - cat changelog.md - - - name: Cache Bun dependencies - uses: actions/cache@v4 + build: + name: 验收 ${{ matrix.name }} + needs: metadata + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.metadata.outputs.matrix) }} + env: + GRADLE_USER_HOME: ${{ github.workspace }}/.cache/gradle + RELEASE_BINARY: ${{ matrix.binary }} + RELEASE_PLATFORM: ${{ matrix.name }} + BUN_TARGET: ${{ matrix.target }} + steps: + - name: 检出待验收提交 + uses: actions/checkout@v5 + - name: 安装固定版本 Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: 安装 JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + - name: 安装固定版本 Gradle + uses: gradle/actions/setup-gradle@v5 with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} + gradle-version: '8.14' + - name: 缓存隔离的 Maven 仓库 + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/java-run-maven-repository + key: ${{ runner.os }}-${{ runner.arch }}-release-maven-${{ hashFiles('tests/fixtures/**/pom.xml') }} restore-keys: | - ${{ runner.os }}-bun- - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Install dependencies - run: bun install - - - name: Build Windows + ${{ runner.os }}-${{ runner.arch }}-release-maven- + - name: 按锁文件安装依赖 + run: bun install --frozen-lockfile + - name: 构建待发布二进制 + shell: bash run: | - bun build src/cli.ts --compile --minify --sourcemap \ - --outfile dist/java-run-windows-x64-baseline-${{ steps.get_tags.outputs.current_tag }} \ - --target=bun-windows-x64-baseline - - - name: Build Linux x64 + bun run compile --target="$BUN_TARGET" --outfile="dist/$RELEASE_BINARY" + - name: JDK 21 完整运行验收 + shell: bash + env: + JAVA_RUN_MAVEN_REPOSITORY: ${{ runner.temp }}/java-run-maven-repository + JAVA_RUN_GRADLE_HOME: ${{ env.GRADLE_USER_HOME }} + run: bun scripts/smoke.ts --cli="dist/$RELEASE_BINARY" --suite=full + - name: 安装 JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + - name: 打包与 JDK 17 解压产物验收 + shell: bash + env: + JAVA_RUN_MAVEN_REPOSITORY: ${{ runner.temp }}/java-run-maven-repository + JAVA_RUN_GRADLE_HOME: ${{ env.GRADLE_USER_HOME }} + run: bun scripts/release.ts package "$RELEASE_PLATFORM" + - name: 上传已验收压缩包 + uses: actions/upload-artifact@v6 + with: + name: package-${{ matrix.name }} + path: dist/${{ matrix.archive }} + if-no-files-found: error + overwrite: true + + artifacts: + name: 汇总与校验已验收产物 + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: 汇总构建产物 + uses: actions/download-artifact@v7 + with: + pattern: package-* + merge-multiple: true + path: dist + - name: 校验五种产物并生成 SHA-256 + run: bun scripts/release.ts checksums + - name: 核对文件校验和 + working-directory: dist + run: sha256sum --check SHA256SUMS + - name: 生成发布说明预览 run: | - bun build src/cli.ts --compile --minify --sourcemap \ - --outfile dist/java-run-linux-x64-baseline-${{ steps.get_tags.outputs.current_tag }} \ - --target=bun-linux-x64-baseline - - - name: Build Linux ARM64 + bun scripts/release.ts notes > release-notes.md + cat release-notes.md >> "$GITHUB_STEP_SUMMARY" + - name: 保存已校验产物 + uses: actions/upload-artifact@v6 + with: + name: release-with-checksums + path: dist/* + if-no-files-found: error + overwrite: true + + release: + name: 发布完整标签产物 + needs: [metadata, checks, artifacts] + if: github.event_name == 'push' && github.ref_type == 'tag' + runs-on: ubuntu-24.04 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.metadata.outputs.tag }} + RELEASE_PRERELEASE: ${{ needs.metadata.outputs.prerelease }} + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - uses: actions/download-artifact@v7 + with: + name: release-with-checksums + path: dist + - name: 核对待上传文件 + working-directory: dist + run: sha256sum --check SHA256SUMS + - name: 生成下载与安装说明 run: | - bun build src/cli.ts --compile --minify --sourcemap \ - --outfile dist/java-run-linux-arm64-${{ steps.get_tags.outputs.current_tag }} \ - --target=bun-linux-arm64 - - - name: Build macOS arm64 + bun scripts/release.ts notes > release-notes.md + gh api --method POST "repos/$GH_REPO/releases/generate-notes" \ + -f tag_name="$RELEASE_TAG" --jq .body >> release-notes.md + - name: 创建或恢复未公开草稿 run: | - bun build src/cli.ts --compile --minify --sourcemap \ - --outfile dist/java-run-darwin-arm64-${{ steps.get_tags.outputs.current_tag }} \ - --target=bun-darwin-arm64 - - - name: Build macOS x64 + if state=$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft); then + if [ "$state" != 'true' ]; then + echo '该标签已经公开发布,请使用新版本标签。' + exit 1 + fi + gh release edit "$RELEASE_TAG" --title "java-run $RELEASE_TAG" \ + --notes-file release-notes.md --prerelease="$RELEASE_PRERELEASE" + else + gh release create "$RELEASE_TAG" --verify-tag --draft \ + --notes-file release-notes.md --title "java-run $RELEASE_TAG" \ + --prerelease="$RELEASE_PRERELEASE" + fi + - name: 上传完整资产并核对草稿 run: | - bun build src/cli.ts --compile --minify --sourcemap \ - --outfile dist/java-run-darwin-x64-${{ steps.get_tags.outputs.current_tag }} \ - --target=bun-darwin-x64 - - - name: 创建发版 - id: release_tag - uses: ncipollo/release-action@v1.14.0 - with: - artifacts: "dist/*" - bodyFile: changelog.md + gh release upload "$RELEASE_TAG" dist/* --clobber + gh release view "$RELEASE_TAG" --json assets --jq '[.assets[].name]' \ + | bun scripts/release.ts verify-assets + - name: 公开已验收版本 + run: | + if [ "$RELEASE_PRERELEASE" = 'true' ]; then + gh release edit "$RELEASE_TAG" --draft=false --prerelease=true --latest=false + else + gh release edit "$RELEASE_TAG" --draft=false --prerelease=false + fi diff --git a/.gitignore b/.gitignore index 9b1ee42..e9ab59c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,175 +1,20 @@ -# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore - -# Logs - -logs -_.log -npm-debug.log_ -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Caches - -.cache - -# Diagnostic reports (https://nodejs.org/api/report.html) - -report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json - -# Runtime data - -pids -_.pid -_.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover - -lib-cov - -# Coverage directory used by tools like istanbul - -coverage -*.lcov - -# nyc test coverage - -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) - -.grunt - -# Bower dependency directory (https://bower.io/) - -bower_components - -# node-waf configuration - -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) - -build/Release - -# Dependency directories - +# 依赖与构建产物 node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) - -web_modules/ - -# TypeScript cache - +/dist/ *.tsbuildinfo -# Optional npm cache directory - -.npm - -# Optional eslint cache - -.eslintcache - -# Optional stylelint cache - -.stylelintcache - -# Microbundle cache - -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history - -.node_repl_history - -# Output of 'npm pack' - -*.tgz - -# Yarn Integrity file - -.yarn-integrity - -# dotenv environment variable files +# 运行日志、测试输出与本地缓存 +logs/ +*.log +*.pid +*.pid.lock +coverage/ +*.lcov +.cache/ +# 本地环境与编辑器 .env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) - -.parcel-cache - -# Next.js build output - -.next -out - -# Nuxt.js build / generate output - -.nuxt -dist - -# Gatsby files - -# Comment in the public line in if your project uses Gatsby and not Next.js - -# https://nextjs.org/blog/next-9-1#public-directory-support - -# public - -# vuepress build output - -.vuepress/dist - -# vuepress v2.x temp and cache directory - -.temp - -# Docusaurus cache and generated files - -.docusaurus - -# Serverless directories - -.serverless/ - -# FuseBox cache - -.fusebox/ - -# DynamoDB Local files - -.dynamodb/ - -# TernJS port file - -.tern-port - -# Stores VSCode versions used for testing VSCode extensions - -.vscode-test - -# yarn v2 - -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -# IntelliJ based IDEs -.idea - -# Finder (MacOS) folder config +.env.* +!.env.example +.idea/ .DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3c358ef --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "js/ts.tsdk.path": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..db0ecd3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,246 @@ +# 参与 java-run 开发 + +java-run 从 Java 源码工作区选择启动目标,由 Maven 或 Gradle 准备源码和运行依赖,再启动 Java。 +使用契约见 [README](README.md),模块职责见 [架构设计](docs/architecture.md),演进方向见 [技术路线](docs/roadmap.md)。 + +## 开发环境与日常检查 + +- 使用 [`.bun-version`](.bun-version) 指定的 Bun,并按仓库锁文件安装依赖 +- 在 PATH 中提供 JDK 的 `java`、`javac` 和 `jar` +- 安装 Maven 和 Gradle,用于真实项目验收;首次运行可能下载插件与依赖 + +CI 使用 JDK 17 / 21 和 Gradle 8.14,夹具以 Java 17 为最低版本。 +目标项目存在 Wrapper 时,运行器优先使用 Wrapper;项目自身的 JDK 和构建工具要求仍需满足。 + +```sh +bun install --frozen-lockfile +bun run check +bun run compile +bun run smoke +``` + +| 命令 | 验证内容 | +| --- | --- | +| `bun run typecheck` | TypeScript 类型检查 | +| `bun test` | 参数、配置、初始化、适配器、主类、类路径、交互和进程行为回归 | +| `bun run check` | 类型检查与回归测试 | +| `bun run compile` | 构建本机独立二进制及 `.sha256` 校验文件到 `dist` | +| `bun run smoke` | 使用该二进制运行真实 Maven / Gradle 夹具 | + +回归测试中的真实 Gradle 用例使用 PATH 中的 Gradle,找不到时会跳过。 +可用 `JAVA_RUN_TEST_GRADLE` 指定命令,并用 `GRADLE_USER_HOME` 隔离测试缓存: + +```sh +GRADLE_USER_HOME=/tmp/java-run-test-gradle \ +JAVA_RUN_TEST_GRADLE=/path/to/gradle \ +bun run check +``` + +## 编译二进制 + +本地编译和发布构建共用 `bun run compile`。 +默认输出 `dist/java-run`,Windows 自动使用 `.exe` 扩展名。 +可用等号形式指定目标与输出路径: + +```sh +bun run compile --target=bun-darwin-arm64 --outfile=dist/java-run +``` + +可用目标见下方[发布平台表](#发布平台与产物)。 +编译成功后,在二进制旁生成 `<二进制文件名>.sha256`,自定义输出路径也遵循这一规则。 +校验文件采用标准 SHA-256 清单格式,文件名相对于其所在目录。 + +二进制不自动加载运行目录中的 `.env` 或 `bunfig.toml`,显式继承的环境变量仍然生效。 + +## 二进制验收 + +smoke 将夹具复制到临时目录,使用独立的 Maven settings、本地仓库和 Gradle 用户目录。 +它从被测二进制的 PATH 中移除当前 Bun 安装目录,验证产物可独立运行。 + +```sh +bun run smoke --suite=quick +bun run smoke --fixture=gradle-reactor +bun run smoke --cli=/path/to/java-run --suite=full --keep +``` + +| 选项 | 范围 | +| --- | --- | +| `--suite=quick` | 帮助、版本、静态预览、基本启动、初始化与配置复用 | +| `--suite=full` | 默认套件;在 quick 基础上验证测试类路径、配置优先级、资源与依赖更新、非零退出 | +| `--fixture=<名称>` | 限定项目夹具,保留帮助、版本和预览检查 | +| `--cli=<路径>` | 指定待验收的二进制,默认使用 `dist` 中的本机产物 | +| `--keep` | 成功后也保留临时目录 | + +成功后默认清理临时目录;失败时保留项目副本及逐项 stdout / stderr 日志,并输出保留位置。 +具体夹具与观测标记见 [夹具说明](tests/fixtures/README.md),验收项数以脚本输出为准。 + +需要复用下载时,指定专用测试缓存: + +```sh +JAVA_RUN_MAVEN_REPOSITORY=/tmp/java-run-test-maven \ +JAVA_RUN_GRADLE_HOME=/tmp/java-run-test-gradle \ +JAVA_RUN_GRADLE_COMMAND=/path/to/gradle \ +bun run smoke +``` + +测试缓存应独立于个人 `~/.m2` 和 `~/.gradle`。 +`JAVA_RUN_GRADLE_COMMAND` 控制 smoke 的 Gradle 命令,`JAVA_RUN_TEST_GRADLE` 控制回归测试的 Gradle 命令。 +smoke 也接受 `JAVA_RUN_SMOKE_SUITE`、`JAVA_RUN_SMOKE_FIXTURE` 和 `JAVA_RUN_SMOKE_KEEP=1`,同名命令行选项优先。 + +## 修改与提交 + +从主分支创建具有单一意图的工作分支,如 `feat/gradle-project-selection` 或 `fix/process-exit-code`。 +修改前检查工作区并保留其他人的未提交内容;同时处理独立修改时使用隔离分支或 worktree。 + +提交遵循 Conventional Commits:type 与 scope 使用英文,subject 使用中文,例如: + +```text +feat(cli): 支持交互生成启动配置 +fix(maven): 保留选定项目的运行依赖范围 +test(gradle): 验证无关应用不参与编译 +docs: 说明参数传递规则 +``` + +提交按可审查的意图拆分,排除无关改动、生成产物和依赖缓存。 +提交前检查差异并运行相关验证;PR 说明应写清触发场景、最终行为、实际测试结果和未验证的平台。 + +源码注释使用中文,说明必要的契约、边界和原因。 +公开 API 的说明放在所属接口、字段或方法,架构取舍放入设计文档。 +Markdown 正文使用正常中文标点,同组列表保持语法与标点统一。 +较长段落按主题拆分,Markdown 源文件按语义换行。 + +## 验证要求 + +行为修改应验证调用者可以观察到的结果。 +构建工具适配的修改需要真实项目验收,模拟输出用于覆盖具体边界。 + +回归重点包括: + +- 单项目与多模块目标、上游依赖和无关项目隔离 +- 自动准备与 `--build=none`,模型、资源或依赖变化后的重新解析 +- 主类声明、唯一入口、多个入口和无入口 +- JVM、应用和构建参数边界,以及空格、中文、`#`、`%` 等字符 +- 默认测试隔离和显式测试类路径 +- 正常与非零退出、平台对应的信号处理和进程树清理 +- 帮助、版本和静态预览的无构建行为 +- 终端选择、非 TTY 行为、EOF / Ctrl+C 取消和退出码 +- `init` 保存后重复运行、参数保留、已有配置保护与 `--force` 重新生成 +- 初始化失败或取消时保留已有配置 + +测试在临时项目或夹具副本中运行,保持 `tests/fixtures` 和用户提供的源仓库不变。 +环境和信号测试使用隔离子进程,平台专用测试的跳过条件应与实际系统能力对应。 +跨平台结论以对应系统上的执行结果为依据,跳过的测试不计为通过验收。 + +## CI 与发布 + +[Check 工作流](.github/workflows/check.yaml) 在分支 push、PR 和复用调用时运行。 +Linux、macOS、Windows 与 JDK 17 / 21 组成六组矩阵,分别执行锁文件安装、类型检查、回归测试、本机编译和完整 smoke。 +最新结果可在 [GitHub Actions](https://github.com/Vanisper/java-run/actions/workflows/check.yaml) 查看。 + +[Release 工作流](.github/workflows/release.yaml) 共用同一套构建与验收步骤,提供以下入口: + +| 入口 | 验收与输出 | +| --- | --- | +| 分支 push 命中 `paths` | 五平台产物演练,保存 Actions artifacts 和发布说明预览;Check 独立运行 | +| `workflow_dispatch` | 手动产物演练,并复用完整 Check | +| `v*` 标签 push | 校验版本与许可证,执行完整 Check 和五平台验收,通过后发布 GitHub Release | + +`paths` 限制分支演练的触发范围,包括工作流、发布脚本、相关测试、版本配置和安装指南等,完整清单以工作流为准。 +标签 push 不受此路径条件限制。 +工作流进入默认分支后,可通过 Actions 页面或 GitHub CLI 选择分支手动演练。 + +### 发布平台与产物 + +[平台清单](scripts/platforms.ts) 统一定义编译目标、原生 runner、包内目录和公开资产名称: + +| ZIP 资产 | 编译目标 | 原生 runner | +| --- | --- | --- | +| `java-run-windows-x64.zip` | `bun-windows-x64` | `windows-2025` | +| `java-run-linux-x64.zip` | `bun-linux-x64` | `ubuntu-24.04` | +| `java-run-linux-arm64.zip` | `bun-linux-arm64` | `ubuntu-24.04-arm` | +| `java-run-darwin-arm64.zip` | `bun-darwin-arm64` | `macos-15` | +| `java-run-darwin-x64.zip` | `bun-darwin-x64` | `macos-15-intel` | + +每个平台依次执行: + +1. 编译一次待分发的二进制及校验文件,在 JDK 21 下执行完整 smoke。 +2. 核对源二进制校验和,并打包 ZIP。Linux / macOS 使用 `zip`,Windows 使用 PowerShell `Compress-Archive`。 +3. 解压实际 ZIP,核对文件集合、内容、二进制校验和及 Unix 可执行权限。 +4. 使用 JDK 17 对解压出的二进制执行 quick 验收,再上传 ZIP。 + +每份 ZIP 只有一个 `java-run-<平台>/` 目录,包含: + +- `java-run` 或 `java-run.exe` +- 对应的 `java-run.sha256` 或 `java-run.exe.sha256` +- 从 [安装指南](docs/installation.md) 复制的 `INSTALL.md` +- 仓库中存在的 `LICENSE`、`LICENSE.md` 或 `LICENSE.txt` + +汇总步骤要求恰好包含五种非空 ZIP 普通文件,然后生成并核对 `SHA256SUMS`。 +包内 `.sha256` 校验二进制,Release 单独提供的 `SHA256SUMS` 校验下载的 ZIP。 +资产名称不带版本号,指定版本通过 `/releases/download/v<版本>/` 下载,最新稳定版使用 `/releases/latest/download/`。 + +源码 Check 在两个 JDK 上均执行完整回归;发布产物在 JDK 21 上执行 full,在 JDK 17 上执行 quick。 +Linux 产物在 Ubuntu 24.04 的 glibc 环境验收,musl 和其他系统版本未纳入验证范围。 + +### 本地演练 + +`scripts/release.ts` 读取当前 `package.json` 的版本,提供五个子命令: + +| 子命令 | 职责 | +| --- | --- | +| `metadata` | 校验版本,输出标签、预发布标识和平台矩阵;标签环境还校验标签与许可证 | +| `package <平台>` | 打包 `dist` 中已有的二进制与校验文件,解压核对并执行 quick | +| `checksums` | 核对 `dist` 中的五份 ZIP 集合,生成 `SHA256SUMS` | +| `notes` | 向标准输出生成下载与安装说明 | +| `verify-assets` | 从标准输入读取远端资产名称的 JSON 数组,核对五份 ZIP 和 `SHA256SUMS` 是否齐全且无额外文件 | + +在对应平台的原生机器上构建并验收 ZIP,例如 macOS arm64: + +```sh +bun scripts/release.ts metadata +bun run compile +bun run smoke --suite=full +bun scripts/release.ts package darwin-arm64 +``` + +`package` 输出 `dist/java-run-darwin-arm64.zip`,其 quick 验收使用当前环境的 JDK。 +本地复现发布矩阵时,先用 JDK 21 编译和执行 full,再切换到 JDK 17 执行 `package`。 + +五个平台的 ZIP 汇集到只含这些文件的 `dist` 目录后,可执行 `checksums`。 +编译留下的裸二进制和 `.sha256` 不属于汇总输入。 +分支与手动演练保存产物和说明预览,不创建标签或 GitHub Release。 + +### 发布版本 + +发布前完成以下准备: + +- 确定开源许可证,添加非空的 `LICENSE`、`LICENSE.md` 或 `LICENSE.txt` +- 确定发布版本,更新 `package.json` 并核对版本输出 +- 完成待发布提交的检查,审查平台产物和支持边界 + +`bun run version` 使用 bumpp 调整 `package.json`,不自动提交、创建标签或推送。 +版本变更通过 PR 合入主分支后,创建并推送 `v` 标签。 +版本必须符合 SemVer,标签必须与包版本严格一致。 + +元数据校验通过后,同一标签提交的六组 Check 与五平台产物验收并行执行。 +全部通过后,工作流创建或恢复未公开草稿,上传五份 ZIP 和 `SHA256SUMS`。 +Release 正文包含下载表、安装说明及 GitHub 生成的变更说明。 + +公开前会核对远端资产集合,缺失或额外文件均使流程失败并保留草稿。 +上传和核对成功后自动公开,无需额外人工审批。 +预发布版本标记为 prerelease,且不标记为 latest;稳定版的 latest 选择使用 GitHub 默认规则。 +同一标签的运行串行执行,不中断正在进行的发布。 + +发布完成后,检查 Release 的标签、下载表、ZIP 内容和校验和,并按安装指南核对下载后的版本输出。 + +### 失败重试 + +网络、下载或上传偶发失败时,可重跑失败任务: + +```sh +gh run rerun --failed +``` + +重跑使用原始提交和 ref,已验收的 Actions artifacts 支持同名覆盖。 +Release 资产只允许在未公开草稿中替换;已经公开的标签版本会被拒绝。 +修改代码后应运行新提交的验收,已发布版本的修正通过新版本发布。 diff --git a/README.md b/README.md index 32b61d4..cd83a9b 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,328 @@ # java-run -## 项目说明 +java-run 为 Maven 和 Gradle 源码工作区提供统一的开发启动入口:选择一个 Java 项目,由构建工具准备源码和运行依赖,再启动独立的 Java 进程。 +普通 Java、Spring Boot 和其他基于 classpath 的应用使用同一套运行契约。 -本项目的立项目的是为了脱离对 java 编辑器的重度依赖,而实现的一个 java 项目启动脚本。 +## 快速开始 -起初笔者尝试使用过 bat 或者 shell 编写过脚本,但是考虑到跨平台的需求,加上脚本实现有些困难,故使用 ts 作为脚本书写语言。 +从 [GitHub Releases](https://github.com/Vanisper/java-run/releases) 下载对应系统和体系结构的 ZIP。 +预编译版本不需要 Bun;运行 Java 项目仍需要符合该项目要求的 JDK,以及 Maven / Gradle 或项目 Wrapper。 +JDK 的 `java` 和 `jar` 必须可用。 -本项目使用 [Bun](https://bun.sh) 创建,可直接编译二进制文件,可执行 `bun run compile`,前往 `dist` 目录查看编译后的二进制文件。 +| 系统与体系结构 | 最新稳定版下载 | +| --- | --- | +| Windows x64 | [java-run-windows-x64.zip](https://github.com/Vanisper/java-run/releases/latest/download/java-run-windows-x64.zip) | +| Linux x64 | [java-run-linux-x64.zip](https://github.com/Vanisper/java-run/releases/latest/download/java-run-linux-x64.zip) | +| Linux arm64 | [java-run-linux-arm64.zip](https://github.com/Vanisper/java-run/releases/latest/download/java-run-linux-arm64.zip) | +| macOS arm64(Apple Silicon) | [java-run-darwin-arm64.zip](https://github.com/Vanisper/java-run/releases/latest/download/java-run-darwin-arm64.zip) | +| macOS x64(Intel) | [java-run-darwin-x64.zip](https://github.com/Vanisper/java-run/releases/latest/download/java-run-darwin-x64.zip) | -## Todo +macOS / Linux 使用 `uname -m` 查看体系结构:`x86_64` 对应 x64,`arm64` / `aarch64` 对应 arm64。 +Windows 可在“设置 → 系统 → 关于”查看系统类型,当前提供 x64 版本。 +Linux 产物在 Ubuntu 24.04 的 glibc 环境验收;musl(如 Alpine)和其他系统版本未纳入发布验收。 -> 当前此程序服务于一个 Jeecg 项目,本质上是 Spring Boot 的多模块项目。 -> -> 是否可通用于通用的 Spring Boot 多模块/单模块项目,还需进一步验证。 -> -当前项目的任务与进展: +按照 [安装指南](docs/installation.md) 核对 SHA-256 并将可执行文件加入 PATH。 +ZIP 内附有二进制校验文件和独立的 `INSTALL.md`。 +随后在 Java 工作区根目录执行: -- [x] 递归解析 Maven pom.xml,识别多模块并提取叶子模块 -- [x] 调用 mvn dependency:build-classpath 构建依赖并做缓存(可用 -r 刷新) -- [x] 汇总 target/classes 与依赖到 Manifest,生成 cp.jar 并通过 -classpath 运行 -- [x] 支持指定 main=、active=,以及 local 前缀组合(local-) -- [x] 可选执行 mvn compile(-c 或 compile) -- [x] 跨平台路径分隔符兼容(Windows/Linux/Mac) -- [ ] 在非 Jeecg 的通用 Spring Boot 项目中验证可用性并完善兼容性 -- [ ] 可配置是否包含测试类路径(includeTests) -- [ ] 更完善的错误提示、日志与失败恢复策略 -- [ ] 增加单元测试与文档示例,完善 README 使用说明 +```sh +java-run +``` + +多个模块或主类可在终端中选择;需要保存选择和启动参数时,执行 [`java-run init`](#保存项目默认值)。 + +默认优先使用工作区根目录的 `mvnw` / `mvnw.cmd` 或 `gradlew` / `gradlew.bat`,没有 Wrapper 时使用 PATH 中的构建工具。 +Wrapper 存在但运行失败时直接报错,不自动换用系统版本;可以用 `--build-command` 显式指定其他可执行文件。 -## Tips +### 从源码构建 -1. 使用本脚本程序之前,确保主机环境存在 `java` 和 `maven` 环境。 +源码构建需要 [Bun](https://bun.sh) **1.4.2**,版本固定在 `.bun-version`。 -2. 确保事先执行过 `mvn compile`,或者可以在使用本脚本程序时,加入 `-c` 传参,详情可以查看 cli 的 `help-log`。 +在 java-run 仓库中安装锁定依赖并编译本机二进制: -## Other +```sh +bun --version +bun install --frozen-lockfile +bun run compile +``` + +产物为 `dist/java-run` 和 `dist/java-run.sha256`;Windows 使用 `dist/java-run.exe` 和 `dist/java-run.exe.sha256`。 +可按[安装指南](docs/installation.md)中的二进制校验命令在 `dist` 目录核对,然后将可执行文件放入 PATH。 -To install dependencies: +源码调试可以直接使用: -```bash -bun install +```sh +bun run src/cli.ts --cwd /path/to/java-project ``` -To run: +## 命令与启动目标 -```bash -bun run start +```sh +java-run [run] [选项] [-- 应用参数...] +java-run init [选项] [-- 应用参数...] +java-run plan [选项] +java-run help +java-run version ``` -This project was created using `bun init` in bun v1.1.39. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. +- `run`:准备并启动应用,是默认命令。 +- `init`:准备并选择启动目标,将结果保存为项目配置,供后续运行复用。 +- `plan`:读取本地配置并预览步骤,不调用构建工具、不生成项目缓存、不发起交互。主类、有效项目模型和运行类路径仍未验证。 +- `help` / `--help` / `-h`:显示帮助,不读取项目配置。 +- `version` / `--version`:显示版本,不读取项目配置。 + +多模块仓库中的聚合项目、库模块和应用模块各有职责,并非每个模块都能执行。 +用 `--module` 选择一个目标,用 `--main` 在需要时指定入口: + +```sh +java-run --module :app +java-run --tool gradle --module :app --main com.example.Application +java-run plan --module :app +``` + +Maven 接受单个 reactor 选择器,如 `app`、`:artifactId` 或 `groupId:artifactId`;Gradle 接受项目路径,如 `:app` 或 `:apps:server`。 +不接受多个目标、排除选择器或可选选择器。 +目录同时存在 Maven 与 Gradle 构建文件时,需要通过 `--tool` 明确选择。 + +### 交互选择 + +当 stdin 和 stderr 都连接终端时,缺少目标或入口的信息可以通过交互补齐: + +- Maven 聚合项目列出有效 reactor 中的 `jar` 项目,Gradle 列出启用了 Java 插件的项目。一个候选自动采用,多个候选通过数字选择。 +- 候选可能是库模块,入口需要在选定目标准备完成后确认。 +- 主类优先采用 `--main` / 配置值,其次采用构建声明。没有声明时查找目标输出中的传统 `public static void main(String[])`;唯一入口直接采用,多个入口通过数字选择。 + +支持的主类声明为 Maven 的 `exec.mainClass` 属性或 Exec Maven Plugin 的 `mainClass`,以及 Gradle application 插件的 `mainClass`。 + +候选发现会执行构建工具配置,可能下载插件、Wrapper 分发包或准备 `buildSrc` 等构建逻辑。 +选定目标后,再按构建策略准备该项目并解析运行依赖。 +非交互环境和 CI 遇到目标或主类歧义时,需要通过 CLI 或 `.java-run.json` 明确指定。 + +交互只选择目标和入口,运行参数通过选项或配置传入。 +普通运行中的选择仅用于本次启动;需要保存时使用 `init`。 +Ctrl+C 或 EOF 取消选择返回 130。 + +### 多入口选择示例 + +[双入口示例项目](examples/multiple-main/README.md)包含 `HelloApplication` 和 `ReportApplication` 两个主类,未配置默认入口。 +将 java-run 放入 PATH 后,在本仓库根目录执行: + +```sh +java-run --cwd examples/multiple-main -- --name=demo +``` + +准备完成后,终端会显示: + +```text +选择启动主类 + 1. com.example.HelloApplication + 2. com.example.ReportApplication +选择 [1-2]: +``` + +输入 `2` 并回车,启动 `ReportApplication`,`--name=demo` 传给它的 `main(String[])`。 +此例只有一个 Maven 项目,菜单选择的是项目中的入口。 + +脚本和 CI 可以直接指定主类: + +```sh +java-run --cwd examples/multiple-main \ + --main com.example.ReportApplication \ + -- --name=demo +``` + +显式主类、配置中的 `mainClass` 或构建声明已经确定入口时,直接启动该入口,不显示主类菜单。 + +## 参数放在哪一层 + +| 层次 | 配置方式 | 示例 | 影响 | +| --- | --- | --- | --- | +| 启动目标 | `--cwd`、`--module`、`--main` | `--module=:app` | 决定工作区、选定项目和 Java 入口 | +| 构建 | `--tool`、`--build`、`--build-arg` | `--build-arg=-Pdev` | 传给 Maven / Gradle,影响构建模型、依赖和准备步骤 | +| JVM | `--jvm-arg` | `--jvm-arg=-Xmx1g`、`--jvm-arg=-Dspring.profiles.active=dev` | 传给应用 JVM,影响内存、系统属性和 agents | +| 应用 | `--arg` 或 `--` 后的参数 | `-- --server.port=8081` | 原样传给 `main(String[])`,由应用解释 | +| 环境 | 启动 java-run 的 shell 或 CI 环境 | `JAVA_HOME`、`GRADLE_USER_HOME`、应用环境变量 | 构建工具和 Java 子进程继承环境;`.env` 需由 shell 或其他工具加载 | + +Maven `-Pdev` 激活的是 **Maven profile**,与 Spring profile 分属不同层。 +Spring profile 使用正常 JVM 属性或应用参数传递。 + +`--build-arg`、`--jvm-arg` 和 `--arg` 都可以重复指定。 +负号开头的值使用等号形式,值中的后续等号、空格和 shell 字符会保留;含空格的整个参数仍需按当前 shell 的规则引用: + +```sh +java-run --module :app \ + --build-arg=-Pdevelopment \ + --jvm-arg=-Xmx1g \ + --jvm-arg=-Dspring.profiles.active=dev \ + --arg="--message=hello world" \ + -- --server.port=8081 +``` + +`--` 后的内容全部属于应用,不再解释为 java-run 选项。 +构建参数用于调整属性、profile、日志和依赖选项,不能改写启动目标、加入额外任务或覆盖 java-run 管理的元数据输出。 + +完整选项: + +| 选项 | 默认值或行为 | +| --- | --- | +| `--cwd ` | 工作区根目录;默认为当前目录,相对调用时的工作目录解析 | +| `--tool ` | `auto`,根据根目录构建文件选择 | +| `--module ` | 单个目标;缺失时按根项目和交互条件处理 | +| `--main ` | 构建声明或目标输出中的唯一传统 main | +| `--jvm-arg=` | 追加一个 JVM 参数,可重复 | +| `--arg=` | 追加一个应用参数,可重复 | +| `--build-arg=` | 追加一个构建工具参数,可重复 | +| `--build ` | `auto` | +| `--include-tests` | 准备并加入测试输出和依赖,默认关闭 | +| `--java ` | 显式指定应用启动使用的 Java 可执行文件 | +| `--build-command ` | 显式覆盖构建工具可执行文件 | +| `--force` | 仅用于 `init`,忽略已有配置并重新生成 | + +未识别的选项、重复标量和无效值均报错。 + +## 保存项目默认值 + +在 Java 工作区中执行 `init`,准备项目并选择要保存的模块和主类: + +```sh +java-run init +java-run +``` + +`init` 在 `--cwd` 指定的工作区根目录生成 `.java-run.json`,不启动应用。 +只有一个候选时自动采用,多个模块或入口在终端中通过数字选择;非交互环境存在歧义时,使用 `--module` / `--main` 明确指定。 + +初始化采用与运行相同的[构建准备流程](#构建与运行行为),可能编译源码、下载依赖或更新构建工具缓存。 +Maven reactor 的自动准备会写入本地 Maven 仓库。 + +运行参数通过选项一起保存。 +例如,初始化 Maven 工作区中的 `app` 模块并保存内存和应用参数: + +```sh +java-run init --module app --jvm-arg=-Xmx1g -- --server.port=8081 +java-run +``` + +生成的配置记录以下设置: + +- 实际使用的构建工具、选定模块和主类 +- 本次显式提供的构建参数、JVM 参数和应用参数 +- 非默认的 `--build` / `--include-tests` 设置 + +`--cwd`、`--java` 和 `--build-command` 不保存到配置。 +自定义工具路径需在调用时通过 CLI 指定;`--java` 只控制应用启动,不改变构建工具使用的 JDK。 + +已有 `.java-run.json` 时,`init` 在运行构建工具前报错并保留原文件。 +需要重新配置时使用 `java-run init --force`;它忽略旧配置,按本次选项重新生成,也可替换格式损坏的配置。 +取消或准备失败时保留原文件。 + +### 配置内容与优先级 + +配置也可以直接编辑。例如: + +```json +{ + "buildTool": "gradle", + "module": ":app", + "mainClass": "com.example.Application", + "jvmArgs": [ + "-Xmx1g", + "-Dspring.profiles.active=dev" + ], + "applicationArgs": [ + "--server.port=8081" + ] +} +``` + +可用字段为 `buildTool`、`module`、`mainClass`、`jvmArgs`、`applicationArgs`、`buildArgs`、`build`、`includeTests`。 +配置使用严格 JSON,不支持注释、未知字段、环境变量插值或配置继承。 + +- `jvmArgs`、`applicationArgs` 和 `buildArgs` 必须是字符串数组。 +- `jvmArgs` 和 `buildArgs` 的元素不能是空字符串或纯空白字符串。 +- `applicationArgs` 原样保留空字符串和空白参数,与 `--` 透传一致。 +- `includeTests` 必须是布尔值。 + +`run` 和 `plan` 只读取该工作区根目录的配置,不向父目录搜索,也不读取选定模块中的配置。 +显式 CLI 标量覆盖文件值,未指定时保留文件值;数组在文件数组之后追加。 +例如,配置已有 `applicationArgs` 时,`--arg` 会追加参数。 + +## 构建与运行行为 + +默认 `--build=auto` 执行必要准备,并由构建工具负责依赖裁决和增量构建: + +| 项目 | 自动准备 | +| --- | --- | +| Maven 单项目 | `compile`;开启 `--include-tests` 时使用 `test-compile` | +| Maven 已选 reactor 模块 | 对目标及上游执行 `install -DskipTests`,然后仅解析选定目标的运行类路径 | +| Gradle Java 项目 | 由任务图执行所需 `classes` / 测试类准备及运行依赖相关任务 | + +Maven reactor 的 `install` 会更新**本地 Maven 仓库**,不执行 `deploy`。 +Maven 生命周期中的测试执行通过 `-DskipTests` 跳过;测试编译可能是 `install` 的一部分,但默认运行类路径仍排除测试输出和测试依赖。 +`--include-tests` 表示把这些内容加入运行环境,不表示运行测试。 + +`--build=none` 不主动编译源码,但仍执行模型与运行依赖解析,必要时仍会下载依赖。 +它要求目标和项目依赖已有可用产物,缺少产物时直接报错。 +只需静态预览时使用 `plan`。 + +每次启动的运行类路径由 Maven / Gradle 裁决,下载和增量计算复用构建工具自身缓存。 +java-run 的临时文件在请求结束后清理;目标项目的 `target` / `build` 和构建工具缓存正常保留。 +classpath 支持空格、中文、`#`、`%` 等路径字符。 + +构建命令在配置的工作区根目录执行,Java 应用的工作目录为选定项目目录。 +JVM 参数按以下顺序传递,因此后续同名系统属性可以覆盖默认值: + +1. 默认 `-Dfile.encoding=UTF-8` +2. 构建声明的参数 +3. 项目配置中的 `jvmArgs` +4. CLI 追加参数 + +Java 命令优先采用 `--java`,其次采用 Gradle 提供的项目工具链,再使用 `JAVA_HOME` 或 PATH。 +Maven 应用启动不读取 Maven toolchains 配置,需要不同 JDK 时使用 `--java` 显式指定。 + +## 与项目原生运行任务的关系 + +java-run 通过独立 Java 进程提供统一的开发启动方式。 +项目也可以使用 [Gradle application 的 `run`](https://docs.gradle.org/current/userguide/application_plugin.html) 或 [Spring Boot 的 `bootRun`](https://docs.spring.io/spring-boot/gradle-plugin/running.html): + +```sh +./gradlew :app:run +./gradlew :app:bootRun +``` + +原生任务可以配置专用环境变量、agents、附加资源或启动前后逻辑,这些任务行为不会自动应用到 java-run。 +依赖这些设置的项目应使用原生任务,或把所需运行参数明确配置给 java-run。 + +## 支持范围与验证 + +支持 Maven `jar` 项目和启用了 Gradle Java 插件的项目,以及传统 `public static void main(String[])` 的 classpath 启动。 +主类发现只检查选定项目的已编译输出,不遍历所有依赖 Jar 寻找应用。 +JPMS、Android、native image、应用守护和热重启、部署不属于当前支持范围。 + +### Windows 编码 + +Windows 的 Java 原生启动器按系统代码页转换命令行参数。 +classpath 文件 URL 可以保留中文路径,但主类名、JVM / 应用参数和传给构建工具的参数仍需能被该编码完整表示。 +java-run 会检测 JDK 的 `native.encoding` 并提前拒绝无法表示的参数,避免它们静默变成 `?`。 + +需要传递任意 Unicode 参数时,应按系统要求启用 UTF-8 区域设置。 +`-Dfile.encoding=UTF-8` 控制应用文件编码,不能替代这项设置;详见 [JDK Windows 启动器说明](https://www.oracle.com/java/technologies/javase/21-0-9-relnotes.html)。 +自定义构建入口自行切换 JDK 时,还需核对其实际使用的工具链。 + +Windows 上的 Maven 配置根超出系统代码页时,需要 Maven **3.9.2 或更新版本**,否则 java-run 会报错。 +该版本支持所需的[命令行属性插值](https://maven.apache.org/docs/3.9.2/release-notes.html),使 `.mvn` 配置和 POM 中的项目根目录属性保持正确路径。 + +### 开发与验证 + +类型检查、快速测试和原生运行验收通过 `bun run check`、`bun run compile`、`bun run smoke` 执行。 +具体环境要求、CI 和发布流程见[参与开发](CONTRIBUTING.md)。 + +## 项目文档 + +| 文档 | 内容 | +| --- | --- | +| [安装指南](docs/installation.md) | 平台选择、下载安装、校验和升级 | +| [架构设计](docs/architecture.md) | 产品边界、模块职责、构建适配与跨平台运行契约 | +| [技术路线](docs/roadmap.md) | 已有工程能力、支持缺口和后续工作的验收条件 | +| [参与开发](CONTRIBUTING.md) | 本地开发、验证、分支协作与发布流程 | +| [验收夹具](tests/fixtures/README.md) | 真实项目场景、隔离方式和验证覆盖 | diff --git a/bump.config.ts b/bump.config.ts index f30e547..7a911b5 100644 --- a/bump.config.ts +++ b/bump.config.ts @@ -1,5 +1,8 @@ -import { defineConfig } from "bumpp" +import { defineConfig } from 'bumpp'; export default defineConfig({ - all: true, -}) + files: ['package.json'], + commit: false, + tag: false, + push: false, +}); diff --git a/bun.lockb b/bun.lockb index b5e29d0..b06a9ae 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..94fafbe --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,253 @@ +# java-run 架构与运行契约 + +java-run 是框架中立的 Java 源码工作区运行器。 +它把选择项目、准备产物、确定入口和启动独立 JVM 组织成统一流程。 +构建模型、依赖版本和项目任务由 Maven 或 Gradle 裁决。 + +运行契约基于 classpath,适用于普通 main 和 Spring Boot 等 Java 应用。 +框架参数通过 JVM 参数或应用参数传入,框架类型不进入核心配置。 + +使用方式见 [README](../README.md),验证与发布流程见 [贡献指南](../CONTRIBUTING.md),后续目标见 [技术路线](roadmap.md)。 + +## 运行目标与入口 + +一次请求对应一个构建项目和一个主类,`init` 保存的配置也对应这个单一目标。 +多模块工作区中的项目可能是应用,也可能是库。 +存在编译输出或应用了 Java 插件,都不能证明项目具有可运行入口。 + +目标和入口分两步确定: + +1. 发现候选项目:Maven 列出有效 reactor 中的 jar 项目,Gradle 列出主构建中应用了 Java 插件的项目。 +2. 准备选定项目:按显式主类、构建声明、已编译入口的顺序确定主类。 + +自动发现只扫描目标项目输出,识别传统的 `public static void main(String[])`。 +唯一入口自动采用;多个入口需要选择;没有入口时报告错误。 +显式主类和构建声明的主类会校验名称格式,实际可加载性和方法有效性由 Java 启动器确认。 + +### 交互选择 + +标准输入与标准错误均为终端时,CLI 可以展示模块或主类菜单。 +唯一候选自动采用,取消选择返回退出码 130。 + +- `run` 使用本次选择启动应用 +- `init` 在准备和选择成功后保存结果 +- 非交互环境存在歧义时,通过 `--module` / `--main` 明确指定;`run` 也可读取项目配置 + +菜单只补齐目标和入口,运行参数由选项或配置提供。 + +候选发现会运行构建工具。 +Gradle 配置期间可能准备 `buildSrc` 或 included build 的构建逻辑。 +发现步骤本身不主动编译候选应用、解析其运行依赖或读取主类 Provider。 + +## 请求与命令流程 + +准备流程使用三个数据契约: + +| 契约 | 职责 | +| --- | --- | +| `RunConfig` | 表达工作目录、运行目标、参数和构建策略 | +| `BuildPlan` | 描述执行构建工具前的静态命令、工作目录和准备说明 | +| `PreparedProject` | 保存构建工具裁决的目标输出、有序类路径、主类、启动 JDK 和默认 JVM 参数 | + +### 配置来源 + +`run` 和 `plan` 读取 `--cwd` 指定的工作区根目录中的 `.java-run.json`。 +配置定位只执行一次,不向父目录查找,也不在选定模块后重新读取。 + +- CLI 标量覆盖文件配置 +- CLI 参数数组追加到文件数组之后 +- 项目配置保存可复用的目标、参数和准备策略 +- `--cwd`、`--java` 和 `--build-command` 由 CLI 指定,不写入项目配置 + +`init` 使用 CLI 请求准备目标、检查产物并确定主类。 +生成的配置记录实际构建工具、模块和主类、显式参数数组,以及非默认的构建策略和测试类路径设置。 +构建声明的默认 JVM 参数仍由构建工具提供。 + +### 执行与保存 + +运行与初始化共用目标选择、项目准备和主类确定流程: + +```mermaid +flowchart TD + request[CLI 请求] --> config[run 读取配置 / init 检查输出文件] + config --> target[构建工具检测与目标选择] + target --> prepare[Maven / Gradle 准备与元数据解析] + prepare --> project[PreparedProject] + project --> entry[产物检查与主类确定] + entry -->|init| save[保存 .java-run.json] + entry -->|run| classpath[Manifest 类路径 Jar] + classpath --> java[独立 Java 进程] +``` + +初始化写入遵循以下规则: + +- 执行外部命令前检查目标文件,默认拒绝覆盖已有配置 +- `--force` 使用本次选项重新生成,忽略旧配置,也可替换损坏的 JSON +- 取消、准备失败或入口无法确定时保留原文件 +- 保存时再次检查目标,通过同目录临时文件完整写入后发布;目录和符号链接不能作为覆盖目标 + +初始化会执行构建准备,但不启动应用 JVM。 + +### 静态命令 + +`plan` 只读取本地构建文件和配置,输出准备步骤及待解析信息。 +它不执行构建工具、不创建临时目录、不交互,也不验证有效模型、主类或依赖文件。 + +`help` 和 `version` 只解析 CLI 参数,不读取项目配置。 + +## 模块职责与依赖 + +```text +src/ +├── cli.ts 请求编排、退出结果与临时工作区生命周期 +├── cli/ 参数和配置读写、帮助、终端选择 +├── build-tools/ 构建工具检测、Maven 与 Gradle 适配器 +├── core/ 运行契约、类路径、主类发现与 Java 启动 +└── process/ 外部进程、环境覆盖、编码检查与信号处理 +``` + +CLI 调用构建适配器完成准备,适配器返回统一的 `PreparedProject`。 +核心模块检查该结果并确定主类,供 `run` 启动 Java 或 `init` 保存配置。 + +CLI 协调终端交互,核心模块通过调用方提供的选择函数确定入口。 +核心模块不依赖终端菜单,进程模块不判断 Maven 或 Gradle 项目语义。 + +构建适配器提供静态计划、候选发现和项目准备函数。 +模型读取、目标定位和任务图的差异由各适配器处理。 +Maven 的有效模型类型与依赖文本解析位于 Maven 适配器,核心模块只消费准备结果。 +新增构建系统或运行模式时,以真实项目和准备结果契约确定接口。 + +## 构建工具适配 + +工作目录同时包含 Maven 与 Gradle 构建文件时,必须显式选择工具。 +构建命令按 CLI 指定命令、工作区 Wrapper、PATH 命令的顺序选择。 +Wrapper 存在但启动失败时保留错误,不回退到另一个版本。 + +### Maven + +运行目标要求 `packaging=jar`。 +适配器读取 Maven 展开的模型,不自行计算属性、继承或依赖冲突。 + +| 项目设置 | 准备行为 | +| --- | --- | +| 单项目 | 执行 `compile`;包含测试类路径时执行 `test-compile` | +| 指定 reactor 模块 | 执行 `-pl <目标> -am install -DskipTests`,准备目标及上游产物 | +| `build=none` | 跳过编译和 install,继续解析有效模型和依赖 | + +固定版本的 Help Plugin 提供目标 POM 和 effective POM,Dependency Plugin 提供运行依赖。 +运行类路径由目标输出和 Maven 裁决的依赖组成;包含测试时,测试输出优先于主输出,并解析 test 依赖作用域。 + +多模块准备通过本地 Maven 仓库衔接上游产物,使随后单独解析目标依赖时能够读取这些构建结果。 +`install` 会执行绑定到该生命周期的插件,并写入本地仓库,不执行 `deploy`。 + +主类声明采用 `exec.mainClass` 属性或 Exec Maven Plugin 的 `mainClass` 配置。 +应用启动 JDK 按 `--java`、`JAVA_HOME`、PATH 的顺序选择,不自动读取 Maven toolchains。 + +### Gradle + +临时 init script 读取目标的 source set、runtimeClasspath、application 配置和 Java toolchain。 +脚本位于请求的临时工作区,不修改项目构建文件。 + +元数据任务只注册到主构建,目标项目路径不会套用到 `buildSrc` 或 included builds。 +项目依赖和复合构建依赖仍由 Gradle 任务图处理。 + +| 项目设置 | 准备行为 | +| --- | --- | +| 默认准备 | 元数据任务依赖目标 source set 的 classes 和 runtimeClasspath 构建依赖 | +| 包含测试 | 采用 test runtimeClasspath,并记录 test 与 main 输出 | +| `build=none` | 元数据任务不依赖源码构建任务,要求运行产物已经存在 | + +application 插件提供声明的主类和默认 JVM 参数,Java toolchain 提供应用启动器。 +`--java` 可以显式覆盖该启动器。 +application 声明 `mainModule` 时明确报错,因为它要求 JPMS 启动语义。 + +### 构建参数与副作用 + +适配器不主动调用测试目标或测试任务。 +项目绑定到 compile、install 或 classes 的行为仍按构建配置执行。 +包含测试类路径表示准备并加载测试输出与依赖。 + +`build=none` 仍执行模型和依赖解析,可能下载依赖或更新构建工具缓存。 +需要无构建副作用的预览时使用 `plan`。 + +构建参数用于调整允许的属性、profile、日志和依赖等选项。 +项目选择、生命周期目标、任务列表、init script 和元数据输出由适配器管理,保证一次请求只准备一个运行目标。 + +## Java 启动 + +### 类路径与主类 + +启动模块先检查类路径文件和目标类目录,再确定主类。 +入口候选仅来自目标项目输出,不包含依赖 Jar 或其他应用。 + +长类路径通过临时 Manifest Jar 传入 Java: + +- 文件路径转换为正确编码的 `file:` URL,目录保留尾部斜线 +- Manifest 按 UTF-8 字节数折行,使用 CRLF 和终止空行 +- 类路径保留构建工具给出的顺序,只去重相同路径 + +启动命令同时返回已确定的主类,供 CLI 展示运行目标。 + +### 参数与工作目录 + +Java 应用的工作目录为选定项目目录。 +JVM 参数按以下顺序排列: + +1. 默认 `-Dfile.encoding=UTF-8` +2. 构建声明的默认参数 +3. 项目配置参数 +4. CLI 追加参数 + +应用参数位于主类之后。 +参数始终保持数组边界,不交给通用 shell 重新拆分。 +临时脚本、元数据和类路径 Jar 在请求结束后统一清理。 + +框架原生任务可能设置额外资源、环境、agent 或自定义 `JavaExec` 行为。 +依赖这些任务行为的项目应使用原生任务,或为 java-run 明确配置所需启动参数。 + +## 进程、退出与编码 + +### 输出与进程生命周期 + +外部命令异步执行。 +构建与元数据命令捕获输出,Java 应用继承终端。 + +捕获模式保留换行和首尾空白,总输出默认限制为 16 MiB。 +非零退出保留命令阶段、工作目录、退出码和诊断;命令不存在时返回 127。 +应用退出码直接成为 CLI 退出码。 + +POSIX 命令使用独立进程组,接收转发的 `SIGINT` 和 `SIGTERM`,三秒后升级终止。 +父进程退出或命令结束时清理受控进程树。 + +Windows 批处理入口通过受控 `cmd` 调用保留参数边界,并阻止 `%NAME%` 被二次展开。 +终止时使用 `taskkill /T /F` 清理进程树,应用关闭钩子不保证执行。 + +### Windows JDK 参数编码 + +Windows JDK 原生启动器可能使用系统代码页转换参数,`file.encoding=UTF-8` 不改变这个转换。 +执行前按真实 JDK 的原生编码验证非 ASCII 参数;无法完整表示时明确失败,诊断不回显原始参数。 + +项目路径通过以下方式传递: + +- Gradle 从临时脚本的 UTF-8 请求读取项目根、目标和输出路径 +- Java 从 Manifest 读取类路径,classpath Jar 参数相对目标项目目录计算 +- Jar 命令在临时目录中使用相对文件名 + +Maven 启动脚本还会注入配置根目录。 +适配器保留显式 `MAVEN_BASEDIR` 或最近 `.mvn` 祖先的含义,并规范化原生目录路径以展开 8.3 别名。 + +配置根无法经 JDK 编码完整传递时,先以相对配置根加载 `.mvn`,再通过子环境恢复模型中的绝对根。 +这条路径要求 Maven 3.9.2 或更新版本。 +子环境覆盖不改变父进程环境。 + +## 支持范围与验证原则 + +支持契约围绕 Maven jar、Gradle Java source set 和传统 Java main 建立。 +JPMS、Android、native image、部署和服务守护属于不同的运行模型。 + +每次请求由构建工具裁决运行信息,下载缓存和增量构建由其自身维护。 +java-run 不维护独立类路径缓存。 +性能优化以模型调用次数、配置成本和真实项目测量为依据。 + +验证覆盖配置契约、真实 JDK 启动、Maven / Gradle 项目夹具和原生二进制验收。 +扩大支持范围需要可运行样本和对应验收;仅构建出平台产物不足以证明该平台可用。 diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..191b7cf --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,164 @@ +# 安装 java-run + +java-run 的预编译版本可直接执行,无需安装 Bun。 +运行 Java 项目需要: + +- 符合目标项目要求的 JDK,能够使用其中的 `java` 和 `jar` +- Maven / Gradle 或项目 Wrapper + +项目存在 `mvnw` / `mvnw.cmd` 或 `gradlew` / `gradlew.bat` 时,java-run 优先使用 Wrapper;否则使用 PATH 中的构建工具。 +发布验收覆盖 JDK 17 和 21,目标项目自身的 Java 版本要求仍需满足。 + +## 选择下载文件 + +在 [GitHub Releases](https://github.com/Vanisper/java-run/releases) 选择版本,下载对应平台的 ZIP 和同一版本的 `SHA256SUMS`: + +| 系统与体系结构 | 最新稳定版下载 | +| --- | --- | +| Windows x64 | [java-run-windows-x64.zip](https://github.com/Vanisper/java-run/releases/latest/download/java-run-windows-x64.zip) | +| Linux x64 | [java-run-linux-x64.zip](https://github.com/Vanisper/java-run/releases/latest/download/java-run-linux-x64.zip) | +| Linux arm64 | [java-run-linux-arm64.zip](https://github.com/Vanisper/java-run/releases/latest/download/java-run-linux-arm64.zip) | +| macOS arm64(Apple Silicon) | [java-run-darwin-arm64.zip](https://github.com/Vanisper/java-run/releases/latest/download/java-run-darwin-arm64.zip) | +| macOS x64(Intel) | [java-run-darwin-x64.zip](https://github.com/Vanisper/java-run/releases/latest/download/java-run-darwin-x64.zip) | + +[最新稳定版校验和](https://github.com/Vanisper/java-run/releases/latest/download/SHA256SUMS) 覆盖这五个 ZIP。 +确认体系结构的方法: + +- macOS / Linux:运行 `uname -m`,`x86_64` 对应 x64,`arm64` / `aarch64` 对应 arm64 +- Windows:在“设置 → 系统 → 关于”查看系统类型,当前提供 x64 版本 + +Linux 产物在 Ubuntu 24.04 的 glibc 环境验收,musl(如 Alpine)和其他系统版本未纳入发布验收。 + +资产名称固定,版本位于 URL 中。 +指定版本时,使用所选 Release 的完整标签替换 `vX.Y.Z`: + +```text +https://github.com/Vanisper/java-run/releases/download/vX.Y.Z/java-run-<平台>.zip +https://github.com/Vanisper/java-run/releases/download/vX.Y.Z/SHA256SUMS +``` + +预发布版本也使用完整标签下载;`/releases/latest/download/` 指向最新稳定版。 + +## 包内文件与校验 + +ZIP 解压后只有一个 `java-run-<平台>/` 目录,内含: + +- `java-run` 或 `java-run.exe` +- 对应的 `java-run.sha256` 或 `java-run.exe.sha256` +- 本安装指南 `INSTALL.md` +- 许可证文件 + +Release 的 `SHA256SUMS` 用于校验下载的 ZIP,包内 `.sha256` 用于校验解压后的二进制。 +按下面的步骤完成两次核对,再安装到 PATH。 + +## macOS / Linux + +下面以 macOS arm64 为例,在空的下载目录中执行。 +根据上表调整 `platform`;要固定版本,将 `release_url` 改为对应的 `/releases/download/vX.Y.Z`。 + +```sh +platform=darwin-arm64 +release_url=https://github.com/Vanisper/java-run/releases/latest/download +asset="java-run-${platform}.zip" +curl -fL "$release_url/$asset" -o "$asset" +curl -fL "$release_url/SHA256SUMS" -o SHA256SUMS +``` + +解压前核对 ZIP 的 SHA-256。 +macOS 使用: + +```sh +shasum -a 256 "$asset" +``` + +Linux 使用: + +```sh +sha256sum "$asset" +``` + +将输出与 `SHA256SUMS` 中该 ZIP 对应的值核对,一致后再继续。 +不一致时,重新下载同一版本的 ZIP 和校验和。 + +```sh +unzip "$asset" +cd "java-run-${platform}" +``` + +在解压目录中核对二进制。 +macOS 使用: + +```sh +shasum -a 256 -c java-run.sha256 +``` + +Linux 使用: + +```sh +sha256sum --check java-run.sha256 +``` + +确认输出 `java-run: OK` 后,检查版本并安装: + +```sh +chmod +x java-run +./java-run version +mkdir -p "$HOME/.local/bin" +cp java-run "$HOME/.local/bin/java-run" +export PATH="$HOME/.local/bin:$PATH" +java-run version +``` + +`chmod +x` 确保可执行权限。 +把上述 `export PATH` 加入当前 shell 的配置文件(如 `~/.zshrc` 或 `~/.bashrc`),使之后的终端也能找到命令。 +安装位置属于当前用户,无需 `sudo`。 + +## Windows + +下载 `java-run-windows-x64.zip` 和同一版本的 `SHA256SUMS`,在下载目录中打开 PowerShell: + +```powershell +Get-FileHash -Algorithm SHA256 .\java-run-windows-x64.zip +``` + +将结果与 `SHA256SUMS` 中该 ZIP 对应的值核对,比较时忽略字母大小写。 +一致后解压并核对二进制: + +```powershell +Expand-Archive -Path .\java-run-windows-x64.zip -DestinationPath .\java-run-download +Set-Location .\java-run-download\java-run-windows-x64 +$expectedHash = ((Get-Content -Raw .\java-run.exe.sha256) -split '\s+')[0] +if ((Get-FileHash -Algorithm SHA256 .\java-run.exe).Hash -ne $expectedHash) { + throw 'java-run.exe SHA-256 校验失败' +} +``` + +校验通过后,在同一目录检查版本并安装: + +```powershell +& .\java-run.exe version +$installDir = Join-Path $env:LOCALAPPDATA 'Programs\java-run' +New-Item -ItemType Directory -Force -Path $installDir | Out-Null +Copy-Item -Force .\java-run.exe $installDir +$env:Path = "$installDir;$env:Path" +java-run version +``` + +在“编辑账户的环境变量”中,将 `%LOCALAPPDATA%\Programs\java-run` 加入用户的 `Path`。 +随后打开新终端,执行 `java-run version` 验证安装。 +安装到当前用户目录无需管理员权限。 + +## 使用与升级 + +在 Java 项目根目录执行 `java-run` 即可启动。 +需要保存模块、主类和启动参数时,执行 `java-run init` 生成项目配置,之后仍使用 `java-run` 启动。 +命令选项见 `java-run help`,交互选择与参数示例见 [项目 README](https://github.com/Vanisper/java-run#readme)。 + +升级步骤: + +1. 下载所选版本的 ZIP 与校验和,完成上述核对和版本检查。 +2. 替换 PATH 中已有的可执行文件;Windows 需先退出正在使用该文件的进程。 +3. 运行 `java-run version`,确认结果与所选 Release 的版本一致。 + +回退时按相同步骤安装指定旧版本。 +从源码构建与参与开发见 [项目仓库](https://github.com/Vanisper/java-run)。 diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..0457d02 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,70 @@ +# java-run 工程能力与技术路线 + +java-run 为 Maven 与 Gradle 的 Java 源码工作区提供统一启动入口。 +它负责选定目标、准备产物、保存启动配置和运行独立 JVM,构建语义由项目使用的构建工具决定。 + +[架构文档](architecture.md) 定义运行契约,[贡献指南](../CONTRIBUTING.md) 说明开发、验收与发布流程。 + +## 工程能力 + +| 能力 | 设计与实现 | 扩展时需要保持的约束 | +| --- | --- | --- | +| 目标与配置 | 单项目、单主类;严格校验项目配置;`init` 与 `run` 共用准备和入口选择 | 交互与非交互具有一致的配置和参数含义 | +| 构建适配 | Maven 有效模型、Gradle source set 和任务图统一为 `PreparedProject` | 构建工具保有依赖裁决权,目标运行类路径保持隔离 | +| Java 启动 | 编译入口扫描、Manifest 类路径、有序 JVM 与应用参数 | 路径编码、参数边界和依赖顺序保持正确 | +| 进程管理 | 异步执行、结构化错误、子环境覆盖和平台进程树清理 | 退出码可追踪,临时资源和受控进程能够清理 | +| 验证与分发 | 回归测试、真实项目夹具、五平台原生 ZIP 验收和两层 SHA-256 校验 | 支持声明与实际执行证据一致,公开产物通过发布门禁 | + +这些契约使 Maven / Gradle 的启动差异集中在构建适配层,日常使用保持一套命令。 +`init` 保存可复用的目标和参数,运行时继续由构建工具解析依赖和应用默认值。 + +支持范围有明确边界: + +- Maven reactor 准备通过本地仓库衔接上游产物,会执行 install 生命周期 +- Gradle 配置会执行项目构建逻辑,通用启动不复现所有自定义运行任务 +- 主类自动发现基于传统 main 和 classpath,不包含 JPMS 或现代 main 语义 +- Windows 进程树采用强制终止,不能保证应用关闭钩子执行 + +## 验证依据 + +验证以用户可观察的行为为准:目标能否启动、配置能否复用、依赖和资源变化能否生效,以及失败后是否保留诊断并清理资源。 +测试范围与夹具用途见 [夹具说明](../tests/fixtures/README.md)。 + +- [Check](../.github/workflows/check.yaml):Ubuntu、macOS、Windows 分别在 JDK 17 和 21 下执行源码回归、二进制编译和完整运行验收 +- [Release](../.github/workflows/release.yaml):五个平台分别构建,JDK 21 完整运行验收,JDK 17 解压产物启动验收,再汇总并校验 ZIP + +固定环境的验收提供对应系统、体系结构和 JDK 的运行证据。 +其他构建工具版本、第三方插件和系统环境需要代表项目补充验证;平台范围见 [安装指南](installation.md)。 + +## 技术路线 + +后续工作按使用证据和收益排序。 +每项能力先确定项目样本、运行契约和验收条件,再扩展接口。 + +### 扩展真实项目覆盖 + +优先补充 Wrapper、自定义输出、构建约定和不同 Maven / Gradle 版本的代表项目。 +工具链验证同时覆盖构建 JDK 与应用启动 JDK 的选择。 + +验收条件是样本能够重复运行,失败边界有明确诊断,已有目标隔离与参数契约保持成立。 + +### 改善重复启动体验 + +根据 `init` 和日常运行反馈,评估命名运行配置、配置编辑辅助和更直接的诊断信息。 +新增配置能力需要明确读取位置、覆盖顺序和保存规则,并能直接用于脚本。 + +验收条件是减少有证据的重复操作,交互与非交互方式能够完成相同的运行目标。 + +### 降低启动准备成本 + +先测量冷准备、温准备、构建工具调用次数和配置成本,再优化 Maven 模型调用或 Gradle 元数据读取。 +优先复用构建工具已有的缓存与增量能力。 + +验收条件是在固定样本中取得可复现的收益,同时保持依赖、资源与配置变更即时生效。 + +### 按需扩展运行模型 + +JPMS、现代 main、原生任务集成和其他构建系统分别评估。 +每种模式需要实际项目、明确的配置及退出契约,以及对应的执行验证。 + +若运行信息无法由 `PreparedProject` 表达,应先确定新的结果契约,再设计适配接口。 diff --git a/examples/multiple-main/.gitignore b/examples/multiple-main/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/examples/multiple-main/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/examples/multiple-main/README.md b/examples/multiple-main/README.md new file mode 100644 index 0000000..91a1189 --- /dev/null +++ b/examples/multiple-main/README.md @@ -0,0 +1,101 @@ +# 多个 main 入口 + +这是一个 Java 17 的单模块 Maven 项目,包含两个传统 `public static void main(String[])` 入口,未声明默认主类: + +- `com.example.HelloApplication` +- `com.example.ReportApplication` + +两个入口分别输出自己的类名和收到的参数,不依赖业务库。 +运行需要 JDK 17 或更新版本,以及 Maven。 +按 [安装指南](../../docs/installation.md) 将 java-run 加入 PATH 后,在 java-run 仓库根目录执行下面的命令。 + +## 在终端选择入口 + +运行示例并传入两个应用参数: + +```sh +java-run --cwd examples/multiple-main -- --format=table "hello world" +``` + +java-run 自动编译项目。 +在标准输入与标准错误均连接终端时,两个入口会按类名排列为候选: + +```text +选择启动主类 + 1. com.example.HelloApplication + 2. com.example.ReportApplication +选择 [1-2]: +``` + +输入 `2` 并回车,`ReportApplication` 会运行并输出: + +```text +entry=com.example.ReportApplication +args=[--format=table, hello world] +``` + +`--` 后的参数传给所选入口。 +`hello world` 是一个参数,终端选择不会改变参数边界。 + +## 明确指定入口 + +脚本、CI 或重定向输入的环境使用 `--main`,无需菜单: + +```sh +java-run --cwd examples/multiple-main \ + --main=com.example.HelloApplication -- --name=Alice +``` + +输出为: + +```text +entry=com.example.HelloApplication +args=[--name=Alice] +``` + +非交互环境既没有保存主类、也没有传入 `--main` 时,java-run 会列出两个候选并要求明确入口,避免等待终端输入。 + +## 保存入口与参数 + +使用 `init` 将选择结果保存到示例项目的 `.java-run.json`: + +```sh +java-run init --cwd examples/multiple-main -- --format=table "hello world" +``` + +此命令同样编译项目并显示两个主类。 +输入 `2` 后保存 `ReportApplication` 和应用参数,此时不运行应用。 +随后执行: + +```sh +java-run --cwd examples/multiple-main +``` + +java-run 直接运行保存的入口,不再显示主类菜单: + +```text +entry=com.example.ReportApplication +args=[--format=table, hello world] +``` + +重复初始化默认保留已有文件并报错。 +需要重新选择时使用 `init --force`,原配置中的入口和参数会被本次选择及选项替换;取消或准备失败时保留原文件。 + +## 从源码运行示例 + +使用仓库中 [`.bun-version`](../../.bun-version) 指定的 Bun 版本,在仓库根目录执行: + +```sh +bun install --frozen-lockfile +bun run src/cli.ts --cwd examples/multiple-main +``` + +也可以编译后运行: + +```sh +bun run compile +./dist/java-run --cwd examples/multiple-main +``` + +其他示例的参数保持相同,只需将 `java-run` 换成 `bun run src/cli.ts` 或 `./dist/java-run`。 +Windows 二进制路径使用 `./dist/java-run.exe`。 diff --git a/examples/multiple-main/pom.xml b/examples/multiple-main/pom.xml new file mode 100644 index 0000000..6342584 --- /dev/null +++ b/examples/multiple-main/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + com.example + multiple-main + 1.0.0 + jar + + 17 + UTF-8 + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + diff --git a/examples/multiple-main/src/main/java/com/example/HelloApplication.java b/examples/multiple-main/src/main/java/com/example/HelloApplication.java new file mode 100644 index 0000000..96ed4e3 --- /dev/null +++ b/examples/multiple-main/src/main/java/com/example/HelloApplication.java @@ -0,0 +1,10 @@ +package com.example; + +import java.util.Arrays; + +public class HelloApplication { + public static void main(String[] args) { + System.out.println("entry=com.example.HelloApplication"); + System.out.println("args=" + Arrays.toString(args)); + } +} diff --git a/examples/multiple-main/src/main/java/com/example/ReportApplication.java b/examples/multiple-main/src/main/java/com/example/ReportApplication.java new file mode 100644 index 0000000..a283c37 --- /dev/null +++ b/examples/multiple-main/src/main/java/com/example/ReportApplication.java @@ -0,0 +1,10 @@ +package com.example; + +import java.util.Arrays; + +public class ReportApplication { + public static void main(String[] args) { + System.out.println("entry=com.example.ReportApplication"); + System.out.println("args=" + Arrays.toString(args)); + } +} diff --git a/package.json b/package.json index 8792df0..5fe8bf2 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,26 @@ { "name": "java-run", - "module": "cli.ts", + "module": "src/cli.ts", "type": "module", "version": "0.0.5", "scripts": { "version": "bumpp", "start": "bun run ./src/cli.ts", - "compile": "bun build ./src/cli.ts --compile --outfile=dist/java-run" + "compile": "bun run scripts/compile.ts", + "typecheck": "tsc --noEmit", + "test": "bun test", + "check": "bun run typecheck && bun test", + "smoke": "bun run scripts/smoke.ts" }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "1.2.5", "bumpp": "^10.1.0", - "@types/cli-progress": "^3.11.6", - "@types/xml2js": "^0.4.14" - }, - "peerDependencies": { - "typescript": "^5.0.0" + "@types/xml2js": "^0.4.14", + "typescript": "5.8.2" }, "dependencies": { - "cli-progress": "^3.12.0", "iconv-lite": "^0.6.3", "jschardet": "^3.1.4", "xml2js": "^0.6.2" } -} \ No newline at end of file +} diff --git a/scripts/build.bat b/scripts/build.bat deleted file mode 100644 index d117297..0000000 --- a/scripts/build.bat +++ /dev/null @@ -1,15 +0,0 @@ -@echo off -REM 保存当前目录 -set "ORIGINAL_DIR=%cd%" - -REM 切换到 bat 所在目录 -pushd "%~dp0" - -REM 执行编译命令(核心操作) -bun build ..\src\cli.ts --compile --outfile ..\dist\java-run - -REM 还原到原始目录 -popd - -REM 可选:清除临时变量 -set "ORIGINAL_DIR=" \ No newline at end of file diff --git a/scripts/checksum.ts b/scripts/checksum.ts new file mode 100644 index 0000000..16dd7a4 --- /dev/null +++ b/scripts/checksum.ts @@ -0,0 +1,47 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { basename } from 'node:path'; + +async function assertFile(path: string): Promise { + const stat = await lstat(path); + if (!stat.isFile() || stat.size === 0) throw new Error(`文件不是非空普通文件:${path}`); +} + +/** 流式计算非空普通文件的 SHA-256,拒绝符号链接 */ +export async function sha256(path: string): Promise { + await assertFile(path); + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +function checksumLine(binary: string, digest: string): string { + const name = basename(binary); + if (/[\r\n]/.test(name)) throw new Error('二进制文件名不能包含换行'); + // GNU 校验和对含反斜杠的文件名使用转义行前缀 + return name.includes('\\') ? `\\${digest} ${name.replaceAll('\\', '\\\\')}\n` : `${digest} ${name}\n`; +} + +/** 原子写入二进制旁的 .sha256,清单只引用同目录文件名 */ +export async function writeBinaryChecksum(binary: string): Promise { + const sidecar = `${binary}.sha256`; + const content = checksumLine(binary, await sha256(binary)); + const temporary = `${sidecar}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, content, { flag: 'wx' }); + await rename(temporary, sidecar); + return sidecar; + } finally { await rm(temporary, { force: true }); } +} + +/** 核对二进制、文件名与旁边的 .sha256;缺失、过期或格式不符时失败 */ +export async function verifyBinaryChecksum(binary: string): Promise { + const sidecar = `${binary}.sha256`; + await assertFile(sidecar); + const content = await readFile(sidecar, 'utf8'); + if (content !== checksumLine(binary, await sha256(binary))) { + throw new Error(`二进制校验和或文件名不一致:${basename(sidecar)}`); + } + return sidecar; +} diff --git a/scripts/compile.ts b/scripts/compile.ts new file mode 100644 index 0000000..f6cfac2 --- /dev/null +++ b/scripts/compile.ts @@ -0,0 +1,68 @@ +import { spawnSync } from 'node:child_process'; +import { rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { writeBinaryChecksum } from './checksum'; +import { platforms, type BuildTarget } from './platforms'; + +const projectRoot = resolve(import.meta.dir, '..'); + +/** 本机或指定平台的独立二进制构建配置 */ +export interface CompileOptions { + target?: BuildTarget; + outfile: string; +} + +/** + * 解析编译入口的选项 + * + * @description 仅接受 --target 和 --outfile 的等号形式;相对输出路径以 cwd 为基准,默认为 dist/java-run + */ +export function parseCompileOptions(argv: readonly string[], cwd = projectRoot): CompileOptions { + const options: CompileOptions = { outfile: resolve(cwd, 'dist/java-run') }; + const specified = new Set(); + for (const argument of argv) { + const separator = argument.indexOf('='); + const name = separator < 0 ? argument : argument.slice(0, separator); + if (name !== '--target' && name !== '--outfile') throw new Error(`未知编译参数:${argument}`); + if (specified.has(name)) throw new Error(`${name} 不能重复指定`); + specified.add(name); + const value = separator < 0 ? '' : argument.slice(separator + 1); + if (!value.trim() || /[\r\n\0]/.test(value)) throw new Error(`${name} 必须使用 ${name}= 指定有效值`); + if (name === '--target') { + const platform = platforms.find(platform => platform.target === value); + if (!platform) throw new Error(`--target 仅支持 ${platforms.map(platform => platform.target).join('、')}`); + options.target = platform.target; + } else { + options.outfile = resolve(cwd, value); + } + } + return options; +} + +/** 编译独立二进制及校验和,构建失败保留 Bun 的退出码 */ +async function main(argv: string[]): Promise { + const options = parseCompileOptions(argv); + const windows = options.target ? options.target.startsWith('bun-windows-') : process.platform === 'win32'; + const binary = windows && !options.outfile.endsWith('.exe') ? `${options.outfile}.exe` : options.outfile; + await rm(`${binary}.sha256`, { force: true }); + const args = [ + 'build', resolve(projectRoot, 'src/cli.ts'), '--compile', '--minify', '--sourcemap', + '--no-compile-autoload-dotenv', '--no-compile-autoload-bunfig', + `--outfile=${options.outfile}`, + ]; + if (options.target) args.push(`--target=${options.target}`); + const result = spawnSync(process.execPath, args, { cwd: projectRoot, stdio: 'inherit' }); + if (result.error) throw result.error; + const status = result.status ?? 1; + if (status === 0) await writeBinaryChecksum(binary); + return status; +} + +if (import.meta.main) { + try { + process.exitCode = await main(Bun.argv.slice(2)); + } catch (error) { + console.error(`java-run:${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/platforms.ts b/scripts/platforms.ts new file mode 100644 index 0000000..65654b9 --- /dev/null +++ b/scripts/platforms.ts @@ -0,0 +1,24 @@ +const definitions = [ + { name: 'windows-x64', runner: 'windows-2025', target: 'bun-windows-x64', binary: 'java-run.exe', label: 'Windows x64' }, + { name: 'linux-x64', runner: 'ubuntu-24.04', target: 'bun-linux-x64', binary: 'java-run', label: 'Linux x64' }, + { name: 'linux-arm64', runner: 'ubuntu-24.04-arm', target: 'bun-linux-arm64', binary: 'java-run', label: 'Linux ARM64' }, + { name: 'darwin-arm64', runner: 'macos-15', target: 'bun-darwin-arm64', binary: 'java-run', label: 'macOS Apple Silicon' }, + { name: 'darwin-x64', runner: 'macos-15-intel', target: 'bun-darwin-x64', binary: 'java-run', label: 'macOS Intel' }, +] as const; + +/** 编译、发布矩阵、归档和下载说明共用的平台清单 */ +export const platforms = definitions.map(platform => ({ + ...platform, + folder: `java-run-${platform.name}`, + archive: `java-run-${platform.name}.zip`, +})); + +/** 支持的 Bun 编译目标 */ +export type BuildTarget = typeof platforms[number]['target']; + +/** 查找发布平台,未知名称时拒绝继续构建或打包 */ +export function platformFor(name: string) { + const platform = platforms.find(platform => platform.name === name); + if (!platform) throw new Error(`不支持的发布平台:${name}`); + return platform; +} diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..d26cc6f --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,255 @@ +import { appendFileSync, readFileSync } from 'node:fs'; +import { chmod, copyFile, lstat, mkdir, mkdtemp, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { platforms, platformFor } from './platforms'; +import { sha256, verifyBinaryChecksum } from './checksum'; + +const projectRoot = resolve(import.meta.dir, '..'); +const licenseNames = ['LICENSE', 'LICENSE.md', 'LICENSE.txt']; +const repository = 'https://github.com/Vanisper/java-run'; + +/** 发布版本、预发布标识与需要原生验收的文件集合 */ +export interface ReleaseMetadata { + version: string; + tag: string; + prerelease: boolean; + matrix: { include: { name: string; runner: string; target: string; binary: string; archive: string }[] }; +} + +/** 校验 SemVer 和可选标签,生成唯一的发布文件清单 */ +export function releaseMetadata(version: string, requestedTag?: string): ReleaseMetadata { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(version); + if (!match || match[0] !== version || match[4]?.split('.').some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0'))) { + throw new Error(`无效的发布版本:${version}`); + } + const tag = `v${version}`; + if (requestedTag !== undefined && requestedTag !== tag) { + throw new Error(`发布标签 ${requestedTag} 与 package.json 版本不一致,应为 ${tag}`); + } + return { + version, tag, prerelease: match[4] !== undefined, + matrix: { include: platforms.map(platform => ({ + name: platform.name, runner: platform.runner, target: platform.target, + binary: platform.binary, archive: platform.archive, + })) }, + }; +} + +/** 为当前版本生成固定下载链接、安装要求和升级说明 */ +export function releaseNotes(version: string): string { + const { tag } = releaseMetadata(version); + const download = `${repository}/releases/download/${encodeURIComponent(tag)}`; + const rows = platforms.map(platform => `| ${platform.label} | [${platform.archive}](${download}/${platform.archive}) |`); + return `## 下载与安装 + +下载对应系统与处理器的 ZIP 后解压,无需安装 Bun。 + +运行环境: + +- 满足 Java 项目要求的 JDK +- 项目的 Maven / Gradle Wrapper,或本机安装的对应构建工具 + +| 平台 | 下载 | +| --- | --- | +${rows.join('\n')} + +每个 ZIP 包含 \`java-run-<平台>/\` 目录。 +目录内提供 \`java-run\`(Windows 为 \`java-run.exe\`)、同名 \`.sha256\` 校验文件、\`INSTALL.md\` 和项目许可证。 + +[SHA256 校验和](${download}/SHA256SUMS) · [安装指南](${repository}/blob/${encodeURIComponent(tag)}/docs/installation.md) + +- 包外的 \`SHA256SUMS\` 用于校验 ZIP +- 包内的 \`.sha256\` 文件用于校验解压后的二进制 + +解压后,在该目录检查版本: + +- macOS / Linux:\`./java-run --version\` +- Windows PowerShell:\`.\\java-run.exe --version\` + +输出应为 \`java-run ${version}\`。 +按安装指南加入 PATH 后,即可在 Java 项目目录使用 \`java-run\`。 + +升级时下载新版本的对应 ZIP,校验后用其中的可执行文件替换原文件,再执行 \`java-run --version\` 确认版本。 +`; +} + +async function assertFile(path: string): Promise { + const stat = await lstat(path); + if (!stat.isFile() || stat.size === 0) throw new Error(`文件不是非空普通文件:${path}`); +} + +async function packageDocuments(documentationRoot: string): Promise<{ name: string; source: string }[]> { + const files = [{ name: 'INSTALL.md', source: resolve(documentationRoot, 'docs/installation.md') }]; + for (const name of licenseNames) { + const source = resolve(documentationRoot, name); + try { await lstat(source); files.push({ name, source }); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + } + for (const file of files) await assertFile(file.source); + return files; +} + +function command(program: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): string { + const result = spawnSync(program, args, { ...options, encoding: 'utf8' }); + if (result.error || result.status !== 0) { + throw new Error(`ZIP 操作失败:${result.error?.message ?? (result.stderr.trim() || program)}`); + } + return result.stdout; +} + +function powershell(script: string, environment: NodeJS.ProcessEnv): string { + return command('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `$ErrorActionPreference = 'Stop'; ${script}`], { + env: { ...process.env, ...environment }, + }); +} + +/** 校验并打包当前平台文件,ZIP 内包含二进制校验和、安装指南及已有许可证 */ +export async function packageRelease(directory: string, platformName: string, documentationRoot = projectRoot): Promise { + const platform = platformFor(platformName); + const binary = resolve(directory, platform.binary); + await assertFile(binary); + const checksum = await verifyBinaryChecksum(binary); + const documents = await packageDocuments(documentationRoot); + // 与产物共用文件系统,避免 Windows runner 的跨盘 rename 失败 + const temporary = await mkdtemp(resolve(directory, '.java-run-package-')); + try { + const folder = join(temporary, platform.folder); + await mkdir(folder); + const stagedBinary = join(folder, platform.binary); + await copyFile(binary, stagedBinary); + await copyFile(checksum, `${stagedBinary}.sha256`); + await verifyBinaryChecksum(stagedBinary); + if (process.platform !== 'win32') await chmod(stagedBinary, 0o755); + for (const file of documents) await copyFile(file.source, join(folder, file.name)); + const archive = join(temporary, platform.archive); + if (process.platform === 'win32') { + powershell('Compress-Archive -LiteralPath $env:JAVA_RUN_PACKAGE_SOURCE -DestinationPath $env:JAVA_RUN_PACKAGE_ARCHIVE -CompressionLevel Optimal', { + JAVA_RUN_PACKAGE_SOURCE: folder, JAVA_RUN_PACKAGE_ARCHIVE: archive, + }); + } else command('zip', ['-q', '-r', archive, platform.folder], { cwd: temporary }); + const output = resolve(directory, platform.archive); + await rename(archive, output); + return output; + } finally { await rm(temporary, { recursive: true, force: true }); } +} + +/** 解包并核对完整文件集合、内容与可执行权限,调用方用完后须 cleanup */ +export async function verifyReleasePackage(directory: string, platformName: string, documentationRoot = projectRoot): Promise<{ binary: string; cleanup: () => Promise }> { + const platform = platformFor(platformName); + const archive = resolve(directory, platform.archive); + await assertFile(archive); + const documents = await packageDocuments(documentationRoot); + const checksumName = `${platform.binary}.sha256`; + const expected = [platform.binary, checksumName, ...documents.map(file => file.name)].map(name => `${platform.folder}/${name}`).sort(); + const entries: string[] = process.platform === 'win32' + ? JSON.parse(powershell("Add-Type -AssemblyName System.IO.Compression.FileSystem; $packageZip = [System.IO.Compression.ZipFile]::OpenRead($env:JAVA_RUN_PACKAGE_ARCHIVE); try { ConvertTo-Json -InputObject @($packageZip.Entries | ForEach-Object { $_.FullName }) -Compress } finally { $packageZip.Dispose() }", { JAVA_RUN_PACKAGE_ARCHIVE: archive })) + : command('unzip', ['-Z1', archive]).trimEnd().split('\n'); + const normalized = entries.map(entry => entry.replaceAll('\\', '/')); + if (new Set(normalized).size !== normalized.length + || JSON.stringify(normalized.filter(entry => entry !== `${platform.folder}/`).sort()) !== JSON.stringify(expected)) { + throw new Error('ZIP 内容与发布目录契约不一致'); + } + const temporary = await mkdtemp(join(tmpdir(), 'java-run-unpack-')); + const cleanup = async () => { await rm(temporary, { recursive: true, force: true }); }; + try { + if (process.platform === 'win32') { + powershell('Expand-Archive -LiteralPath $env:JAVA_RUN_PACKAGE_ARCHIVE -DestinationPath $env:JAVA_RUN_PACKAGE_DESTINATION', { + JAVA_RUN_PACKAGE_ARCHIVE: archive, JAVA_RUN_PACKAGE_DESTINATION: temporary, + }); + } else command('unzip', ['-q', archive, '-d', temporary]); + const folder = join(temporary, platform.folder); + if (!(await lstat(folder)).isDirectory()) throw new Error('ZIP 顶层必须为发布目录'); + const binary = join(folder, platform.binary); + for (const file of [ + { name: platform.binary, source: resolve(directory, platform.binary) }, + { name: checksumName, source: resolve(directory, checksumName) }, + ...documents, + ]) { + const unpacked = join(folder, file.name); + await assertFile(unpacked); + await assertFile(file.source); + if (await sha256(unpacked) !== await sha256(file.source)) throw new Error(`ZIP 文件内容不一致:${file.name}`); + } + await verifyBinaryChecksum(binary); + if (process.platform !== 'win32' && ((await lstat(binary)).mode & 0o111) !== 0o111) { + throw new Error('ZIP 中的 java-run 未保留可执行权限'); + } + return { binary, cleanup }; + } catch (error) { await cleanup(); throw error; } +} + +/** 公开前核对远端资产,确保草稿只包含已验收文件与校验和 */ +export function assertReleaseAssets(assets: unknown, version: string): void { + const expected = [...releaseMetadata(version).matrix.include.map(platform => platform.archive), 'SHA256SUMS'].sort(); + if (!Array.isArray(assets) || !assets.every(asset => typeof asset === 'string') + || JSON.stringify([...assets].sort()) !== JSON.stringify(expected)) { + throw new Error('Release 资产集合与已验收文件不一致,请检查草稿中的缺失或额外资产'); + } +} + +/** + * 为完整的已验收产物集合生成 SHA256SUMS + * + * @description 缺失、多余、空文件或非普通文件均失败;允许重新生成已有清单,校验通过前不覆盖它 + */ +export async function writeReleaseChecksums(directory: string, version: string): Promise { + const expected = releaseMetadata(version).matrix.include.map(platform => platform.archive).sort(); + const actual = (await readdir(directory)).filter(file => file !== 'SHA256SUMS').sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`发布产物集合不完整或包含额外文件;应为:${expected.join('、')}`); + } + const lines: string[] = []; + for (const file of expected) { + const path = resolve(directory, file); + const stat = await lstat(path); + if (!stat.isFile() || stat.size === 0) throw new Error(`发布产物不是非空普通文件:${file}`); + lines.push(`${await sha256(path)} ${file}`); + } + const manifest = `${lines.join('\n')}\n`; + await writeFile(resolve(directory, 'SHA256SUMS'), manifest); + return manifest; +} + +async function main(argv: string[]): Promise { + const version: unknown = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')).version; + if (typeof version !== 'string') throw new Error('package.json 必须包含版本字符串'); + if (argv.length !== (argv[0] === 'package' ? 2 : 1)) throw new Error('用法:bun scripts/release.ts metadata | notes | checksums | verify-assets | package <平台>'); + if (argv[0] === 'metadata') { + const metadata = releaseMetadata(version, process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : undefined); + if (process.env.GITHUB_REF_TYPE === 'tag') { + if (!licenseNames.some(file => { + try { return readFileSync(resolve(projectRoot, file), 'utf8').trim().length > 0; } catch { return false; } + })) throw new Error('正式发布需要先补齐 LICENSE 文件'); + } + console.log(JSON.stringify(metadata, null, 2)); + if (process.env.GITHUB_OUTPUT) { + const outputs = Object.entries(metadata).map(([key, value]) => `${key}=${typeof value === 'object' ? JSON.stringify(value) : value}`); + appendFileSync(process.env.GITHUB_OUTPUT, `${outputs.join('\n')}\n`); + } + } else if (argv[0] === 'notes') { + console.log(releaseNotes(version)); + } else if (argv[0] === 'package') { + const directory = resolve(projectRoot, 'dist'); + await packageRelease(directory, argv[1]!); + const unpacked = await verifyReleasePackage(directory, argv[1]!); + try { + const result = spawnSync(process.execPath, [resolve(projectRoot, 'scripts/smoke.ts'), `--cli=${unpacked.binary}`, '--suite=quick'], { stdio: 'inherit' }); + if (result.error || result.status !== 0) throw new Error(`解包后的启动验收失败:${result.error?.message ?? result.status}`); + } finally { await unpacked.cleanup(); } + } else if (argv[0] === 'checksums') { + console.log(await writeReleaseChecksums(resolve(projectRoot, 'dist'), version)); + } else if (argv[0] === 'verify-assets') { + assertReleaseAssets(await Bun.stdin.json(), version); + console.log('Release 资产集合已核对'); + } else throw new Error(`未知发布步骤:${argv[0]}`); +} + +if (import.meta.main) { + try { await main(Bun.argv.slice(2)); } + catch (error) { + console.error(`java-run:${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/smoke.ts b/scripts/smoke.ts new file mode 100644 index 0000000..a2af1c4 --- /dev/null +++ b/scripts/smoke.ts @@ -0,0 +1,374 @@ +import { spawn } from 'node:child_process'; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { assertJavaArguments, JavaArgumentEncodingError, readJavaNativeEncoding } from '../src/process/java-arguments'; + +type Fixture = 'boot-single' | 'boot-reactor' | 'plain' | 'gradle-reactor'; +type Suite = 'quick' | 'full'; + +interface SmokeOptions { + executable: string; + suite: Suite; + fixture?: Fixture; + keep: boolean; +} + +interface ProcessResult { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + timedOut: boolean; +} + +interface Expectations { + code?: number; + stdout?: string[]; + stdoutExact?: string; + stderr?: string[]; + absent?: string[]; +} + +const projectRoot = path.resolve(import.meta.dir, '..'); +const fixtureNames: Fixture[] = ['boot-single', 'boot-reactor', 'plain', 'gradle-reactor']; + +function parseOptions(): SmokeOptions { + let executable = path.join(projectRoot, 'dist', process.platform === 'win32' ? 'java-run.exe' : 'java-run'); + let suite = process.env.JAVA_RUN_SMOKE_SUITE ?? 'full'; + let fixture = process.env.JAVA_RUN_SMOKE_FIXTURE; + let keep = process.env.JAVA_RUN_SMOKE_KEEP === '1'; + let positional = false; + for (const arg of process.argv.slice(2)) { + if (arg.startsWith('--cli=')) executable = path.resolve(arg.slice('--cli='.length)); + else if (arg.startsWith('--suite=')) suite = arg.slice('--suite='.length); + else if (arg.startsWith('--fixture=')) fixture = arg.slice('--fixture='.length); + else if (arg === '--keep') keep = true; + else if (!arg.startsWith('-') && !positional) { + executable = path.resolve(arg); + positional = true; + } else throw new Error(`未知 smoke 参数: ${arg}`); + } + if (suite !== 'quick' && suite !== 'full') throw new Error('--suite 只支持 quick 或 full'); + if (fixture && !fixtureNames.includes(fixture as Fixture)) { + throw new Error(`--fixture 只支持 ${fixtureNames.join('、')}`); + } + return { executable, suite, fixture: fixture as Fixture | undefined, keep }; +} + +/** 从二进制的环境中移除当前 Bun 安装目录,验证其可独立运行 */ +function binaryEnvironment(): NodeJS.ProcessEnv { + const env = { ...process.env }; + const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path') ?? 'PATH'; + const bunDirectory = path.dirname(process.execPath); + const normalize = (value: string) => process.platform === 'win32' ? path.resolve(value).toLowerCase() : path.resolve(value); + env[pathKey] = (env[pathKey] ?? '').split(path.delimiter) + .filter(value => value && normalize(value) !== normalize(bunDirectory)).join(path.delimiter); + return env; +} + +async function invoke(executable: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let timedOut = false; + let forceTimer: ReturnType | undefined; + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGINT'); + forceTimer = setTimeout(() => child.kill('SIGKILL'), 5_000); + }, 600_000); + child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk))); + child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk))); + child.on('error', error => { + clearTimeout(timer); + if (forceTimer) clearTimeout(forceTimer); + reject(error); + }); + child.on('close', (code, signal) => { + clearTimeout(timer); + if (forceTimer) clearTimeout(forceTimer); + resolve({ code, signal, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), timedOut }); + }); + }); +} + +async function exists(file: string): Promise { + try { + await stat(file); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + +/** 检查静态预览没有生成构建工具输出或项目缓存 */ +async function assertUnbuilt(directory: string): Promise { + for (const name of ['target', 'build', '.gradle', '.cache']) { + if (await exists(path.join(directory, name))) throw new Error(`静态预览生成了 ${name}`); + } + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isDirectory()) await assertUnbuilt(path.join(directory, entry.name)); + } +} + +async function main(): Promise { + const options = parseOptions(); + if (!await exists(options.executable)) throw new Error(`二进制不存在: ${options.executable},请先运行 bun run compile`); + const workspace = await mkdtemp(path.join(os.tmpdir(), 'java-run-smoke-')); + const repository = path.resolve(process.env.JAVA_RUN_MAVEN_REPOSITORY ?? path.join(workspace, 'repository')); + const gradleHome = path.resolve(process.env.JAVA_RUN_GRADLE_HOME ?? path.join(workspace, 'gradle-home')); + const settings = path.join(workspace, 'settings.xml'); + const logDirectory = path.join(workspace, 'logs'); + const env = binaryEnvironment(); + env.GRADLE_USER_HOME = gradleHome; + const projects = new Map(); + let failed = true; + let checks = 0; + + try { + await mkdir(repository, { recursive: true }); + await mkdir(gradleHome, { recursive: true }); + await mkdir(logDirectory); + const java = process.env.JAVA_HOME ? path.join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') : 'java'; + const nativeEncoding = await readJavaNativeEncoding(java, workspace); + let unicodeArguments = true; + try { + await assertJavaArguments(java, ['hello world #中文%'], workspace); + } catch (error) { + if (!(error instanceof JavaArgumentEncodingError)) throw error; + unicodeArguments = false; + } + const specialValue = unicodeArguments ? 'hello world #中文%' : 'hello world #%'; + const configValue = unicodeArguments ? 'config value #中文%' : 'config value #%'; + console.log(`Java native encoding: ${nativeEncoding}; Unicode argv: ${unicodeArguments ? 'supported' : 'explicit rejection required'}`); + await writeFile(settings, '\n'); + for (const fixture of fixtureNames) { + const destination = path.join(workspace, `${fixture} 空格#中文%`); + await cp(path.join(projectRoot, 'tests', 'fixtures', fixture), destination, { + recursive: true, + filter: source => !['target', 'build', '.gradle', '.cache'].includes(path.basename(source)), + }); + projects.set(fixture, destination); + } + const plainMavenConfiguration = path.join(projects.get('plain')!, '.mvn'); + await mkdir(plainMavenConfiguration); + await writeFile(path.join(plainMavenConfiguration, 'maven.config'), '-Dfixture.maven.config=project-root\n'); + + const check = async (name: string, args: string[], expected: Expectations = {}, cwd = workspace) => { + const started = Date.now(); + const result = await invoke(options.executable, args, cwd, env); + await writeFile(path.join(logDirectory, `${name}.stdout.log`), result.stdout); + await writeFile(path.join(logDirectory, `${name}.stderr.log`), result.stderr); + const violations: string[] = []; + if (result.timedOut) violations.push('执行超时'); + if (result.code !== (expected.code ?? 0)) violations.push(`退出码应为 ${expected.code ?? 0},实际为 ${result.code} (${result.signal ?? 'no signal'})`); + if (expected.stdoutExact !== undefined && result.stdout !== expected.stdoutExact) { + violations.push(`stdout 应为 ${JSON.stringify(expected.stdoutExact)},实际为 ${JSON.stringify(result.stdout)}`); + } + for (const marker of expected.stdout ?? []) { + if (!result.stdout.includes(marker)) violations.push(`缺少 stdout 标记: ${marker}`); + } + for (const marker of expected.stderr ?? []) { + if (!result.stderr.includes(marker)) violations.push(`缺少 stderr 标记: ${marker}`); + } + for (const marker of expected.absent ?? []) { + if ((result.stdout + result.stderr).includes(marker)) violations.push(`出现禁止标记: ${marker}`); + } + if (violations.length) { + console.error(`FAIL ${name}:\n${violations.join('\n')}`); + console.error(`stdout:\n${result.stdout.slice(-16_000)}\nstderr:\n${result.stderr.slice(-16_000)}`); + throw new Error(`${name} 未通过;完整输出: ${logDirectory}`); + } + checks++; + console.log(`PASS ${name} (${((Date.now() - started) / 1000).toFixed(1)}s)`); + return result; + }; + const mavenBuildArguments = ['-s', settings, `-Dmaven.repo.local=${repository}`]; + const argumentsFor = (fixture: Fixture, ...extra: string[]) => [ + 'run', `--cwd=${projects.get(fixture)!}`, + ...(fixture === 'gradle-reactor' + ? [`--build-command=${process.env.JAVA_RUN_GRADLE_COMMAND ?? 'gradle'}`] + : mavenBuildArguments.map(arg => `--build-arg=${arg}`)), + ...extra, + ]; + const commonArguments = ['--jvm-arg=-Dspring.profiles.active=smoke', `--jvm-arg=-Dfixture.jvm=${specialValue}`, `--arg=--message=${specialValue}`, '--arg=second']; + const commonMarkers = [`[fixture] jvm-value=${specialValue}`, `[fixture] arg=--message=${specialValue}`, '[fixture] arg=second']; + const mavenMarkers = ['[fixture] spring-profile=smoke', '[fixture] maven-profile=ci', ...commonMarkers]; + const withoutTests = ['[fixture] test-dependency=absent', '[fixture] test-class=absent']; + const withTests = ['[fixture] test-dependency=present', '[fixture] test-class=present']; + const selected = (fixture: Fixture) => !options.fixture || options.fixture === fixture; + + await check('help', ['help'], { stdout: ['java-run'] }); + const packageVersion = (JSON.parse(await readFile(path.join(projectRoot, 'package.json'), 'utf8')) as { version: string }).version; + await check('version', ['version'], { stdoutExact: `java-run ${packageVersion}\n` }); + const previewFixture = options.fixture ?? 'boot-reactor'; + const previewArgs = argumentsFor(previewFixture, ...(['boot-reactor', 'gradle-reactor'].includes(previewFixture) ? ['--module=app'] : [])) + .filter(arg => !arg.startsWith('--build-command=')); + previewArgs.push(`--build-command=${path.join(workspace, 'missing-build-tool')}`); + previewArgs[0] = 'plan'; + await check('plan', previewArgs, { stdout: ['missing-build-tool'], absent: ['[fixture]'] }); + for (const directory of projects.values()) await assertUnbuilt(directory); + + if (selected('boot-single')) { + await check('boot-single', argumentsFor('boot-single', '--build-arg=-Pci', ...commonArguments), { + stdout: ['[fixture] kind=boot-single', ...mavenMarkers, ...withoutTests], + }); + if (options.suite === 'full') { + await check('boot-single-tests', argumentsFor('boot-single', '--include-tests'), { + stdout: ['[fixture] kind=boot-single', ...withTests], + }); + } + } + + if (selected('boot-reactor')) { + await check('boot-reactor', argumentsFor('boot-reactor', '--module=app', '--build-arg=-Pci', ...commonArguments), { + stdout: ['[fixture] kind=boot-reactor-app', '[fixture] library=library-v1', '[fixture] library-version=1.0.0', ...mavenMarkers, ...withoutTests], + absent: ['FORBIDDEN_OTHER_APP'], + }); + if (await exists(path.join(projects.get('boot-reactor')!, 'other-app', 'target'))) { + throw new Error('Maven auto 准备构建了无关的 other-app'); + } + if (options.suite === 'full') { + await check('boot-reactor-tests', argumentsFor('boot-reactor', '--module=:app', '--include-tests'), { + stdout: ['[fixture] kind=boot-reactor-app', ...withTests], + absent: ['FORBIDDEN_OTHER_APP'], + }); + const pom = path.join(projects.get('boot-reactor')!, 'pom.xml'); + await writeFile(pom, (await readFile(pom, 'utf8')).replace('library-v1', 'library-v2')); + await check('boot-reactor-pom-update', argumentsFor('boot-reactor', '--module=app'), { + stdout: ['[fixture] library=library-v2', ...withoutTests], + absent: ['FORBIDDEN_OTHER_APP'], + }); + } + } + + if (selected('plain')) { + const directArguments = ['--main=org.javarun.fixture.PlainApplication']; + await check('plain', argumentsFor('plain', ...directArguments, '--build-arg=-Pci', ...commonArguments), { + stdout: ['[fixture] kind=plain', '[fixture] dependency-version=2.18.0', '[fixture] maven-config=project-root', + '[fixture] maven-root-absolute=true', '[fixture] maven-root-config=present', ...mavenMarkers, ...withoutTests], + }); + if (!unicodeArguments) { + const unsupportedValue = 'token-secret-中文'; + for (const [name, argument] of [ + ['plain-unrepresentable-application-argument', `--arg=${unsupportedValue}`], + ['plain-unrepresentable-jvm-argument', `--jvm-arg=-Dfixture.jvm=${unsupportedValue}`], + ]) { + await check(name!, argumentsFor('plain', ...directArguments, '--build=none', argument!), { + code: 1, + stderr: ['无法完整表示', '系统区域设置'], + absent: ['[fixture]', unsupportedValue], + }); + } + } + const configPath = path.join(projects.get('plain')!, '.java-run.json'); + const initArguments = argumentsFor('plain', '--build=none', `--jvm-arg=-Dfixture.jvm=${configValue}`, '--arg=--from-config'); + initArguments[0] = 'init'; + await check('plain-init', initArguments, { stderr: ['已保存'], absent: ['[fixture]'] }); + const initialized = JSON.parse(await readFile(configPath, 'utf8')); + if (initialized.buildTool !== 'maven' || initialized.mainClass !== 'org.javarun.fixture.PlainApplication' + || initialized.build !== 'none' || JSON.stringify(initialized.buildArgs) !== JSON.stringify(mavenBuildArguments) + || JSON.stringify(initialized.jvmArgs) !== JSON.stringify([`-Dfixture.jvm=${configValue}`]) + || JSON.stringify(initialized.applicationArgs) !== JSON.stringify(['--from-config'])) { + throw new Error('Maven init 未正确保存入口和参数'); + } + await check('project-config-default', [], { + stdout: ['[fixture] kind=plain', `[fixture] jvm-value=${configValue}`, '[fixture] arg=--from-config'], + }, projects.get('plain')!); + await check('init-existing-config', ['init', `--cwd=${projects.get('plain')!}`, `--build-command=${path.join(workspace, 'missing-build-tool')}`], { + code: 1, stderr: ['配置已存在', 'init --force'], absent: ['[fixture]'], + }); + if (options.suite === 'full') { + await check('project-config-override', ['--main=org.javarun.fixture.PlainApplication', '--jvm-arg=-Dfixture.jvm=cli value', '--arg=--from-cli'], { + stdout: ['[fixture] jvm-value=cli value', '[fixture] arg=--from-config', '[fixture] arg=--from-cli'], + }, projects.get('plain')!); + } + await rm(configPath); + if (options.suite === 'full') { + await rm(plainMavenConfiguration, { recursive: true }); + const ancestorConfiguration = path.join(workspace, '.mvn'); + await mkdir(ancestorConfiguration); + await writeFile(path.join(ancestorConfiguration, 'maven.config'), '-Dfixture.maven.config=ancestor-root\n'); + await check('plain-ancestor-maven-config', argumentsFor('plain', ...directArguments), { + stdout: ['[fixture] kind=plain', '[fixture] maven-config=ancestor-root', + '[fixture] maven-root-absolute=true', '[fixture] maven-root-config=present'], + }); + await check('plain-tests', argumentsFor('plain', ...directArguments, '--include-tests'), { + stdout: ['[fixture] kind=plain', ...withTests], + }); + await check('plain-test-isolation', argumentsFor('plain', ...directArguments, '--build=none'), { + stdout: ['[fixture] kind=plain', ...withoutTests], + }); + await check('plain-exit', argumentsFor('plain', ...directArguments, '--build=none', '--arg=--exit=7'), { + code: 7, + stdout: ['[fixture] kind=plain', '[fixture] arg=--exit=7'], + }); + const pom = path.join(projects.get('plain')!, 'pom.xml'); + await writeFile(pom, (await readFile(pom, 'utf8')).replace('2.18.0', '2.19.0')); + await check('plain-pom-update', argumentsFor('plain', ...directArguments, '--build=none'), { + stdout: ['[fixture] dependency-version=2.19.0', ...withoutTests], + }); + } + } + + if (selected('gradle-reactor')) { + await check('gradle-reactor', argumentsFor('gradle-reactor', '--module=app', '--build-arg=-PfixtureProfile=ci', ...commonArguments), { + stdout: ['[fixture] kind=gradle-reactor-app', '[fixture] library=gradle-library-v1', '[fixture] library-version=1.0.0', '[fixture] dependency-version=2.18.0', '[fixture] gradle-profile=ci', ...commonMarkers, ...withoutTests], + absent: ['FORBIDDEN_OTHER_APP'], + }); + if (await exists(path.join(projects.get('gradle-reactor')!, 'other-app', 'build'))) { + throw new Error('Gradle auto 准备构建了无关的 other-app'); + } + const configPath = path.join(projects.get('gradle-reactor')!, '.java-run.json'); + const initArguments = argumentsFor('gradle-reactor', '--module=:app', '--build=none', '--arg=--from-config'); + initArguments[0] = 'init'; + await check('gradle-init', initArguments, { stderr: ['已保存'], absent: ['[fixture]', 'FORBIDDEN_OTHER_APP'] }); + const initialized = JSON.parse(await readFile(configPath, 'utf8')); + if (initialized.buildTool !== 'gradle' || initialized.module !== ':app' + || initialized.mainClass !== 'org.javarun.fixture.GradleApplication' || initialized.build !== 'none' + || JSON.stringify(initialized.applicationArgs) !== JSON.stringify(['--from-config']) + || 'buildCommand' in initialized) { + throw new Error('Gradle init 未正确保存入口和参数'); + } + await check('gradle-config-default', [`--build-command=${process.env.JAVA_RUN_GRADLE_COMMAND ?? 'gradle'}`], { + stdout: ['[fixture] kind=gradle-reactor-app', '[fixture] arg=--from-config'], absent: ['FORBIDDEN_OTHER_APP'], + }, projects.get('gradle-reactor')!); + await rm(configPath); + if (options.suite === 'full') { + await check('gradle-reactor-tests', argumentsFor('gradle-reactor', '--module=:app', '--include-tests'), { + stdout: ['[fixture] kind=gradle-reactor-app', ...withTests], + absent: ['FORBIDDEN_OTHER_APP'], + }); + await check('gradle-test-isolation', argumentsFor('gradle-reactor', '--module=app', '--build=none'), { + stdout: ['[fixture] kind=gradle-reactor-app', ...withoutTests], + absent: ['FORBIDDEN_OTHER_APP'], + }); + const libraryBuild = path.join(projects.get('gradle-reactor')!, 'lib', 'build.gradle'); + await writeFile(libraryBuild, (await readFile(libraryBuild, 'utf8')).replaceAll('gradle-library-v1', 'gradle-library-v2')); + await check('gradle-resource-update', argumentsFor('gradle-reactor', '--module=app'), { + stdout: ['[fixture] library=gradle-library-v2', ...withoutTests], + absent: ['FORBIDDEN_OTHER_APP'], + }); + const applicationBuild = path.join(projects.get('gradle-reactor')!, 'app', 'build.gradle'); + await writeFile(applicationBuild, (await readFile(applicationBuild, 'utf8')).replace('commons-io:commons-io:2.18.0', 'commons-io:commons-io:2.19.0')); + await check('gradle-dependency-update', argumentsFor('gradle-reactor', '--module=app', '--build=none'), { + stdout: ['[fixture] dependency-version=2.19.0', ...withoutTests], + absent: ['FORBIDDEN_OTHER_APP'], + }); + } + } + failed = false; + console.log(`已通过 ${checks} 项原生二进制 smoke (${options.suite}, ${process.platform}/${process.arch})`); + } finally { + if (failed || options.keep) console.log(`保留 smoke 临时目录: ${workspace}`); + else await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 }); + } +} + +await main().catch(error => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/src/build-tools/detect.ts b/src/build-tools/detect.ts new file mode 100644 index 0000000..3f3e93d --- /dev/null +++ b/src/build-tools/detect.ts @@ -0,0 +1,16 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { RunConfig } from '../core/types'; + +/** 根据构建文件选择适配器,双工具工作区必须显式选择 */ +export function detectBuildTool(config: RunConfig): 'maven' | 'gradle' { + const hasMaven = existsSync(join(config.cwd, 'pom.xml')); + const hasGradle = ['settings.gradle', 'settings.gradle.kts', 'build.gradle', 'build.gradle.kts'].some(name => existsSync(join(config.cwd, name))); + if (config.buildTool === 'maven' && !hasMaven) throw new Error(`目录中没有 pom.xml:${config.cwd}`); + if (config.buildTool === 'gradle' && !hasGradle) throw new Error(`目录中没有 Gradle 构建文件:${config.cwd}`); + if (config.buildTool !== 'auto') return config.buildTool; + if (hasMaven && hasGradle) throw new Error('目录同时包含 Maven 和 Gradle 构建,请通过 --tool 指定使用的工具'); + if (hasMaven) return 'maven'; + if (hasGradle) return 'gradle'; + throw new Error(`没有发现 Maven 或 Gradle 项目:${config.cwd},请使用 --cwd 指定源码工作区`); +} diff --git a/src/build-tools/gradle.ts b/src/build-tools/gradle.ts new file mode 100644 index 0000000..ac95e03 --- /dev/null +++ b/src/build-tools/gradle.ts @@ -0,0 +1,280 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { CommandError, runCommand } from '../process/exec'; +import { assertJavaArguments } from '../process/java-arguments'; +import type { BuildPlan, CommandSpec, PreparedProject, RunConfig } from '../core/types'; + +const projectFiles = ['settings.gradle', 'settings.gradle.kts', 'build.gradle', 'build.gradle.kts']; +const simpleBuildArguments = new Set([ + '--offline', '--refresh-dependencies', '--stacktrace', '--full-stacktrace', + '--info', '--debug', '--quiet', '-q', '-i', '-d', '-s', '-S', + '--build-cache', '--no-build-cache', '--parallel', '--no-parallel', + '--no-watch-fs', '--watch-fs', +]); + +function targetPath(module: string | undefined): string { + if (!module || module === ':') return ':'; + const value = module.replace(/^\.\//, '').replace(/\\/g, '/').replace(/^:/, ''); + const segments = value.split(/[/:]/); + if (segments.some(segment => !segment || segment === '.' || segment === '..' || /[,\r\n\0]/.test(segment))) { + throw new Error('--module 必须是单个 Gradle 项目路径,例如 :apps:admin-server 或 app/sub'); + } + return `:${segments.join(':')}`; +} + +function validateBuildArguments(args: string[]): void { + for (const argument of args) { + const property = /^(?:-D|-P|--system-prop=|--project-prop=)(.+)$/.exec(argument)?.[1]; + if (property && !property.startsWith('javaRun.')) continue; + if (simpleBuildArguments.has(argument)) continue; + if (/^--(?:warning-mode=(?:all|fail|summary|none)|max-workers=\d+|priority=(?:normal|low))$/.test(argument)) continue; + throw new Error(`不支持的 Gradle --build-arg:${argument};只接受构建属性和日志、依赖选项,不能改变项目、init script 或任务列表`); + } +} + +function gradleCommand(config: RunConfig): string { + if (config.buildCommand) return config.buildCommand; + const wrapper = join(config.cwd, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'); + return existsSync(wrapper) ? wrapper : 'gradle'; +} + +function metadataPaths(workspace: string) { + const directory = resolve(workspace); + return { + script: join(directory, 'gradle-init.gradle'), + output: join(directory, 'gradle-project.json'), + task: `javaRunMetadata_${createHash('sha256').update(directory).digest('hex').slice(0, 16)}`, + }; +} + +function commandSpecification(config: RunConfig, workspace: string, discovery = false): CommandSpec { + if (!projectFiles.some(file => existsSync(join(config.cwd, file)))) { + throw new Error(`找不到 Gradle settings 或 build 文件:${config.cwd}`); + } + validateBuildArguments(config.buildArgs); + if (!discovery) targetPath(config.module); + const paths = metadataPaths(workspace); + const task = discovery ? `${paths.task}_discover` : paths.task; + const build = discovery ? 'none' : config.build; + const args = [ + '--no-daemon', '--console=plain', '--no-configuration-cache', '-I', relative(config.cwd, paths.script), + ...config.buildArgs, + `-DjavaRun.metadataTask=${task}`, + `-DjavaRun.build=${build}`, + `-DjavaRun.includeTests=${discovery ? false : config.includeTests}`, + `-DjavaRun.discover=${discovery}`, + ]; + if (build === 'none') args.push('-Porg.gradle.java.installations.auto-download=false'); + args.push(`:${task}`); + return { command: gradleCommand(config), args, cwd: config.cwd, stage: discovery ? 'Gradle 项目选择' : 'Gradle 项目准备' }; +} + +/** + * 预览单个 Gradle 项目的准备命令 + * + * @description 只读取本地文件,workspace 是临时元数据目录;不执行 Gradle 或创建文件 + */ +export function planGradle(config: RunConfig, workspace: string): BuildPlan { + return { + tool: 'gradle', + commands: [commandSpecification(config, workspace)], + notes: [ + `目标 Gradle 项目:${targetPath(config.module)}`, + config.build === 'auto' + ? 'Gradle 任务图准备目标项目和运行依赖,不执行测试' + : '不构建源码,要求目标项目和运行依赖已有可用产物', + '主类、运行类路径和 Java 工具链将在执行 Gradle 后解析', + ], + }; +} + +const initScript = String.raw` +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import org.gradle.api.GradleException +import org.gradle.api.plugins.JavaApplication +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.jvm.toolchain.JavaToolchainService + +def javaRunRequest = new JsonSlurper().parseText(new String('@JAVA_RUN_REQUEST@'.decodeBase64(), 'UTF-8')) + +gradle.projectsEvaluated { + // init script 也会进入辅助构建,元数据任务只注册到请求的主构建 + def requestedRoot = new File(javaRunRequest.root).canonicalFile + if (gradle.rootProject.projectDir.canonicalFile != requestedRoot) { + return + } + def taskName = System.getProperty('javaRun.metadataTask') + if (Boolean.parseBoolean(System.getProperty('javaRun.discover'))) { + gradle.rootProject.tasks.register(taskName) { + doLast { + def candidates = gradle.rootProject.allprojects.findAll { + it.extensions.findByType(JavaPluginExtension) != null + }.sort { a, b -> a.path <=> b.path }.collect { + [value: it.path, label: it.path + '(入口待解析)'] + } + def output = new File(javaRunRequest.output) + output.parentFile.mkdirs() + output.setText(JsonOutput.toJson(candidates), 'UTF-8') + } + } + return + } + def requestedPath = javaRunRequest.target + def target = gradle.rootProject.findProject(requestedPath) + if (target == null) { + throw new GradleException("[JAVA_RUN:NO_PROJECT] Project not found; select an existing project with --module") + } + def javaExtension = target.extensions.findByType(JavaPluginExtension) + if (javaExtension == null) { + throw new GradleException("[JAVA_RUN:NO_JAVA_PLUGIN] Selected project has no Java plugin; select a Java project with --module") + } + def sourceSets = target.extensions.getByType(SourceSetContainer) + def includeTests = Boolean.parseBoolean(System.getProperty('javaRun.includeTests')) + def selected = sourceSets.getByName(includeTests ? 'test' : 'main') + def included = includeTests ? [sourceSets.getByName('test'), sourceSets.getByName('main')] : [selected] + def metadataTask = target.tasks.register(taskName) { + if (System.getProperty('javaRun.build') == 'auto') { + dependsOn selected.classesTaskName + dependsOn selected.runtimeClasspath.buildDependencies + } + doLast { + def allOutputs = included.collectMany { it.output.files as List }.toSet() + def classes = included.collectMany { it.output.classesDirs.files as List } + .findAll { it.isDirectory() }.collect { it.absolutePath }.unique() + if (classes.isEmpty()) { + throw new GradleException("[JAVA_RUN:NO_CLASSES] No compiled classes; prepare outputs or use --build=auto") + } + def runtime = selected.runtimeClasspath.files.findAll { it.exists() || !allOutputs.contains(it) } + .collect { it.absolutePath }.unique() + def toolchains = target.extensions.getByType(JavaToolchainService) + def launcher = toolchains.launcherFor(javaExtension.toolchain).get() + def application = target.extensions.findByType(JavaApplication) + if (application != null && application.mainModule.isPresent()) { + throw new GradleException("[JAVA_RUN:UNSUPPORTED_JPMS] JPMS mainModule is unsupported; use the project's native run task") + } + def mainClass = application == null ? null : application.mainClass.orNull + def jvmArgs = application == null ? [] : application.applicationDefaultJvmArgs.collect { it.toString() } + def output = new File(javaRunRequest.output) + output.parentFile.mkdirs() + output.setText(JsonOutput.toJson([ + directory: target.projectDir.absolutePath, + classesDirectories: classes, + runtimeClasspath: runtime, + javaCommand: launcher.executablePath.asFile.absolutePath, + mainClass: mainClass, + jvmArgs: jvmArgs + ]), 'UTF-8') + } + } + if (target != gradle.rootProject) { + // 根任务固定为 ASCII,目标模块的名称只从 UTF-8 请求读取 + gradle.rootProject.tasks.register(taskName) { + dependsOn metadataTask + } + } +} +`.trimStart(); + +function initializationScript(config: RunConfig, workspace: string, discovery = false): string { + const request = { + root: resolve(config.cwd), + target: discovery ? ':' : targetPath(config.module), + output: discovery ? join(resolve(workspace), 'gradle-projects.json') : metadataPaths(workspace).output, + }; + const encoded = Buffer.from(JSON.stringify(request), 'utf8').toString('base64'); + return initScript.replace('@JAVA_RUN_REQUEST@', encoded); +} + +async function executeGradle(spec: CommandSpec, config: RunConfig): Promise { + const java = process.env.JAVA_HOME + ? join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') + : 'java'; + await assertJavaArguments(java, spec.args, spec.cwd); + console.error(`java-run:${spec.stage}`); + const result = await runCommand(spec, { capture: true }); + if (result.exitCode !== 0) { + // JDK 17 的控制台编码可能丢失中文,受控失败只跨进程传递 ASCII 错误码 + const code = /\[JAVA_RUN:(NO_PROJECT|NO_JAVA_PLUGIN|NO_CLASSES|UNSUPPORTED_JPMS)\]/ + .exec(result.stderr + result.stdout)?.[1]; + const diagnostics: Record = { + NO_PROJECT: `找不到 Gradle 项目:${targetPath(config.module)},请用 --module 指定实际项目路径`, + NO_JAVA_PLUGIN: `选定项目 ${targetPath(config.module)} 没有 Java 插件,请用 --module 指定 Java 应用项目`, + NO_CLASSES: '选定项目没有已编译类;请准备产物或使用 --build=auto', + UNSUPPORTED_JPMS: '本版本不支持 JPMS mainModule;请使用项目的原生运行任务', + }; + throw new CommandError(spec, result, code ? new Error(diagnostics[code]) : undefined); + } +} + +function readProject(output: string): PreparedProject { + let metadata: unknown; + try { + metadata = JSON.parse(readFileSync(output, 'utf8')); + } catch (error) { + throw new Error('Gradle 未输出有效的项目元数据', { cause: error }); + } + if (!metadata || typeof metadata !== 'object') throw new Error('Gradle 项目元数据必须是对象'); + const values = metadata as Record; + const file = (value: unknown, name: string): string => { + if (typeof value !== 'string' || !isAbsolute(value)) throw new Error(`Gradle 元数据 ${name} 必须是绝对路径`); + return value; + }; + const files = (value: unknown, name: string): string[] => { + if (!Array.isArray(value)) throw new Error(`Gradle 元数据 ${name} 必须是路径数组`); + return [...new Set(value.map(item => file(item, name)))]; + }; + if (values.mainClass != null && typeof values.mainClass !== 'string') throw new Error('Gradle 元数据 mainClass 无效'); + if (!Array.isArray(values.jvmArgs) || values.jvmArgs.some(argument => typeof argument !== 'string')) { + throw new Error('Gradle 元数据 jvmArgs 必须是字符串数组'); + } + return { + directory: file(values.directory, 'directory'), + classesDirectories: files(values.classesDirectories, 'classesDirectories'), + classpath: files(values.runtimeClasspath, 'runtimeClasspath'), + javaCommand: file(values.javaCommand, 'javaCommand'), + mainClass: typeof values.mainClass === 'string' ? values.mainClass : undefined, + jvmArgs: values.jvmArgs as string[], + }; +} + +/** + * 由 Gradle 任务图准备目标项目并读取已裁决的运行元数据 + * + * @description 临时 init script 只写入 workspace,不修改项目构建脚本;构建失败保留退出码和完整输出 + */ +export async function prepareGradle(config: RunConfig, workspace: string): Promise { + const plan = planGradle(config, workspace); + const paths = metadataPaths(workspace); + mkdirSync(resolve(workspace), { recursive: true }); + writeFileSync(paths.script, initializationScript(config, workspace)); + rmSync(paths.output, { force: true }); + const spec = plan.commands[0]!; + await executeGradle(spec, config); + return readProject(paths.output); +} + +/** + * 列出可供交互选择的 Gradle Java 项目 + * + * @description + * - 执行项目配置和列表任务,构建逻辑可能需要准备 buildSrc 或 included builds + * - 不主动编译候选应用、解析其运行依赖或读取主类 Provider;候选仍需准备后确认入口 + */ +export async function discoverGradleProjects(config: RunConfig, workspace: string): Promise<{ value: string; label: string }[]> { + const spec = commandSpecification(config, workspace, true); + const paths = metadataPaths(workspace); + const output = join(resolve(workspace), 'gradle-projects.json'); + mkdirSync(resolve(workspace), { recursive: true }); + writeFileSync(paths.script, initializationScript(config, workspace, true)); + rmSync(output, { force: true }); + await executeGradle(spec, config); + const candidates: unknown = JSON.parse(readFileSync(output, 'utf8')); + if (!Array.isArray(candidates) || candidates.some(candidate => + !candidate || typeof candidate.value !== 'string' || !candidate.value.startsWith(':') || typeof candidate.label !== 'string')) { + throw new Error('Gradle 未输出有效的 Java 项目候选列表'); + } + return candidates as { value: string; label: string }[]; +} diff --git a/src/build-tools/maven.ts b/src/build-tools/maven.ts new file mode 100644 index 0000000..bda23f0 --- /dev/null +++ b/src/build-tools/maven.ts @@ -0,0 +1,261 @@ +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { parseStringPromise } from 'xml2js'; +import { CommandError, runCommand } from '../process/exec'; +import { assertJavaArguments, JavaArgumentEncodingError } from '../process/java-arguments'; +import type { BuildPlan, CommandSpec, PreparedProject, RunConfig } from '../core/types'; + +/** Maven 选定项目的有效元数据 */ +export interface MavenProject { + pomFile: string; + directory: string; + groupId: string; + artifactId: string; + version: string; + packaging: string; + outputDirectory: string; + testOutputDirectory: string; + mainClass?: string; +} + +const HELP_PLUGIN = 'org.apache.maven.plugins:maven-help-plugin:3.5.1'; +const DEPENDENCY_PLUGIN = 'org.apache.maven.plugins:maven-dependency-plugin:3.8.1'; +const windowsMavenVersions = new Map>(); +const baseDirectoryProperty = '-Dmaven.multiModuleProjectDirectory=${env.JAVA_RUN_MAVEN_BASE_DIRECTORY}'; + +/** 优先采用项目 Wrapper,存在时不回退到其他 Maven 版本 */ +function resolveMaven(config: RunConfig): string { + if (config.buildCommand) return config.buildCommand; + const wrapper = join(config.cwd, process.platform === 'win32' ? 'mvnw.cmd' : 'mvnw'); + return existsSync(wrapper) ? wrapper : process.platform === 'win32' ? 'mvn.cmd' : 'mvn'; +} + +function validateBuildArgs(args: string[]): void { + const flags = new Set(['-o', '--offline', '-U', '--update-snapshots', '-nsu', '--no-snapshot-updates', + '-e', '--errors', '-X', '--debug', '-q', '--quiet', '-C', '--strict-checksums', '-c', '--lax-checksums']); + const valueOptions = new Set(['-s', '--settings', '-gs', '--global-settings', '-t', '--toolchains', '-T', '--threads', '-P', '--activate-profiles']); + for (let index = 0; index < args.length; index++) { + const arg = args[index]!; + if (flags.has(arg)) continue; + if (arg.startsWith('-D') && arg.length > 2) { + const name = arg.slice(2).split('=', 1)[0]!; + if (!['output', 'outputEncoding', 'expression', 'includeScope', 'excludeScope', 'skipTests', 'maven.test.skip', 'maven.main.skip', 'maven.install.skip', 'maven.multiModuleProjectDirectory'].includes(name) + && !name.startsWith('mdep.') && !name.startsWith('exec.')) continue; + throw new Error(`构建属性 ${name} 由 java-run 管理,请使用对应的启动配置`); + } + const equal = arg.indexOf('='); + if (equal > 0 && valueOptions.has(arg.slice(0, equal)) && arg.slice(equal + 1)) continue; + if (/^-(?:P|T).+/.test(arg)) continue; + if (valueOptions.has(arg) && args[index + 1] && !args[index + 1]!.startsWith('-')) { + index++; + continue; + } + throw new Error(`不支持的 Maven --build-arg:${arg};只接受属性、profile、settings、toolchains 和构建选项,不能改变项目或目标列表`); + } +} + +function command(config: RunConfig, goals: string[], stage: string, alsoMake = false): CommandSpec { + validateBuildArgs(config.buildArgs); + const args = ['-B', '-ntp', ...config.buildArgs, '-f', 'pom.xml']; + if (config.module) args.push('-pl', config.module); + if (alsoMake && config.module) args.push('-am'); + args.push(...goals); + return { command: resolveMaven(config), args, cwd: config.cwd, stage }; +} + +/** + * 将 Maven 配置根转换为相对工作目录的路径 + * + * @description 显式配置优先,否则采用最近的 .mvn 祖先;找不到时采用工作目录,跨盘路径保持绝对形式 + */ +export function resolveMavenBaseDirectory(cwd: string, configuredBase?: string): string { + const directory = resolve(cwd); + let base = configuredBase ? resolve(directory, configuredBase) : directory; + if (!configuredBase) { + while (!existsSync(join(base, '.mvn'))) { + const parent = dirname(base); + if (parent === base) { + base = directory; + break; + } + base = parent; + } + } + return relative(directory, base) || '.'; +} + +async function executeMaven(spec: CommandSpec): Promise { + let env: NodeJS.ProcessEnv | undefined; + // Maven 对 Windows 子模块规范化路径,执行根也需展开 8.3 别名才能匹配相对模块选择器 + let execution = process.platform === 'win32' ? { ...spec, cwd: realpathSync.native(spec.cwd) } : spec; + let bridge = false; + const java = buildJava(); + await assertJavaArguments(java, execution.args, execution.cwd); + if (process.platform === 'win32') { + const configuredKey = Object.keys(process.env).find(name => name.toLowerCase() === 'maven_basedir'); + const configuredBase = resolveMavenBaseDirectory(execution.cwd, configuredKey ? process.env[configuredKey] : undefined); + const resolvedBase = resolve(execution.cwd, configuredBase); + const absoluteBase = existsSync(resolvedBase) ? realpathSync.native(resolvedBase) : resolvedBase; + const base = relative(execution.cwd, absoluteBase) || '.'; + try { + await assertJavaArguments(java, [`-Dmaven.multiModuleProjectDirectory=${absoluteBase}`], execution.cwd); + env = { MAVEN_BASEDIR: absoluteBase }; + } catch (error) { + if (!(error instanceof JavaArgumentEncodingError)) throw error; + await assertJavaArguments(java, [`-Dmaven.multiModuleProjectDirectory=${base}`], execution.cwd); + // 初始化先用可表示的相对根,Maven CLI 再从 Unicode 子环境还原模型中的绝对根 + env = { MAVEN_BASEDIR: base, JAVA_RUN_MAVEN_BASE_DIRECTORY: absoluteBase }; + execution = { ...execution, args: [...execution.args, baseDirectoryProperty] }; + bridge = true; + } + } + if (bridge) await validateWindowsMavenVersion(execution, env!); + console.error(`java-run:${spec.stage}`); + const result = await runCommand(execution, { capture: true, env }); + if (result.exitCode !== 0) throw new CommandError(execution, result); +} + +async function validateWindowsMavenVersion(spec: CommandSpec, env: NodeJS.ProcessEnv): Promise { + const key = `${spec.command}\0${spec.cwd}`; + let checked = windowsMavenVersions.get(key); + if (!checked) { + checked = (async () => { + const versionCommand = { ...spec, args: ['-B', '-ntp', '-version'], stage: '检测 Windows Maven 兼容性' }; + const result = await runCommand(versionCommand, { capture: true, env }); + if (result.exitCode !== 0) throw new CommandError(versionCommand, result); + const output = `${result.stdout}\n${result.stderr}`.replace(/\x1b\[[0-9;]*m/g, ''); + const version = /Apache Maven\s+(\d+)\.(\d+)\.(\d+)/.exec(output); + if (!version) throw new Error('无法确认 Maven 版本;Windows 需要 Maven 3.9.2 或更新版本以保留配置根的 Unicode 路径'); + const [, major, minor, patch] = version.map(Number); + if (major! < 3 || (major === 3 && (minor! < 9 || (minor === 9 && patch! < 2)))) { + throw new Error(`Windows 需要 Maven 3.9.2 或更新版本以保留配置根语义,实际版本为 ${version[1]}.${version[2]}.${version[3]}`); + } + })(); + windowsMavenVersions.set(key, checked); + } + await checked; +} + +/** 预览 Maven 的单目标准备与解析命令,不执行 Maven */ +export function planMaven(config: RunConfig, workspace: string): BuildPlan { + const commands: CommandSpec[] = []; + if (config.build === 'auto') { + const goal = config.module ? 'install' : config.includeTests ? 'test-compile' : 'compile'; + commands.push(command(config, [goal, '-DskipTests'], config.module ? '安装目标模块及上游依赖到本地仓库' : '编译目标项目', true)); + } + commands.push(command(config, [`${HELP_PLUGIN}:evaluate`, '-Dexpression=project.file', `-Doutput=${join(workspace, 'project-file.txt')}`, '-q'], '解析目标 POM')); + commands.push(command(config, [`${HELP_PLUGIN}:effective-pom`, `-Doutput=${join(workspace, 'effective-pom.xml')}`, '-q'], '读取 Maven 有效模型')); + commands.push(command(config, [`${DEPENDENCY_PLUGIN}:build-classpath`, `-DincludeScope=${config.includeTests ? 'test' : 'runtime'}`, + `-Dmdep.outputFile=${join(workspace, 'dependencies.txt')}`, '-DoutputEncoding=UTF-8', '-Dmdep.regenerateFile=true', '-q'], '解析目标运行依赖')); + return { + tool: 'maven', commands, + notes: [config.module && config.build === 'auto' ? '自动准备使用 Maven install,仅写本地仓库,不执行 deploy' : '构建与依赖模型由 Maven 处理', + '仅解析选定项目,不合并其他模块的类路径'], + }; +} + +async function validateRoot(config: RunConfig): Promise { + const file = join(config.cwd, 'pom.xml'); + if (!existsSync(file)) throw new Error(`找不到 Maven POM:${file}`); + const document = await parseStringPromise(readFileSync(file, 'utf8'), { explicitArray: false }); + if (!document.project) throw new Error(`无效的 Maven POM:${file}`); + if (!config.module && (document.project.packaging === 'pom' || document.project.modules)) { + throw new Error('聚合项目需要选择一个启动模块:请使用 --module=app 或在 .java-run.json 中配置 module'); + } +} + +/** 从 Maven 输出提取单个有效项目,不自行展开属性或继承 */ +export async function readEffectiveProject(xml: string, pomFile: string): Promise { + const document = await parseStringPromise(xml, { explicitArray: false }); + const projects = document.projects?.project; + if (Array.isArray(projects) && projects.length !== 1) throw new Error('启动请求必须只选择一个 Maven 项目'); + const project = document.project ?? (Array.isArray(projects) ? projects[0] : projects); + if (!project) throw new Error('Maven 未输出有效项目模型'); + const directory = dirname(pomFile); + const scalar = (value: unknown, name: string): string => { + if (typeof value !== 'string' || !value || value.includes('${')) throw new Error(`Maven 有效模型中的 ${name} 无效或未展开`); + return value; + }; + const outputPath = (value: unknown, name: string): string => { + const text = scalar(value, name); + return resolve(directory, text); + }; + const plugins = project.build?.plugins?.plugin; + const pluginList = !plugins ? [] : Array.isArray(plugins) ? plugins : [plugins]; + const configuredMain = project.properties?.['exec.mainClass'] ?? pluginList.find((plugin: { artifactId?: string }) => + plugin.artifactId === 'exec-maven-plugin')?.configuration?.mainClass; + return { + pomFile, directory, + groupId: scalar(project.groupId, 'groupId'), + artifactId: scalar(project.artifactId, 'artifactId'), + version: scalar(project.version, 'version'), + packaging: project.packaging || 'jar', + outputDirectory: outputPath(project.build?.outputDirectory, 'build.outputDirectory'), + testOutputDirectory: outputPath(project.build?.testOutputDirectory, 'build.testOutputDirectory'), + mainClass: typeof configuredMain === 'string' && !configuredMain.includes('${') ? configuredMain : undefined, + }; +} + +/** 保持 Maven 依赖顺序,只添加选定项目的编译输出 */ +export function buildClasspath(project: MavenProject, dependencyText: string, includeTests = false): string[] { + if (!existsSync(project.outputDirectory)) { + throw new Error(`找不到编译输出:${project.outputDirectory},请使用默认自动构建模式准备项目`); + } + const outputs = [project.outputDirectory]; + if (includeTests && existsSync(project.testOutputDirectory)) { + outputs.unshift(project.testOutputDirectory); + } + const dependencies = dependencyText.trim().split(delimiter).map(value => value.trim()).filter(Boolean) + .map(value => resolve(project.directory, value)); + for (const dependency of dependencies) { + if (!existsSync(dependency)) throw new Error(`依赖文件不存在:${dependency},请重新准备 Maven 依赖`); + } + return Array.from(new Set([...outputs, ...dependencies])); +} + +/** 构建选定项目并获取 Maven 裁决后的运行类路径 */ +export async function prepareMaven(config: RunConfig, workspace: string): Promise { + await validateRoot(config); + const plan = planMaven(config, workspace); + for (const spec of plan.commands) { + await executeMaven(spec); + } + const pomFile = readFileSync(join(workspace, 'project-file.txt'), 'utf8').trim(); + if (!isAbsolute(pomFile) || !existsSync(pomFile)) throw new Error('Maven 未返回有效的目标 POM 路径'); + const project = await readEffectiveProject(readFileSync(join(workspace, 'effective-pom.xml'), 'utf8'), pomFile); + if (project.packaging !== 'jar') throw new Error(`当前仅支持基于 classpath 的 jar 项目,目标 packaging=${project.packaging}`); + const classpath = buildClasspath(project, readFileSync(join(workspace, 'dependencies.txt'), 'utf8'), config.includeTests); + return { + directory: project.directory, + classesDirectories: config.includeTests ? [project.outputDirectory, project.testOutputDirectory] : [project.outputDirectory], + classpath, + mainClass: project.mainClass, + jvmArgs: [], + }; +} + +/** 判断 Maven 根是否需要选择启动项目,只读取本地 POM */ +export async function needsMavenModule(config: RunConfig): Promise { + if (config.module) return false; + const document = await parseStringPromise(readFileSync(join(config.cwd, 'pom.xml'), 'utf8'), { explicitArray: false }); + return document.project?.packaging === 'pom' || Boolean(document.project?.modules); +} + +/** 列出有效 reactor 中的 jar 项目,候选不等同于已有可运行主类 */ +export async function discoverMavenProjects(config: RunConfig, workspace: string): Promise<{ value: string; label: string }[]> { + const output = join(workspace, 'module-list.xml'); + const spec = command({ ...config, module: undefined }, [`${HELP_PLUGIN}:effective-pom`, `-Doutput=${output}`, '-q'], '读取 Maven 模块候选'); + await executeMaven(spec); + const document = await parseStringPromise(readFileSync(output, 'utf8'), { explicitArray: false }); + const value = document.projects?.project ?? document.project; + const projects = Array.isArray(value) ? value : value ? [value] : []; + return projects.filter(project => (project.packaging || 'jar') === 'jar').map(project => { + const selector = `${project.groupId}:${project.artifactId}`; + const main = project.properties?.['exec.mainClass']; + return { value: selector, label: `${selector}(${main || '主类待解析,可能是库模块'})` }; + }); +} + +function buildJava(): string { + return process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') : 'java'; +} diff --git a/src/classpath-builder.ts b/src/classpath-builder.ts deleted file mode 100644 index 0ec2d72..0000000 --- a/src/classpath-builder.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { join, resolve } from 'path'; -import { existsSync, mkdirSync, readFileSync } from 'fs'; -import cliProgress from 'cli-progress'; -import { spawnSync } from 'child_process'; -import { delimiter } from './constant' - -// 初始化进度条 -const progressBar = new cliProgress.SingleBar({ - format: '🚀 进度: {bar} | 模块: {value}/{total} | 耗时: {duration}s', - barCompleteChar: '\u2588', - barIncompleteChar: '\u2591', - hideCursor: true -}); - -// 配置常量 -const CACHE_DIR = resolve('.cache/dependency-paths'); -// 初始化缓存目录 -if (!existsSync(CACHE_DIR)) { - mkdirSync(CACHE_DIR, { recursive: true }); -} -const TARGET_DIRS = ['classes', 'test-classes']; // 需要包含的编译目录 - -interface ClasspathConfig { - refresh?: boolean; - includeTests?: boolean; -} - -export function buildClasspath(modulesInfos: { module: string, modulePath: string, groupId: string, version: string }[], config: ClasspathConfig = {}) { - const modules = modulesInfos.map(item => item.modulePath) - // 1. 生成模块路径 - const moduleClassPaths = modules.flatMap(m => - TARGET_DIRS.map(d => `${resolve(m, 'target', d)}/`) - ).filter(p => existsSync(p)); - - const modulePaths = modules.flatMap(m => resolve(m, 'pom.xml')).filter(p => existsSync(p)).map(item => item.replace(/\\/g, '/').replace(/\/pom\.xml$/, '')); - - // 2. 获取依赖路径(带缓存) - const depPaths = getDependencyPaths(modulePaths, config.refresh); - - const disableModules = modulesInfos.map(item => join(item.groupId.split('.').join('/'), item.module, item.version, `${item.module}-${item.version}.jar`)); - - // 3. 组合完整类路径 - return [ - ...moduleClassPaths, - ...depPaths.filter(item => !disableModules.some(i => item.endsWith(i))), - ]; -} - -function getDependencyPaths(modulePaths: string[], refresh = false) { - console.log(); - console.log('⏳ 获取项目完整依赖', refresh ? '刷新缓存' : '使用缓存'); - progressBar.start(modulePaths.length, 0); - const paths: string[] = []; - - // 递归处理每个模块路径 - const processModule = (modulePath: string) => { - const absPath = resolve(modulePath); - const pomPath = join(absPath, 'pom.xml'); - - if (!existsSync(pomPath)) { - throw new Error(`找不到 pom.xml: ${pomPath}`); - } - - // 生成唯一缓存文件名 (基于模块路径哈希) - const hash = Buffer.from(absPath).toString('base64url'); - const cacheFile = join(CACHE_DIR, `${hash}.cache`); - - // 检查缓存有效性 - const cacheValid = existsSync(cacheFile) - && existsSync(join(absPath, 'target')); - // && (Date.now() - require('fs').statSync(cacheFile).mtimeMs < 3600_000); - - if (!refresh && cacheValid) { - paths.push(readFileSync(cacheFile, 'utf-8')); - return progressBar.increment(); - } - - // 执行 mvn 命令生成依赖路径 - const result = spawnSync('mvn', [ - 'dependency:build-classpath', - '-Dmdep.outputFile=' + cacheFile, - // '-Dmdep.includeScope=compile,runtime', - '-Dmdep.useRepositoryLayout=true', - '-q', - '-B', - '-f', pomPath - ], { stdio: 'pipe' }); - - if (result.status !== 0) { - throw new Error(`依赖解析失败: ${absPath}\n尝试执行 mvn clean install -U \n${result.stdout?.toString()}`); - } - - // 收集当前模块路径 - paths.push(readFileSync(cacheFile, 'utf-8')); - - // 递归处理子模块 - const childModules = detectChildModules(pomPath); - childModules.forEach(child => - processModule(join(absPath, child))); - progressBar.increment(); - }; - - // 开始处理 - modulePaths.forEach(processModule); - progressBar.stop(); - - // 合并路径并去重 - return Array.from(new Set(paths.flatMap(p => p.split(delimiter).map(item => resolve(item))))) - .filter((v, i, a) => a.indexOf(v) === i); -} - -// 辅助函数:解析子模块列表 -function detectChildModules(pomPath: string): string[] { - try { - const content = readFileSync(pomPath, 'utf-8'); - const match = content.match(/(.*?)<\/modules>/s); - return match?.[1]?.match(/(.*?)<\/module>/g) - ?.map(m => m.replace(/<\/?module>/g, '').trim()) || []; - } catch { - return []; - } -} diff --git a/src/cli.ts b/src/cli.ts index 5451230..07b9c10 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,148 +1,73 @@ -import path, { resolve } from 'path'; -import { writeFileSync } from 'fs'; -import getMavenModules from "./find-maven-modules"; -import { buildClasspath } from './classpath-builder'; -import { executeCommand } from "./exec"; -import parseArgvs from "./parse-argvs"; -import helpLog, { defaultConfig } from "./help-log"; - -parseArgvs.set(Bun.argv); - -// ----------- 配置参数 ----------- -/** 不运行主程序,只执行其他操作 */ -const NOT_RUN = !!parseArgvs.booleans(['not-run', 'no-run']); -/** 是否刷新缓存 */ -const REFRESH_CACHE: boolean = !!parseArgvs.boolean('-r'); -/** 启动类 */ -let TARGET_CLASS = defaultConfig.mainClass - -if (parseArgvs.key("main")) { - const temp = parseArgvs.key("main") - if (temp) TARGET_CLASS = temp -} - -/** 指定配置文件 */ -let PROFILES_ACTIVE = defaultConfig.profilesActive -if (parseArgvs.key("active")) { - const temp = parseArgvs.key("active") - if (temp) PROFILES_ACTIVE = temp -} - -const LOCAL_FLAG_KEY = 'local' -const PROFILES_LOCAL_FLAG = !!parseArgvs.boolean(LOCAL_FLAG_KEY) -if (PROFILES_LOCAL_FLAG) { - PROFILES_ACTIVE = PROFILES_ACTIVE.replace(`${LOCAL_FLAG_KEY}-`, "") - PROFILES_ACTIVE = `${LOCAL_FLAG_KEY}-${PROFILES_ACTIVE}` -} - -const MVN_COMPILE_FLAG = !!parseArgvs.booleans(["compile", "-c"]) - -/** 没有指定 start 参数,只输出帮助信息 */ -const HELP_FLAG = !!parseArgvs.booleans(["help", "-help", "--help", "h", "-h", "--h"]) || !parseArgvs.boolean('start') - -// 获取项目版本号 -function getProjectVersion(): string { - return executeCommand('mvn', [ - 'help:evaluate', - '-Dexpression=project.version', - '-q', - '-DforceStdout' - ]); -} - -function getProjectGroupId(projectName?: string): string { - return executeCommand('mvn', [ - 'help:evaluate', - '-Dexpression=project.groupId', - '-q', - '-DforceStdout', - ...(projectName ? ['-pl', projectName] : []) - ]); -} - -// 构建项目 -function buildProject(): void { - console.log('正在构建项目...'); - executeCommand('mvn', ['clean', 'package']); -} - -// 启动Java应用 -function startApplication(classpath: string, mainClass: string, profile: string): void { - const jvmArgs = [ - `-Dspring.profiles.active=${profile}`, - `-Dspring.output.ansi.enabled=always`, - `-Dfile.encoding=UTF-8`, - '-classpath', classpath, - mainClass - ]; - - console.log('启动应用程序...\n'); - executeCommand('java', jvmArgs, { - stdio: 'inherit', - windowsHide: true - }); -} - -// 主流程 -async function main() { - if (HELP_FLAG) { - return helpLog() - } - - if (MVN_COMPILE_FLAG) { - executeCommand('mvn', ['compile']); +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs } from './cli/args'; +import helpLog from './cli/help'; +import { detectBuildTool } from './build-tools/detect'; +import { discoverMavenProjects, needsMavenModule, planMaven, prepareMaven } from './build-tools/maven'; +import { discoverGradleProjects, planGradle, prepareGradle } from './build-tools/gradle'; +import { chooseCandidate, SelectionCancelledError } from './cli/selection'; +import { toProjectConfig } from './cli/config'; +import { createProjectConfigWriter } from './cli/init'; +import { createLaunchCommand, resolveMainClass } from './core/launch'; +import { CommandError, runCommand } from './process/exec'; +import { version } from '../package.json'; + +/** 执行一个 CLI 请求,保留 Java 或构建工具的失败退出码 */ +export async function main(argv: string[]): Promise { + let workspace: string | undefined; + try { + const config = parseArgs(argv); + if (config.action === 'help') { helpLog(); return 0; } + if (config.action === 'version') { console.log(`java-run ${version}`); return 0; } + const configWriter = config.action === 'init' ? createProjectConfigWriter(config.cwd, config.force) : undefined; + const tool = detectBuildTool(config); + if (config.action === 'plan') { + const previewWorkspace = join(tmpdir(), ''); + const plan = tool === 'maven' ? planMaven(config, previewWorkspace) : planGradle(config, previewWorkspace); + console.log(JSON.stringify({ ...plan, launch: { java: config.javaCommand || '由工具链解析', main: config.mainClass || '由项目声明或唯一 main 方法确定', + jvmArgs: config.jvmArgs, applicationArgs: config.applicationArgs }, + notes: [...plan.notes, '这是静态预览,未验证有效项目模型、主类和依赖文件'] }, null, 2)); + return 0; + } + workspace = mkdtempSync(join(tmpdir(), 'java-run-')); + if (!config.module && (config.action === 'init' || process.stdin.isTTY && process.stderr.isTTY)) { + const discoverModules = tool === 'gradle' || await needsMavenModule(config); + if (discoverModules) { + const candidates = tool === 'gradle' ? await discoverGradleProjects(config, workspace) + : await discoverMavenProjects(config, workspace); + config.module = candidates.length === 1 ? candidates[0]!.value + : await chooseCandidate(candidates, '选择启动项目(库模块可能没有 main)'); + console.error(`java-run:已选择 --module=${config.module}`); + } + } + const project = tool === 'maven' ? await prepareMaven(config, workspace) : await prepareGradle(config, workspace); + const selectMainClass = process.stdin.isTTY && process.stderr.isTTY + ? (candidates: readonly string[]) => chooseCandidate(candidates.map(value => ({ value, label: value })), '选择启动主类') + : undefined; + if (configWriter) { + const mainClass = await resolveMainClass(config, project, selectMainClass); + configWriter.save(toProjectConfig(config, tool, mainClass)); + console.error(`java-run:已保存 ${configWriter.path}\n在该工作区运行 java-run 即可启动 ${mainClass}(${tool})`); + return 0; + } + const launch = await createLaunchCommand(config, project, workspace, selectMainClass); + console.error(`java-run:运行 ${launch.mainClass}(${tool})`); + return (await runCommand(launch)).exitCode; + } catch (error) { + if (error instanceof SelectionCancelledError) { console.error('java-run:已取消选择'); return error.exitCode; } + if (error instanceof CommandError) { + console.error(`java-run:${error.message}\n工作目录:${error.cwd}`); + if (error.stdout.trim()) console.error(error.stdout.trim()); + if (error.stderr.trim()) console.error(error.stderr.trim()); + if (error.cause instanceof Error) console.error(error.cause.message); + return error.exitCode || 1; + } + console.error(`java-run:${error instanceof Error ? error.message : String(error)}`); + return 1; + } finally { + if (workspace) rmSync(workspace, { recursive: true, force: true }); } - - // 1. 获取模块路径 - const modulesInfos = await getMavenModules(false); - - // 2. 获取项目元数据 - const version = getProjectVersion(); - console.log(`项目版本: ${version}`); - - // 3. 构建项目 - // buildProject(); - - // 4. 准备运行环境 - const fullClasspath = buildClasspath(modulesInfos, { - refresh: REFRESH_CACHE, - includeTests: false - }); - - console.log('项目完整依赖数:', fullClasspath.length); - const classpathFile = resolve(".cache", "classpath.cache"); - - const classPathString = fullClasspath.map((item) => `file://${path.normalize(item).replace(/\\/g, '/')}`).join(' ') - const fullString = `Class-Path: ${classPathString}`; - const lines: string[] = []; - let remaining = fullString; - // 处理首行 (最多72字符) - lines.push(remaining.substring(0, 72)); - remaining = remaining.slice(72); - // 处理后续行 (每行71字符 + 前导空格) - while (remaining.length > 0) { - const line = ` ${remaining.substring(0, 71)}`; - lines.push(line); - remaining = remaining.slice(71); - } - - writeFileSync(classpathFile, [ - 'Manifest-Version: 1.0', - ...lines, - 'Created-By: Generated Tool', - '' // 必须的空行 - ].join('\n')) - - const result = executeCommand('jar', ['-cvfm', '.cache/cp.jar', classpathFile]) - console.log(result); - - if (NOT_RUN) return - // 5. 启动应用 - startApplication('.cache/cp.jar', TARGET_CLASS, PROFILES_ACTIVE); - console.log('\n🎉 应用正常退出'); } -main().catch(err => { - console.error('运行失败:', err); - process.exit(1); -}); +if (import.meta.main) process.exitCode = await main(Bun.argv.slice(2)); diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..48db9df --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,160 @@ +import { resolve } from 'node:path'; +import type { RunConfig } from '../core/types'; +import { buildStrategy, buildTool, mainClass, moduleSelector, readProjectConfig } from './config'; + +/** CLI 解析结果,包含不写入项目配置的覆盖确认开关 */ +export interface CliConfig extends RunConfig { + force: boolean; +} + +function requireValue(value: unknown, option: string): string { + if (typeof value !== 'string' || !value.trim()) throw new Error(`${option} 必须是非空字符串`); + return value; +} + +/** + * 解析启动参数并合并项目根目录下的 .java-run.json + * + * @description + * - argv 不包含运行时和脚本路径,通常由 process.argv.slice(2) 提供 + * - 默认执行 run;init、help 和 version 只解析参数,不读取项目配置 + * - CLI 标量覆盖配置,数组在配置之后追加,-- 后的参数全部传给应用 + * - --cwd 相对 cwd 解析,只读取最终目录中的配置,不向父目录查找 + * - 未知选项、重复标量、无效值或配置抛出 Error,不执行外部命令 + */ +export function parseArgs(argv: string[], cwd = process.cwd()): CliConfig { + const config: CliConfig = { + action: 'run', + force: false, + cwd: resolve(cwd), + buildTool: 'auto', + jvmArgs: [], + applicationArgs: [], + buildArgs: [], + build: 'auto', + includeTests: false, + }; + const scalarOptions = new Set(); + let hasHelp = false; + let hasVersion = false; + let requestedCwd: string | undefined; + + function once(key: string): void { + if (scalarOptions.has(key)) throw new Error(`${key} 不能重复指定`); + scalarOptions.add(key); + } + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]!; + if (argument === '--') { + config.applicationArgs.push(...argv.slice(index + 1)); + break; + } + const separatorIndex = argument.indexOf('='); + const key = separatorIndex < 0 ? argument : argument.slice(0, separatorIndex); + const inlineValue = separatorIndex < 0 ? undefined : argument.slice(separatorIndex + 1); + + function flag(): void { + if (inlineValue !== undefined) throw new Error(`${key} 不接受参数值`); + } + + function value(): string { + if (inlineValue !== undefined) return requireValue(inlineValue, key); + const next = argv[index + 1]; + if (next === undefined || next.startsWith('-')) { + throw new Error(`${key} 缺少参数值;负号开头的值请使用 ${key}=`); + } + index++; + return requireValue(next, key); + } + + function scalar(): string { + once(key); + return value(); + } + + switch (key) { + case 'init': + case 'run': + case 'plan': + case 'help': + case 'version': + flag(); + once('命令'); + config.action = key; + break; + case '--help': + case '-h': + flag(); + hasHelp = true; + break; + case '--version': + flag(); + hasVersion = true; + break; + case '--cwd': + requestedCwd = scalar(); + break; + case '--tool': + config.buildTool = buildTool(scalar(), key); + break; + case '--module': + config.module = moduleSelector(scalar(), key); + break; + case '--main': + config.mainClass = mainClass(scalar(), key); + break; + case '--jvm-arg': + config.jvmArgs.push(value()); + break; + case '--arg': + config.applicationArgs.push(value()); + break; + case '--build-arg': + config.buildArgs.push(value()); + break; + case '--build': + config.build = buildStrategy(scalar(), key); + break; + case '--include-tests': + flag(); + once(key); + config.includeTests = true; + break; + case '--force': + flag(); + once(key); + config.force = true; + break; + case '--java': + config.javaCommand = scalar(); + break; + case '--build-command': + config.buildCommand = scalar(); + break; + default: + throw new Error(`未知参数:${argument};使用 --help 查看帮助`); + } + } + + if (config.force && config.action !== 'init') throw new Error('--force 仅允许用于 init 命令'); + if (requestedCwd !== undefined) config.cwd = resolve(cwd, requestedCwd); + if (hasHelp) config.action = 'help'; + else if (hasVersion) config.action = 'version'; + if (config.action === 'init' || config.action === 'help' || config.action === 'version') return config; + + const projectConfig = readProjectConfig(config.cwd); + const result: CliConfig = { ...config, ...projectConfig }; + // 只用显式 CLI 选项覆盖文件配置,避免默认值遮盖用户保存的设置 + const scalarFields = [ + ['--tool', 'buildTool'], ['--module', 'module'], ['--main', 'mainClass'], + ['--build', 'build'], ['--include-tests', 'includeTests'], + ] as const; + for (const [option, field] of scalarFields) { + if (scalarOptions.has(option)) Object.assign(result, { [field]: config[field] }); + } + result.jvmArgs = [...(projectConfig.jvmArgs ?? []), ...config.jvmArgs]; + result.applicationArgs = [...(projectConfig.applicationArgs ?? []), ...config.applicationArgs]; + result.buildArgs = [...(projectConfig.buildArgs ?? []), ...config.buildArgs]; + return result; +} diff --git a/src/cli/config.ts b/src/cli/config.ts new file mode 100644 index 0000000..e752f86 --- /dev/null +++ b/src/cli/config.ts @@ -0,0 +1,140 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { isJavaClassName } from '../core/java-class'; +import type { RunConfig } from '../core/types'; + +/** 可以保存到项目根目录 .java-run.json 的启动设置 */ +export type ProjectConfig = Partial>; + +const CONFIG_KEYS = new Set([ + 'buildTool', 'module', 'mainClass', 'jvmArgs', 'applicationArgs', + 'buildArgs', 'build', 'includeTests', +]); + +function requireValue(value: unknown, option: string): string { + if (typeof value !== 'string' || !value.trim()) throw new Error(`${option} 必须是非空字符串`); + return value; +} + +/** 验证并去除单个构建模块选择器的首尾空白 */ +export function moduleSelector(value: unknown, option: string): string { + const selector = requireValue(value, option).trim(); + if (selector.includes(',') || /^[!\-?]/.test(selector)) { + throw new Error(`${option} 必须指定单个模块,不能多选、排除或使用可选选择器`); + } + return selector; +} + +/** 验证 Java 主类全名,无效时抛出包含选项名称的错误 */ +export function mainClass(value: unknown, option: string): string { + const name = requireValue(value, option); + if (!isJavaClassName(name)) throw new Error(`${option} 必须是有效的 Java 类全名`); + return name; +} + +/** 验证构建工具选项,无效时抛出包含选项名称的错误 */ +export function buildTool(value: unknown, option: string): RunConfig['buildTool'] { + if (value !== 'auto' && value !== 'maven' && value !== 'gradle') { + throw new Error(`${option} 仅支持 auto、maven 或 gradle`); + } + return value; +} + +/** 验证构建策略选项,无效时抛出包含选项名称的错误 */ +export function buildStrategy(value: unknown, option: string): RunConfig['build'] { + if (value !== 'auto' && value !== 'none') throw new Error(`${option} 仅支持 auto 或 none`); + return value; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringArray(value: unknown, field: string): string[] { + if (!Array.isArray(value)) throw new Error(`${field} 必须是字符串数组`); + return value.map(item => { + if (field === 'applicationArgs') { + if (typeof item !== 'string') throw new Error(`${field} 必须是字符串数组`); + return item; + } + return requireValue(item, field); + }); +} + +/** 验证项目配置对象,拒绝未知字段和无效值并返回独立数组 */ +export function parseProjectConfig(data: unknown): ProjectConfig { + if (!isObject(data)) throw new Error('配置必须是 JSON 对象'); + const result: ProjectConfig = {}; + for (const [field, value] of Object.entries(data)) { + if (!CONFIG_KEYS.has(field)) throw new Error(`未知配置项:${field}`); + switch (field) { + case 'buildTool': + result.buildTool = buildTool(value, field); + break; + case 'module': + result.module = moduleSelector(value, field); + break; + case 'mainClass': + result.mainClass = mainClass(value, field); + break; + case 'jvmArgs': + result.jvmArgs = stringArray(value, field); + break; + case 'applicationArgs': + result.applicationArgs = stringArray(value, field); + break; + case 'buildArgs': + result.buildArgs = stringArray(value, field); + break; + case 'build': + result.build = buildStrategy(value, field); + break; + case 'includeTests': + if (typeof value !== 'boolean') throw new Error('includeTests 必须是布尔值'); + result.includeTests = value; + break; + } + } + return result; +} + +/** 读取指定目录中的项目配置;文件不存在时返回空配置,其余读取或格式错误抛出 Error */ +export function readProjectConfig(cwd: string): ProjectConfig { + const path = resolve(cwd, '.java-run.json'); + let source: string; + try { + source = readFileSync(path, 'utf8'); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return {}; + throw new Error(`无法读取配置 ${path}:${error instanceof Error ? error.message : String(error)}`); + } + + try { + return parseProjectConfig(JSON.parse(source)); + } catch (error) { + throw new Error(`配置无效 ${path}:${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * 将已选定的启动设置转换为可保存的项目配置 + * + * @description 始终保存实际构建工具和主类,省略空数组及默认构建策略,不保存 CLI 工具路径或命令状态 + */ +export function toProjectConfig( + config: RunConfig, + tool: 'maven' | 'gradle', + resolvedMain: string, +): ProjectConfig { + const result: ProjectConfig = { buildTool: tool, mainClass: resolvedMain }; + if (config.module !== undefined) result.module = config.module; + if (config.jvmArgs.length) result.jvmArgs = config.jvmArgs; + if (config.applicationArgs.length) result.applicationArgs = config.applicationArgs; + if (config.buildArgs.length) result.buildArgs = config.buildArgs; + if (config.build !== 'auto') result.build = config.build; + if (config.includeTests) result.includeTests = true; + return parseProjectConfig(result); +} diff --git a/src/cli/help.ts b/src/cli/help.ts new file mode 100644 index 0000000..e10db92 --- /dev/null +++ b/src/cli/help.ts @@ -0,0 +1,68 @@ +/** 返回 CLI 帮助文本,不执行命令或修改进程状态 */ +export function getHelpText(): string { + return `java-run — Java 源码工作区运行器 + +用法: + java-run [run] [选项] [-- 应用参数...] + java-run init [选项] [-- 应用参数...] + java-run plan [选项] + java-run help | version + +命令: + run 准备并启动应用,默认命令 + init 准备并选择目标,生成项目启动配置,不启动应用 + plan 预览构建步骤,不运行构建工具或写入项目文件 + help, --help, -h 显示帮助,不读取项目配置 + version, --version 显示版本,不读取项目配置 + +常用选项: + --cwd 工作区根目录,默认当前目录 + --module 单个 Maven reactor 选择器或 Gradle 项目路径 + --main 启动类,默认使用构建声明或唯一的已编译 main 方法 + --jvm-arg= JVM 参数,可重复;负号开头的值必须用等号 + --arg= 应用参数,可重复;也可使用 -- 透传 + +高级选项: + --tool auto(默认)/ maven / gradle + --build auto(默认)/ none + auto 执行必要的源码准备,由构建工具管理依赖 + none 不主动构建源码,要求产物已经准备好 + --build-arg= 构建工具参数,可重复;负号开头的值必须用等号 + --include-tests 准备并加入测试输出和测试依赖,不执行测试 + --java 应用启动使用的 Java 可执行文件 + --build-command 构建工具可执行文件,默认优先使用项目 Wrapper + --force 仅用于 init,忽略已有配置并重新生成 + +项目配置: + run / plan 从 --cwd 指定的根目录读取 .java-run.json,不向父目录查找。 + 可用字段:buildTool、module、mainClass、jvmArgs、applicationArgs、 + buildArgs、build、includeTests + CLI 标量覆盖配置,数组在配置之后追加。 + 配置使用严格 JSON,不展开环境变量;未知字段和无效类型均报错。 + init 保存实际构建工具、模块、主类及本次显式参数,准备时可能编译或下载依赖。 + init 遇到已有配置时拒绝;--force 从本次选项重新生成,不合并旧配置。 + --cwd、--java、--build-command 不写入配置;取消或准备失败不写配置。 + plan 只预览步骤,主类和运行类路径仍待构建工具解析。 + +交互选择: + 终端中缺少目标或存在多个主类时补充选择,库模块不一定可运行。 + 仅选择目标和入口,参数通过选项提供;run 本次使用,init 保存到配置。 + 非交互环境请用 --module / --main 明确指定,run 也可读取配置;取消返回 130。 + +示例: + java-run + java-run init + java-run init --module app --jvm-arg=-Xmx1g -- --server.port=8081 + java-run --module :app + java-run plan --tool gradle --module :app + java-run --jvm-arg=-Xmx1g -- --server.port=8081 + java-run --main com.example.Application --build-arg=-Dcustom.mode=dev +`; +} + +/** 输出帮助,进程退出由调用方决定 */ +export function helpLog(): void { + console.log(getHelpText()); +} + +export default helpLog; diff --git a/src/cli/init.ts b/src/cli/init.ts new file mode 100644 index 0000000..7aef7ae --- /dev/null +++ b/src/cli/init.ts @@ -0,0 +1,51 @@ +import { linkSync, lstatSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import type { ProjectConfig } from './config'; + +/** 已检查目标位置、等待保存启动配置的写入器 */ +export interface ProjectConfigWriter { + readonly path: string; + /** 完整写入后发布配置;目标冲突或写入失败时抛出错误并清理暂存文件 */ + save(config: ProjectConfig): void; +} + +function checkDestination(path: string, force: boolean): void { + const existing = lstatSync(path, { throwIfNoEntry: false }); + if (!existing) return; + if (!force) throw new Error(`配置已存在:${path},如需重新生成,请使用 init --force`); + if (!existing.isFile()) throw new Error(`无法替换配置:${path} 不是普通文件`); +} + +/** + * 检查配置位置并返回延迟写入器,不创建或读取现有配置 + * + * @description 默认拒绝已有目标;force 仅允许替换普通文件,保存时再次检查并从同目录暂存文件发布 + */ +export function createProjectConfigWriter(cwd: string, force = false): ProjectConfigWriter { + const path = join(cwd, '.java-run.json'); + checkDestination(path, force); + return { + path, + save(config) { + checkDestination(path, force); + const temporary = join(cwd, `.java-run-init-${randomUUID()}.tmp`); + try { + writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { flag: 'wx' }); + if (force) { + checkDestination(path, true); + renameSync(temporary, path); + } else { + try { + linkSync(temporary, path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') checkDestination(path, false); + throw error; + } + } + } finally { + rmSync(temporary, { force: true }); + } + }, + }; +} diff --git a/src/cli/selection.ts b/src/cli/selection.ts new file mode 100644 index 0000000..ebec928 --- /dev/null +++ b/src/cli/selection.ts @@ -0,0 +1,80 @@ +import { createInterface } from 'node:readline'; + +/** 用户取消交互选择,调用方应以退出码 130 结束请求 */ +export class SelectionCancelledError extends Error { + readonly exitCode = 130; + + constructor() { + super('已取消选择'); + this.name = 'SelectionCancelledError'; + } +} + +/** 交互选项的稳定标识和展示文字 */ +export interface SelectionCandidate { + value: string; + label: string; +} + +/** + * 在终端中用序号选择候选项 + * + * @description + * - 仅在 stdin 和 stderr 都连接终端时提示,菜单输出到 stderr + * - 非交互环境要求调用方通过参数或项目配置指定目标 + * - EOF、Ctrl+C 或 SIGINT 抛出 SelectionCancelledError,保留取消退出码 + * - 调用方只应在目标缺失且确实需要用户选择时调用 + */ +export function chooseCandidate(candidates: SelectionCandidate[], question: string): Promise { + if (!candidates.length) return Promise.reject(new Error('没有可供选择的候选项,请使用 --module / --main 或 .java-run.json 指定目标')); + if (!process.stdin.isTTY || !process.stderr.isTTY) { + return Promise.reject(new Error('当前为非交互环境,请使用 --module / --main 或 .java-run.json 明确指定目标')); + } + if (process.stdin.readableEnded || process.stdin.destroyed) return Promise.reject(new SelectionCancelledError()); + + return new Promise((resolve, reject) => { + const reader = createInterface({ input: process.stdin, output: process.stderr, terminal: true }); + let settled = false; + + function finish(value?: string): void { + if (settled) return; + settled = true; + reader.removeListener('line', onLine); + reader.removeListener('close', onClose); + reader.removeListener('SIGINT', onInterrupt); + process.removeListener('SIGINT', onInterrupt); + reader.close(); + if (value === undefined) reject(new SelectionCancelledError()); + else resolve(value); + } + + function onLine(line: string): void { + const answer = line.trim(); + const index = /^[1-9]\d*$/.test(answer) ? Number(answer) - 1 : -1; + if (Number.isSafeInteger(index) && index >= 0 && index < candidates.length) { + finish(candidates[index]!.value); + return; + } + process.stderr.write(`请输入 1 到 ${candidates.length} 之间的序号\n`); + reader.prompt(); + } + + function onClose(): void { + finish(); + } + + function onInterrupt(): void { + process.stderr.write('\n'); + finish(); + } + + reader.on('line', onLine); + reader.once('close', onClose); + reader.on('SIGINT', onInterrupt); + process.once('SIGINT', onInterrupt); + process.stderr.write(`${question}\n`); + candidates.forEach((candidate, index) => process.stderr.write(` ${index + 1}. ${candidate.label}\n`)); + reader.setPrompt(`选择 [1-${candidates.length}]:`); + reader.prompt(); + }); +} diff --git a/src/colors.ts b/src/colors.ts deleted file mode 100644 index 514ba08..0000000 --- a/src/colors.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 定义 ANSI 颜色代码 -export const Colors = { - Reset: "\x1b[0m", - Bright: "\x1b[1m", - Dim: "\x1b[2m", - Underscore: "\x1b[4m", - Blink: "\x1b[5m", - Reverse: "\x1b[7m", - Hidden: "\x1b[8m", - - // 前景色(文字颜色) - FgBlack: "\x1b[30m", - FgRed: "\x1b[31m", - FgGreen: "\x1b[32m", - FgYellow: "\x1b[33m", - FgBlue: "\x1b[34m", - FgMagenta: "\x1b[35m", - FgCyan: "\x1b[36m", - FgWhite: "\x1b[37m", - - // 背景色 - BgBlack: "\x1b[40m", - BgRed: "\x1b[41m", - BgGreen: "\x1b[42m", - BgYellow: "\x1b[43m", - BgBlue: "\x1b[44m", - BgMagenta: "\x1b[45m", - BgCyan: "\x1b[46m", - BgWhite: "\x1b[47m", -}; - \ No newline at end of file diff --git a/src/constant.ts b/src/constant.ts deleted file mode 100644 index 6aba6a3..0000000 --- a/src/constant.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** 路径分隔符兼容处理 */ -export const delimiter = process.platform === 'win32' ? ';' : ':'; diff --git a/src/core/classpath.ts b/src/core/classpath.ts new file mode 100644 index 0000000..dbf3513 --- /dev/null +++ b/src/core/classpath.ts @@ -0,0 +1,26 @@ +import { statSync } from 'node:fs'; +import { resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +/** 将文件系统路径编码为 Manifest 使用的 URL */ +export function classpathUrl(file: string): string { + const absolute = resolve(file); + return pathToFileURL(statSync(absolute).isDirectory() && !absolute.endsWith(sep) ? absolute + sep : absolute).href; +} + +/** 生成按 UTF-8 字节折行且带终止空行的 Manifest */ +export function createManifest(classpath: string[]): string { + const header = `Class-Path: ${classpath.map(classpathUrl).join(' ')}`; + const lines: string[] = []; + let line = ''; + for (const character of header) { + if (Buffer.byteLength(line + character, 'utf8') > 70) { + // CRLF 也占用两个字节,后续行的空格占用一个字节 + lines.push(line); + line = ' '; + } + line += character; + } + lines.push(line); + return ['Manifest-Version: 1.0', ...lines, '', ''].join('\r\n'); +} diff --git a/src/core/java-class.ts b/src/core/java-class.ts new file mode 100644 index 0000000..9d77954 --- /dev/null +++ b/src/core/java-class.ts @@ -0,0 +1,6 @@ +const CLASS_NAME = /^[\p{L}\p{Nl}\p{Sc}\p{Pc}][\p{L}\p{Nl}\p{Sc}\p{Pc}\p{Mn}\p{Mc}\p{Nd}\p{Cf}]*(?:\.[\p{L}\p{Nl}\p{Sc}\p{Pc}][\p{L}\p{Nl}\p{Sc}\p{Pc}\p{Mn}\p{Mc}\p{Nd}\p{Cf}]*)*$/u; + +/** 校验传统 Java 类全名,支持 Unicode 标识符和内部类名 */ +export function isJavaClassName(value: string): boolean { + return CLASS_NAME.test(value); +} diff --git a/src/core/launch.ts b/src/core/launch.ts new file mode 100644 index 0000000..e58b6af --- /dev/null +++ b/src/core/launch.ts @@ -0,0 +1,51 @@ +import { dirname, isAbsolute, join, relative } from 'node:path'; +import { existsSync, statSync, writeFileSync } from 'node:fs'; +import { createManifest } from './classpath'; +import { discoverMainClass, type MainClassSelector } from './main-class'; +import { isJavaClassName } from './java-class'; +import { CommandError, runCommand } from '../process/exec'; +import { assertJavaArguments } from '../process/java-arguments'; +import type { CommandSpec, PreparedProject, RunConfig } from './types'; + +function resolveJar(java: string): string { + const name = process.platform === 'win32' ? 'jar.exe' : 'jar'; + if (isAbsolute(java)) return join(dirname(java), name); + const candidate = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', name) : undefined; + return candidate && existsSync(candidate) ? candidate : name; +} + +/** + * 检查准备产物并确定启动主类 + * + * @description 显式主类优先于项目声明;两者都缺失且有多个入口时才调用 selectMainClass,显式或声明的主类只检查名称格式 + */ +export async function resolveMainClass(config: RunConfig, project: PreparedProject, selectMainClass?: MainClassSelector): Promise { + for (const path of project.classpath) { + if (!existsSync(path)) throw new Error(`运行类路径缺少产物:${path},请使用默认自动构建模式准备项目`); + } + const classes = project.classesDirectories.filter(path => existsSync(path) && statSync(path).isDirectory()); + if (!classes.length) throw new Error('目标项目没有已编译的类目录,请使用默认自动构建模式'); + let main = config.mainClass || project.mainClass; + if (!main) main = await discoverMainClass(classes, selectMainClass); + if (!isJavaClassName(main)) throw new Error(`无效的 Java 主类:${main}`); + return main; +} + +/** + * 生成独立 Java 进程的启动命令并返回实际主类 + * + * @description 缺少产物、无法确定主类或生成类路径失败时抛出错误 + */ +export async function createLaunchCommand(config: RunConfig, project: PreparedProject, workspace: string, selectMainClass?: MainClassSelector): Promise { + const main = await resolveMainClass(config, project, selectMainClass); + const java = config.javaCommand || project.javaCommand || (process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') : 'java'); + const manifest = join(workspace, 'MANIFEST.MF'); + const classpathJar = join(workspace, 'classpath.jar'); + const args = ['-Dfile.encoding=UTF-8', ...project.jvmArgs, ...config.jvmArgs, '-classpath', relative(project.directory, classpathJar), main, ...config.applicationArgs]; + await assertJavaArguments(java, args, project.directory); + writeFileSync(manifest, createManifest(project.classpath)); + const spec = { command: resolveJar(java), args: ['cfm', 'classpath.jar', 'MANIFEST.MF'], cwd: workspace, stage: '生成运行类路径' }; + const result = await runCommand(spec, { capture: true }); + if (result.exitCode !== 0) throw new CommandError(spec, result); + return { command: java, args, cwd: project.directory, stage: '运行 Java 应用', mainClass: main }; +} diff --git a/src/core/main-class.ts b/src/core/main-class.ts new file mode 100644 index 0000000..6736f62 --- /dev/null +++ b/src/core/main-class.ts @@ -0,0 +1,83 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** 读取 class 文件的方法表,只识别传统 public static main(String[]) */ +export function hasMainMethod(data: Buffer): boolean { + try { + let offset = 0; + const u1 = () => data.readUInt8(offset++); + const u2 = () => { const value = data.readUInt16BE(offset); offset += 2; return value; }; + const u4 = () => { const value = data.readUInt32BE(offset); offset += 4; return value; }; + if (u4() !== 0xcafebabe) return false; + offset += 4; + const count = u2(); + const strings = new Map(); + for (let index = 1; index < count; index++) { + const tag = u1(); + switch (tag) { + case 1: { const length = u2(); strings.set(index, data.toString('utf8', offset, offset + length)); offset += length; break; } + case 3: case 4: case 9: case 10: case 11: case 12: case 17: case 18: offset += 4; break; + case 5: case 6: offset += 8; index++; break; + case 7: case 8: case 16: case 19: case 20: offset += 2; break; + case 15: offset += 3; break; + default: return false; + } + } + offset += 6; + const interfaces = u2(); + offset += interfaces * 2; + const skipAttributes = () => { + const length = u2(); + for (let index = 0; index < length; index++) { u2(); const size = u4(); offset += size; } + }; + const fields = u2(); + for (let index = 0; index < fields; index++) { offset += 6; skipAttributes(); } + const methods = u2(); + for (let index = 0; index < methods; index++) { + const flags = u2(); + const name = strings.get(u2()); + const descriptor = strings.get(u2()); + if ((flags & 0x0009) === 0x0009 && name === 'main' && descriptor === '([Ljava/lang/String;)V') return true; + skipAttributes(); + } + return false; + } catch { + return false; + } +} + +/** 列出目标项目编译输出中的传统 Java 主类 */ +export function findMainClasses(directories: string[]): string[] { + const candidates = new Set(); + const walk = (root: string, relative = '') => { + for (const entry of readdirSync(join(root, relative), { withFileTypes: true })) { + const path = join(relative, entry.name); + if (entry.isDirectory()) walk(root, path); + else if (entry.isFile() && entry.name.endsWith('.class') && hasMainMethod(readFileSync(join(root, path)))) { + candidates.add(path.slice(0, -6).replace(/[\\/]/g, '.')); + } + } + }; + for (const directory of directories) walk(directory); + return [...candidates].sort(); +} + +/** + * 从多个已确认入口中选择一个主类 + * + * @description candidates 至少包含两个按类名排序的入口;返回其中一个,选择失败时抛出的错误原样传递 + */ +export type MainClassSelector = (candidates: readonly string[]) => Promise; + +/** + * 在目标项目输出中解析主类 + * + * @description 唯一入口直接采用;多个入口时调用 select,未提供时抛出含候选列表的错误 + */ +export async function discoverMainClass(directories: string[], select?: MainClassSelector): Promise { + const choices = findMainClasses(directories); + if (choices.length === 1) return choices[0]!; + if (!choices.length) throw new Error('目标项目中未找到 public static main(String[]),请检查构建产物或用 --main 指定入口'); + if (select) return select(choices); + throw new Error(`目标项目有多个主类,请使用 --main 指定:\n${choices.map(name => ` ${name}`).join('\n')}`); +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..852d585 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,43 @@ +/** 从源码工作区运行 Java 应用的配置 */ +export interface RunConfig { + action: 'help' | 'version' | 'run' | 'plan' | 'init'; + cwd: string; + buildTool: 'auto' | 'maven' | 'gradle'; + /** Maven reactor 选择器或 Gradle 项目路径,只能选择一个项目 */ + module?: string; + /** 未指定时采用构建工具声明的主类或唯一的已编译 main 方法 */ + mainClass?: string; + jvmArgs: string[]; + applicationArgs: string[]; + buildArgs: string[]; + /** auto 由适配器执行必要准备;none 要求产物已经准备好 */ + build: 'auto' | 'none'; + includeTests: boolean; + javaCommand?: string; + buildCommand?: string; +} + +/** 保持参数边界的单个外部命令 */ +export interface CommandSpec { + command: string; + args: string[]; + cwd: string; + stage: string; +} + +/** 构建工具裁决后可交给统一 Java 启动器的信息 */ +export interface PreparedProject { + directory: string; + classesDirectories: string[]; + classpath: string[]; + mainClass?: string; + javaCommand?: string; + jvmArgs: string[]; +} + +/** 未执行构建工具时可预览的准备步骤 */ +export interface BuildPlan { + tool: 'maven' | 'gradle'; + commands: CommandSpec[]; + notes: string[]; +} diff --git a/src/exec.ts b/src/exec.ts deleted file mode 100644 index 1f512d8..0000000 --- a/src/exec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import iconv from 'iconv-lite'; -import jschardet from 'jschardet'; -import { spawnSync } from 'child_process'; - -/** 执行命令并获取输出 */ -export function executeCommand(command: string, args: string[], options?: Parameters[2]): string { - const result = spawnSync(command, args, { ...options }); - - if (result.status !== 0) { - console.error(`❌ 执行错误: ${command} ${args.join(' ')}`); - console.error(result.error); - // console.log(result.stderr); - process.exit(1); - } - - if (!Buffer.isBuffer(result.stdout)) return result.stdout.trim(); - - // 检测编码 - const detected = jschardet.detect(result.stdout); - if (result.stdout.length && detected.encoding) { - const str = iconv.decode(result.stdout, detected.encoding); - return str.trim(); - } - - return result.stdout.toString().trim(); -} \ No newline at end of file diff --git a/src/find-maven-modules.ts b/src/find-maven-modules.ts deleted file mode 100644 index 9602318..0000000 --- a/src/find-maven-modules.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { join, relative, resolve } from 'path'; -import { existsSync, readFileSync } from 'fs'; -import cliProgress from 'cli-progress'; -import { parseStringPromise } from 'xml2js'; - -// 初始化进度条 -const progressBar = new cliProgress.SingleBar({ - format: '🚀 进度: {bar} | 模块: {value}/{total} | 耗时: {duration}s', - barCompleteChar: '\u2588', - barIncompleteChar: '\u2591', - hideCursor: true -}); - -// 性能监控 -const startTime = performance.now(); -let totalModules = 0; -let leafModules = 0; - -// 递归解析pom.xml -async function parsePomModules(pomPath: string, baseDir: string): Promise<{ groupId: string, module: string, modulePath: string, version: string }[]> { - if (!existsSync(pomPath)) return []; - - try { - // 读取并解析XML - const xmlContent = readFileSync(pomPath, 'utf-8'); - const pom = await parseStringPromise(xmlContent); - - // 提取模块列表 - const modules: string[] = pom.project?.modules?.[0]?.module || []; - const groupId = pom.project?.groupId?.[0] || pom.project?.parent?.[0]?.groupId?.[0] || ''; - const artifactId = pom.project?.artifactId?.[0] || pom.project?.parent?.[0]?.artifactId?.[0] || ''; - const version = pom.project?.version?.[0] || pom.project?.parent?.[0]?.version?.[0] || ''; - - const validModules = modules.filter(m => m && typeof m === 'string'); - - // 进度条更新 - totalModules += validModules.length; - progressBar.setTotal(totalModules); - progressBar.increment(validModules.length); - - // 无子模块 → 叶子模块 - if (validModules.length === 0) { - leafModules++; - progressBar.setTotal(++totalModules); - progressBar.increment() - const modulePath = relative(baseDir, join(pomPath, '..')); - return [{ groupId, module: artifactId ,modulePath, version }]; - } - - // 递归处理子模块 - const results = await Promise.all(validModules.map(async (module) => { - const childPom = resolve(join(pomPath, '..', module, 'pom.xml')); - return parsePomModules(childPom, baseDir); - })); - - return (await Promise.all(results)).flat(); - } catch (e) { - console.error(`解析失败: ${pomPath}`, e); - return []; - } -} - -// 主程序 -export default async function main(isLog = true) { - console.log(); - console.log('⏳ 搜寻项目模块依赖信息'); - progressBar.start(0, 0); - - const baseDir = process.cwd(); - const rootPom = join(baseDir, 'pom.xml'); - const modules = await parsePomModules(rootPom, baseDir); - - progressBar.stop(); - - // 输出构建路径 - const result = modules.map(m =>({ ...m, module: m.module.replace(/\\/g, '/') })); - - // 性能报告 - const duration = (performance.now() - startTime) / 1000; - isLog && console.log(` -📊 性能报告: - 总耗时: ${duration.toFixed(1) !== "0.0" ? `${duration.toFixed(1)}s` : `${(duration * 1000).toFixed(2)}ms` } - 解析模块数: ${totalModules} - 叶子模块数: ${leafModules} - 速度: ${(totalModules/duration).toFixed(1)} modules/s -`); - - return result -} - -// main(false).then(console.log).catch(console.error); \ No newline at end of file diff --git a/src/help-log.ts b/src/help-log.ts deleted file mode 100644 index 5e7b930..0000000 --- a/src/help-log.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Colors } from "./colors"; - -export const defaultConfig = { - mainClass: "org.jeecg.JeecgSystemApplication", - profilesActive: "dev", -} - -export default () => { - console.log(` - ${Colors.FgGreen}${'='.repeat(60)} - ${Colors.Bright} Spring Boot 项目快捷启动工具 ${Colors.Reset} By Vanisper - ${Colors.FgGreen}${'='.repeat(60)}${Colors.Reset} - - ${Colors.FgCyan}Usage:${Colors.Reset} - java-run [script] [options] - - ${Colors.FgCyan}Options:${Colors.Reset} - ${Colors.FgYellow}start${Colors.Reset} 执行脚本程序,不指定时会显示此帮助信息 - ${Colors.FgYellow}not-run, no-run${Colors.Reset} 不运行主程序,仅执行其他操作 - ${Colors.FgYellow}-c, compile${Colors.Reset} 执行 ${Colors.FgMagenta}mvn compile${Colors.Reset},使用 mvn 将 java 源码编译为字节码,这是项目运行的前提条件 - ${Colors.FgYellow}-r${Colors.Reset} 强制刷新项目依赖 class-paths 缓存 - ${Colors.FgYellow}main=${Colors.Reset} 指定启动类 (默认: ${Colors.FgMagenta}${defaultConfig.mainClass}${Colors.Reset}) - ${Colors.FgYellow}active=${Colors.Reset} 指定激活的配置文件 (默认: ${Colors.FgMagenta}${defaultConfig.profilesActive}${Colors.Reset}) - ${Colors.FgYellow}local${Colors.Reset} 启用本地配置,组合使用时格式为 local- - (示例: ${Colors.FgBlue}active=dev local → 最终生效: local-dev${Colors.Reset}) - ${Colors.FgYellow}-h, --help${Colors.Reset} 显示本帮助信息 - - ${Colors.FgGreen}${'-'.repeat(60)}${Colors.Reset} - ${Colors.FgCyan}示例:${Colors.Reset} - ${Colors.FgBlue}# 使用默认配置启动${Colors.Reset} - java-run start - - ${Colors.FgBlue}# 指定测试环境配置并强制刷新缓存${Colors.Reset} - java-run start active=test -r - - ${Colors.FgBlue}# 使用缓存生成 cp-jar 包,不运行 java 主程序,并使用 local-prod 配置${Colors.Reset} - java-run start no-run active=prod local - ${Colors.FgGreen}${'-'.repeat(60)}${Colors.Reset} -`); - process.exit(0); // 显示帮助后退出 -} diff --git a/src/parse-argvs.ts b/src/parse-argvs.ts deleted file mode 100644 index caabe45..0000000 --- a/src/parse-argvs.ts +++ /dev/null @@ -1,35 +0,0 @@ -let argvs: string[] | undefined = undefined - -function checkArgvs(params: string[] | undefined) { - if (!params) return false - if (!Array.isArray(params)) return false - - return true -} - -function parseBoolean(key: string, params?: string[]) { - if (checkArgvs(argvs) && !checkArgvs(params)) params = argvs - if (!checkArgvs(params)) return - - return params?.includes(key) -} - -function parseKey(key: string, separator = "=", params?: string[]) { - if (checkArgvs(argvs) && !checkArgvs(params)) params = argvs - if (!checkArgvs(params)) return - - const temp = params?.find(item => item.startsWith(key)) - if (!temp) return - return temp.replace(key, "").split('=').at(-1) -} - -export default { - set: (params: string[]) => { - if (!checkArgvs(params)) return - - argvs = params - }, - boolean: parseBoolean, - booleans: (params: string[]) => params.some(item => parseBoolean(item)), - key: parseKey, -} diff --git a/src/process/exec.ts b/src/process/exec.ts new file mode 100644 index 0000000..3f0a212 --- /dev/null +++ b/src/process/exec.ts @@ -0,0 +1,263 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { statSync } from 'node:fs'; +import { constants } from 'node:os'; +import path from 'node:path'; +import iconv from 'iconv-lite'; +import jschardet from 'jschardet'; +import type { CommandSpec } from '../core/types'; + +/** 外部命令的退出状态和捕获输出 */ +export interface CommandResult { + /** 信号终止时使用 128 加信号编号 */ + exitCode: number; + /** 正常退出时为空,Windows 信号终止保留父进程收到的信号 */ + signal: NodeJS.Signals | null; + /** inherit 模式下为空字符串 */ + stdout: string; + /** inherit 模式下为空字符串 */ + stderr: string; +} + +/** 命令执行失败时保留调用位置、退出状态和诊断输出 */ +export class CommandError extends Error { + readonly command: string; + readonly args: string[]; + readonly cwd: string; + readonly stage: string; + readonly exitCode: number; + readonly signal: NodeJS.Signals | null; + readonly stdout: string; + readonly stderr: string; + + constructor(spec: CommandSpec, result: CommandResult, cause?: unknown) { + super(`${spec.stage}失败:${spec.command}(退出码 ${result.exitCode})`, { cause }); + this.name = 'CommandError'; + this.command = spec.command; + this.args = [...spec.args]; + this.cwd = spec.cwd; + this.stage = spec.stage; + this.exitCode = result.exitCode; + this.signal = result.signal; + this.stdout = result.stdout; + this.stderr = result.stderr; + } +} + +/** 异步命令的输出捕获和信号转发选项 */ +export interface RunCommandOptions { + /** + * 本次子进程的环境覆盖,未指定的变量继承父环境 + * + * @description undefined 删除变量;Windows 运行时可补回 PATH 等必需系统变量 + */ + env?: NodeJS.ProcessEnv; + /** 捕获 stdout 和 stderr;默认直接继承当前终端 */ + capture?: boolean; + /** 转发 SIGINT、SIGTERM 并在父进程退出时清理子进程;默认启用 */ + forwardSignals?: boolean; + /** 捕获输出的总字节上限,默认 16 MiB */ + maxBuffer?: number; +} + +function decodeOutput(output: Buffer): string { + if (output.length === 0) return ''; + const detected = jschardet.detect(output); + return detected.encoding && iconv.encodingExists(detected.encoding) + ? iconv.decode(output, detected.encoding) + : output.toString('utf8'); +} + +function signalExitCode(signal: NodeJS.Signals | null): number { + return signal ? 128 + (constants.signals[signal] ?? 0) : 1; +} + +function spawnExitCode(error: unknown): number { + return (error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT' ? 127 : 1; +} + +function windowsEnvironmentValue(env: NodeJS.ProcessEnv, name: string): string | undefined { + const key = Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase()); + return key ? env[key] : undefined; +} + +function childEnvironment(overrides: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const [name, value] of Object.entries(overrides ?? {})) { + const names = process.platform === 'win32' + ? Object.keys(env).filter(key => key.toLowerCase() === name.toLowerCase()) + : [name]; + for (const key of names) delete env[key]; + if (value !== undefined) env[name] = value; + } + return env; +} + +function resolveWindowsCommand(command: string, cwd: string, env: NodeJS.ProcessEnv): string { + const extensions = path.extname(command) + ? [''] + : ['', ...(windowsEnvironmentValue(env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD').split(';')]; + const directories = command.includes('/') || command.includes('\\') + ? [cwd] + : [cwd, ...(windowsEnvironmentValue(env, 'PATH') ?? '').split(';')]; + for (const directory of directories) { + for (const extension of extensions) { + const candidate = path.resolve(directory.replace(/^"|"$/g, ''), `${command}${extension}`); + try { + if (statSync(candidate).isFile()) return candidate; + } catch { + // PATH 中不存在的目录不影响后续候选项 + } + } + } + return command; +} + +const cmdMetaCharacters = /([()\][%!^"`<>&|;, *?])/g; + +function escapeWindowsArgument(argument: string, doubleEscape: boolean): string { + // 参照 cross-spawn 的两层解析策略,保留引号前及末尾的反斜杠 + let escaped = `"${argument.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"` + .replace(cmdMetaCharacters, '^$1'); + if (doubleEscape) escaped = escaped.replace(cmdMetaCharacters, '^$1'); + return escaped; +} + +function prepareCommand(command: string, args: string[], cwd: string, env = process.env) { + if (process.platform !== 'win32') return { command, args, env, windowsVerbatimArguments: false }; + const resolved = resolveWindowsCommand(command, cwd, env); + if (!/\.(?:cmd|bat)$/i.test(resolved)) return { command, args, env, windowsVerbatimArguments: false }; + if ([resolved, ...args].some(value => /[\r\n\0]/.test(value))) { + throw new Error('Windows 批处理入口不支持包含换行或空字符的命令和参数'); + } + const doubleEscape = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(resolved); + const words = [ + path.normalize(resolved).replace(cmdMetaCharacters, '^$1'), + ...args.map(argument => escapeWindowsArgument(argument, doubleEscape)), + ]; + const childEnv = { ...env }; + const prefix = `JAVA_RUN_EXEC_${randomUUID().replace(/-/g, '')}`; + // cmd 变量替换不递归,先放入环境变量可阻止参数中的 %NAME% 被展开 + const commandLine = words.map((word, index) => { + const name = `${prefix}_${index}`; + childEnv[name] = word; + return `%${name}%`; + }).join(' '); + return { + command: windowsEnvironmentValue(env, 'COMSPEC') ?? 'cmd.exe', + args: ['/d', '/v:off', '/s', '/c', `"${commandLine}"`], + env: childEnv, + windowsVerbatimArguments: true, + }; +} + +/** + * 执行外部命令并等待完整退出 + * + * @description + * - 正常退出及非零退出均返回结果,启动失败或输出超限抛出 CommandError + * - 默认继承终端,capture 模式保留输出中的换行和首尾空白 + * - Unix 子进程使用独立进程组,转发信号及退出清理只作用于该命令的进程树 + */ +export function runCommand(spec: CommandSpec, options: RunCommandOptions = {}): Promise { + const maxBuffer = options.maxBuffer ?? 16 * 1024 * 1024; + if (!Number.isSafeInteger(maxBuffer) || maxBuffer < 0) { + return Promise.reject(new RangeError('maxBuffer 必须是非负安全整数')); + } + return new Promise((resolve, reject) => { + const detached = process.platform !== 'win32'; + let child: ReturnType; + try { + const prepared = prepareCommand(spec.command, spec.args, spec.cwd, childEnvironment(options.env)); + child = spawn(prepared.command, prepared.args, { + cwd: spec.cwd, + env: prepared.env, + stdio: options.capture ? ['inherit', 'pipe', 'pipe'] : 'inherit', + detached, + shell: false, + windowsVerbatimArguments: prepared.windowsVerbatimArguments, + }); + } catch (error) { + reject(new CommandError(spec, { exitCode: spawnExitCode(error), signal: null, stdout: '', stderr: '' }, error)); + return; + } + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let capturedBytes = 0; + let outputExceeded = false; + let spawnError: Error | undefined; + let escalation: ReturnType | undefined; + let receivedSignal: NodeJS.Signals | null = null; + + function killTree(signal: NodeJS.Signals): void { + if (!child.pid) return; + if (process.platform === 'win32') { + // Windows 没有 POSIX 进程组,taskkill /T 覆盖批处理启动的 JVM + const taskkill = path.join(windowsEnvironmentValue(process.env, 'SystemRoot') ?? 'C:\\Windows', 'System32', 'taskkill.exe'); + spawnSync(taskkill, ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true, timeout: 5000 }); + return; + } + try { + process.kill(-child.pid, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') child.kill(signal); + } + } + + function forwardSignal(signal: NodeJS.Signals): void { + killTree(receivedSignal ? 'SIGKILL' : signal); + receivedSignal ??= signal; + escalation ??= setTimeout(() => killTree('SIGKILL'), 3000); + escalation.unref(); + } + + const onSigint = () => forwardSignal('SIGINT'); + const onSigterm = () => forwardSignal('SIGTERM'); + const onParentExit = () => killTree('SIGKILL'); + if (options.forwardSignals !== false) { + process.on('SIGINT', onSigint); + process.on('SIGTERM', onSigterm); + process.on('exit', onParentExit); + } + + function capture(chunks: Buffer[], data: Buffer): void { + const remaining = Math.max(0, maxBuffer - capturedBytes); + if (remaining > 0) chunks.push(Buffer.from(data.subarray(0, remaining))); + capturedBytes += data.length; + if (capturedBytes > maxBuffer && !outputExceeded) { + outputExceeded = true; + killTree('SIGKILL'); + } + } + + child.stdout?.on('data', (data: Buffer) => capture(stdout, data)); + child.stderr?.on('data', (data: Buffer) => capture(stderr, data)); + child.once('error', error => { spawnError = error; }); + child.once('exit', () => { + // 组长退出后清理仍持有终端或输出管道的后代进程 + killTree('SIGKILL'); + }); + child.once('close', (exitCode, signal) => { + if (escalation) clearTimeout(escalation); + process.removeListener('SIGINT', onSigint); + process.removeListener('SIGTERM', onSigterm); + process.removeListener('exit', onParentExit); + const effectiveSignal = signal ?? (process.platform === 'win32' ? receivedSignal : null); + const result: CommandResult = { + exitCode: spawnError ? spawnExitCode(spawnError) + : process.platform === 'win32' && receivedSignal ? signalExitCode(receivedSignal) + : exitCode ?? signalExitCode(effectiveSignal), + signal: effectiveSignal, + stdout: decodeOutput(Buffer.concat(stdout)), + stderr: decodeOutput(Buffer.concat(stderr)), + }; + if (spawnError || outputExceeded) { + reject(new CommandError(spec, result, + spawnError ?? new Error(`命令输出超过 ${maxBuffer} 字节`))); + } else { + resolve(result); + } + }); + }); +} diff --git a/src/process/java-arguments.ts b/src/process/java-arguments.ts new file mode 100644 index 0000000..eb37bfd --- /dev/null +++ b/src/process/java-arguments.ts @@ -0,0 +1,55 @@ +import { isAbsolute } from 'node:path'; +import iconv from 'iconv-lite'; +import { CommandError, runCommand } from './exec'; + +const javaEncodings = new Map(); + +/** 参数无法完整经过 JDK 原生命令行编码,诊断不包含原始参数 */ +export class JavaArgumentEncodingError extends Error { + readonly encoding: string; + + constructor(encoding: string) { + super(`Windows JDK 的命令行编码 ${encoding} 无法完整表示部分参数;请在系统区域设置中启用“使用 Unicode UTF-8 提供全球语言支持”并按系统要求重启,或改用 ASCII 参数。-Dfile.encoding=UTF-8 不能改变命令行编码`); + this.name = 'JavaArgumentEncodingError'; + this.encoding = encoding; + } +} + +/** 读取真实 JDK 的原生编码,检测失败时保留命令诊断 */ +export async function readJavaNativeEncoding(javaCommand: string, cwd: string): Promise { + const cached = isAbsolute(javaCommand) ? javaEncodings.get(javaCommand) : undefined; + if (cached) return cached; + const spec = { command: javaCommand, args: ['-XshowSettings:properties', '-version'], cwd, stage: '检测 Java 原生命令行编码' }; + const result = await runCommand(spec, { capture: true }); + if (result.exitCode !== 0) throw new CommandError(spec, result); + const properties = `${result.stdout}\n${result.stderr}`; + const encoding = /^\s*native\.encoding\s*=\s*(\S+)\s*$/m.exec(properties)?.[1] + ?? /^\s*sun\.jnu\.encoding\s*=\s*(\S+)\s*$/m.exec(properties)?.[1]; + if (!encoding) throw new Error('JDK 未报告 native.encoding 或 sun.jnu.encoding,无法安全传递非 ASCII 参数'); + if (isAbsolute(javaCommand)) javaEncodings.set(javaCommand, encoding); + return encoding; +} + +/** + * 检查参数能否完整经过指定的 Java 原生命令行编码 + * + * @description 不可表示的参数抛出 JavaArgumentEncodingError;无法识别的编码抛出 Error,不触发参数降级 + */ +export function assertRepresentableArguments(args: readonly string[], encoding: string): void { + if (!iconv.encodingExists(encoding)) { + throw new Error(`无法验证 Windows JDK 的命令行编码 ${encoding};请使用支持 UTF-8 的系统区域设置或 ASCII 参数`); + } + if (args.some(argument => iconv.decode(iconv.encode(argument, encoding), encoding) !== argument)) { + throw new JavaArgumentEncodingError(encoding); + } +} + +/** + * 在 Windows 调用 Java 工具前检查参数,避免原生启动器静默替换字符 + * + * @description 其他平台及纯 ASCII 参数不执行探针;编码检测失败保留命令诊断,参数校验错误不包含原始参数 + */ +export async function assertJavaArguments(javaCommand: string, args: readonly string[], cwd: string): Promise { + if (process.platform !== 'win32' || args.every(argument => /^[\x00-\x7f]*$/.test(argument))) return; + assertRepresentableArguments(args, await readJavaNativeEncoding(javaCommand, cwd)); +} diff --git a/tests/compile.test.ts b/tests/compile.test.ts new file mode 100644 index 0000000..02a8803 --- /dev/null +++ b/tests/compile.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; +import { sha256, verifyBinaryChecksum, writeBinaryChecksum } from '../scripts/checksum'; +import { parseCompileOptions } from '../scripts/compile'; +import { version } from '../package.json'; + +const compileScript = resolve(import.meta.dir, '../scripts/compile.ts'); + +describe('独立二进制构建', () => { + test('本机输出与五个平台选项使用同一入口,拒绝未知、重复和无效选项', () => { + const root = resolve('compile-workspace'); + expect(parseCompileOptions([], root)).toEqual({ outfile: join(root, 'dist/java-run') }); + for (const target of [ + 'bun-windows-x64', 'bun-linux-x64', 'bun-linux-arm64', 'bun-darwin-arm64', 'bun-darwin-x64', + ] as const) { + expect(parseCompileOptions([`--target=${target}`, '--outfile=dist/custom app=a'], root)) + .toEqual({ target, outfile: join(root, 'dist/custom app=a') }); + } + for (const args of [ + ['--minify'], ['--target'], ['--target=unknown'], ['--target= bun-linux-arm64'], + ['--target=bun-linux-arm64', '--target=bun-darwin-arm64'], + ['--outfile='], ['--outfile= '], ['--outfile=a', '--outfile=b'], ['--outfile=a\0b'], ['--outfile=a\nb'], + ]) expect(() => parseCompileOptions(args, root)).toThrow(); + }); + + test('实际构建生成并更新文件名对应的校验和,二进制保留环境与版本契约', async () => { + const root = mkdtempSync(join(tmpdir(), 'java-run-compile-')); + try { + const outfile = join(root, 'custom app=a'); + const executable = process.platform === 'win32' ? `${outfile}.exe` : outfile; + const compiled = spawnSync(process.execPath, [compileScript, `--outfile=${outfile}`], { encoding: 'utf8' }); + expect(compiled.status).toBe(0); + const sidecar = `${executable}.sha256`; + const digest = createHash('sha256').update(readFileSync(executable)).digest('hex'); + expect(readFileSync(sidecar, 'utf8')).toBe(`${digest} ${basename(executable)}\n`); + expect(await verifyBinaryChecksum(executable)).toBe(sidecar); + writeFileSync(sidecar, `${'0'.repeat(64)} old-filename\n`); + const recompiled = spawnSync(process.execPath, [compileScript, `--outfile=${outfile}`], { encoding: 'utf8' }); + expect(recompiled.status).toBe(0); + expect(await verifyBinaryChecksum(executable)).toBe(sidecar); + const dotenvRoot = 'JAVA_RUN_UNEXPECTED_DOTENV_ROOT'; + writeFileSync(join(root, '.env'), `TMPDIR=${dotenvRoot}\nTEMP=${dotenvRoot}\nTMP=${dotenvRoot}\n`); + writeFileSync(join(root, 'bunfig.toml'), 'this is not valid TOML = ['); + writeFileSync(join(root, 'pom.xml'), ''); + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (['tmpdir', 'temp', 'tmp'].includes(key.toLowerCase())) delete env[key]; + } + const binaryVersion = spawnSync(executable, ['version'], { cwd: root, env, encoding: 'utf8' }); + expect(binaryVersion.status).toBe(0); + expect(binaryVersion.stdout).toBe(`java-run ${version}\n`); + expect(binaryVersion.stderr).toBe(''); + const preview = spawnSync(executable, ['plan'], { cwd: root, env, encoding: 'utf8' }); + expect(preview.status).toBe(0); + expect(preview.stdout).not.toContain(dotenvRoot); + const inheritedRoot = join(root, 'inherited-temporary-directory'); + mkdirSync(inheritedRoot); + const inherited = spawnSync(executable, ['plan'], { + cwd: root, env: { ...env, TMPDIR: inheritedRoot, TEMP: inheritedRoot, TMP: inheritedRoot }, encoding: 'utf8', + }); + expect(inherited.status).toBe(0); + const metadata = JSON.parse(inherited.stdout); + expect(metadata.commands[1].args).toContain(`-Doutput=${join(inheritedRoot, '', 'project-file.txt')}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 30000); + + test('参数错误保留已有输出,编译失败清除旧校验和并保留原输出', () => { + const root = mkdtempSync(join(tmpdir(), 'java-run-compile-failure-')); + try { + const outfile = join(root, 'blocked'); + const executable = process.platform === 'win32' ? `${outfile}.exe` : outfile; + const sidecar = `${executable}.sha256`; + const scripts = join(root, 'scripts'); + mkdirSync(scripts); + for (const name of ['compile.ts', 'checksum.ts', 'platforms.ts']) { + copyFileSync(resolve(import.meta.dir, `../scripts/${name}`), join(scripts, name)); + } + const brokenCompile = join(scripts, 'compile.ts'); + mkdirSync(join(root, 'src')); + writeFileSync(join(root, 'src/cli.ts'), 'export const = invalid source'); + writeFileSync(executable, 'previous binary'); + writeFileSync(sidecar, 'previous checksum\n'); + const invalid = spawnSync(process.execPath, [brokenCompile, `--outfile=${outfile}`, '--unknown'], { encoding: 'utf8' }); + expect(invalid.status).not.toBe(0); + expect(readFileSync(sidecar, 'utf8')).toBe('previous checksum\n'); + const failed = spawnSync(process.execPath, [brokenCompile, `--outfile=${outfile}`], { encoding: 'utf8' }); + expect(failed.status).not.toBe(0); + expect(existsSync(sidecar)).toBe(false); + expect(readFileSync(executable, 'utf8')).toBe('previous binary'); + } finally { rmSync(root, { recursive: true, force: true }); } + }, 30000); + + test('校验拒绝过期摘要、其他文件名、格式错误及无效文件', async () => { + const root = mkdtempSync(join(tmpdir(), 'java-run-checksum-')); + try { + const executable = join(root, 'custom app=a'); + const sidecar = `${executable}.sha256`; + writeFileSync(executable, 'binary version one'); + await expect(verifyBinaryChecksum(executable)).rejects.toThrow(); + expect(await writeBinaryChecksum(executable)).toBe(sidecar); + expect(await verifyBinaryChecksum(executable)).toBe(sidecar); + const content = readFileSync(sidecar, 'utf8'); + writeFileSync(executable, 'binary version two'); + await expect(verifyBinaryChecksum(executable)).rejects.toThrow(); + writeFileSync(executable, 'binary version one'); + for (const invalid of [content.replace('custom app=a', 'another-file'), content.trimEnd(), `${content}extra\n`, '']) { + writeFileSync(sidecar, invalid); + await expect(verifyBinaryChecksum(executable)).rejects.toThrow(); + } + rmSync(sidecar); + mkdirSync(sidecar); + await expect(verifyBinaryChecksum(executable)).rejects.toThrow(); + rmSync(sidecar, { recursive: true }); + writeFileSync(executable, ''); + await expect(writeBinaryChecksum(executable)).rejects.toThrow(); + await expect(sha256(root)).rejects.toThrow(); + if (process.platform !== 'win32') { + const linked = join(root, 'linked'); + writeFileSync(executable, 'valid binary'); + symlinkSync(executable, linked); + await expect(sha256(linked)).rejects.toThrow(); + symlinkSync(executable, sidecar); + await expect(verifyBinaryChecksum(executable)).rejects.toThrow(); + } + } finally { rmSync(root, { recursive: true, force: true }); } + }); + + test.skipIf(process.platform === 'win32')('反斜杠文件名使用 GNU 校验和转义格式', async () => { + const root = mkdtempSync(join(tmpdir(), 'java-run-checksum-escape-')); + try { + const executable = join(root, 'custom\\app'); + writeFileSync(executable, 'binary'); + const sidecar = await writeBinaryChecksum(executable); + const digest = createHash('sha256').update('binary').digest('hex'); + expect(readFileSync(sidecar, 'utf8')).toBe(`\\${digest} custom\\\\app\n`); + expect(await verifyBinaryChecksum(executable)).toBe(sidecar); + const verified = process.platform === 'darwin' + ? spawnSync('shasum', ['-a', '256', '-c', basename(sidecar)], { cwd: root, encoding: 'utf8' }) + : spawnSync('sha256sum', ['-c', basename(sidecar)], { cwd: root, encoding: 'utf8' }); + expect(verified.status).toBe(0); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +}); diff --git a/tests/exec.test.ts b/tests/exec.test.ts new file mode 100644 index 0000000..72e971c --- /dev/null +++ b/tests/exec.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runCommand } from '../src/process/exec'; +import type { CommandSpec } from '../src/core/types'; + +const temporaryDirectories: string[] = []; +const processChild = fileURLToPath(new URL('./helpers/process-child.ts', import.meta.url)); + +function temporaryDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'java-run exec # ')); + temporaryDirectories.push(directory); + return directory; +} + +function command(source: string, cwd = process.cwd(), args: string[] = []): CommandSpec { + return { command: process.execPath, args: ['-e', source, '--', ...args], cwd, stage: '进程测试' }; +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitUntil(condition: () => boolean, timeout = 5000): Promise { + const deadline = Date.now() + timeout; + while (!condition()) { + if (Date.now() > deadline) throw new Error('等待真实进程状态超时'); + await new Promise(resolve => setTimeout(resolve, 10)); + } +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +describe('异步执行', () => { + test('捕获 stdout 与 stderr 并保留原始空白', async () => { + const result = await runCommand(command('process.stdout.write(" out \\n"); process.stderr.write(" err \\n")'), { capture: true }); + expect(result).toEqual({ exitCode: 0, signal: null, stdout: ' out \n', stderr: ' err \n' }); + }); + + test('非零退出返回完整结果,默认继承终端返回空输出', async () => { + expect(await runCommand(command('process.exit(5)'))).toEqual({ exitCode: 5, signal: null, stdout: '', stderr: '' }); + }); + + test('非零退出的 stderr 可用于 Maven 或 Java 诊断', async () => { + const result = await runCommand(command('process.stdout.write("前置输出\\n"); process.stderr.write("编译失败\\n"); process.exit(7)'), { capture: true }); + expect(result).toEqual({ exitCode: 7, signal: null, stdout: '前置输出\n', stderr: '编译失败\n' }); + }); + + test('参数中的空格、中文和 shell 字符保持原边界', async () => { + const args = ['hello world', '中文#目录', '-Dkey=a=b', '%JAVA_RUN%&echo bad', '"quoted"', 'C:\\trailing\\', '']; + const result = await runCommand(command('console.log(JSON.stringify(process.argv.slice(1)))', temporaryDirectory(), args), { capture: true }); + expect(JSON.parse(result.stdout)).toEqual(args); + }); + + test('子环境覆盖保留未指定变量且不修改父环境', async () => { + const pathName = Object.keys(process.env).find(name => name.toLowerCase() === 'path')!; + const originalPath = process.env[pathName]; + const originalValue = process.env.JAVA_RUN_CHILD_OPTION; + const value = 'child value 中文 # %'; + const source = 'console.log(JSON.stringify({value:process.env.JAVA_RUN_CHILD_OPTION,path:process.env[Object.keys(process.env).find(name=>name.toLowerCase()==="path")]}))'; + const result = await runCommand(command(source), { capture: true, env: { JAVA_RUN_CHILD_OPTION: value } }); + expect(JSON.parse(result.stdout)).toEqual({ value, path: originalPath }); + expect({ path: process.env[pathName], value: process.env.JAVA_RUN_CHILD_OPTION }) + .toEqual({ path: originalPath, value: originalValue }); + }); + + test('undefined 仅从子环境移除变量,Windows 覆盖按名称忽略大小写', async () => { + const variableName = 'JAVA_RUN_CHILD_REMOVE'; + const originalValue = process.env[variableName]; + const removalName = process.platform === 'win32' ? variableName.toLowerCase() : variableName; + process.env[variableName] = 'parent-only-value'; + try { + const removed = await runCommand(command('console.log(JSON.stringify({value:process.env.JAVA_RUN_CHILD_REMOVE}))'), { + capture: true, env: { [removalName]: undefined }, + }); + expect(JSON.parse(removed.stdout)).toEqual({}); + expect(process.env[variableName]).toBe('parent-only-value'); + } finally { + if (originalValue === undefined) delete process.env[variableName]; + else process.env[variableName] = originalValue; + } + const pathName = Object.keys(process.env).find(name => name.toLowerCase() === 'path')!; + const originalPath = process.env[pathName]; + const overrideName = process.platform === 'win32' ? pathName.toLowerCase() : pathName; + const source = 'console.log(JSON.stringify({path:process.env[Object.keys(process.env).find(name=>name.toLowerCase()==="path")]}))'; + const overwritten = await runCommand(command(source), { capture: true, env: { [overrideName]: 'child-only-path' } }); + expect(JSON.parse(overwritten.stdout)).toEqual({ path: 'child-only-path' }); + expect({ path: process.env[pathName] }).toEqual({ path: originalPath }); + }); + + test('不存在的命令拒绝 Promise 并保留阶段', async () => { + const spec = { command: path.join(temporaryDirectory(), 'missing-command'), args: [], cwd: process.cwd(), stage: '依赖解析' }; + await expect(runCommand(spec, { capture: true })).rejects.toMatchObject({ name: 'CommandError', exitCode: 127, stage: '依赖解析' }); + }); + + test('执行完毕及启动失败都移除当前调用的信号监听', async () => { + const signals = ['SIGINT', 'SIGTERM', 'exit'] as const; + const original = signals.map(signal => process.listenerCount(signal)); + await runCommand(command('process.exit(0)')); + expect(signals.map(signal => process.listenerCount(signal))).toEqual(original); + await runCommand({ command: path.join(temporaryDirectory(), 'missing'), args: [], cwd: process.cwd(), stage: '启动失败' }).catch(() => {}); + expect(signals.map(signal => process.listenerCount(signal))).toEqual(original); + }); + + test('捕获输出超限时终止命令,保留有界诊断输出', async () => { + await expect(runCommand(command('process.stdout.write("x".repeat(10000)); setInterval(() => {}, 1000)'), { + capture: true, + maxBuffer: 128, + })).rejects.toMatchObject({ name: 'CommandError', stdout: 'x'.repeat(128) }); + }); +}); + +describe.skipIf(process.platform === 'win32')('Unix 进程树清理', () => { + test.each(['SIGTERM', 'SIGINT'] as const)('%s 转发到独立子进程组且不遗留 JVM 式后代', async signal => { + const directory = temporaryDirectory(); + const runner = spawn(process.execPath, [processChild, 'runner', directory], { stdio: 'ignore' }); + const completed = new Promise((resolve, reject) => { + runner.once('error', reject); + runner.once('exit', () => resolve()); + }); + const childPidPath = path.join(directory, 'child.pid'); + const leafPidPath = path.join(directory, 'leaf.pid'); + let childPid: number | undefined; + let leafPid: number | undefined; + try { + await waitUntil(() => existsSync(childPidPath) && existsSync(leafPidPath)); + childPid = Number(readFileSync(childPidPath, 'utf8')); + leafPid = Number(readFileSync(leafPidPath, 'utf8')); + runner.kill(signal); + await completed; + await waitUntil(() => !isAlive(childPid!) && !isAlive(leafPid!)); + const result = JSON.parse(readFileSync(path.join(directory, 'result.json'), 'utf8')); + expect(result).toMatchObject({ signal, exitCode: signal === 'SIGINT' ? 130 : 143 }); + } finally { + runner.kill('SIGKILL'); + if (childPid && isAlive(childPid)) process.kill(-childPid, 'SIGKILL'); + if (leafPid && isAlive(leafPid)) process.kill(leafPid, 'SIGKILL'); + } + }, 10000); + + test('主进程正常结束也清理继续运行的后代', async () => { + const directory = temporaryDirectory(); + const result = await runCommand({ command: process.execPath, args: [processChild, 'orphan', directory], cwd: directory, stage: '正常退出清理' }, { capture: true }); + const leafPid = Number(readFileSync(path.join(directory, 'leaf.pid'), 'utf8')); + expect(result.exitCode).toBe(0); + await waitUntil(() => !isAlive(leafPid)); + }, 10000); + + test('执行器所在父进程退出时清理整个子进程组', async () => { + const directory = temporaryDirectory(); + const runner = spawn(process.execPath, [processChild, 'exiting-runner', directory], { stdio: 'ignore' }); + await new Promise((resolve, reject) => { + runner.once('error', reject); + runner.once('exit', () => resolve()); + }); + const childPid = Number(readFileSync(path.join(directory, 'child.pid'), 'utf8')); + const leafPid = Number(readFileSync(path.join(directory, 'leaf.pid'), 'utf8')); + await waitUntil(() => !isAlive(childPid) && !isAlive(leafPid)); + }, 10000); +}); + +describe.skipIf(process.platform !== 'win32')('Windows 批处理入口', () => { + test('空格路径及特殊参数经 cmd 转发到原生命令', async () => { + const directory = temporaryDirectory(); + const batch = path.join(directory, 'Maven entry.cmd'); + writeFileSync(batch, `@echo off\r\n"${process.execPath}" "${processChild}" args %*\r\n`); + const args = ['hello world', '中文#路径', '-Dkey=a=b', 'a&echo bad', '%PATH%', '%UNDEFINED_JAVA_RUN_VAR%', 'bang!', '']; + const result = await runCommand({ command: batch, args, cwd: directory, stage: 'Windows 参数转发' }, { capture: true }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(args); + }); + + test('批处理参数包含换行时拒绝执行', async () => { + const directory = temporaryDirectory(); + const batch = path.join(directory, 'entry.cmd'); + writeFileSync(batch, '@echo off\r\nexit /b 0\r\n'); + await expect(runCommand({ command: batch, args: ['value\r\necho injected'], cwd: directory, stage: '批处理参数检查' })) + .rejects.toMatchObject({ name: 'CommandError', exitCode: 1 }); + }); + + test('转发入口清理批处理及全部后代,并保留终止状态', async () => { + const directory = temporaryDirectory(); + const runner = spawn(process.execPath, [processChild, 'windows-runner', directory], { stdio: 'ignore' }); + const completed = new Promise((resolve, reject) => { + runner.once('error', reject); + runner.once('exit', () => resolve()); + }); + let childPid: number | undefined; + let leafPid: number | undefined; + try { + await waitUntil(() => existsSync(path.join(directory, 'result.json')), 10000); + await completed; + childPid = Number(readFileSync(path.join(directory, 'child.pid'), 'utf8')); + leafPid = Number(readFileSync(path.join(directory, 'leaf.pid'), 'utf8')); + expect(JSON.parse(readFileSync(path.join(directory, 'result.json'), 'utf8'))) + .toMatchObject({ signal: 'SIGTERM', exitCode: 143 }); + await waitUntil(() => !isAlive(childPid!) && !isAlive(leafPid!)); + } finally { + for (const pid of [runner.pid, childPid, leafPid]) { + if (pid && isAlive(pid)) spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }); + } + } + }, 15000); +}); diff --git a/tests/fixtures/.gitignore b/tests/fixtures/.gitignore new file mode 100644 index 0000000..3004565 --- /dev/null +++ b/tests/fixtures/.gitignore @@ -0,0 +1,3 @@ +target/ +.gradle/ +build/ diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..0e52fc2 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,69 @@ +# Java 源码启动验证夹具 + +这些项目用于 [smoke 脚本](../../scripts/smoke.ts) 的原生二进制验收,覆盖普通 Java、Spring Boot 依赖和 Gradle 多项目构建。 +运行方式与测试环境见 [开发指南](../../CONTRIBUTING.md#二进制验收)。 + +脚本先复制夹具到临时目录,过滤 `target`、`build`、`.gradle` 和 `.cache` 等产物,再执行构建。 +Maven 使用独立 settings 和本地仓库,Gradle 使用独立用户目录。 +夹具源码应始终保持可从未构建状态运行。 + +## 项目与观测点 + +| 夹具 | 验证内容 | +| --- | --- | +| `boot-single` | Spring Boot 运行依赖、主类发现、正常退出和各层参数 | +| `boot-reactor` | `app` 依赖 `lib`、上游库与资源更新、有效 POM 变化、无关项目隔离 | +| `plain` | 显式主类、初始化与配置复用、测试类路径、参数优先级、依赖变化、Maven 配置根和非零退出 | +| `gradle-reactor` | `application` 主类声明、模块配置复用、上游库、资源与依赖变化、测试类路径和无关项目隔离 | + +夹具以 Java 17 为最低版本,依赖版本在 POM 和 Gradle 构建文件中固定。 +Gradle 夹具不携带 Wrapper,CI 安装固定版本 Gradle;本地可通过 `JAVA_RUN_GRADLE_COMMAND` 指定 smoke 使用的命令。 + +应用输出 `[fixture]` 标记供脚本断言: + +| 观测点 | 判断依据 | +| --- | --- | +| Boot 启动 | 禁用 Web,输出标记后关闭上下文并正常退出 | +| 无关项目隔离 | `other-app` 没有生成构建目录,输出中没有 `FORBIDDEN_OTHER_APP` | +| 构建参数 | Maven 的 `ci` profile 或 Gradle 的 `-PfixtureProfile=ci` 改变资源中的 profile 标记 | +| JVM 参数 | Spring profile 通过 JVM 系统属性传入,与构建参数分别验证 | +| 测试类路径 | `test-dependency` 和 `test-class` 默认均为 `absent`,启用后均为 `present` | + +测试依赖标记来自 `commons-lang3`,测试类标记来自各项目的测试源码。 +资源和依赖版本均由应用读取实际加载结果,作为模型解析与类路径组装的验证依据。 + +## 验收范围 + +quick 套件验证帮助、版本、静态预览和基本启动,并覆盖 Maven / Gradle 的初始化与配置复用。 +`init` 用例检查保存的入口和参数、初始化期间不启动应用,以及已有配置的覆盖保护。 + +full 套件还会在同一临时副本中修改构建声明与资源,再运行应用验证更新结果: + +- Maven reactor 更新库资源标记,目标应用读取更新后的上游库 +- `plain` 修改 `commons.io.version`,实际加载的依赖版本随之变化 +- Gradle 修改库资源和应用依赖,分别验证资源准备与依赖重新解析 +- 启用测试类路径后恢复默认运行,测试输出与依赖不进入普通启动 +- 在 `.java-run.json` 默认值上追加或覆盖 CLI 参数,验证合并顺序 +- 应用收到 `--exit=7` 后退出,运行器保留退出码 + +`plain` 在临时副本中创建 `.mvn/maven.config`,验证配置根的两个位置: + +- 项目目录中的配置 +- 最近祖先目录中的配置 + +两种情况均检查属性参与资源过滤,并确认 `${maven.multiModuleProjectDirectory}` 是指向配置根的绝对路径。 +这些配置文件由脚本生成,夹具源码保持独立。 + +## 参数与平台编码 + +临时项目目录包含空格、中文、`#` 和 `%`,所有系统均保留这些路径。 +脚本根据实际 JDK 的原生命令行编码选择参数断言: + +- 能够完整表示 Unicode 参数时,验证参数往返传递 +- 无法完整表示时,使用可表示参数完成启动,并检查 JVM 参数和应用参数的拒绝行为 + +拒绝用例要求明确报错、应用未启动,且诊断不泄漏参数值。 +验收项数取决于套件、选定夹具和实际编码能力,以脚本输出为准。 + +smoke 验证真实构建模型与原生二进制启动。 +交互、信号转发和进程树清理由对应回归测试验证。 diff --git a/tests/fixtures/boot-reactor/app/pom.xml b/tests/fixtures/boot-reactor/app/pom.xml new file mode 100644 index 0000000..619175b --- /dev/null +++ b/tests/fixtures/boot-reactor/app/pom.xml @@ -0,0 +1,37 @@ + + + 4.0.0 + + org.javarun.fixture + boot-reactor + 1.0.0 + + app + + + org.javarun.fixture + lib + ${project.version} + + + org.springframework.boot + spring-boot-starter + + + org.apache.commons + commons-lang3 + 3.17.0 + test + + + + + + org.springframework.boot + spring-boot-maven-plugin + 3.4.4 + + + + diff --git a/tests/fixtures/boot-reactor/app/src/main/java/org/javarun/fixture/ReactorApplication.java b/tests/fixtures/boot-reactor/app/src/main/java/org/javarun/fixture/ReactorApplication.java new file mode 100644 index 0000000..5bff4e0 --- /dev/null +++ b/tests/fixtures/boot-reactor/app/src/main/java/org/javarun/fixture/ReactorApplication.java @@ -0,0 +1,34 @@ +package org.javarun.fixture; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ConfigurableApplicationContext; + +/** 仅启动 app 并验证 lib 依赖的 reactor 夹具 */ +@SpringBootApplication +public class ReactorApplication { + public static void main(String[] args) throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run(ReactorApplication.class, args)) { + System.out.println("[fixture] kind=boot-reactor-app"); + System.out.println("[fixture] library=" + LibraryMarker.value("library.marker")); + System.out.println("[fixture] library-version=" + LibraryMarker.value("library.version")); + System.out.println("[fixture] spring-profile=" + String.join(",", context.getEnvironment().getActiveProfiles())); + System.out.println("[fixture] maven-profile=" + context.getEnvironment().getProperty("fixture.maven.profile")); + System.out.println("[fixture] jvm-value=" + System.getProperty("fixture.jvm", "absent")); + for (String arg : args) { + System.out.println("[fixture] arg=" + arg); + } + System.out.println("[fixture] test-dependency=" + presence("org.apache.commons.lang3.StringUtils")); + System.out.println("[fixture] test-class=" + presence("org.javarun.fixture.ReactorTestMarker")); + } + } + + private static String presence(String className) { + try { + Class.forName(className); + return "present"; + } catch (ClassNotFoundException ignored) { + return "absent"; + } + } +} diff --git a/tests/fixtures/boot-reactor/app/src/main/resources/application.properties b/tests/fixtures/boot-reactor/app/src/main/resources/application.properties new file mode 100644 index 0000000..67bccbd --- /dev/null +++ b/tests/fixtures/boot-reactor/app/src/main/resources/application.properties @@ -0,0 +1,4 @@ +spring.main.web-application-type=none +spring.main.banner-mode=off +logging.level.root=ERROR +fixture.maven.profile=@fixture.maven.profile@ diff --git a/tests/fixtures/boot-reactor/app/src/test/java/org/javarun/fixture/ReactorTestMarker.java b/tests/fixtures/boot-reactor/app/src/test/java/org/javarun/fixture/ReactorTestMarker.java new file mode 100644 index 0000000..97bff77 --- /dev/null +++ b/tests/fixtures/boot-reactor/app/src/test/java/org/javarun/fixture/ReactorTestMarker.java @@ -0,0 +1,7 @@ +package org.javarun.fixture; + +/** 用于检测测试输出目录是否进入运行类路径 */ +public final class ReactorTestMarker { + private ReactorTestMarker() { + } +} diff --git a/tests/fixtures/boot-reactor/lib/pom.xml b/tests/fixtures/boot-reactor/lib/pom.xml new file mode 100644 index 0000000..77dfcf7 --- /dev/null +++ b/tests/fixtures/boot-reactor/lib/pom.xml @@ -0,0 +1,19 @@ + + + 4.0.0 + + org.javarun.fixture + boot-reactor + 1.0.0 + + lib + + + + src/main/resources + true + + + + diff --git a/tests/fixtures/boot-reactor/lib/src/main/java/org/javarun/fixture/LibraryMarker.java b/tests/fixtures/boot-reactor/lib/src/main/java/org/javarun/fixture/LibraryMarker.java new file mode 100644 index 0000000..10ba83b --- /dev/null +++ b/tests/fixtures/boot-reactor/lib/src/main/java/org/javarun/fixture/LibraryMarker.java @@ -0,0 +1,22 @@ +package org.javarun.fixture; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +/** 从依赖模块的实际产物读取标记 */ +public final class LibraryMarker { + private LibraryMarker() { + } + + public static String value(String name) throws IOException { + Properties properties = new Properties(); + try (InputStream stream = LibraryMarker.class.getResourceAsStream("/library.properties")) { + if (stream == null) { + throw new IOException("library.properties missing"); + } + properties.load(stream); + } + return properties.getProperty(name); + } +} diff --git a/tests/fixtures/boot-reactor/lib/src/main/resources/library.properties b/tests/fixtures/boot-reactor/lib/src/main/resources/library.properties new file mode 100644 index 0000000..3ee683f --- /dev/null +++ b/tests/fixtures/boot-reactor/lib/src/main/resources/library.properties @@ -0,0 +1,2 @@ +library.marker=@fixture.library.marker@ +library.version=@project.version@ diff --git a/tests/fixtures/boot-reactor/other-app/pom.xml b/tests/fixtures/boot-reactor/other-app/pom.xml new file mode 100644 index 0000000..60dbc72 --- /dev/null +++ b/tests/fixtures/boot-reactor/other-app/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + org.javarun.fixture + boot-reactor + 1.0.0 + + other-app + + + org.springframework.boot + spring-boot-starter + + + + + + org.springframework.boot + spring-boot-maven-plugin + 3.4.4 + + + + diff --git a/tests/fixtures/boot-reactor/other-app/src/main/java/org/javarun/fixture/OtherApplication.java b/tests/fixtures/boot-reactor/other-app/src/main/java/org/javarun/fixture/OtherApplication.java new file mode 100644 index 0000000..cd5a596 --- /dev/null +++ b/tests/fixtures/boot-reactor/other-app/src/main/java/org/javarun/fixture/OtherApplication.java @@ -0,0 +1,9 @@ +package org.javarun.fixture; + +/** 被误启动时令 smoke 明确失败 */ +public final class OtherApplication { + public static void main(String[] args) { + System.out.println("[fixture] FORBIDDEN_OTHER_APP"); + System.exit(23); + } +} diff --git a/tests/fixtures/boot-reactor/pom.xml b/tests/fixtures/boot-reactor/pom.xml new file mode 100644 index 0000000..7ab49f8 --- /dev/null +++ b/tests/fixtures/boot-reactor/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.4 + + + org.javarun.fixture + boot-reactor + 1.0.0 + pom + + lib + app + other-app + + + 17 + base + library-v1 + + + + ci + + ci + + + + diff --git a/tests/fixtures/boot-single/pom.xml b/tests/fixtures/boot-single/pom.xml new file mode 100644 index 0000000..465a00d --- /dev/null +++ b/tests/fixtures/boot-single/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.4 + + + org.javarun.fixture + boot-single + 1.0.0 + + 17 + base + + + + org.springframework.boot + spring-boot-starter + + + org.apache.commons + commons-lang3 + 3.17.0 + test + + + + + + org.springframework.boot + spring-boot-maven-plugin + 3.4.4 + + + + + + ci + + ci + + + + diff --git a/tests/fixtures/boot-single/src/main/java/org/javarun/fixture/SingleApplication.java b/tests/fixtures/boot-single/src/main/java/org/javarun/fixture/SingleApplication.java new file mode 100644 index 0000000..9517697 --- /dev/null +++ b/tests/fixtures/boot-single/src/main/java/org/javarun/fixture/SingleApplication.java @@ -0,0 +1,36 @@ +package org.javarun.fixture; + +import java.util.Arrays; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ConfigurableApplicationContext; + +/** 单模块启动契约夹具 */ +@SpringBootApplication +public class SingleApplication { + public static void main(String[] args) { + try (ConfigurableApplicationContext context = SpringApplication.run(SingleApplication.class, args)) { + System.out.println("[fixture] kind=boot-single"); + System.out.println("[fixture] spring-profile=" + String.join(",", context.getEnvironment().getActiveProfiles())); + System.out.println("[fixture] maven-profile=" + context.getEnvironment().getProperty("fixture.maven.profile")); + System.out.println("[fixture] jvm-value=" + System.getProperty("fixture.jvm", "absent")); + for (String arg : args) { + System.out.println("[fixture] arg=" + arg); + } + System.out.println("[fixture] test-dependency=" + presence("org.apache.commons.lang3.StringUtils")); + System.out.println("[fixture] test-class=" + presence("org.javarun.fixture.SingleTestMarker")); + if (Arrays.asList(args).contains("--exit=7")) { + System.exit(7); + } + } + } + + private static String presence(String className) { + try { + Class.forName(className); + return "present"; + } catch (ClassNotFoundException ignored) { + return "absent"; + } + } +} diff --git a/tests/fixtures/boot-single/src/main/resources/application.properties b/tests/fixtures/boot-single/src/main/resources/application.properties new file mode 100644 index 0000000..67bccbd --- /dev/null +++ b/tests/fixtures/boot-single/src/main/resources/application.properties @@ -0,0 +1,4 @@ +spring.main.web-application-type=none +spring.main.banner-mode=off +logging.level.root=ERROR +fixture.maven.profile=@fixture.maven.profile@ diff --git a/tests/fixtures/boot-single/src/test/java/org/javarun/fixture/SingleTestMarker.java b/tests/fixtures/boot-single/src/test/java/org/javarun/fixture/SingleTestMarker.java new file mode 100644 index 0000000..236e021 --- /dev/null +++ b/tests/fixtures/boot-single/src/test/java/org/javarun/fixture/SingleTestMarker.java @@ -0,0 +1,7 @@ +package org.javarun.fixture; + +/** 用于检测测试输出目录是否进入运行类路径 */ +public final class SingleTestMarker { + private SingleTestMarker() { + } +} diff --git a/tests/fixtures/gradle-reactor/app/build.gradle b/tests/fixtures/gradle-reactor/app/build.gradle new file mode 100644 index 0000000..456d246 --- /dev/null +++ b/tests/fixtures/gradle-reactor/app/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'application' + +application { + mainClass = 'org.javarun.fixture.GradleApplication' +} + +dependencies { + implementation project(':lib') + implementation 'commons-io:commons-io:2.18.0' + testImplementation 'org.apache.commons:commons-lang3:3.17.0' +} + +processResources { + def fixtureProfile = providers.gradleProperty('fixtureProfile').getOrElse('base') + inputs.property('fixtureProfile', fixtureProfile) + filesMatching('fixture.properties') { + expand(fixtureProfile: fixtureProfile) + } +} diff --git a/tests/fixtures/gradle-reactor/app/src/main/java/org/javarun/fixture/GradleApplication.java b/tests/fixtures/gradle-reactor/app/src/main/java/org/javarun/fixture/GradleApplication.java new file mode 100644 index 0000000..722d7a1 --- /dev/null +++ b/tests/fixtures/gradle-reactor/app/src/main/java/org/javarun/fixture/GradleApplication.java @@ -0,0 +1,42 @@ +package org.javarun.fixture; + +import java.io.InputStream; +import java.util.Arrays; +import java.util.Properties; +import org.apache.commons.io.IOUtils; + +/** 验证 Gradle application 主类、依赖项目和测试类路径 */ +public final class GradleApplication { + public static void main(String[] args) throws Exception { + Properties properties = new Properties(); + try (InputStream stream = GradleApplication.class.getResourceAsStream("/fixture.properties")) { + if (stream == null) { + throw new IllegalStateException("fixture.properties missing"); + } + properties.load(stream); + } + System.out.println("[fixture] kind=gradle-reactor-app"); + System.out.println("[fixture] library=" + GradleLibraryMarker.value("library.marker")); + System.out.println("[fixture] library-version=" + GradleLibraryMarker.value("library.version")); + System.out.println("[fixture] dependency-version=" + IOUtils.class.getPackage().getImplementationVersion()); + System.out.println("[fixture] gradle-profile=" + properties.getProperty("fixture.gradle.profile")); + System.out.println("[fixture] jvm-value=" + System.getProperty("fixture.jvm", "absent")); + for (String arg : args) { + System.out.println("[fixture] arg=" + arg); + } + System.out.println("[fixture] test-dependency=" + presence("org.apache.commons.lang3.StringUtils")); + System.out.println("[fixture] test-class=" + presence("org.javarun.fixture.GradleTestMarker")); + if (Arrays.asList(args).contains("--exit=7")) { + System.exit(7); + } + } + + private static String presence(String className) { + try { + Class.forName(className); + return "present"; + } catch (ClassNotFoundException ignored) { + return "absent"; + } + } +} diff --git a/tests/fixtures/gradle-reactor/app/src/main/resources/fixture.properties b/tests/fixtures/gradle-reactor/app/src/main/resources/fixture.properties new file mode 100644 index 0000000..fe33f7d --- /dev/null +++ b/tests/fixtures/gradle-reactor/app/src/main/resources/fixture.properties @@ -0,0 +1 @@ +fixture.gradle.profile=${fixtureProfile} diff --git a/tests/fixtures/gradle-reactor/app/src/test/java/org/javarun/fixture/GradleTestMarker.java b/tests/fixtures/gradle-reactor/app/src/test/java/org/javarun/fixture/GradleTestMarker.java new file mode 100644 index 0000000..c4ea4e2 --- /dev/null +++ b/tests/fixtures/gradle-reactor/app/src/test/java/org/javarun/fixture/GradleTestMarker.java @@ -0,0 +1,7 @@ +package org.javarun.fixture; + +/** 用于检测 Gradle 测试输出目录是否进入运行类路径 */ +public final class GradleTestMarker { + private GradleTestMarker() { + } +} diff --git a/tests/fixtures/gradle-reactor/build.gradle b/tests/fixtures/gradle-reactor/build.gradle new file mode 100644 index 0000000..3fd04b9 --- /dev/null +++ b/tests/fixtures/gradle-reactor/build.gradle @@ -0,0 +1,18 @@ +subprojects { + apply plugin: 'java' + group = 'org.javarun.fixture' + version = '1.0.0' + + repositories { + mavenCentral() + } + + java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + } +} diff --git a/tests/fixtures/gradle-reactor/lib/build.gradle b/tests/fixtures/gradle-reactor/lib/build.gradle new file mode 100644 index 0000000..bff5a90 --- /dev/null +++ b/tests/fixtures/gradle-reactor/lib/build.gradle @@ -0,0 +1,7 @@ +processResources { + inputs.property('libraryMarker', 'gradle-library-v1') + inputs.property('libraryVersion', project.version) + filesMatching('library.properties') { + expand(libraryMarker: 'gradle-library-v1', libraryVersion: project.version) + } +} diff --git a/tests/fixtures/gradle-reactor/lib/src/main/java/org/javarun/fixture/GradleLibraryMarker.java b/tests/fixtures/gradle-reactor/lib/src/main/java/org/javarun/fixture/GradleLibraryMarker.java new file mode 100644 index 0000000..ac77bf4 --- /dev/null +++ b/tests/fixtures/gradle-reactor/lib/src/main/java/org/javarun/fixture/GradleLibraryMarker.java @@ -0,0 +1,22 @@ +package org.javarun.fixture; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +/** 从 Gradle 依赖项目的资源读取实际产物标记 */ +public final class GradleLibraryMarker { + private GradleLibraryMarker() { + } + + public static String value(String name) throws IOException { + Properties properties = new Properties(); + try (InputStream stream = GradleLibraryMarker.class.getResourceAsStream("/library.properties")) { + if (stream == null) { + throw new IOException("library.properties missing"); + } + properties.load(stream); + } + return properties.getProperty(name); + } +} diff --git a/tests/fixtures/gradle-reactor/lib/src/main/resources/library.properties b/tests/fixtures/gradle-reactor/lib/src/main/resources/library.properties new file mode 100644 index 0000000..947169a --- /dev/null +++ b/tests/fixtures/gradle-reactor/lib/src/main/resources/library.properties @@ -0,0 +1,2 @@ +library.marker=${libraryMarker} +library.version=${libraryVersion} diff --git a/tests/fixtures/gradle-reactor/other-app/build.gradle b/tests/fixtures/gradle-reactor/other-app/build.gradle new file mode 100644 index 0000000..ee9acb5 --- /dev/null +++ b/tests/fixtures/gradle-reactor/other-app/build.gradle @@ -0,0 +1,5 @@ +apply plugin: 'application' + +application { + mainClass = 'org.javarun.fixture.OtherGradleApplication' +} diff --git a/tests/fixtures/gradle-reactor/other-app/src/main/java/org/javarun/fixture/OtherGradleApplication.java b/tests/fixtures/gradle-reactor/other-app/src/main/java/org/javarun/fixture/OtherGradleApplication.java new file mode 100644 index 0000000..73d8992 --- /dev/null +++ b/tests/fixtures/gradle-reactor/other-app/src/main/java/org/javarun/fixture/OtherGradleApplication.java @@ -0,0 +1,9 @@ +package org.javarun.fixture; + +/** 被误启动时令 smoke 明确失败 */ +public final class OtherGradleApplication { + public static void main(String[] args) { + System.out.println("[fixture] FORBIDDEN_OTHER_APP"); + System.exit(23); + } +} diff --git a/tests/fixtures/gradle-reactor/settings.gradle b/tests/fixtures/gradle-reactor/settings.gradle new file mode 100644 index 0000000..2e5acb5 --- /dev/null +++ b/tests/fixtures/gradle-reactor/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'java-run-gradle-fixture' +include 'lib', 'app', 'other-app' diff --git a/tests/fixtures/plain/pom.xml b/tests/fixtures/plain/pom.xml new file mode 100644 index 0000000..a0fe65e --- /dev/null +++ b/tests/fixtures/plain/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + org.javarun.fixture + plain + 1.0.0 + + 17 + UTF-8 + 2.18.0 + base + absent + + + + commons-io + commons-io + ${commons.io.version} + + + org.apache.commons + commons-lang3 + 3.17.0 + test + + + + + + src/main/resources + true + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + org.apache.maven.plugins + maven-install-plugin + 3.1.3 + + + + + + ci + + ci + + + + diff --git a/tests/fixtures/plain/src/main/java/org/javarun/fixture/PlainApplication.java b/tests/fixtures/plain/src/main/java/org/javarun/fixture/PlainApplication.java new file mode 100644 index 0000000..ae8c186 --- /dev/null +++ b/tests/fixtures/plain/src/main/java/org/javarun/fixture/PlainApplication.java @@ -0,0 +1,49 @@ +package org.javarun.fixture; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Properties; +import org.apache.commons.io.IOUtils; + +/** 验证直接 Java 模式的类路径、参数和退出码 */ +public final class PlainApplication { + public static void main(String[] args) throws Exception { + Properties properties = new Properties(); + try (InputStream stream = PlainApplication.class.getResourceAsStream("/fixture.properties")) { + if (stream == null) { + throw new IllegalStateException("fixture.properties missing"); + } + properties.load(new InputStreamReader(stream, StandardCharsets.UTF_8)); + } + System.out.println("[fixture] kind=plain"); + System.out.println("[fixture] dependency-version=" + IOUtils.class.getPackage().getImplementationVersion()); + System.out.println("[fixture] maven-profile=" + properties.getProperty("fixture.maven.profile")); + System.out.println("[fixture] maven-config=" + properties.getProperty("fixture.maven.config")); + Path mavenRoot = Path.of(properties.getProperty("fixture.maven.root")); + System.out.println("[fixture] maven-root-absolute=" + mavenRoot.isAbsolute()); + System.out.println("[fixture] maven-root-config=" + (Files.isRegularFile(mavenRoot.resolve(".mvn/maven.config")) ? "present" : "absent")); + System.out.println("[fixture] spring-profile=" + System.getProperty("spring.profiles.active", "absent")); + System.out.println("[fixture] jvm-value=" + System.getProperty("fixture.jvm", "absent")); + for (String arg : args) { + System.out.println("[fixture] arg=" + arg); + } + System.out.println("[fixture] test-dependency=" + presence("org.apache.commons.lang3.StringUtils")); + System.out.println("[fixture] test-class=" + presence("org.javarun.fixture.PlainTestMarker")); + if (Arrays.asList(args).contains("--exit=7")) { + System.exit(7); + } + } + + private static String presence(String className) { + try { + Class.forName(className); + return "present"; + } catch (ClassNotFoundException ignored) { + return "absent"; + } + } +} diff --git a/tests/fixtures/plain/src/main/resources/fixture.properties b/tests/fixtures/plain/src/main/resources/fixture.properties new file mode 100644 index 0000000..9d85906 --- /dev/null +++ b/tests/fixtures/plain/src/main/resources/fixture.properties @@ -0,0 +1,3 @@ +fixture.maven.profile=${fixture.maven.profile} +fixture.maven.config=${fixture.maven.config} +fixture.maven.root=${maven.multiModuleProjectDirectory} diff --git a/tests/fixtures/plain/src/test/java/org/javarun/fixture/PlainTestMarker.java b/tests/fixtures/plain/src/test/java/org/javarun/fixture/PlainTestMarker.java new file mode 100644 index 0000000..cfbdb90 --- /dev/null +++ b/tests/fixtures/plain/src/test/java/org/javarun/fixture/PlainTestMarker.java @@ -0,0 +1,7 @@ +package org.javarun.fixture; + +/** 用于检测测试输出目录是否进入运行类路径 */ +public final class PlainTestMarker { + private PlainTestMarker() { + } +} diff --git a/tests/gradle.test.ts b/tests/gradle.test.ts new file mode 100644 index 0000000..2f7e30d --- /dev/null +++ b/tests/gradle.test.ts @@ -0,0 +1,291 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CommandError } from '../src/process/exec'; +import { discoverGradleProjects, planGradle, prepareGradle } from '../src/build-tools/gradle'; +import type { RunConfig } from '../src/core/types'; + +const temporaryDirectories: string[] = []; +const availableGradle = process.env.JAVA_RUN_TEST_GRADLE ?? Bun.which('gradle'); + +function directory(): string { + const value = realpathSync(mkdtempSync(join(tmpdir(), 'java-run Gradle # 中文 % '))); + temporaryDirectories.push(value); + return value; +} + +function externalWorkspace(): string { + const value = realpathSync(mkdtempSync(join(tmpdir(), 'java-run-metadata-'))); + temporaryDirectories.push(value); + return value; +} + +function config(cwd: string, options: Partial = {}): RunConfig { + return { + action: 'run', cwd, buildTool: 'gradle', jvmArgs: [], applicationArgs: [], + buildArgs: [], build: 'auto', includeTests: false, ...options, + }; +} + +function file(root: string, name: string, contents: string | Buffer): void { + const location = join(root, name); + mkdirSync(join(location, '..'), { recursive: true }); + writeFileSync(location, contents); +} + +function expectSameDirectory(actual: string, expected: string): void { + // Java 与 Bun 可能分别保留长名或 8.3 短名,按目录身份验证目标 + const actualStat = statSync(actual, { bigint: true }); + const expectedStat = statSync(expected, { bigint: true }); + expect(actualStat.isDirectory()).toBe(true); + expect(actualStat.ino).not.toBe(0n); + expect({ device: actualStat.dev, inode: actualStat.ino }) + .toEqual({ device: expectedStat.dev, inode: expectedStat.ino }); +} + +function reactor(): string { + const root = directory(); + file(root, 'settings.gradle', "rootProject.name = 'runner-fixture'\ninclude 'app', 'lib', 'other'\n"); + file(root, 'app/build.gradle', ` +plugins { id 'application' } +dependencies { + implementation project(':lib') + runtimeOnly files('runtime-marker.jar') + testRuntimeOnly files('test-marker.jar') +} +application { + mainClass = 'example.Main' + applicationDefaultJvmArgs = ['-Dmessage=hello world', '-Xmx128m'] +} +tasks.named('test') { doFirst { throw new GradleException('不应执行测试') } } +`); + file(root, 'lib/build.gradle', "plugins { id 'java-library' }\n"); + file(root, 'other/build.gradle', "plugins { id 'java' }\ntasks.named('compileJava') { doFirst { throw new GradleException('不应编译无关项目') } }\n"); + file(root, 'app/src/main/java/example/Main.java', 'package example; public class Main { public static void main(String[] args) { System.out.println(Lib.value()); } }'); + file(root, 'lib/src/main/java/example/Lib.java', 'package example; public class Lib { public static String value() { return "fixture"; } }'); + file(root, 'app/src/test/java/example/TestMain.java', 'package example; public class TestMain { public static void main(String[] args) { System.out.println("test"); } }'); + file(root, 'other/src/main/java/example/Other.java', 'package example; public class Other {}'); + const emptyJar = Buffer.from('504b0506000000000000000000000000000000000000', 'hex'); + file(root, 'app/runtime-marker.jar', emptyJar); + file(root, 'app/test-marker.jar', emptyJar); + return root; +} + +afterEach(() => { + for (const value of temporaryDirectories.splice(0)) rmSync(value, { recursive: true, force: true }); +}); + +describe('Gradle 计划契约', () => { + test('预览不创建 workspace 或修改项目,支持 Kotlin settings 和模块路径转换', () => { + const root = directory(); + file(root, 'settings.gradle.kts', 'rootProject.name = "kotlin-settings"'); + const workspace = join(root, 'metadata'); + const plan = planGradle(config(root, { module: 'apps/admin-server', buildArgs: ['-Pfeature=a b', '--offline'] }), workspace); + expect(plan.commands[0]?.args).toContain(join('metadata', 'gradle-init.gradle')); + expect(plan.commands[0]?.args.some(value => /^-DjavaRun\.(?:root|target|output)=/.test(value))).toBe(false); + expect(plan.commands[0]?.args).toContain('-Pfeature=a b'); + expect(plan.notes).toContain('目标 Gradle 项目::apps:admin-server'); + expect(plan.commands[0]?.args.at(-1)).toMatch(/^:javaRunMetadata_/); + expect(existsSync(workspace)).toBe(false); + expect(readFileSync(join(root, 'settings.gradle.kts'), 'utf8')).toBe('rootProject.name = "kotlin-settings"'); + }); + + test('项目 Wrapper 优先,显式命令覆盖 Wrapper', () => { + const root = directory(); + file(root, 'build.gradle', "plugins { id 'java' }"); + const wrapper = process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'; + file(root, wrapper, ''); + expect(planGradle(config(root), join(root, 'metadata')).commands[0]?.command).toBe(join(root, wrapper)); + expect(planGradle(config(root, { buildCommand: 'custom-gradle' }), join(root, 'metadata')).commands[0]?.command).toBe('custom-gradle'); + }); + + test.each([':app,:lib', ':app::sub', '../app'])('拒绝多个或无效目标 %s', module => { + const root = directory(); + file(root, 'build.gradle', ''); + expect(() => planGradle(config(root, { module }), join(root, 'metadata'))).toThrow('--module'); + }); + + test.each(['test', '--project-dir=elsewhere', '-Iother.gradle', '-xcompileJava', '--configuration-cache', '-DjavaRun.output=elsewhere', '-DjavaRun.root=elsewhere'])('拒绝改变准备结构的参数 %s', argument => { + const root = directory(); + file(root, 'settings.gradle', ''); + expect(() => planGradle(config(root, { buildArgs: [argument] }), join(root, 'metadata'))).toThrow('--build-arg'); + }); + + test('Wrapper 启动失败保留错误,不回退系统 Gradle', async () => { + const root = directory(); + file(root, 'build.gradle', ''); + file(root, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew', 'not executable'); + await expect(prepareGradle(config(root), join(root, 'metadata'))).rejects.toBeInstanceOf(CommandError); + }); + + test('Wrapper 的 ASCII 错误码恢复中文诊断且保留原始失败', async () => { + const root = directory(); + file(root, 'build.gradle', ''); + const wrapper = join(root, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'); + const output = '[JAVA_RUN:NO_PROJECT] Selected project missing; use --module'; + writeFileSync(wrapper, process.platform === 'win32' + ? `@echo off\r\necho ${output}\r\nexit /b 7\r\n` + : `#!/bin/sh\nprintf '%s\\n' '${output}'\nexit 7\n`); + if (process.platform !== 'win32') chmodSync(wrapper, 0o755); + try { + await prepareGradle(config(root, { module: ':服务' }), join(root, 'metadata')); + throw new Error('预期 Wrapper 原始失败'); + } catch (error) { + expect(error).toBeInstanceOf(CommandError); + const commandError = error as CommandError; + expect(commandError.command).toBe(wrapper); + expect(commandError.cwd).toBe(root); + expect(commandError.exitCode).toBe(7); + expect(commandError.stdout.trim()).toBe(output); + expect(commandError.stderr).toBe(''); + expect(commandError.cause).toBeInstanceOf(Error); + expect((commandError.cause as Error).message).toContain('找不到 Gradle 项目::服务'); + } + }); +}); + +describe.skipIf(!availableGradle)('真实 Gradle 项目', () => { + test('中文项目根和模块使用项目外元数据目录,任务参数保持 ASCII', async () => { + const root = reactor(); + const module = 'app中文'; + renameSync(join(root, 'app'), join(root, module)); + file(root, 'settings.gradle', `rootProject.name = 'runner-fixture'\ninclude '${module}', 'lib', 'other'\n`); + const workspace = externalWorkspace(); + const options = { module: `:${module}`, buildCommand: availableGradle! }; + const plan = planGradle(config(root, options), workspace); + expect(plan.commands[0]?.args.every(value => /^[\x00-\x7f]*$/.test(value))).toBe(true); + const candidates = await discoverGradleProjects(config(root, { buildCommand: availableGradle! }), workspace); + expect(candidates.map(candidate => candidate.value)).toContain(`:${module}`); + const prepared = await prepareGradle(config(root, options), workspace); + expectSameDirectory(prepared.directory, join(root, module)); + expect(prepared.classpath.every(value => existsSync(value))).toBe(true); + expectSameDirectory(JSON.parse(readFileSync(join(workspace, 'gradle-project.json'), 'utf8')).directory, join(root, module)); + expect(existsSync(join(root, 'metadata'))).toBe(false); + expect(existsSync(join(root, 'other', 'build'))).toBe(false); + }, 120000); + + test('buildSrc 约定插件与 included build 只参与必要任务,不接收主构建目标', async () => { + const root = reactor(); + file(root, 'buildSrc/build.gradle', ` +plugins { id 'java-gradle-plugin' } +gradlePlugin { + plugins { + runnerConventions { + id = 'runner.java-conventions' + implementationClass = 'RunnerConventions' + } + } +} +`); + file(root, 'buildSrc/src/main/java/RunnerConventions.java', ` +import org.gradle.api.Plugin; +import org.gradle.api.Project; +public class RunnerConventions implements Plugin { + public void apply(Project project) { project.getPluginManager().apply("java"); } +} +`); + file(root, 'settings.gradle', "rootProject.name = 'runner-fixture'\ninclude 'app', 'lib', 'other'\nincludeBuild 'included-lib'\n"); + file(root, 'included-lib/settings.gradle', "rootProject.name = 'included-lib'"); + file(root, 'included-lib/build.gradle', "plugins { id 'java-library' }\ngroup = 'fixture'\nversion = '1.0'\n"); + file(root, 'included-lib/src/main/java/example/Lib.java', 'package example; public class Lib { public static String value() { return "included"; } }'); + const appBuild = join(root, 'app/build.gradle'); + writeFileSync(appBuild, readFileSync(appBuild, 'utf8') + .replace("plugins { id 'application' }", "plugins { id 'application'; id 'runner.java-conventions' }") + .replace("implementation project(':lib')", "implementation 'fixture:included-lib:1.0'")); + const prepared = await prepareGradle(config(root, { module: ':app', buildCommand: availableGradle! }), join(root, 'metadata')); + expectSameDirectory(prepared.directory, join(root, 'app')); + expect(prepared.classpath.some(value => value.endsWith('included-lib-1.0.jar'))).toBe(true); + expect(prepared.classpath.every(value => existsSync(value))).toBe(true); + expect(existsSync(join(root, 'other/build'))).toBe(false); + const candidates = await discoverGradleProjects(config(root, { buildCommand: availableGradle! }), join(root, 'metadata-discovery')); + expect(candidates.map(candidate => candidate.value)).toEqual([':app', ':lib', ':other']); + }, 120000); + + test('发现 app 与 library 候选时不编译且不读取主类 Provider', async () => { + const root = reactor(); + const buildFile = join(root, 'app/build.gradle'); + writeFileSync(buildFile, readFileSync(buildFile, 'utf8').replace("mainClass = 'example.Main'", + "mainClass = providers.provider { throw new GradleException('不应读取主类 Provider') }")); + const candidates = await discoverGradleProjects(config(root, { buildCommand: availableGradle! }), join(root, 'metadata-discovery')); + expect(candidates).toEqual([ + { value: ':app', label: ':app(入口待解析)' }, + { value: ':lib', label: ':lib(入口待解析)' }, + { value: ':other', label: ':other(入口待解析)' }, + ]); + for (const project of ['app', 'lib', 'other']) expect(existsSync(join(root, project, 'build'))).toBe(false); + }, 60000); + + test('普通 Java 项目保留主类自动发现职责且忽略空测试输出', async () => { + const root = directory(); + file(root, 'settings.gradle', "rootProject.name = 'plain-java'"); + file(root, 'build.gradle', "plugins { id 'java' }"); + file(root, 'src/main/java/example/Main.java', 'package example; public class Main { public static void main(String[] args) {} }'); + const prepared = await prepareGradle(config(root, { includeTests: true, buildCommand: availableGradle! }), join(root, 'metadata')); + expect(prepared.mainClass).toBeUndefined(); + expect(prepared.jvmArgs).toEqual([]); + expect(prepared.classesDirectories.some(value => value.endsWith(join('java', 'main')))).toBe(true); + expect(prepared.classesDirectories.some(value => value.endsWith(join('java', 'test')))).toBe(false); + expect(prepared.classpath.every(value => existsSync(value))).toBe(true); + }, 60000); + + test('准备目标和上游 Jar,默认类路径隔离测试及无关项目', async () => { + const root = reactor(); + const prepared = await prepareGradle(config(root, { module: ':app', buildCommand: availableGradle! }), join(root, 'metadata')); + expect(prepared.mainClass).toBe('example.Main'); + expect(prepared.jvmArgs).toEqual(['-Dmessage=hello world', '-Xmx128m']); + expectSameDirectory(prepared.directory, join(root, 'app')); + expect(prepared.javaCommand && existsSync(prepared.javaCommand)).toBe(true); + expect(prepared.classpath.some(value => value.endsWith('lib.jar'))).toBe(true); + expect(prepared.classpath.some(value => value.endsWith('runtime-marker.jar'))).toBe(true); + expect(prepared.classpath.some(value => value.endsWith('test-marker.jar') || value.endsWith(join('java', 'test')))).toBe(false); + expect(prepared.classpath.every(value => existsSync(value))).toBe(true); + expect(existsSync(join(root, 'other', 'build'))).toBe(false); + expect(existsSync(join(root, 'app', 'build/classes/java/test'))).toBe(false); + }, 60000); + + test('include-tests 准备测试输出及依赖,后续 none 不触发编译', async () => { + const root = reactor(); + const options = { module: 'app', includeTests: true, buildCommand: availableGradle! }; + const prepared = await prepareGradle(config(root, options), join(root, 'metadata-first')); + expect(prepared.classesDirectories.some(value => value.endsWith(join('java', 'test')))).toBe(true); + expect(prepared.classesDirectories.some(value => value.endsWith(join('java', 'main')))).toBe(true); + expect(prepared.classpath.some(value => value.endsWith('test-marker.jar'))).toBe(true); + file(root, 'app/src/main/java/example/Main.java', 'invalid Java source that must not be compiled'); + const existing = await prepareGradle(config(root, { ...options, build: 'none' }), join(root, 'metadata-existing')); + expect(existing.classesDirectories).toEqual(prepared.classesDirectories); + expect(existing.classpath).toEqual(prepared.classpath); + }, 60000); + + test('聚合根没有 Java 插件时明确要求 module', async () => { + const root = reactor(); + try { + await prepareGradle(config(root, { buildCommand: availableGradle! }), join(root, 'metadata')); + throw new Error('预期聚合根准备失败'); + } catch (error) { + expect(error).toBeInstanceOf(CommandError); + const commandError = error as CommandError; + expect(commandError.cause).toBeInstanceOf(Error); + expect((commandError.cause as Error).message).toContain('没有 Java 插件'); + expect(commandError.stderr + commandError.stdout).toContain('[JAVA_RUN:NO_JAVA_PLUGIN]'); + expect(commandError.stderr + commandError.stdout).toContain('--module'); + } + }, 60000); + + test('none 在源码存在但产物缺失时失败且不编译源码', async () => { + const root = reactor(); + try { + await prepareGradle(config(root, { module: ':app', build: 'none', buildCommand: availableGradle! }), join(root, 'metadata')); + throw new Error('预期缺少已编译产物时失败'); + } catch (error) { + expect(error).toBeInstanceOf(CommandError); + const commandError = error as CommandError; + expect(commandError.cause).toBeInstanceOf(Error); + expect((commandError.cause as Error).message).toContain('没有已编译类'); + expect(commandError.stderr + commandError.stdout).toContain('[JAVA_RUN:NO_CLASSES]'); + expect(commandError.stderr + commandError.stdout).toContain('--build=auto'); + expect(existsSync(join(root, 'app/build/classes/java/main'))).toBe(false); + expect(existsSync(join(root, 'lib/build/libs/lib.jar'))).toBe(false); + } + }, 60000); +}); diff --git a/tests/helpers/process-child.ts b/tests/helpers/process-child.ts new file mode 100644 index 0000000..68f7db9 --- /dev/null +++ b/tests/helpers/process-child.ts @@ -0,0 +1,54 @@ +import { spawn } from 'node:child_process'; +import { existsSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runCommand } from '../../src/process/exec'; + +const [mode, directory] = process.argv.slice(2); +const helperPath = fileURLToPath(import.meta.url); + +if (mode === 'args') { + console.log(JSON.stringify(process.argv.slice(3))); +} else if (mode === 'runner' || mode === 'exiting-runner') { + const running = runCommand({ + command: process.execPath, + args: [helperPath, 'tree', directory!], + cwd: directory!, + stage: '信号转发测试', + }); + if (mode === 'exiting-runner') { + setInterval(() => { + if (existsSync(path.join(directory!, 'leaf.pid'))) process.exit(0); + }, 10); + } else { + writeFileSync(path.join(directory!, 'result.json'), JSON.stringify(await running)); + } +} else if (mode === 'windows-runner') { + const batch = path.join(directory!, 'tree entry.cmd'); + writeFileSync(batch, `@echo off\r\n"${process.execPath}" "${helperPath}" tree "${directory}"\r\n`); + const running = runCommand({ command: batch, args: [], cwd: directory!, stage: 'Windows 进程树清理' }); + const ready = setInterval(() => { + if (existsSync(path.join(directory!, 'leaf.pid'))) { + clearInterval(ready); + // Windows 没有可移植的 POSIX 信号发送,直接触发执行器已注册的转发入口 + process.emit('SIGTERM'); + } + }, 10); + writeFileSync(path.join(directory!, 'result.json'), JSON.stringify(await running)); +} else if (mode === 'tree' || mode === 'orphan') { + writeFileSync(path.join(directory!, 'child.pid'), String(process.pid)); + spawn(process.execPath, [helperPath, 'leaf', directory!], { stdio: 'inherit' }); + if (mode === 'orphan') { + const ready = setInterval(() => { + if (existsSync(path.join(directory!, 'leaf.pid'))) { + clearInterval(ready); + process.exit(0); + } + }, 10); + } else { + setInterval(() => {}, 1000); + } +} else if (mode === 'leaf') { + writeFileSync(path.join(directory!, 'leaf.pid'), String(process.pid)); + setInterval(() => {}, 1000); +} diff --git a/tests/init.test.ts b/tests/init.test.ts new file mode 100644 index 0000000..f2b336a --- /dev/null +++ b/tests/init.test.ts @@ -0,0 +1,306 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from '../src/cli/args'; +import { createProjectConfigWriter } from '../src/cli/init'; + +const directories: string[] = []; +const cli = fileURLToPath(new URL('../src/cli.ts', import.meta.url)); + +function directory(): string { + const root = mkdtempSync(join(tmpdir(), 'java-run-init-')); + directories.push(root); + return root; +} + +function file(root: string, name: string, source: string): string { + const location = join(root, name); + mkdirSync(dirname(location), { recursive: true }); + writeFileSync(location, source); + return location; +} + +function compileClasses(root: string, names: string[]): string { + const classes = join(root, 'classes'); + mkdirSync(classes); + const sources = names.length ? names : ['Library']; + for (const name of sources) { + file(root, `${name}.java`, `public class ${name} { ${names.length + ? 'public static void main(String[] args) throws Exception { new java.io.File("application-started").createNewFile(); }' + : 'public static String value() { return "library"; }'} }`); + } + const result = spawnSync('javac', ['-encoding', 'UTF-8', '-d', classes, ...sources.map(name => `${name}.java`)], { + cwd: root, encoding: 'utf8', timeout: 20000, + }); + if (result.status !== 0) throw new Error(result.stderr || result.error?.message || 'javac 未成功完成'); + return classes; +} + +function wrapper(root: string, source: string): string { + const runner = file(root, 'fake-build.js', source); + const location = join(root, process.platform === 'win32' ? 'fake-build.cmd' : 'fake-build'); + const quote = (value: string) => `'${value.replace(/'/g, "'\\''")}'`; + writeFileSync(location, process.platform === 'win32' + ? `@echo off\r\n"${process.execPath}" "${runner}" %*\r\nexit /b %errorlevel%\r\n` + : `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(runner)} "$@"\n`); + if (process.platform !== 'win32') chmodSync(location, 0o755); + return location; +} + +interface GradleFixtureOptions { + entries?: string[]; + candidates?: { value: string; label: string }[]; + declaredMain?: string; + failureCode?: number; +} + +function gradleFixture(options: GradleFixtureOptions = {}) { + const root = directory(); + file(root, 'settings.gradle', "rootProject.name = 'init-fixture'\n"); + const classes = compileClasses(root, options.entries ?? ['Entry']); + const candidates = options.candidates ?? [{ value: ':', label: '根项目' }]; + const command = wrapper(root, ` + import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; + const args = process.argv.slice(2); + appendFileSync('build-invocations.jsonl', JSON.stringify(args) + '\\n'); + if (${options.failureCode ?? 0}) process.exit(${options.failureCode ?? 0}); + const script = readFileSync(args[args.indexOf('-I') + 1], 'utf8'); + const encoded = /new String\\('([^']+)'\\.decodeBase64\\(\\)/.exec(script)?.[1]; + if (!encoded) throw new Error('Missing build request'); + const request = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')); + if (args.includes('-DjavaRun.discover=true')) { + writeFileSync(request.output, JSON.stringify(${JSON.stringify(candidates)})); + } else { + writeFileSync(request.output, JSON.stringify({ + directory: ${JSON.stringify(root)}, classesDirectories: [${JSON.stringify(classes)}], + runtimeClasspath: [${JSON.stringify(classes)}], javaCommand: ${JSON.stringify(process.execPath)}, + mainClass: ${JSON.stringify(options.declaredMain ?? null)}, jvmArgs: ['-Dproject.default=keep-in-build'] + })); + } + `); + return { root, command, config: join(root, '.java-run.json'), log: join(root, 'build-invocations.jsonl') }; +} + +function mavenFixture(aggregate = false) { + const root = directory(); + file(root, 'pom.xml', `4.0.0fixtureinit1${aggregate + ? 'pomapp' : ''}`); + const classes = compileClasses(root, ['MavenEntry']); + const command = wrapper(root, ` + import { appendFileSync, writeFileSync } from 'node:fs'; + const args = process.argv.slice(2); + appendFileSync('build-invocations.jsonl', JSON.stringify(args) + '\\n'); + const output = args.find(value => value.startsWith('-Doutput='))?.slice(9); + if (args.includes('-Dexpression=project.file')) writeFileSync(output, ${JSON.stringify(join(root, 'pom.xml'))}); + else if (args.some(value => value.endsWith(':effective-pom'))) { + const escape = value => value.replace(/&/g, '&').replace(/' + escape(${JSON.stringify(join(root, 'test-classes'))}) + ''; + writeFileSync(output, ${aggregate} && !args.includes('-pl') + ? 'fixturerootpom' + project + '' + : project); + } else { + const dependencies = args.find(value => value.startsWith('-Dmdep.outputFile='))?.slice('-Dmdep.outputFile='.length); + if (dependencies) writeFileSync(dependencies, ''); + } + `); + return { root, command, config: join(root, '.java-run.json'), log: join(root, 'build-invocations.jsonl') }; +} + +function init(fixture: { root: string; command: string }, args: string[] = []) { + const result = spawnSync(process.execPath, [cli, 'init', '--cwd', fixture.root, '--build-command', fixture.command, ...args], { + encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'], + }); + if (result.error) throw result.error; + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +function configuration(fixture: { config: string }) { + return JSON.parse(readFileSync(fixture.config, 'utf8')); +} + +afterEach(() => { + for (const root of directories.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('生成项目启动配置', () => { + test('唯一 Gradle 项目与入口自动保存,参数可再次读取且不执行应用', () => { + const fixture = gradleFixture(); + const result = init(fixture, ['--java=unused-java-command', '--jvm-arg=-Xmx128m', '--build-arg=--offline', '--', 'a b', '', '--mode=demo']); + expect(result.code).toBe(0); + expect(configuration(fixture)).toEqual({ + buildTool: 'gradle', module: ':', mainClass: 'Entry', jvmArgs: ['-Xmx128m'], + buildArgs: ['--offline'], applicationArgs: ['a b', '', '--mode=demo'], + }); + const next = parseArgs(['--cwd', fixture.root]); + expect(next.mainClass).toBe('Entry'); + expect(next.applicationArgs).toEqual(['a b', '', '--mode=demo']); + expect(next.jvmArgs).toEqual(['-Xmx128m']); + expect(next.javaCommand).toBeUndefined(); + expect(next.buildCommand).toBeUndefined(); + expect(existsSync(join(fixture.root, 'application-started'))).toBe(false); + expect(result.stderr).not.toContain('java-run:运行'); + }, 30000); + + test('Maven 单项目保存自动发现的主类,默认配置保持简洁', () => { + const fixture = mavenFixture(); + expect(init(fixture).code).toBe(0); + expect(configuration(fixture)).toEqual({ buildTool: 'maven', mainClass: 'MavenEntry' }); + expect(parseArgs(['--cwd', fixture.root]).buildTool).toBe('maven'); + expect(existsSync(join(fixture.root, 'application-started'))).toBe(false); + }, 30000); + + test('Maven reactor 唯一 jar 项目保存有效模块选择器并用于后续准备', () => { + const fixture = mavenFixture(true); + expect(init(fixture).code).toBe(0); + expect(configuration(fixture)).toEqual({ buildTool: 'maven', module: 'fixture:init', mainClass: 'MavenEntry' }); + expect(parseArgs(['--cwd', fixture.root]).module).toBe('fixture:init'); + const calls: string[][] = readFileSync(fixture.log, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + expect(calls.slice(1).every(args => args[args.indexOf('-pl') + 1] === 'fixture:init')).toBe(true); + expect(calls.some(args => args.includes('install') && args.includes('-am'))).toBe(true); + }, 30000); + + test('唯一子项目自动选择,保留显式构建策略和测试输出设置', () => { + const fixture = gradleFixture({ candidates: [{ value: ':apps:server', label: '服务项目' }] }); + expect(init(fixture, ['--build=none', '--include-tests']).code).toBe(0); + expect(configuration(fixture)).toEqual({ + buildTool: 'gradle', module: ':apps:server', mainClass: 'Entry', build: 'none', includeTests: true, + }); + }, 30000); + + test('非交互多模块要求显式选择,尚未准备目标或保存文件', () => { + const fixture = gradleFixture({ candidates: [{ value: ':app', label: '应用' }, { value: ':lib', label: '库' }] }); + const result = init(fixture); + expect(result.code).toBe(1); + expect(result.stderr).toContain('--module'); + expect(existsSync(fixture.config)).toBe(false); + const calls: string[][] = readFileSync(fixture.log, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('-DjavaRun.discover=true'); + expect(init(fixture, ['--module=:app']).code).toBe(0); + expect(configuration(fixture).module).toBe(':app'); + }, 30000); + + test('未声明的多个主类在非交互环境失败,显式入口可以完成初始化', () => { + const fixture = gradleFixture({ entries: ['First', 'Second'] }); + const result = init(fixture); + expect(result.code).toBe(1); + expect(result.stderr).toContain('--main'); + expect(result.stderr).toContain('First'); + expect(result.stderr).toContain('Second'); + expect(existsSync(fixture.config)).toBe(false); + expect(init(fixture, ['--main=Second']).code).toBe(0); + expect(configuration(fixture).mainClass).toBe('Second'); + }, 30000); + + test('构建声明消除入口歧义,显式主类优先于声明', () => { + const fixture = gradleFixture({ entries: ['First', 'Second'], declaredMain: 'First' }); + expect(init(fixture).code).toBe(0); + expect(configuration(fixture).mainClass).toBe('First'); + expect(init(fixture, ['--force', '--main=Second']).code).toBe(0); + expect(configuration(fixture).mainClass).toBe('Second'); + }, 30000); + + test('库模块没有 main 时不生成配置', () => { + const fixture = gradleFixture({ entries: [] }); + const result = init(fixture); + expect(result.code).toBe(1); + expect(result.stderr).toContain('未找到 public static main'); + expect(existsSync(fixture.config)).toBe(false); + }, 30000); + + test.each(['{"mainClass":"Old","jvmArgs":["-Dold=true"]}', '{invalid-json'])('已有配置在任何构建之前拒绝覆盖:%s', existing => { + const fixture = gradleFixture(); + writeFileSync(fixture.config, existing); + const result = init(fixture); + expect(result.code).toBe(1); + expect(result.stderr).toContain('--force'); + expect(readFileSync(fixture.config, 'utf8')).toBe(existing); + expect(existsSync(fixture.log)).toBe(false); + }, 30000); + + test('force 重新生成而不合并原有数组或模块,亦可修复无效 JSON', () => { + const fixture = gradleFixture(); + writeFileSync(fixture.config, JSON.stringify({ module: ':old', mainClass: 'Old', jvmArgs: ['-Dold=true'], applicationArgs: ['old'], build: 'none' })); + expect(init(fixture, ['--force', '--arg=new']).code).toBe(0); + expect(configuration(fixture)).toEqual({ buildTool: 'gradle', module: ':', mainClass: 'Entry', applicationArgs: ['new'] }); + writeFileSync(fixture.config, '{invalid-json'); + expect(init(fixture, ['--force']).code).toBe(0); + expect(configuration(fixture)).toEqual({ buildTool: 'gradle', module: ':', mainClass: 'Entry' }); + }, 30000); + + test('force 遇到构建失败时保留原文件并保留失败退出码', () => { + const fixture = gradleFixture({ failureCode: 7 }); + const original = '{invalid-json'; + writeFileSync(fixture.config, original); + expect(init(fixture, ['--force']).code).toBe(7); + expect(readFileSync(fixture.config, 'utf8')).toBe(original); + }, 30000); +}); + +describe('CLI 启动入口', () => { + test('前置 classpath JVM 参数不影响受控类路径或入口日志', () => { + const fixture = mavenFixture(); + const result = spawnSync(process.execPath, [ + cli, '--cwd', fixture.root, '--build-command', fixture.command, + '--jvm-arg=-classpath', '--jvm-arg=unused-classpath', + ], { encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'] }); + if (result.error) throw result.error; + expect(result.status).toBe(0); + expect(result.stderr).toContain('java-run:运行 MavenEntry(maven)'); + expect(existsSync(join(fixture.root, 'application-started'))).toBe(true); + }, 30000); +}); + +describe('项目配置写入保护', () => { + test('准备期间另一进程创建配置时不覆盖,并清理暂存文件', () => { + const root = directory(); + const writer = createProjectConfigWriter(root); + writeFileSync(writer.path, '{"mainClass":"Other"}'); + expect(() => writer.save({ buildTool: 'maven', mainClass: 'Entry' })).toThrow(); + expect(readFileSync(writer.path, 'utf8')).toBe('{"mainClass":"Other"}'); + expect(readdirSync(root)).toEqual(['.java-run.json']); + }); + + test.each([false, true])('目录目标始终受保护(force=%s)', force => { + const root = directory(); + mkdirSync(join(root, '.java-run.json')); + expect(() => createProjectConfigWriter(root, force)).toThrow(); + expect(readdirSync(root)).toEqual(['.java-run.json']); + }); + + test('force 保存前重新检查突然出现的目录', () => { + const root = directory(); + const writer = createProjectConfigWriter(root, true); + mkdirSync(writer.path); + expect(() => writer.save({ mainClass: 'Entry' })).toThrow(); + expect(readdirSync(root)).toEqual(['.java-run.json']); + }); + + for (const force of [false, true]) { + test.skipIf(process.platform === 'win32')(`拒绝普通及悬空符号链接(force=${force})`, () => { + const root = directory(); + const target = file(root, 'target.json', '{"mainClass":"Protected"}'); + const path = join(root, '.java-run.json'); + symlinkSync(target, path); + expect(() => createProjectConfigWriter(root, force)).toThrow(); + expect(readFileSync(target, 'utf8')).toBe('{"mainClass":"Protected"}'); + rmSync(path); + symlinkSync(join(root, 'missing.json'), path); + expect(() => createProjectConfigWriter(root, force)).toThrow(); + expect(existsSync(join(root, 'missing.json'))).toBe(false); + expect(readdirSync(root).sort()).toEqual(['.java-run.json', 'target.json']); + }); + } + + test('正常保存和显式覆盖不留下暂存文件', () => { + const root = directory(); + createProjectConfigWriter(root).save({ buildTool: 'maven', mainClass: 'First' }); + createProjectConfigWriter(root, true).save({ buildTool: 'gradle', mainClass: 'Second' }); + expect(JSON.parse(readFileSync(join(root, '.java-run.json'), 'utf8'))).toEqual({ buildTool: 'gradle', mainClass: 'Second' }); + expect(readdirSync(root)).toEqual(['.java-run.json']); + }); +}); diff --git a/tests/java-arguments.test.ts b/tests/java-arguments.test.ts new file mode 100644 index 0000000..600ccee --- /dev/null +++ b/tests/java-arguments.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import iconv from 'iconv-lite'; +import { assertJavaArguments, assertRepresentableArguments, JavaArgumentEncodingError } from '../src/process/java-arguments'; +import { runCommand } from '../src/process/exec'; + +describe('Java 原生命令行编码', () => { + test('Windows 单字节编码拒绝中文和组合字符,诊断不泄漏参数', () => { + const secret = 'token-secret-中文'; + for (const argument of [secret, 'Cafe\u0305']) { + let error: Error | undefined; + try { + assertRepresentableArguments([argument], 'windows1252'); + } catch (caught) { + error = caught as Error; + } + expect(error).toBeInstanceOf(JavaArgumentEncodingError); + expect((error as JavaArgumentEncodingError).encoding).toBe('windows1252'); + expect(error?.message).toContain('windows1252'); + expect(error?.message).toContain('系统区域设置'); + expect(error?.message).not.toContain(argument); + } + }); + + test('编码校验接受原生可表示的参数,UTF-8 保留 Unicode', () => { + expect(() => assertRepresentableArguments(['space # % \' "', '', 'Café'], 'windows1252')).not.toThrow(); + expect(() => assertRepresentableArguments(['中文'], 'gbk')).not.toThrow(); + expect(() => assertRepresentableArguments(['中文', 'Cafe\u0305', '😀'], 'UTF-8')).not.toThrow(); + expect(() => assertRepresentableArguments(['😀'], 'gbk')).toThrow('无法完整表示'); + let unknownEncoding: unknown; + try { + assertRepresentableArguments(['中文'], 'unknown-charset'); + } catch (error) { + unknownEncoding = error; + } + expect(unknownEncoding).toBeInstanceOf(Error); + expect(unknownEncoding).not.toBeInstanceOf(JavaArgumentEncodingError); + expect((unknownEncoding as Error).message).toContain('无法验证'); + }); + + test('ASCII 参数和非 Windows 平台不调用 Java 探针', async () => { + await assertJavaArguments('missing-java-argument-probe', ['plain # %'], process.cwd()); + if (process.platform !== 'win32') { + await assertJavaArguments('missing-java-argument-probe', ['中文'], process.cwd()); + } + }); + + test('真实 Windows JDK 按原生编码接受参数或提前明确拒绝', async () => { + if (process.platform !== 'win32') { + await assertJavaArguments('java', ['token-secret-中文😀'], process.cwd()); + return; + } + const java = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', 'java.exe') : 'java'; + const result = await runCommand({ command: java, args: ['-XshowSettings:properties', '-version'], cwd: process.cwd(), stage: 'Windows JDK 实测' }, { capture: true }); + expect(result.exitCode).toBe(0); + const properties = `${result.stdout}\n${result.stderr}`; + const encoding = /^\s*native\.encoding\s*=\s*(\S+)\s*$/m.exec(properties)?.[1] + ?? /^\s*sun\.jnu\.encoding\s*=\s*(\S+)\s*$/m.exec(properties)?.[1]; + expect(encoding).toBeDefined(); + const argument = 'token-secret-中文😀'; + if (iconv.decode(iconv.encode(argument, encoding!), encoding!) === argument) { + await assertJavaArguments(java, [argument], process.cwd()); + } else { + await expect(assertJavaArguments(java, [argument], process.cwd())).rejects.toBeInstanceOf(JavaArgumentEncodingError); + } + await expect(assertJavaArguments('missing-java-argument-probe', [argument], process.cwd())) + .rejects.toMatchObject({ name: 'CommandError', stage: '检测 Java 原生命令行编码', exitCode: 127 }); + }); +}); diff --git a/tests/launch.test.ts b/tests/launch.test.ts new file mode 100644 index 0000000..50f493c --- /dev/null +++ b/tests/launch.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import { createManifest } from '../src/core/classpath'; +import { discoverMainClass, hasMainMethod } from '../src/core/main-class'; +import { createLaunchCommand } from '../src/core/launch'; +import { runCommand } from '../src/process/exec'; +import { assertJavaArguments, JavaArgumentEncodingError } from '../src/process/java-arguments'; +import { parseArgs } from '../src/cli/args'; +import { detectBuildTool } from '../src/build-tools/detect'; +import { buildClasspath, planMaven, readEffectiveProject, type MavenProject } from '../src/build-tools/maven'; + +const directories: string[] = []; +function temporary(): string { + const directory = mkdtempSync(join(tmpdir(), 'java-run launch 中文 # % ')); + directories.push(directory); + return directory; +} +function compile(directory: string, name: string, source: string): string { + const output = join(directory, 'classes'); + mkdirSync(output, { recursive: true }); + const file = join(directory, `${name}.java`); + writeFileSync(file, source); + const result = spawnSync('javac', ['-encoding', 'UTF-8', '-d', 'classes', `${name}.java`], { cwd: directory, encoding: 'utf8' }); + if (result.status !== 0) throw new Error(result.stderr || result.error?.message); + return output; +} +async function acceptsJavaArguments(args: string[], cwd: string): Promise { + try { + await assertJavaArguments('java', args, cwd); + return true; + } catch (error) { + if (error instanceof JavaArgumentEncodingError) return false; + throw error; + } +} +function project(directory: string): MavenProject { + return { directory, pomFile: join(directory, 'pom.xml'), groupId: 'fixture', artifactId: 'app', version: '1', packaging: 'jar', + outputDirectory: join(directory, 'classes'), testOutputDirectory: join(directory, 'test-classes') }; +} +afterEach(() => { for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); }); + +describe('目标类路径', () => { + test('空依赖不添加工作目录,测试输出仅显式启用时加入', () => { + const directory = temporary(); + const p = project(directory); + mkdirSync(p.outputDirectory); mkdirSync(p.testOutputDirectory); + expect(buildClasspath(p, '')).toEqual([p.outputDirectory]); + expect(buildClasspath(p, '', true)).toEqual([p.testOutputDirectory, p.outputDirectory]); + const dep = join(directory, 'dependency.jar'); writeFileSync(dep, ''); + expect(buildClasspath(p, [dep, dep].join(delimiter))).toEqual([p.outputDirectory, dep]); + expect(() => buildClasspath(p, join(directory, 'missing.jar'))).toThrow('依赖文件不存在'); + }); + test('空测试源集不要求生成测试目录', () => { + const directory = temporary(); const p = project(directory); mkdirSync(p.outputDirectory); + expect(buildClasspath(p, '', true)).toEqual([p.outputDirectory]); + }); + test('真实 JDK 可加载含空格、中文、#、% 的目录并保留可表示的应用参数', async () => { + const directory = temporary(); + const classes = compile(directory, 'Hello', 'public class Hello { public static void main(String[] args) { System.out.print(String.join("|", args)); } }'); + const manifest = createManifest([classes]); + expect(manifest).toContain('%23'); expect(manifest).toContain('%25'); expect(manifest).toContain('%20'); + for (const line of manifest.split('\r\n')) expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(70); + const requested = ['空格 值', 'a=b', 'quote\'"']; + const unicodeArguments = await acceptsJavaArguments(requested, directory); + if (!unicodeArguments) { + await expect(createLaunchCommand(parseArgs(['--cwd', directory, '--', ...requested]), { directory, classesDirectories: [classes], classpath: [classes], jvmArgs: [] }, directory)) + .rejects.toThrow('无法完整表示'); + } + const applicationArgs = unicodeArguments ? requested : ['space value', 'a=b', 'quote\'"']; + const config = parseArgs(['--cwd', directory, '--', ...applicationArgs]); + const launch = await createLaunchCommand(config, { directory, classesDirectories: [classes], classpath: [classes], jvmArgs: [] }, directory); + const result = await runCommand(launch, { capture: true }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(applicationArgs.join('|')); + }, 20000); + + test('JVM 参数按原生编码完整传递或提前报错', async () => { + const directory = temporary(); + const classes = compile(directory, 'PropertyEntry', 'public class PropertyEntry { public static void main(String[] args) { System.out.print(System.getProperty("fixture.jvm")); } }'); + const value = 'token-secret-中文'; + const project = { directory, classesDirectories: [classes], classpath: [classes], jvmArgs: [] }; + const argument = `-Dfixture.jvm=${value}`; + if (await acceptsJavaArguments([argument], directory)) { + const launch = await createLaunchCommand(parseArgs(['--cwd', directory, `--jvm-arg=${argument}`]), project, directory); + expect((await runCommand(launch, { capture: true })).stdout).toBe(value); + } else { + const error = await createLaunchCommand(parseArgs(['--cwd', directory, `--jvm-arg=${argument}`]), project, directory) + .then(() => undefined, caught => caught as Error); + expect(error?.message).toContain('无法完整表示'); + expect(error?.message).not.toContain(value); + const launch = await createLaunchCommand(parseArgs(['--cwd', directory, '--jvm-arg=-Dfixture.jvm=ascii-value']), project, directory); + expect((await runCommand(launch, { capture: true })).stdout).toBe('ascii-value'); + } + }, 20000); + test('应用 JVM 默认使用 UTF-8,项目声明和 CLI 可按顺序覆盖', async () => { + const directory = temporary(); + const classes = compile(directory, 'Charset', 'public class Charset { public static void main(String[] args) { System.out.print(System.getProperty("file.encoding")); } }'); + const config = parseArgs(['--cwd', directory, '--jvm-arg=-Dfile.encoding=US-ASCII']); + const launch = await createLaunchCommand(config, { directory, classesDirectories: [classes], classpath: [classes], jvmArgs: ['-Dfile.encoding=ISO-8859-1'] }, directory); + expect(launch.args.slice(0, 3)).toEqual(['-Dfile.encoding=UTF-8', '-Dfile.encoding=ISO-8859-1', '-Dfile.encoding=US-ASCII']); + expect((await runCommand(launch, { capture: true })).stdout).toBe('US-ASCII'); + }, 20000); +}); + +describe('主类选择', () => { + test('组合字符主类可发现,并按原生编码启动或明确拒绝', async () => { + const directory = temporary(); + const name = 'Cafe\u0301'; + // 源文件名保持 ASCII,避免 macOS / JDK 17 的 NFC 转换影响 public 类与文件名匹配 + const classes = compile(directory, 'UnicodeEntry', `class ${name} { public static void main(String[] args) { System.out.print("UNICODE_MAIN_OK"); } }`); + const config = parseArgs(['--cwd', directory, `--main=${name}`]); + expect(await discoverMainClass([classes])).toBe(name); + const project = { directory, classesDirectories: [classes], classpath: [classes], jvmArgs: [] }; + if (await acceptsJavaArguments([name], directory)) { + const launch = await createLaunchCommand(config, project, directory); + expect((await runCommand(launch, { capture: true })).stdout).toBe('UNICODE_MAIN_OK'); + } else { + await expect(createLaunchCommand(config, project, directory)).rejects.toThrow('无法完整表示'); + compile(directory, 'FallbackEntry', 'public class FallbackEntry { public static void main(String[] args) { System.out.print("ASCII_MAIN_OK"); } }'); + const launch = await createLaunchCommand(parseArgs(['--cwd', directory, '--main=FallbackEntry']), project, directory); + expect((await runCommand(launch, { capture: true })).stdout).toBe('ASCII_MAIN_OK'); + } + expect(() => parseArgs(['--main=Invalid³'])).toThrow('Java 类全名'); + }, 20000); + test('通过 class 方法表发现唯一入口,注释与非 public/static 方法不会被当成入口', async () => { + const directory = temporary(); + const classes = compile(directory, 'Entry', 'public class Entry { private static void main(int x) {} public static void main(String[] args) {} }'); + compile(directory, 'Other', 'public class Other { public void main(String[] args) {} }'); + expect(await discoverMainClass([classes])).toBe('Entry'); + expect(hasMainMethod(readFileSync(join(classes, 'Other.class')))).toBe(false); + expect(hasMainMethod(Buffer.from('invalid'))).toBe(false); + }, 20000); + test('多个主类要求明确选择并列出候选', async () => { + const directory = temporary(); + const classes = compile(directory, 'First', 'public class First { public static void main(String[] args) {} }'); + compile(directory, 'Second', 'public class Second { public static void main(String[] args) {} }'); + await expect(discoverMainClass([classes])).rejects.toThrow('First\n Second'); + }, 20000); + + test('只有未声明的多个入口调用选择器,显式主类优先于项目声明', async () => { + const directory = temporary(); + const classes = compile(directory, 'First', 'public class First { public static void main(String[] args) { System.out.print("FIRST"); } }'); + let selectionCount = 0; + const selector = async (candidates: readonly string[]) => { + selectionCount++; + expect(candidates).toEqual(['First', 'Second']); + return 'Second'; + }; + expect(await discoverMainClass([classes], selector)).toBe('First'); + expect(selectionCount).toBe(0); + compile(directory, 'Second', 'public class Second { public static void main(String[] args) { System.out.print("SECOND"); } }'); + const project = { directory, classesDirectories: [classes], classpath: [classes], jvmArgs: [], mainClass: 'First' }; + const declared = await createLaunchCommand(parseArgs(['--cwd', directory]), project, directory, selector); + expect((await runCommand(declared, { capture: true })).stdout).toBe('FIRST'); + const explicit = await createLaunchCommand(parseArgs(['--cwd', directory, '--main=Second']), project, directory, selector); + expect((await runCommand(explicit, { capture: true })).stdout).toBe('SECOND'); + expect(selectionCount).toBe(0); + const selected = await createLaunchCommand(parseArgs(['--cwd', directory]), { ...project, mainClass: undefined }, directory, selector); + expect((await runCommand(selected, { capture: true })).stdout).toBe('SECOND'); + expect(selectionCount).toBe(1); + const cancellation = new Error('selection-cancelled'); + await expect(discoverMainClass([classes], () => Promise.reject(cancellation))).rejects.toBe(cancellation); + }, 20000); +}); + +describe('构建工具模型', () => { + test('存在两种工具时要求显式选择,计划不创建文件', () => { + const directory = temporary(); writeFileSync(join(directory, 'pom.xml'), ''); writeFileSync(join(directory, 'build.gradle.kts'), ''); + const config = parseArgs(['plan', '--cwd', directory]); + expect(() => detectBuildTool(config)).toThrow('同时包含'); + expect(detectBuildTool({ ...config, buildTool: 'gradle' })).toBe('gradle'); + const plan = planMaven({ ...config, buildTool: 'maven', module: 'app' }, join(directory, 'not-created')); + expect(plan.commands[0]?.args).toContain('-am'); expect(plan.commands[0]?.args).toContain('install'); + for (const spec of plan.commands.slice(1)) expect(spec.args).not.toContain('-am'); + expect(existsSync(join(directory, 'not-created'))).toBe(false); + }); + test('有效模型中的自定义输出与主类使用 Maven 已裁决值', async () => { + const directory = temporary(); + const p = await readEffectiveProject(`fixtureapp2example.Main${directory}/custom/classes${directory}/custom/tests`, join(directory, 'pom.xml')); + expect(p.outputDirectory).toBe(join(directory, 'custom/classes')); expect(p.mainClass).toBe('example.Main'); expect(p.version).toBe('2'); + await expect(readEffectiveProject('', join(directory, 'pom.xml'))).rejects.toThrow('一个 Maven 项目'); + }); +}); diff --git a/tests/maven.test.ts b/tests/maven.test.ts new file mode 100644 index 0000000..bd8ec79 --- /dev/null +++ b/tests/maven.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import { planMaven, prepareMaven, resolveMavenBaseDirectory } from '../src/build-tools/maven'; +import { parseArgs } from '../src/cli/args'; +import { assertJavaArguments, JavaArgumentEncodingError } from '../src/process/java-arguments'; + +const config = parseArgs(['plan']); +const temporaryDirectories: string[] = []; + +function directory(): string { + const root = mkdtempSync(join(tmpdir(), 'java-run-maven-base-')); + temporaryDirectories.push(root); + return root; +} + +afterEach(() => { + for (const root of temporaryDirectories.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('Maven 配置根', () => { + test('无 .mvn 时保留当前项目目录,使用纯 ASCII 相对路径', () => { + const root = directory(); + const project = join(root, '中文项目 # %'); + mkdirSync(project); + expect(resolveMavenBaseDirectory(project)).toBe('.'); + }); + + test('嵌套模块采用最近 .mvn 祖先,不固定为当前目录', () => { + const root = directory(); + const nearest = join(root, '聚合项目'); + const project = join(nearest, '模块', 'child'); + mkdirSync(project, { recursive: true }); + mkdirSync(join(root, '.mvn')); + mkdirSync(join(nearest, '.mvn')); + expect(resolveMavenBaseDirectory(project)).toBe(join('..', '..')); + mkdirSync(join(project, '.mvn')); + expect(resolveMavenBaseDirectory(project)).toBe('.'); + }); + + test('显式绝对配置根优先于最近 .mvn,转换后仍指向原目标', () => { + const root = directory(); + const project = join(root, 'workspace', 'app'); + const configured = join(root, 'shared'); + mkdirSync(join(project, '.mvn'), { recursive: true }); + mkdirSync(configured); + expect(resolveMavenBaseDirectory(project, configured)).toBe(relative(project, configured)); + }); + + test('显式相对配置根和空配置遵循 Maven 的选择语义', () => { + const root = directory(); + const project = join(root, 'workspace', 'app'); + mkdirSync(join(root, 'workspace', '.mvn'), { recursive: true }); + mkdirSync(project); + expect(resolveMavenBaseDirectory(project, '../..')).toBe(join('..', '..')); + expect(resolveMavenBaseDirectory(project, '')).toBe('..'); + }); + + test.skipIf(process.platform !== 'win32')('跨盘显式配置根保留绝对路径,交由原生编码检查', () => { + expect(resolveMavenBaseDirectory('C:\\project', 'D:\\shared 中文')).toBe('D:\\shared 中文'); + }); + + test.skipIf(process.platform !== 'win32')('旧版 Maven 仅在需要 Unicode 环境插值时明确拒绝', async () => { + const root = directory(); + const project = join(root, '中文项目'); + const workspace = join(root, 'metadata'); + const batch = join(root, 'old-maven.cmd'); + mkdirSync(project); + mkdirSync(workspace); + writeFileSync(join(project, 'pom.xml'), '4.0.0'); + writeFileSync(batch, '@echo off\r\nif "%~3"=="-version" (\r\n echo Apache Maven 3.8.8\r\n exit /b 0\r\n)\r\necho EXPECTED_OLD_MAVEN_BUILD 1>&2\r\nexit /b 7\r\n'); + const java = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', 'java.exe') : 'java'; + let requiresBridge = false; + try { + await assertJavaArguments(java, [`-Dmaven.multiModuleProjectDirectory=${project}`], project); + } catch (error) { + if (!(error instanceof JavaArgumentEncodingError)) throw error; + requiresBridge = true; + } + const prepared = prepareMaven(parseArgs(['--cwd', project, '--build-command', batch]), workspace); + if (requiresBridge) { + await expect(prepared).rejects.toThrow('Maven 3.9.2'); + } else { + await expect(prepared).rejects.toMatchObject({ name: 'CommandError', exitCode: 7, stderr: expect.stringContaining('EXPECTED_OLD_MAVEN_BUILD') }); + } + }); +}); + +describe('Maven 构建参数边界', () => { + test('允许独立的属性、profile 和 settings 路径,不把路径当成目标', () => { + const buildArgs = ['-Dcustom.value=a=b', '-Dmaven.repo.local=/tmp/repository', '-Pci', '-s', '/tmp/settings with spaces.xml', '--threads=2', '--offline']; + expect(planMaven({ ...config, buildArgs }, '/tmp/metadata').commands[0]!.args).toContain('/tmp/settings with spaces.xml'); + }); + test.each(['deploy', 'clean', '-plapp', '--projects=other', '-fother.xml', '-am', '-N', '--fail-never', '-s', '-P', '-Doutput=other', '-DoutputEncoding=GBK', '-Dexpression=other', '-DskipTests=false', '-Dmdep.outputFile=other', '-Dexec.mainClass=Other', '-Dmaven.multiModuleProjectDirectory=other'])('拒绝覆盖计划或执行额外目标的参数 %s', argument => { + expect(() => planMaven({ ...config, buildArgs: [argument] }, '/tmp/metadata')).toThrow(); + }); +}); diff --git a/tests/parse-argvs.test.ts b/tests/parse-argvs.test.ts new file mode 100644 index 0000000..edf63c4 --- /dev/null +++ b/tests/parse-argvs.test.ts @@ -0,0 +1,371 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { parseArgs } from '../src/cli/args'; +import { toProjectConfig } from '../src/cli/config'; +import { getHelpText, helpLog } from '../src/cli/help'; + +let projectDirectory: string; + +beforeEach(() => { + projectDirectory = mkdtempSync(join(tmpdir(), 'java-run-cli-')); +}); + +afterEach(() => { + rmSync(projectDirectory, { recursive: true, force: true }); +}); + +function parse(argv: string[]) { + return parseArgs(argv, projectDirectory); +} + +function saveConfig(data: unknown, directory = projectDirectory): void { + writeFileSync(join(directory, '.java-run.json'), JSON.stringify(data)); +} + +describe('框架中立的启动参数', () => { + test('无参数默认运行,构建工具与构建策略默认自动选择', () => { + const config = parse([]); + expect(config.action).toBe('run'); + expect(config.force).toBe(false); + expect(config.cwd).toBe(projectDirectory); + expect(config.buildTool).toBe('auto'); + expect(config.build).toBe('auto'); + expect(config.mainClass).toBeUndefined(); + expect(config.module).toBeUndefined(); + expect(config.includeTests).toBe(false); + expect(config.jvmArgs).toEqual([]); + expect(config.applicationArgs).toEqual([]); + expect(config.buildArgs).toEqual([]); + }); + + test('支持启动、规划、初始化、帮助和版本命令', () => { + for (const action of ['run', 'plan', 'init', 'help', 'version'] as const) { + expect(parse([action]).action).toBe(action); + } + for (const alias of ['--help', '-h']) { + expect(parse(['run', alias]).action).toBe('help'); + } + expect(parse(['--version']).action).toBe('version'); + expect(parse(['run', '--version']).action).toBe('version'); + expect(parse(['--version', '--help']).action).toBe('help'); + expect(() => parse(['plan', 'run'])).toThrow('命令'); + }); + + test('参数名精确匹配,不接受旧入口或框架专用选项', () => { + for (const argument of [ + 'start', '-c', 'compile', 'local', 'active=dev', 'main=example.App', + '--backend=boot', '--profile=dev', '--property=a=b', '--dry-run', '--refresh', + '--module-extra=app', '--tool-extra=maven', 'running', + ]) { + expect(() => parse([argument])).toThrow('未知参数'); + expect(() => parse(['--help', argument])).toThrow('未知参数'); + } + }); + + test('标量重复和重复布尔开关报错', () => { + for (const args of [ + ['--main=a.App', '--main=b.App'], ['--module=app', '--module=:app'], + ['--tool=maven', '--tool=gradle'], ['--cwd=one', '--cwd=two'], + ['--java=java', '--java=other'], ['--build-command=mvn', '--build-command=other'], + ['--build=auto', '--build=none'], ['--include-tests', '--include-tests'], + ]) { + expect(() => parse(args)).toThrow('不能重复指定'); + } + }); + + test('模块原样保留构建工具选择器,只允许单个目标', () => { + for (const module of ['app', ':app', ':apps:admin-server', ':', 'apps/my app']) { + expect(parse([`--module=${module}`]).module).toBe(module); + } + for (const selector of ['app,lib', '!app', '-app', '?app']) { + expect(() => parse([`--module=${selector}`])).toThrow('单个模块'); + } + }); + + test('验证主类语法并允许 Java 内部类和中文标识符', () => { + expect(parse(['--main=com.example.Outer$Inner']).mainClass).toBe('com.example.Outer$Inner'); + expect(parse(['--main=示例.应用']).mainClass).toBe('示例.应用'); + for (const name of ['1App', 'com..App', 'com.App;exit', 'com.App-name', 'com/App']) { + expect(() => parse([`--main=${name}`])).toThrow('Java 类全名'); + } + }); + + test('工具和构建策略仅接受枚举值', () => { + expect(parse(['--tool=maven', '--build=none']).buildTool).toBe('maven'); + expect(parse(['--tool=gradle', '--build=none']).build).toBe('none'); + expect(() => parse(['--tool=ant'])).toThrow('auto、maven 或 gradle'); + expect(() => parse(['--build=install'])).toThrow('auto 或 none'); + expect(() => parse(['--build=compile'])).toThrow('auto 或 none'); + }); + + test('按第一个等号分割值,保留重复数组参数的顺序和边界', () => { + const config = parse([ + '--jvm-arg=-Dtoken=a=b', '--jvm-arg=-Xmx1g', + '--arg=--message=hello world', '--arg', 'a=b', + '--build-arg=-Pdevelopment', '--build-arg=-Dname=a=b', + ]); + expect(config.jvmArgs).toEqual(['-Dtoken=a=b', '-Xmx1g']); + expect(config.applicationArgs).toEqual(['--message=hello world', 'a=b']); + expect(config.buildArgs).toEqual(['-Pdevelopment', '-Dname=a=b']); + }); + + test('-- 后全部透传应用,不再解释 CLI 选项', () => { + const config = parse(['--arg=first', '--', '--help', 'main=anything', '', '--unknown=value']); + expect(config.action).toBe('run'); + expect(config.applicationArgs).toEqual(['first', '--help', 'main=anything', '', '--unknown=value']); + expect(config.mainClass).toBeUndefined(); + }); + + test('负号值要求用等号,空值和缺失值直接报错', () => { + for (const args of [['--jvm-arg', '-Xmx1g'], ['--build-arg', '-Pdev'], ['--main', '--help']]) { + expect(() => parse(args)).toThrow('负号开头'); + } + for (const args of [ + ['--main='], ['--cwd= '], ['--module='], ['--arg='], ['--jvm-arg='], + ['--build-arg='], ['--tool'], ['--build-command='], ['--java'], + ]) { + expect(() => parse(args)).toThrow(); + } + }); + + test('命令和布尔开关不接受值,工具路径可包含空格和特殊字符', () => { + for (const argument of ['--help=true', 'run=true', '--include-tests=false', '--version=yes']) { + expect(() => parse([argument])).toThrow('不接受参数值'); + } + const config = parse([ + '--include-tests', '--java', '/tools/java home #/bin/java', '--build-command=/tools/build tool', + ]); + expect(config.includeTests).toBe(true); + expect(config.javaCommand).toBe('/tools/java home #/bin/java'); + expect(config.buildCommand).toBe('/tools/build tool'); + }); +}); + +describe('初始化参数', () => { + test('init 只保留显式启动参数,不读取或合并原配置', () => { + saveConfig({ + buildTool: 'maven', module: ':old', mainClass: 'example.Old', + jvmArgs: ['-Xms256m'], applicationArgs: ['old'], buildArgs: ['--offline'], + build: 'none', includeTests: true, + }); + const config = parse([ + 'init', '--cwd=.', '--tool=gradle', '--module=:new', '--main=example.New', + '--jvm-arg=-Xmx1g', '--build-arg=--info', '--arg=first', '--', 'second', '', ' ', + ]); + expect(config.action).toBe('init'); + expect(config.buildTool).toBe('gradle'); + expect(config.module).toBe(':new'); + expect(config.mainClass).toBe('example.New'); + expect(config.jvmArgs).toEqual(['-Xmx1g']); + expect(config.applicationArgs).toEqual(['first', 'second', '', ' ']); + expect(config.buildArgs).toEqual(['--info']); + expect(config.build).toBe('auto'); + expect(config.includeTests).toBe(false); + expect(parse(['init']).mainClass).toBeUndefined(); + expect(parse([]).mainClass).toBe('example.Old'); + }); + + test('init 与 force 不解析无效旧配置,也不修改文件', () => { + const path = join(projectDirectory, '.java-run.json'); + writeFileSync(path, '{invalid'); + for (const argv of [['init'], ['init', '--force'], ['--force', 'init']]) { + const config = parse(argv); + expect(config.action).toBe('init'); + expect(config.force).toBe(argv.includes('--force')); + expect(config.mainClass).toBeUndefined(); + expect(readFileSync(path, 'utf8')).toBe('{invalid'); + } + expect(() => parse(['init', '--unknown'])).toThrow('未知参数'); + }); + + test('force 只属于 init,重复或带值时拒绝', () => { + for (const argv of [['--force'], ['run', '--force'], ['plan', '--force'], ['help', '--force'], ['version', '--force']]) { + expect(() => parse(argv)).toThrow('--force 仅允许用于 init'); + } + expect(() => parse(['init', '--force', '--force'])).toThrow('不能重复指定'); + expect(() => parse(['init', '--force=true'])).toThrow('不接受参数值'); + expect(() => parse(['init', 'run'])).toThrow('命令'); + expect(parse(['init', '--', '--force']).force).toBe(false); + expect(parse(['init', '--', '--force']).applicationArgs).toEqual(['--force']); + }); + + test('生成配置保存实际工具和主类,省略默认值与 CLI 专属字段', () => { + const config = parse([ + 'init', '--force', '--java=/tools/java', '--build-command=/tools/gradle', + ]); + const projectConfig = toProjectConfig(config, 'gradle', 'example.Resolved'); + expect(projectConfig).toEqual({ buildTool: 'gradle', mainClass: 'example.Resolved' }); + saveConfig(projectConfig); + expect(parse([]).buildTool).toBe('gradle'); + expect(parse([]).mainClass).toBe('example.Resolved'); + expect(parse([]).javaCommand).toBeUndefined(); + expect(parse([]).buildCommand).toBeUndefined(); + expect(parse([]).force).toBe(false); + }); + + test('生成的完整配置可往返读取,保留应用参数的空白及边界', () => { + const config = parse([ + 'init', '--module=:app', '--build=none', '--include-tests', + '--jvm-arg=-Dmessage=a=b', '--build-arg=--offline', + '--arg=hello world', '--', '', ' ', '--arg=literal', 'a=b', + ]); + const projectConfig = toProjectConfig(config, 'maven', 'example.App'); + expect(projectConfig).toEqual({ + buildTool: 'maven', module: ':app', mainClass: 'example.App', + jvmArgs: ['-Dmessage=a=b'], applicationArgs: ['hello world', '', ' ', '--arg=literal', 'a=b'], + buildArgs: ['--offline'], build: 'none', includeTests: true, + }); + saveConfig(projectConfig); + const reloaded = parse([]); + expect(reloaded.action).toBe('run'); + expect(reloaded.module).toBe(':app'); + expect(reloaded.build).toBe('none'); + expect(reloaded.includeTests).toBe(true); + expect(reloaded.jvmArgs).toEqual(config.jvmArgs); + expect(reloaded.applicationArgs).toEqual(config.applicationArgs); + expect(reloaded.buildArgs).toEqual(config.buildArgs); + projectConfig.jvmArgs!.push('-Xmx1g'); + projectConfig.applicationArgs!.push('changed'); + projectConfig.buildArgs!.push('--info'); + expect(config.jvmArgs).toEqual(['-Dmessage=a=b']); + expect(config.applicationArgs).toEqual(['hello world', '', ' ', '--arg=literal', 'a=b']); + expect(config.buildArgs).toEqual(['--offline']); + }); +}); + +describe('项目配置', () => { + test('读取完整中立配置,标量覆盖,数组依次追加', () => { + saveConfig({ + buildTool: 'gradle', module: ':configured', mainClass: 'example.Configured', + jvmArgs: ['-Xms256m'], applicationArgs: ['configured'], buildArgs: ['--offline'], + build: 'none', includeTests: false, + }); + const config = parse([ + '--tool=maven', '--module=:cli', '--main=example.Cli', '--build=auto', '--include-tests', + '--jvm-arg=-Xmx1g', '--arg=cli', '--build-arg=-Pdev', '--', 'trailing', + ]); + expect(config.buildTool).toBe('maven'); + expect(config.module).toBe(':cli'); + expect(config.mainClass).toBe('example.Cli'); + expect(config.build).toBe('auto'); + expect(config.includeTests).toBe(true); + expect(config.jvmArgs).toEqual(['-Xms256m', '-Xmx1g']); + expect(config.applicationArgs).toEqual(['configured', 'cli', 'trailing']); + expect(config.buildArgs).toEqual(['--offline', '-Pdev']); + }); + + test('plan 与 run 都读取配置,没有 CLI 覆盖时保留文件值', () => { + saveConfig({ buildTool: 'gradle', module: ':app', build: 'none', includeTests: true }); + for (const command of ['run', 'plan'] as const) { + const config = parse([command]); + expect(config.action).toBe(command); + expect(config.buildTool).toBe('gradle'); + expect(config.module).toBe(':app'); + expect(config.build).toBe('none'); + expect(config.includeTests).toBe(true); + } + }); + + test('先解析最终 cwd 再读配置,且不向父目录查找', () => { + saveConfig({ mainClass: 'example.Parent' }); + const child = join(projectDirectory, 'child'); + mkdirSync(child); + expect(parseArgs([], child).mainClass).toBeUndefined(); + saveConfig({ mainClass: 'example.Child' }, child); + for (const args of [['--cwd=child', '--arg=x'], ['--arg=x', '--cwd=child']]) { + const config = parse(args); + expect(config.cwd).toBe(child); + expect(config.mainClass).toBe('example.Child'); + expect(config.applicationArgs).toEqual(['x']); + } + expect(parseArgs(['--cwd', child], resolve('unrelated cwd')).cwd).toBe(child); + }); + + test('配置未知字段拒绝,包括旧框架字段和只允许 CLI 的工具路径', () => { + for (const key of ['backend', 'springProfiles', 'properties', 'cwd', 'javaCommand', 'buildCommand', 'action', 'force', 'extra']) { + saveConfig({ [key]: 'x' }); + expect(() => parse([])).toThrow(`未知配置项:${key}`); + } + }); + + test('配置对象、字段类型与枚举严格验证', () => { + for (const config of [ + null, [], 'value', 12, { buildTool: null }, { buildTool: 'ant' }, { module: 42 }, + { module: 'a,b' }, { mainClass: false }, { mainClass: 'invalid-name' }, + { jvmArgs: '-Xmx1g' }, { jvmArgs: [12] }, { jvmArgs: [' '] }, { applicationArgs: [null] }, + { buildArgs: [null] }, { buildArgs: [''] }, { build: 'install' }, { includeTests: 'true' }, + ]) { + saveConfig(config); + expect(() => parse([])).toThrow('配置无效'); + } + writeFileSync(join(projectDirectory, '.java-run.json'), '{not JSON'); + expect(() => parse([])).toThrow(join(projectDirectory, '.java-run.json')); + }); + + test('不做环境变量插值,读取后不修改文件或缓存配置', () => { + const literal = '-Dname=${JAVA_RUN_UNEXPANDED_VALUE}'; + saveConfig({ jvmArgs: [literal] }); + const first = parse([]); + expect(first.jvmArgs).toEqual([literal]); + first.jvmArgs.push('mutated'); + expect(parse([]).jvmArgs).toEqual([literal]); + saveConfig({ jvmArgs: ['changed'] }); + expect(parse([]).jvmArgs).toEqual(['changed']); + }); + + test('CLI 无效时先报告参数错误,不读取项目配置', () => { + writeFileSync(join(projectDirectory, '.java-run.json'), '{invalid'); + expect(() => parse(['--unknown'])).toThrow('未知参数'); + }); +}); + +describe('帮助与解析副作用', () => { + test('帮助和版本不会读取配置或要求项目存在', () => { + writeFileSync(join(projectDirectory, '.java-run.json'), '{invalid'); + for (const args of [ + ['help'], ['version'], ['--help'], ['-h'], ['--version'], ['run', '--help'], + ['init', '--help'], ['init', '--force', '--help'], ['init', '--force', '--version'], + ]) { + expect(() => parse(args)).not.toThrow(); + expect(() => parseArgs(args, join(projectDirectory, 'does-not-exist'))).not.toThrow(); + } + }); + + test('不会修改输入参数或当前工作目录', () => { + const args = ['plan', '--cwd=child', '--arg=unchanged']; + const original = [...args]; + const currentCwd = process.cwd(); + const first = parse(args); + first.applicationArgs.push('mutated'); + expect(parse(args).applicationArgs).toEqual(['unchanged']); + expect(args).toEqual(original); + expect(process.cwd()).toBe(currentCwd); + }); + + test('帮助描述通用主类和构建适配,不出现旧业务或框架默认值', () => { + const help = getHelpText(); + expect(help).toContain('Maven'); + expect(help).toContain('Gradle'); + expect(help).toContain('--tool'); + expect(help).toContain('--build-arg'); + expect(help).toContain('.java-run.json'); + expect(help).not.toContain('Jeecg'); + expect(help).not.toContain('Spring'); + expect(help).not.toContain('--profile'); + }); + + test('帮助输出由调用方决定退出时机', () => { + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { throw new Error('不应退出'); }); + try { + helpLog(); + expect(log).toHaveBeenCalledWith(getHelpText()); + expect(exit).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + exit.mockRestore(); + } + }); +}); diff --git a/tests/release.test.ts b/tests/release.test.ts new file mode 100644 index 0000000..5401570 --- /dev/null +++ b/tests/release.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { assertReleaseAssets, packageRelease, releaseMetadata, releaseNotes, verifyReleasePackage, writeReleaseChecksums } from '../scripts/release'; +import { writeBinaryChecksum } from '../scripts/checksum'; + +function updateArchive(archive: string, entry: string, content?: string): void { + const temporary = mkdtempSync(join(tmpdir(), 'java-run-zip-update-')); + try { + let result; + if (process.platform === 'win32') { + const script = "Add-Type -AssemblyName System.IO.Compression; Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [System.IO.Compression.ZipFile]::Open($env:JAVA_RUN_TEST_ARCHIVE, [System.IO.Compression.ZipArchiveMode]::Update); try { $previous = $archive.Entries | Where-Object { $_.FullName.Replace('\\', '/') -eq $env:JAVA_RUN_TEST_ENTRY }; $previous.Delete(); if ($env:JAVA_RUN_TEST_CONTENT) { $entry = $archive.CreateEntry($env:JAVA_RUN_TEST_ENTRY); $stream = $entry.Open(); try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($env:JAVA_RUN_TEST_CONTENT); $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Dispose() } } } finally { $archive.Dispose() }"; + result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `$ErrorActionPreference = 'Stop'; ${script}`], { + env: { ...process.env, JAVA_RUN_TEST_ARCHIVE: archive, JAVA_RUN_TEST_ENTRY: entry, JAVA_RUN_TEST_CONTENT: content ?? '' }, + encoding: 'utf8', + }); + } else if (content === undefined) { + result = spawnSync('zip', ['-q', '-d', archive, entry], { encoding: 'utf8' }); + } else { + mkdirSync(join(temporary, entry, '..'), { recursive: true }); + writeFileSync(join(temporary, entry), content); + result = spawnSync('zip', ['-q', archive, entry], { cwd: temporary, encoding: 'utf8' }); + } + if (result.error || result.status !== 0) throw new Error(`修改测试 ZIP 失败:${result.error?.message ?? result.stderr}`); + } finally { rmSync(temporary, { recursive: true, force: true }); } +} + +describe('发布产物契约', () => { + test('标签必须匹配包版本,稳定版与预发布生成五个独立产物', () => { + const stable = releaseMetadata('1.2.3', 'v1.2.3'); + expect(stable.prerelease).toBe(false); + expect(stable.matrix.include).toHaveLength(5); + expect(new Set(stable.matrix.include.map(platform => platform.archive)).size).toBe(5); + expect(stable.matrix.include.find(platform => platform.name === 'windows-x64')).toEqual({ + name: 'windows-x64', runner: 'windows-2025', target: 'bun-windows-x64', + binary: 'java-run.exe', archive: 'java-run-windows-x64.zip', + }); + expect(releaseMetadata('2.0.0').matrix).toEqual(stable.matrix); + expect(releaseMetadata('1.2.3-rc.1+build.7').prerelease).toBe(true); + expect(releaseMetadata('1.2.3+build.7').prerelease).toBe(false); + expect(() => releaseMetadata('1.2.3', 'v1.2.4')).toThrow('版本不一致'); + for (const invalid of ['v1.2.3', '1.2', '01.2.3', '1.2.3-01', '1.2.3-rc..1', '1.2.3\n']) { + expect(() => releaseMetadata(invalid)).toThrow('无效的发布版本'); + } + }); + + test('草稿只允许验收过的文件与校验和,旧资产或缺失文件阻止公开', () => { + const assets = [...releaseMetadata('1.2.3').matrix.include.map(platform => platform.archive), 'SHA256SUMS']; + expect(() => assertReleaseAssets(assets.toReversed(), '1.2.3')).not.toThrow(); + expect(() => assertReleaseAssets(assets.slice(1), '1.2.3')).toThrow('资产集合'); + expect(() => assertReleaseAssets([...assets, 'java-run'], '1.2.3')).toThrow('资产集合'); + expect(() => assertReleaseAssets([...assets, assets[0]], '1.2.3')).toThrow('资产集合'); + expect(() => assertReleaseAssets({ assets }, '1.2.3')).toThrow('资产集合'); + }); + + test('清单覆盖实际五份文件,缺失、多余或空产物不能生成校验和', async () => { + const directory = mkdtempSync(join(tmpdir(), 'java-run-release-')); + const files = releaseMetadata('1.2.3').matrix.include.map(platform => platform.archive); + try { + writeFileSync(join(directory, 'SHA256SUMS'), 'previous-checksums\n'); + await expect(writeReleaseChecksums(directory, '1.2.3')).rejects.toThrow('产物集合'); + expect(readFileSync(join(directory, 'SHA256SUMS'), 'utf8')).toBe('previous-checksums\n'); + for (const file of files) writeFileSync(join(directory, file), `binary:${file}`); + writeFileSync(join(directory, 'unexpected-file'), 'extra'); + await expect(writeReleaseChecksums(directory, '1.2.3')).rejects.toThrow('额外文件'); + rmSync(join(directory, 'unexpected-file')); + writeFileSync(join(directory, files[0]!), ''); + await expect(writeReleaseChecksums(directory, '1.2.3')).rejects.toThrow('非空普通文件'); + writeFileSync(join(directory, files[0]!), `binary:${files[0]}`); + const manifest = await writeReleaseChecksums(directory, '1.2.3'); + const lines = manifest.trimEnd().split('\n'); + expect(lines).toHaveLength(5); + for (const file of files) { + const digest = createHash('sha256').update(readFileSync(join(directory, file))).digest('hex'); + expect(lines).toContain(`${digest} ${file}`); + } + expect(readFileSync(join(directory, 'SHA256SUMS'), 'utf8')).toBe(manifest); + expect(await writeReleaseChecksums(directory, '1.2.3')).toBe(manifest); + } finally { rmSync(directory, { recursive: true, force: true }); } + }); + + test('正文下载链接与固定 ZIP 资产一致,并说明依赖、版本验证和替换升级', () => { + const notes = releaseNotes('1.2.3-rc.1'); + for (const platform of releaseMetadata('1.2.3-rc.1').matrix.include) { + expect(notes).toContain(`https://github.com/Vanisper/java-run/releases/download/v1.2.3-rc.1/${platform.archive}`); + } + expect(notes).toContain('https://github.com/Vanisper/java-run/releases/download/v1.2.3-rc.1/SHA256SUMS'); + expect(notes).toContain('https://github.com/Vanisper/java-run/blob/v1.2.3-rc.1/docs/installation.md'); + expect(notes).toContain('无需安装 Bun'); + expect(notes).toContain('JDK'); + expect(notes).toContain('Wrapper'); + expect(notes).toContain('java-run 1.2.3-rc.1'); + expect(notes).toContain('替换原文件'); + expect(notes).toContain('包外的 `SHA256SUMS` 用于校验 ZIP'); + expect(notes).toContain('包内的 `.sha256` 文件用于校验解压后的二进制'); + expect(releaseNotes('1.2.3+build.7')).toContain('/download/v1.2.3%2Bbuild.7/'); + }); + + test('原生 ZIP 保留完整目录、字节与权限,重打包移除旧文件', async () => { + const directory = mkdtempSync(join(tmpdir(), 'java-run-release-zip-')); + const documentation = join(directory, 'project'); + const platform = process.platform === 'win32' ? 'windows-x64' : 'linux-x64'; + const binaryName = process.platform === 'win32' ? 'java-run.exe' : 'java-run'; + const binary = join(directory, binaryName); + const bytes = '#!/bin/sh\nprintf "release-archive-test\\n"\n'; + mkdirSync(join(documentation, 'docs'), { recursive: true }); + writeFileSync(join(documentation, 'docs/installation.md'), '# 安装指南\n'); + writeFileSync(join(documentation, 'LICENSE'), 'Release package license\n'); + writeFileSync(binary, bytes); + chmodSync(binary, 0o755); + try { + await writeBinaryChecksum(binary); + const archive = await packageRelease(directory, platform, documentation); + expect(archive).toBe(join(directory, `java-run-${platform}.zip`)); + const unpacked = await verifyReleasePackage(directory, platform, documentation); + try { + expect(unpacked.binary).toEndWith(`/java-run-${platform}/${binaryName}`.replaceAll('/', process.platform === 'win32' ? '\\' : '/')); + expect(readFileSync(unpacked.binary)).toEqual(Buffer.from(bytes)); + const digest = createHash('sha256').update(Buffer.from(bytes)).digest('hex'); + expect(readFileSync(`${unpacked.binary}.sha256`, 'utf8')).toBe(`${digest} ${binaryName}\n`); + expect(readFileSync(`${unpacked.binary}.sha256`)).toEqual(readFileSync(`${binary}.sha256`)); + expect(readFileSync(join(unpacked.binary, '..', 'INSTALL.md'), 'utf8')).toBe('# 安装指南\n'); + expect(readFileSync(join(unpacked.binary, '..', 'LICENSE'), 'utf8')).toBe('Release package license\n'); + if (process.platform !== 'win32') { + expect(statSync(unpacked.binary).mode & 0o111).toBe(0o111); + const execution = spawnSync(unpacked.binary, [], { encoding: 'utf8' }); + expect(execution.status).toBe(0); + expect(execution.stdout).toBe('release-archive-test\n'); + } + } finally { await unpacked.cleanup(); } + expect(existsSync(unpacked.binary)).toBe(false); + rmSync(join(documentation, 'LICENSE')); + await expect(verifyReleasePackage(directory, platform, documentation)).rejects.toThrow('ZIP 内容'); + writeFileSync(binary, `${bytes}# replacement\n`); + await expect(packageRelease(directory, platform, documentation)).rejects.toThrow(); + await writeBinaryChecksum(binary); + await packageRelease(directory, platform, documentation); + const replacement = await verifyReleasePackage(directory, platform, documentation); + try { + expect(readFileSync(replacement.binary, 'utf8')).toBe(`${bytes}# replacement\n`); + expect(existsSync(join(replacement.binary, '..', 'LICENSE'))).toBe(false); + } finally { await replacement.cleanup(); } + } finally { rmSync(directory, { recursive: true, force: true }); } + }, 30000); + + test('缺少安装指南、ZIP 损坏或打包后字节发生变化时验收失败', async () => { + const directory = mkdtempSync(join(tmpdir(), 'java-run-release-invalid-')); + const documentation = join(directory, 'project'); + const platform = process.platform === 'win32' ? 'windows-x64' : 'linux-x64'; + const binaryName = process.platform === 'win32' ? 'java-run.exe' : 'java-run'; + mkdirSync(join(documentation, 'docs'), { recursive: true }); + writeFileSync(join(directory, binaryName), 'binary'); + try { + await writeBinaryChecksum(join(directory, binaryName)); + await expect(packageRelease(directory, platform, documentation)).rejects.toThrow(); + expect(existsSync(join(directory, `java-run-${platform}.zip`))).toBe(false); + writeFileSync(join(documentation, 'docs/installation.md'), 'Installation instructions\n'); + await expect(packageRelease(directory, 'unknown-platform', documentation)).rejects.toThrow('不支持的发布平台'); + await packageRelease(directory, platform, documentation); + writeFileSync(join(directory, binaryName), 'changed binary'); + await expect(verifyReleasePackage(directory, platform, documentation)).rejects.toThrow('文件内容不一致'); + writeFileSync(join(directory, `java-run-${platform}.zip`), 'not a ZIP'); + await expect(verifyReleasePackage(directory, platform, documentation)).rejects.toThrow('ZIP 操作失败'); + } finally { rmSync(directory, { recursive: true, force: true }); } + }, 30000); + + test('二进制校验文件必须存在且有效,ZIP 缺失或替换校验文件时拒绝验收', async () => { + const directory = mkdtempSync(join(tmpdir(), 'java-run-release-checksum-')); + const documentation = join(directory, 'project'); + const platform = process.platform === 'win32' ? 'windows-x64' : 'linux-x64'; + const binaryName = process.platform === 'win32' ? 'java-run.exe' : 'java-run'; + const binary = join(directory, binaryName); + const checksum = `${binary}.sha256`; + const entry = `java-run-${platform}/${binaryName}.sha256`; + mkdirSync(join(documentation, 'docs'), { recursive: true }); + writeFileSync(join(documentation, 'docs/installation.md'), 'Installation instructions\n'); + writeFileSync(binary, 'binary'); + try { + await expect(packageRelease(directory, platform, documentation)).rejects.toThrow(); + expect(existsSync(join(directory, `java-run-${platform}.zip`))).toBe(false); + writeFileSync(checksum, `${'0'.repeat(64)} ${binaryName}\n`); + await expect(packageRelease(directory, platform, documentation)).rejects.toThrow(); + await writeBinaryChecksum(binary); + const archive = await packageRelease(directory, platform, documentation); + updateArchive(archive, entry); + await expect(verifyReleasePackage(directory, platform, documentation)).rejects.toThrow('ZIP 内容'); + await packageRelease(directory, platform, documentation); + const changed = `${'0'.repeat(64)} ${binaryName}\n`; + updateArchive(archive, entry, changed); + await expect(verifyReleasePackage(directory, platform, documentation)).rejects.toThrow('文件内容不一致'); + writeFileSync(checksum, changed); + await expect(verifyReleasePackage(directory, platform, documentation)).rejects.toThrow(); + await writeBinaryChecksum(binary); + await expect(verifyReleasePackage(directory, platform, documentation)).rejects.toThrow('文件内容不一致'); + } finally { rmSync(directory, { recursive: true, force: true }); } + }, 30000); +}); diff --git a/tests/selection.test.ts b/tests/selection.test.ts new file mode 100644 index 0000000..5313f20 --- /dev/null +++ b/tests/selection.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from 'bun:test'; +import { spawn } from 'node:child_process'; +import { chooseCandidate } from '../src/cli/selection'; + +const selectionModule = new URL('../src/cli/selection.ts', import.meta.url).href; +const candidates = [ + { value: ':app', label: '应用模块 app' }, + { value: ':apps:admin-server', label: '管理模块 admin-server' }, +]; + +async function runSelection(input: string | undefined, tty: boolean, signal?: NodeJS.Signals) { + const source = ` + import { chooseCandidate } from ${JSON.stringify(selectionModule)}; + Object.defineProperty(process.stdin, 'isTTY', { value: ${tty} }); + Object.defineProperty(process.stderr, 'isTTY', { value: ${tty} }); + const counts = process.listenerCount('SIGINT'); + try { + const result = await chooseCandidate(${JSON.stringify(candidates)}, '选择启动模块'); + console.log(JSON.stringify({ value: result, remainingSignalListeners: process.listenerCount('SIGINT') - counts })); + } catch (error) { + console.log(JSON.stringify({ error: error.message, name: error.name, remainingSignalListeners: process.listenerCount('SIGINT') - counts })); + process.exitCode = error.exitCode || 1; + } + `; + const child = spawn(process.execPath, ['-e', source], { stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + let signalled = false; + const completed = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + child.stdout.on('data', (value: Buffer) => { stdout += value.toString('utf8'); }); + child.stderr.on('data', (value: Buffer) => { + stderr += value.toString('utf8'); + if (signal && !signalled && stderr.includes('选择 [1-2]')) { + signalled = true; + child.kill(signal); + } + }); + if (input !== undefined) child.stdin.end(input); + const deadline = setTimeout(() => { child.kill('SIGKILL'); }, 3000); + try { + const code = await completed; + return { code, stdout, stderr, result: stdout ? JSON.parse(stdout) : undefined }; + } finally { + clearTimeout(deadline); + child.stdin.destroy(); + } +} + +describe('终端候选选择', () => { + test('无候选项明确提示参数路径', async () => { + await expect(chooseCandidate([], '选择模块')).rejects.toThrow('--module / --main'); + }); + + test('非交互环境立即失败,不提示或等待输入', async () => { + const result = await runSelection(undefined, false); + expect(result.code).toBe(1); + expect(result.result.error).toContain('非交互环境'); + expect(result.result.error).toContain('.java-run.json'); + expect(result.stderr).toBe(''); + }); + + test('数字选择返回原始标识,菜单在 stderr 且监听得到清理', async () => { + const result = await runSelection('2\n', true); + expect(result.code).toBe(0); + expect(result.result).toEqual({ value: ':apps:admin-server', remainingSignalListeners: 0 }); + expect(result.stderr).toContain('1. 应用模块 app'); + expect(result.stderr).toContain('2. 管理模块 admin-server'); + }); + + test('无效输入重新提示,只有有效序号才完成', async () => { + const result = await runSelection('0\n3\nabc\n1.5\n1\n', true); + expect(result.code).toBe(0); + expect(result.result.value).toBe(':app'); + expect(result.stderr.match(/请输入 1 到 2 之间的序号/g)).toHaveLength(4); + }); + + test('EOF 取消以 130 退出且不保留监听', async () => { + const result = await runSelection('', true); + expect(result.code).toBe(130); + expect(result.result).toMatchObject({ name: 'SelectionCancelledError', remainingSignalListeners: 0 }); + }); + + test('终端 Ctrl+C 取消以 130 退出', async () => { + const result = await runSelection('\u0003', true); + expect(result.code).toBe(130); + expect(result.result.name).toBe('SelectionCancelledError'); + }); + + test.skipIf(process.platform === 'win32')('实际 SIGINT 取消以 130 退出', async () => { + const result = await runSelection(undefined, true, 'SIGINT'); + expect(result.code).toBe(130); + expect(result.result).toMatchObject({ name: 'SelectionCancelledError', remainingSignalListeners: 0 }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 238655f..c55a4f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,19 @@ { "compilerOptions": { - // Enable latest features - "lib": ["ESNext", "DOM"], + "lib": ["ESNext"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - - // Bundler mode "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, - // Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false + "noUnusedLocals": true, + "noUnusedParameters": true } }