From 4ba582286ddc6f8a4fcbb7e51a54e3cb1362be30 Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Mon, 5 Oct 2026 10:01:21 +0800 Subject: [PATCH 01/26] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E8=AF=84=E4=BC=B0=E4=B8=8E=E5=BC=80=E6=BA=90=E6=BC=94?= =?UTF-8?q?=E8=BF=9B=E8=B7=AF=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/assessment-and-roadmap.md | 252 +++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/assessment-and-roadmap.md diff --git a/docs/assessment-and-roadmap.md b/docs/assessment-and-roadmap.md new file mode 100644 index 0000000..62592f4 --- /dev/null +++ b/docs/assessment-and-roadmap.md @@ -0,0 +1,252 @@ +# java-run 实现评估与开源技术路线 + +评估日期:2026-10-05。代码基线:`000c23bc2f11ad80224c179cd12e1757a5b5c09b`,包版本 `0.0.5`。目标按本次讨论确定为通用开源 CLI。 + +**当前项目已经完成特定项目启动工具的主流程,并具备自动发布能力,但尚未达到可对陌生 Maven 项目承诺兼容的阶段。** 最值得保留的是 TypeScript / Bun 工具链、独立 Java 进程、长类路径处理思路和已有发布流程。下一步的重点是运行正确性、Maven 语义和可重复验证。 + +建议继续投入,先以 Maven / Spring Boot 开发启动为明确范围。短期修复可复现问题;中期以启动模块为中心,让 Maven 负责模型与依赖裁决;随后用真实项目夹具和原生操作系统测试支撑支持范围。性能优化应在正确性基线建立后进行。 + +## 评估范围与证据 + +本次阅读了全部 8 个 TypeScript 源文件、README、配置、构建脚本、发布工作流及本地可见的 17 个提交。在隔离副本安装锁定依赖,执行类型检查、本机编译及针对性探针;查阅 Maven、Java、Spring Boot、Bun 和 Exec Maven Plugin 官方资料。 + +文中的“已复现”指本地实际执行,“源码确认”指实现直接体现的行为,“风险推断”指结合实现与官方语义得出的可能后果,“建议”指后续方案。 + +本次没有运行完整 Jeecg 或 Spring Boot 项目,没有执行真实多模块 Maven reactor 集成验证,没有测试 Windows / Linux 二进制,也没有检验线上 Release 产物。对 Maven 依赖缓存的探针使用模拟 `mvn`,只证明本工具的缓存控制行为。不能把这些结果扩大为完整兼容性验收。 + +## 实现现状 + +当前执行链如下;`main=` 只改变启动类,并不选择或收窄 Maven 模块。 + +```mermaid +flowchart TD + A[解析 CLI 参数] --> B{包含 start} + B -->|否| C[输出帮助] + B -->|是| D[可选执行 mvn compile] + D --> E[递归原始 POM 并收集全部叶子模块] + E --> F[逐模块解析依赖或读取缓存] + F --> G[合并全部模块输出目录和依赖] + G --> H[写 Manifest 并生成 cp.jar] + H --> I{包含 no-run} + I -->|是| J[结束] + I -->|否| K[启动独立 Java 进程] +``` + +| 能力 | 当前实现 | 面向通用工具的评价 | +| --- | --- | --- | +| 模块发现 | 递归静态 ``,提取叶子模块;单模块可作为根模块返回 | 基础流程成立,但不是 Maven 有效模型 | +| 依赖解析 | 调用 `dependency:build-classpath`,读取输出文件并去重 | 复用了 Maven,但逐模块解析后取并集丢失了目标应用语义 | +| 工作区输出 | 加入已有 `target/classes` 和 `target/test-classes`,按路径后缀排除模块 Jar | 固定目录和坐标拼接适用于有限结构 | +| 启动与配置 | 独立 Java 进程,支持主类、Spring profile、本地 profile 前缀和可选编译 | 主要入口齐备,仍绑定 Jeecg 默认主类;缺目标模块和通用参数转发 | +| 缓存 | 按模块绝对路径生成文件名,支持 `-r` | 有性能意识,失效条件不足 | +| 长类路径 | 使用 Manifest 和 `cp.jar` | 方向可保留,路径编码存在已复现问题 | +| 分发 | 本机编译,tag 触发五种 OS / 架构产物构建与 Release | 构建目标已有覆盖,目标系统运行尚无验证门禁 | + +源码入口:[主流程](../src/cli.ts)、[模块发现](../src/find-maven-modules.ts)、[类路径构建](../src/classpath-builder.ts)、[发布流程](../.github/workflows/release.yaml)。 + +以下语义需要在文档中明确:`active=` 是 Spring profile,不是 Maven `-P`;`no-run` 仍解析依赖并写缓存、生成 Jar,不是无副作用的 dry-run;`-c` 只执行 `mvn compile`,不能据此保证后续逐模块解析能找到未安装的兄弟模块产物。 + +## 需要优先处理的实现问题 + +### Java 正常退出后被 CLI 判为失败 + +**已复现,稳定版本的阻断项。** [exec.ts](../src/exec.ts) 第 16 行在 `stdout` 不是 Buffer 时直接调用 `.trim()`;[cli.ts](../src/cli.ts) 第 80 行使用 `stdio: 'inherit'`,此时成功结束的子进程没有可捕获的 stdout。 + +本地调用 `executeCommand('java', ['-version'], { stdio: 'inherit' })` 后,Java 正常退出,CLI 却因 `null.trim()` 抛错并返回 1。相同执行路径用于应用运行,因此应用正常关闭后也可能被报告为失败。这不表示 Java 一定无法启动。 + +建议把“捕获输出”和“继承终端”作为明确的执行模式,返回结构化结果,包含退出码、信号和可选输出。进程内部工具函数不直接 `process.exit`,由 CLI 顶层决定退出行为,并保留子进程失败原因。 + +### Manifest 路径编码会导致类加载失败 + +**已复现,稳定版本的阻断项。** [cli.ts](../src/cli.ts) 第 115 行直接将路径拼为 `file://...`,没有 URL 编码。使用同样的 Manifest 生成逻辑、真实 `javac` / `jar` / `java` 验证,普通目录可运行;目录含空格或 `#` 时出现 `ClassNotFoundException`。 + +建议使用标准路径到 URL 的转换,保持目录 URL 的尾部斜杠,并验证最终 Jar。Manifest 的行长限制按 UTF-8 字节计算;当前按字符串字符切行值得修正,但 `jar` 可能重新折行,本次中文目录样例成功,不能据源码直接断言中文必然失败。Windows 盘符及 UNC 路径仍需原生测试。[JAR 规范](https://docs.oracle.com/en/java/javase/21/docs/specs/jar/jar.html)、[pathToFileURL](https://nodejs.org/api/url.html#urlpathtofileurlpath-options) + +### 测试类路径开关没有生效 + +**测试目录问题已复现,依赖作用域问题由源码和官方文档确认。** [classpath-builder.ts](../src/classpath-builder.ts) 第 21、31 行无条件枚举 `classes` 与 `test-classes`,没有读取 `includeTests`;调用方传入 `false` 仍会包含测试输出。 + +第 79 行的 Maven 调用没有指定依赖作用域。`dependency:build-classpath` 默认包含所有依赖;普通运行时通常应使用 `-DincludeScope=runtime`。第 82 行的注释参数 `-Dmdep.includeScope=compile,runtime` 不能直接取消注释作为修复。测试输出目录和测试依赖需要同时控制,具体 Boot 开发启动语义则应遵循选定后端的契约。[插件参数](https://maven.apache.org/plugins/maven-dependency-plugin/build-classpath-mojo.html) + +### 缓存可能持续返回旧依赖 + +**已复现。** [classpath-builder.ts](../src/classpath-builder.ts) 第 69 行只检查缓存文件及模块 `target` 是否存在。探针将 POM 中依赖从 v1 改为 v2,第二次仍读取 v1,模拟 Maven 的调用次数没有增加;强制刷新才得到 v2。 + +建议第一步采用保守失效或默认重新解析,先保证结果正确。恢复缓存时,至少区分目标模块、相关 POM / 父模型、Maven profiles、属性、scope、工具链与缓存格式版本,检查引用文件存在性。外部父模型、SNAPSHOT 和 settings 变化需要额外策略;不能承诺一个根 POM 哈希解决全部问题。缓存写入应原子化,失败结果不得覆盖有效记录。 + +### 原始 XML 与 Maven 有效模型存在差距 + +**多项边界已复现。** [find-maven-modules.ts](../src/find-maven-modules.ts) 第 29 行只读顶层 modules,第 32 行直接读版本文本,第 52 行固定追加 `pom.xml`。探针结果包括: + +- 默认激活 profile 内的模块未被发现,聚合根被当成叶子 +- `${revision}` 没有展开,随后参与模块 Jar 排除时匹配失败 +- module 指向具体 POM 文件时无法识别 +- 缺失子 POM 被跳过,畸形 XML 被记录后仍返回其余模块 + +最后一项表示模块发现函数无法明确报告不完整结果;真实 CLI 后续 Maven 步骤可能报错,不能推断一定会带着错误模块集启动。 + +建议将原始 XML 解析限于快速发现和提示,把 profile、继承、属性插值、依赖管理交给 Maven。`help:effective-pom` 可帮助获取有效模型,但它不等于解析后的依赖图或 classpath。[Model Builder](https://maven.apache.org/ref/3.9.11/maven-model-builder/)、[effective-pom](https://maven.apache.org/plugins/maven-help-plugin/effective-pom-mojo.html)、[POM 聚合定义](https://maven.apache.org/pom.html#aggregation-or-multi-module) + +### 所有叶子模块的并集不能代表一个应用 + +**行为由源码确认,冲突后果属于风险推断。** [cli.ts](../src/cli.ts) 第 97 行获取全部叶子模块,[classpath-builder.ts](../src/classpath-builder.ts) 第 29、43、108 行合并所有输出与依赖,只按路径去重。 + +例如仓库包含 `app-a` 与 `app-b`,分别依赖某库的 v1 和 v2,启动 `app-a` 时也可能带入 `app-b` 的目录及 v2。两个不同 Jar 路径不会被 Set 去掉;重复类和配置资源的加载顺序可能受到无关模块影响。本次未运行这一完整框架冲突场景。 + +Maven 的版本裁决以当前项目的依赖图为依据。因此,通用化应先确定启动模块,再取得该模块经 Maven 裁决的依赖闭包和顺序;聚合关系不能替代依赖关系。[Maven 依赖机制](https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html) + +### 参数和诊断契约需要收紧 + +**参数与空依赖问题已复现,其余由源码确认。** + +| 问题 | 证据 | 建议 | +| --- | --- | --- | +| 键名前缀误匹配 | `mainframe=example.App` 被当成 main;`activeX=prod` 被当成 active | 精确匹配参数名,拒绝未知参数,说明重复参数策略 | +| 值被截断 | `main=a=b` 只保留 b;解析器的 separator 参数未使用 | 只按第一个分隔符切分,再验证取值 | +| 空依赖变成 cwd | 空字符串经过 `resolve('')`,意外进入 classpath | 去掉空记录并验证路径类别 | +| 错误输出丢失 | executor 主要输出 `result.error`;stderr 被注释,依赖失败主要拼 stdout | 保留命令阶段、cwd、退出码、stderr,并区分找不到程序与命令执行失败 | +| 导入即写缓存目录 | classpath 模块顶层执行 `mkdirSync` | 将文件写入移动到显式执行阶段,让帮助和参数验证可以独立运行 | + +依据:[参数解析](../src/parse-argvs.ts) 第 17 至 23 行、[类路径构建](../src/classpath-builder.ts) 第 16、89、108 行、[执行器](../src/exec.ts) 第 9 至 16 行。 + +## 工程能力评价 + +本评价针对仓库展示出的工程证据。它不能单独用于判断作者的职级、其他项目经验或能力上限,也不以是否使用大型框架作为成熟度标准。 + +| 维度 | 已展示的能力 | 当前需要补足的证据 | +| --- | --- | --- | +| 问题识别与实现 | 从脱离 IDE 的真实需求出发,实现完整启动链 | 独立项目的适用性验证和明确支持范围 | +| 工具集成 | 能组织 Maven、JDK、文件系统、编码和命令行流程 | 外部工具的失败契约与复杂 Maven 语义 | +| 代码组织 | 执行、参数、模块发现和类路径已拆分;启用 strict,本次类型检查通过 | 显式 cwd / 配置边界、少量纯函数、去除全局状态及导入副作用 | +| 交付自动化 | bumpp、版本 tag、自动生成说明、五个二进制目标 | 固定工具链、正确绑定触发 tag、运行验证后发布 | +| 质量保障 | 历史中持续修复编码、路径和错误提示 | 仓库没有测试与 fixture;修复尚未固化为回归保护 | +| 跨平台意识 | 分隔符适配、输出编码处理、多平台构建 | Windows / Linux / macOS 原生启动、退出和路径测试 | +| 开源使用体验 | 有帮助示例,README 坦诚说明 Jeecg 来源与待验证项 | 安装说明、兼容表、故障排查、许可证、贡献入口 | + +**总体判断:已经证明场景驱动的实现与交付能力;通用工具所需的语义建模、边界验证和维护保障尚未形成完整证据。** 发布自动化领先于质量验证,是本项目当前最明显的不平衡。 + +当前源码约 493 行,保持简单是优点。改进不需要 DI 框架、插件市场或多层服务架构;明确运行配置、启动计划和进程执行三个边界即可显著降低维护成本。 + +发布流程还存在具体改进点:[release.yaml](../.github/workflows/release.yaml) 第 24 行取全仓库最高版本 tag,而非本次触发 tag,重跑旧 tag 可能产生错误版本名或日志范围;第 76 行附近把提交文本直接插入 shell,应改为可靠的数据传递或标准发布说明机制。现有工作流只在 tag 触发,未包含 PR 类型检查、测试或目标系统运行检查。 + +已提交 `bun.lockb` 是可复现性的基础,本次 frozen 安装成功。仍应固定 Bun 版本,并让 CI 显式执行锁文件安装、类型检查和测试;仅保留 strict 配置无法覆盖进程输出等运行时契约。 + +## 技术路线选择 + +### 保留技术栈并缩小首版支持承诺 + +建议保留 TypeScript / Bun。官方已经支持独立可执行文件和跨平台编译,当前也实际完成了本机构建,没有证据表明换成 Go、Rust 或 Java 能直接解决主要问题。[Bun 可执行文件文档](https://bun.com/docs/bundler/executables) + +首个通用 alpha 建议优先支持 **Maven 3 的 Spring Boot 开发启动**,以显式目标模块为入口,再扩展普通 Java main。建议先将 JDK 17 / 21 纳入验证矩阵;这些是拟定范围,不能标成已经支持。Maven 4、旧 JDK、Gradle、JPMS、WAR 容器与复杂 attached artifacts 逐项按需求验证后加入。 + +产品价值应落在更简单的启动配置、Wrapper / 工具链发现、错误解释、可检查的运行计划和可靠的跨平台分发。自有快速启动模式需要用实际数据证明收益;不应把绕过 Maven 本身当成目标。 + +### 先建立 Maven 托管执行的正确性基线 + +| 方案 | 优点 | 主要成本 | 建议 | +| --- | --- | --- | --- | +| 持续扩展当前 XML 解析器 | 延续现有代码与缓存 | Maven 特例会持续增加,依赖正确性难保证 | 不作为通用化主路线 | +| CLI 编排 `spring-boot:run` | 尊重 Boot 项目插件配置,已有 JVM / 应用参数和 profile 契约 | 处理目标模块、构建准备、版本差异与参数映射 | 第一阶段优先验证的正式后端 | +| CLI 编排 `exec:exec` | 普通 Java main、独立 JVM、Maven 生成 classpath | 插件版本、参数引用、长类路径和 reactor 产物准备 | 普通 Java 支持的后续候选 | +| Maven 解析加自有 launcher | 可缓存解析结果、减少重复 Maven 启动、控制工作区输出 | 需要证明依赖顺序、缓存、进程和路径行为一致 | 保留为受限模式,通过对照验收后扩大范围 | +| 新建 Maven 桥接插件 | 可直接取得 MavenProject / session 等信息 | 增加 JVM 组件和发布维护面 | 仅在现有插件无法满足已验证需求时采用 | + +`exec:java` 在 Maven JVM 内执行,其线程和 JVM 参数行为不能简单等同于独立 Java 进程,因此不建议作为长期运行服务的默认后端。`exec:exec` 有 `%classpath` 和长类路径支持,说明当前 cp.jar 思路有成熟先例。[Exec 用法](https://www.mojohaus.org/exec-maven-plugin/usage.html)、[Exec Java 示例](https://www.mojohaus.org/exec-maven-plugin/examples/example-exec-for-java-programs.html)、[Exec 参数](https://www.mojohaus.org/exec-maven-plugin/exec-mojo.html) + +首轮只实现一个正式后端即可。Boot run 使用独立进程并提供应用参数、JVM 参数和测试 classpath 配置,应优先复用这些契约。[Spring Boot run](https://docs.spring.io/spring-boot/maven-plugin/run.html) + +多模块构建与启动应明确分开:先准备目标及其依赖,再只运行目标。不能直接用 `-pl app -am spring-boot:run` 假定只会运行 app;Spring 官方多模块示例先 install,再对 application 模块执行 run。若暂时要求上游模块已安装,应明确说明;若提供准备命令,应把 install 作为显式选项。避免 install 的同会话 reactor 方案需要单独验证。[Spring 多模块指南](https://spring.io/guides/gs/multi-module/)、[Maven reactor](https://maven.apache.org/guides/mini/guide-multiple-modules.html) + +### 建立小而明确的内部边界 + +建议的流程是: + +```mermaid +flowchart LR + A[CLI 输入] --> B[RunConfig] + B --> C[项目和工具链解析] + C --> D[Maven 托管模型与执行语义] + D --> E[LaunchPlan] + E --> F[ProcessRunner] +``` + +- `RunConfig`:明确 cwd、目标模块、运行后端、Spring profiles、Maven profiles、JVM 参数、应用参数及构建策略 +- `LaunchPlan`:列出需要执行的步骤、可执行文件、参数数组、工作目录及解析来源,支持预览和问题复现 +- `ProcessRunner`:统一处理输出模式、失败结果、退出码与信号;平台差异集中在此边界 + +Maven 托管后端可以在执行时解析依赖;计划中应明确这一点。未来自有 launcher 的计划则需要保存经 Maven 裁决的有序 classpath。不能为了统一类型,假装两者都已完成同样的解析。 + +优先使用项目 Maven Wrapper,再回退到显式配置或 PATH 中的 Maven。Wrapper 存在但执行失败时,应报告原因,避免悄悄换版本。Windows 的 `mvnw.cmd` 调用和带空格参数必须在 Windows 上测试。[Maven Wrapper](https://maven.apache.org/tools/wrapper/) + +进程长期运行时宜采用异步 spawn,显式验证 Ctrl-C、终止信号和退出码传播。保留参数数组,避免把用户参数拼接为通用 shell 字符串。启动计划和调试日志需要避免输出敏感属性值。 + +## 分阶段实施计划 + +以下是规划估算,按一名熟悉 TypeScript / Maven 的开发者、可使用三平台 CI、第一版仅完成一个正式后端计算。外部私服、复杂插件和旧 JDK 适配可能增加投入,工期不是交付承诺。 + +| 阶段 | 预计投入 | 主要交付 | 退出条件 | +| --- | --- | --- | --- | +| A 修复并建立回归保护 | 2 至 4 人日 | 执行器、URL 路径、参数、测试目录及 scope、保守缓存策略;typecheck / test / PR 校验 | 本阶段缺陷有回归测试,正常与失败退出正确,Jeecg 场景补实跑;其余模型边界登记为限制,交阶段 B 验收 | +| B 建立通用运行基线 | 5 至 10 人日 | 目标模块、RunConfig / LaunchPlan、Wrapper、Maven 与 Spring profile 分离、一个 Maven 托管后端 | 单模块和 app→lib 正常启动,无关 app 不参与;干净工作区的构建前提明确可复现 | +| C 完成开源 alpha 分发 | 4 至 7 人日 | 原生三平台 smoke、固定工具链、修发布 tag、安装与排障文档、支持表、许可证、产物校验 | 对外声明的每个 OS / 架构有对应证据;无 Bun 环境可运行产物 | +| D 按数据优化快速模式 | 另计 5 至 10 人日 | Maven 权威解析结果缓存、自有 launcher 对照验证、性能基准 | 依赖与资源选择符合约定,缓存失效正确,温启动收益可测量且不损害正确性 | + +前三阶段约 11 至 21 人日,可按 2 至 4 个工程周加必要缓冲规划。阶段 D 不进入首个 alpha 的必要范围。版本号应表达真实支持范围,不以新增参数数量作为发布标准。 + +建议先拆出三个可审查的工作包: + +1. **执行与路径正确性**:修复 inherited stdio、退出结果、特殊路径及参数解析;提交对应回归用例与 CI 校验。 +2. **启动目标与 Maven 基线**:明确支持矩阵,选择启动模块,验证 Boot 后端和多模块准备流程,输出可检查的运行计划。 +3. **跨平台 alpha 发布**:补原生运行矩阵、固定构建环境、修正 tag 来源,完成 README、安装示例、排障和许可证。 + +## 验收矩阵 + +测试应围绕用户可观察的契约,不追求镜像实现或单纯覆盖率数字。最小集如下: + +| 场景 | 必须观察到的结果 | +| --- | --- | +| 单模块 Boot 与原有 Jeecg | 明确主类或插件配置后能启动;原场景无回归 | +| app 依赖 lib | 使用预期版本和最新构建结果;未准备依赖时给明确提示 | +| 同仓库两个独立 app | 启动一个不会加入另一个的类、配置和冲突版本 | +| profile 与属性坐标 | Maven profile、Spring profile 分别生效;`${revision}` 由 Maven 正确处理 | +| 测试隔离 | 默认行为符合后端契约;开启测试模式时同时处理测试输出和依赖 | +| 空格、中文、`#`、`%` 路径 | 主类、依赖和资源可实际加载;Windows 盘符另行覆盖 | +| 正常退出、非零退出、Ctrl-C | CLI 退出结果符合约定;目标支持平台无遗留 Java 子进程 | +| 缺 Maven / Java、错误 POM | 明确失败阶段与可行动提示,不返回不完整的成功结果 | +| POM / 父模型 / profile 变化 | 重新解析或明确使缓存失效;结果可解释 | +| 无 Bun 环境的发布二进制 | 可执行帮助及真实启动样例 | + +自有 launcher 正式启用前,再加入自定义 outputDirectory、classifier、长 classpath、空依赖、缓存并发写入与 SNAPSHOT 策略测试。当前五种二进制构建目标不能直接转成五种已验证支持承诺;无法提供原生验证的目标应明确标注实验状态。 + +性能指标建议分别记录冷解析、温启动、缓存失效后的启动耗时和 Maven 子进程次数,并与选定的官方运行方式对照。本次未做性能基准,现阶段不设没有依据的提速百分比。 + +## 本次验证记录 + +原始代码与依赖安装、编译产物均放在临时副本,项目业务源码未改动。主验证环境为 macOS arm64、Bun 1.4.2、JDK 21.0.12.1;锁定安装得到 TypeScript 5.8.2 和 `@types/bun` 1.2.5。Maven 3.9.16 已检测可用,但本次未完成真实 reactor 集成测试。 + +| 检查 | 结果 | 证明范围 | +| --- | --- | --- | +| `bun install --frozen-lockfile --ignore-scripts` | 通过,安装 53 个包 | 当前环境可按已有锁文件安装 | +| `./node_modules/.bin/tsc --noEmit` | 通过 | 当前静态类型检查通过,不代表运行时契约正确 | +| `bun run compile` | 通过,打包 133 个模块 | 本机可生成独立二进制 | +| `./dist/java-run --help` | 退出 0 | 本机产物的帮助入口可运行 | +| inherited stdio 执行 `java -version` | Java 成功后出现 `null.trim()`,CLI 退出 1 | 已复现执行器结果处理缺陷 | +| 按现有逻辑生成 cp.jar 并运行 Hello | 普通路径、中文路径成功;空格、`#` 路径失败 | 已复现 URL 编码影响类加载 | +| 模块与参数探针 | profile、属性版本、自定义 POM、参数前缀等边界失败 | 已证明工具自身解析行为 | +| 模拟 Maven 的类路径与缓存探针 | `includeTests:false` 仍含测试目录;POM 改动后仍用旧缓存 | 已证明配置与失效逻辑缺陷,不代替真实 Maven 集成 | + +执行器复现命令,在安装依赖后的仓库副本运行: + +```sh +bun -e 'import { executeCommand } from "./src/exec.ts"; executeCommand("java", ["-version"], { stdio: "inherit" });' +``` + +参数复现命令: + +```sh +bun -e 'import p from "./src/parse-argvs.ts"; p.set(["mainframe=example.App", "active=prod=blue"]); console.log(p.key("main"), p.key("active"));' +``` + +当前输出为 `example.App blue`。前者错误接受了非 main 参数,后者丢失了值的前段。 + +本报告是后续实现与验收的基线。随着阶段 A 至 C 完成,应更新已复现问题的状态和兼容矩阵,而不是继续沿用本次评估时的结论。 From 40e87cf498002588db7ec591a9be364817395c0a Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Mon, 5 Oct 2026 10:18:28 +0800 Subject: [PATCH 02/26] =?UTF-8?q?docs:=20=E6=98=8E=E7=A1=AE=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E4=B8=AD=E7=AB=8B=E7=9A=84=20Java=20=E6=BA=90?= =?UTF-8?q?=E7=A0=81=E8=BF=90=E8=A1=8C=E5=99=A8=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/implementation-plan.md | 30 ++++++++++++++++++++++++++++++ docs/product-design.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 docs/implementation-plan.md create mode 100644 docs/product-design.md diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..4cce58b --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,30 @@ +# java-run 重构实施计划 + +本轮在 `feat/open-source-cli` 分支完成框架中立的 Java 源码工作区运行器,定位与契约以 [产品设计](product-design.md) 为准。原评估报告保留为旧实现的证据基线,其中优先特殊化 Spring Boot 的路线已经被本设计替代。 + +## 工作包与提交边界 + +1. 确定产品定位、自动准备行为和 Maven / Gradle 的职责 +2. 实现严格的配置解析、统一进程执行、主类选择和 Java 启动 +3. 完成 Maven 与 Gradle 两种构建工具适配,让工具自身裁决依赖和输出目录 +4. 添加真实项目夹具、二进制 smoke、原生系统 CI 和可靠 Release +5. 完成用户文档与实际验证记录,形成可审查的分支提交 + +移除旧 `start`、`-c`、`active=`、`local`、`no-run` 等参数,避免继续维护源于旧脚本的操作模型。默认运行自动准备源码;`--build=none` 是明确的高级覆盖,`plan` 则仅作静态预览。 + +本轮不生成版本 tag,分支内按清晰意图提交;版本发布由检查通过后的 tag 工作流完成。保留原主分支作为重构前的基线。 + +## 验收要求 + +- Maven / Gradle 共用运行契约,Spring 等框架通过普通参数配置 +- 正常与非零退出可追溯,POSIX 信号不遗留应用子进程 +- 帮助、版本和 plan 不触发构建工具或创建项目缓存 +- 多模块只解析选定应用;依赖准备由对应构建工具执行 +- 唯一 main 可以发现,多个主类明确要求选择 +- 测试输出、测试依赖、特殊路径、依赖与资源变更均有验证 +- 原生操作系统 CI 运行本机二进制;本地未运行的系统不宣称已经验证 +- 用户 java-template 在隔离副本补充验证,不修改原仓库 + +## 状态 + +实施中。完成后记录实际测试结果、提交边界和仍需原生平台验证的限制。 diff --git a/docs/product-design.md b/docs/product-design.md new file mode 100644 index 0000000..1098e6c --- /dev/null +++ b/docs/product-design.md @@ -0,0 +1,31 @@ +# java-run 产品定位与运行契约 + +java-run 是框架中立的 Java 源码工作区运行器。它为 Maven 与 Gradle 项目提供统一的开发启动入口,并将构建模型、依赖裁决和增量编译保留在构建工具中。本轮以这个定位完成重构,替代原评估报告中优先特殊化 Spring Boot 的实施建议。 + +## 用户需要掌握的内容 + +项目中执行 `java-run` 即启动默认应用。多模块项目在 `.java-run.json` 保存启动目标和参数,随后无需重复输入。没有明确目标或有多个主类时,工具给出选择要求,避免启动整个仓库或凭名称猜测应用。 + +命令只分为 `run`、`plan`、`help`、`version`。`plan` 读取本地项目配置并预览构建步骤,不执行构建工具,也不生成项目缓存;它明确表示主类和类路径仍待构建工具解析。 + +常用选项为 `--cwd`、`--module`、`--main`、`--jvm-arg`、`--arg`。高级用户可以选择 `--tool=maven|gradle`、`--build=none`、`--build-arg`、`--include-tests`、`--java` 和 `--build-command`。没有专用 Spring profile 参数,Spring 参数通过正常 JVM 或应用参数传递。 + +## 自动准备的含义 + +Maven 单项目自动编译;选定 reactor 模块时,自动 install 该模块和上游依赖,再只解析选定模块的运行类路径。这里的 install 是 Maven 的本地仓库操作,计划和运行提示都会说明,不会发布到远程仓库。当前选择这个保守方案,是为了避免单独解析模块时读取缺失或陈旧的兄弟模块 Jar。 + +Gradle 将 classes 与项目依赖交给任务图,不先单独编译每个模块,也不做 Maven 式的 install。通过临时 init script 获取选定项目的主 source set 和运行依赖;不改用户的构建脚本。`--build=none` 在两种工具中都表示不主动构建源码,缺失产物直接报错。 + +不会单独执行测试;`--include-tests` 只准备并加入测试输出和测试依赖。默认使用正常运行时依赖。重新解析依赖由构建工具自身缓存加速,java-run 不维护独立的失效不完整的 classpath 缓存。 + +## 框架与技术栈 + +Spring Boot、普通 Java main 和其他基于 classpath 的应用共享启动机制。Gradle 已有 application 的 mainClass 和默认 JVM 参数可以作为元数据;框架专用 run 任务的额外逻辑不自动模拟。如果用户依赖 bootRun 或自定义 JavaExec 的特殊环境、agents、附加资源,应继续使用该原生任务或显式配置,不宣称通用 runner 与所有任务等价。 + +目前保留 TypeScript / Bun,原因是 CLI、跨平台二进制和进程编排已有可用基础。关键改动在产品契约与构建工具适配,语言切换不能替代这项工作。JPMS、Android、native image、部署与服务守护不属于首版的支持范围。 + +## 内部模块与验证 + +CLI 解析配置,构建工具模块生成经过裁决的 PreparedProject,主类模块选择入口,启动模块生成 URL 编码正确的 classpath Jar,执行模块负责退出和信号。Maven 与 Gradle 是真实存在的两种适配器,框架不进入核心配置类型。 + +验证以同一契约覆盖两种工具:单项目、多模块依赖、无关项目隔离、参数边界、测试隔离、POM 或 Gradle 构建变更、特殊路径、正常和失败退出。用户提供的 java-template 作为 Gradle 实例补充验证,不修改其源仓库。 From 95f1b5f7e65169372d85707ab594ba68b3bbd409 Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Mon, 5 Oct 2026 10:42:19 +0800 Subject: [PATCH 03/26] =?UTF-8?q?feat!:=20=E9=87=8D=E6=9E=84=E4=B8=BA=20Ma?= =?UTF-8?q?ven=20=E4=B8=8E=20Gradle=20=E9=80=9A=E7=94=A8=E6=BA=90=E7=A0=81?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一自动准备、单模块类路径、主类发现、项目配置与终端补选,按职责组织源码并验证退出、信号和特殊路径。 BREAKING CHANGE: 以 run、plan 和严格长选项替代 start、-c、active=、local、no-run 与旧缓存操作。 --- .bun-version | 1 + bun.lockb | Bin 20537 -> 17899 bytes package.json | 20 +-- src/build-tools/detect.ts | 16 ++ src/build-tools/gradle.ts | 243 +++++++++++++++++++++++++++++ src/build-tools/maven.ts | 156 +++++++++++++++++++ src/classpath-builder.ts | 122 --------------- src/cli.ts | 202 +++++++----------------- src/cli/args.ts | 243 +++++++++++++++++++++++++++++ src/cli/help.ts | 60 ++++++++ src/cli/selection.ts | 80 ++++++++++ src/colors.ts | 31 ---- src/constant.ts | 2 - src/core/classpath.ts | 44 ++++++ src/core/java-class.ts | 6 + src/core/launch.ts | 40 +++++ src/core/main-class.ts | 71 +++++++++ src/core/types.ts | 56 +++++++ src/exec.ts | 26 ---- src/find-maven-modules.ts | 91 ----------- src/help-log.ts | 41 ----- src/parse-argvs.ts | 35 ----- src/process/exec.ts | 245 +++++++++++++++++++++++++++++ tests/exec.test.ts | 154 ++++++++++++++++++ tests/gradle.test.ts | 223 +++++++++++++++++++++++++++ tests/helpers/process-child.ts | 42 +++++ tests/launch.test.ts | 117 ++++++++++++++ tests/maven.test.ts | 15 ++ tests/parse-argvs.test.ts | 274 +++++++++++++++++++++++++++++++++ tests/selection.test.ts | 97 ++++++++++++ 30 files changed, 2250 insertions(+), 503 deletions(-) create mode 100644 .bun-version create mode 100644 src/build-tools/detect.ts create mode 100644 src/build-tools/gradle.ts create mode 100644 src/build-tools/maven.ts delete mode 100644 src/classpath-builder.ts create mode 100644 src/cli/args.ts create mode 100644 src/cli/help.ts create mode 100644 src/cli/selection.ts delete mode 100644 src/colors.ts delete mode 100644 src/constant.ts create mode 100644 src/core/classpath.ts create mode 100644 src/core/java-class.ts create mode 100644 src/core/launch.ts create mode 100644 src/core/main-class.ts create mode 100644 src/core/types.ts delete mode 100644 src/exec.ts delete mode 100644 src/find-maven-modules.ts delete mode 100644 src/help-log.ts delete mode 100644 src/parse-argvs.ts create mode 100644 src/process/exec.ts create mode 100644 tests/exec.test.ts create mode 100644 tests/gradle.test.ts create mode 100644 tests/helpers/process-child.ts create mode 100644 tests/launch.test.ts create mode 100644 tests/maven.test.ts create mode 100644 tests/parse-argvs.test.ts create mode 100644 tests/selection.test.ts 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/bun.lockb b/bun.lockb index b5e29d09d018546dcdc4058beb5e0ece6bf2e027..b06a9ae5d56527dd78007564e6cc4107d00b87eb 100755 GIT binary patch delta 2924 zcmc&$U2IfE6uvXN+r4+&+p^R`f420$^oK5Op=`Glwgq}Y7AlpH6^+KWY+);H*KS)| zqO=e({s(ei3SvZgP!kc&N&*53#t;;UU}AzOrW#{FG(m%6Nhp5j?)E`bAAHf1oO|wf z=FH4FGiT1sk$vLYb7H5vwB)vJ*mCSZ^T7}Lw}188*k^TXA3b?(-D6)|=~=VuhvW11 zKhUI@G+cGO@jukswHZPQ$yom}Nbc9MA=JzIzEJm2a4^^&33d$xb*NfkkPd?;No8U| zzGjQrhgjlV;NSgi86O zogB%A_8y|(AuO~__tCy2EA35QCS59hmu$!mmC8~K`I1V#DTa1IRg_woTB$M7r)k)v z7<$#_lZ_g!oMt$8K*>ux8NHK!!MYvR=n@+5pphe)5qDRoM%^Qu9MtJF7e6 z+p%e8#NN0aOV(p+%s_BpOblVJx}E3$pO&6|P==@2lmBSxWApzHOFxk^!Fys-bnA>B zNykf;Qe{RO-Olt2jqc70(3K3G4rKX7B8>x$X6p2y%P(|#(-oig9M}Y3j)+sy3FVZNK6Cib#PUnDTQbulo4g)=r>lYdH3DBk- zohtJDB8!Id0+gPs(`BG+D$NhjX`mPK{UV3H1RBiKX;Fb+JY>1Ut<@)ic@ zBGAi)eo;hMfu1hVX?2lb%%pGwfhN*eXlSK)8sPK;Pv z0p?xHX8@YSDjR}MR8fLApU!#gb*3Fp$0Y*SfqB&V*z;-RlRH_r;Fg)|xI&c_@HA?v zO&#Xy#}yG3**v#=rumd)fHT2aU>7(WJPVuy&IRXz*}DQTS41wwTt~P#@$-Q_#?7P@ zgSntxk9+=oH2}y_-#@Qb#t5Jyl-0LPARrlRk=YYH5=_+oKD>h&GLpu4UOrp z$!D3?Q-A8mmOV$|@d9@>dwkSENiZnlileINx#bEM;%GVBPX%jh?6z4Vc4TXb|xo!R3@xRz(*VMN6hQfW( j@RAn=t-ohQ^jy=sDxGR3&M=eOn>&^d+#6{oQ9Al34z}Eb delta 4457 zcmb^!3v^Re^1YYWl9x8L;nOB<+J;a{3QgMd8%m)cY6=8|A{qgymNu5uenOKXRM!+` z3sxwFkzK6HXB8EcEyu|6TeP@8KnudU%7S&#^(fHFQg#LFO4*s0yqa^wvqz8joO$!+ zbLY;?o%`OM-ly1u+u3$g^7XZ8C!4yH_AIn^ZXUVu^ok<`w=P7sS1PI}o#f6qT7R=^ z4kM22jrIg@TaKS#nCkN7<;I4_x)rrG>D6wAS%U%v!!*0gSBNSz@hKo(5a2(~GE6wq znMf0k;QZKyI2vgO;xNRE5R)Cd%#W8e*1B9cXJvV94Wq=D2;MJf;WwU(SKL4~HLCZ@ zfMc_{&QQo@GAV?<07f>WfUnreX$LL|c@xfdZMj*)?se&l&%IV}exv3}#n!&cg?IPlbrr|y zKN;I4=npPrpF3~8vCZYPa5~lynZy`-b1?3AC@olAT<>!xk$yLfjp!_LB;0_6%NXksn9HwZ{p?L3?62NT7?ZL%S9Ov{jzAdATw~sc{mqpRSlrPf`Wh} zj0{f8a=57>P(92lG=wnBy(nbiXh=SHA_TrcrXtiogEWVhDGG&Fxm8$(G80a~2>i>0 z93T!DL#A<`#Eyn)PYaoH(}(Xd@%fUa;J-c=L} zIyu8k7bTb>ZmAp=hFOJIk?D_nRd_!4g&Zy;a{^WcF&Q`=KIG*>0h+?CT)O~oBYIl^ zh0@A}D_|a?LIrdvt-`}tS(FbR3KjW6=MZHM$rrvrs#vty&kxz07zT%iTe-3@;3BNt z&M=r6VO8~Fxzes<@ZuOH?s^IqDS0o|LKym%!+FBtT!b~`brj}8PsB9tt8l1}oQj*L z%EBW+n~vKc4tXHuyceURx@WLP=zNIdb{MOV&W$(-6HO`FQUN&kz$x1gUN{$1Tu&fBaUE94w?h}?_>Y^w^g8&(-)9(hPe|5VTx>u zpS&$5IUYB0+zyjGAAxW|V11?_z$gshBE%FCR*9&12V!aboLlDo3412`@iT3i_b2R` zKM&vEONROJTej4{mp^LH0{c*anCAWeEgOsG%tIleDP8aVGh&FGIB8rhVj58wYw ztNz%1zQg*utD7gcZXHlNC#7$fef-!f-B0X09NzHs)0NBKIp070`ZZy7V$r&Py>xzE z`vcpr;+iZ&CK(35S3`sEdaeBqMa6iV{N+8X_arTjIri?jhhkptP-PoRTCTM8ZNBr? z*<-_YrfZt64eooSZTG}^eQ55k@(wf9u=OJDT9xp(4-MY@}> ze6V%>;y*-vU6g!$=JJ%YOE;Ue5jn*N&R_BTHs;~Q@@1W8G*gq?j;0AU3qCzidNEg3 z_Uob`@95Vm4c=@x+x@5Y7G-=Fd+F3P^UhWEZx4TcgK>83qqR+~zTURciVdfy3cZ>S z`&;&3eC}iYUELA=da9Ix%*J> zzz%ooi&b!G$E%%9Ihs=io9{W482$eA3oqBJS}N~<>rAV>Z}+K##lC$d^FIsnPWqUU zhy3nO=a$VsGk3}L6z{y!raXI?`qL9JTh)e(zIo4{6)Ckb1 z?O=#e!Y|Y|b`%^(>MT-5jg5_hl^Q#&ja5PqQff$!v4cgcg!M5tHU|EV)J3GG#M)Rb zJQ-_;EpbZt5~+BYq_xBJcqKfqwXr(5gw)qa&5pCNdUz(z4zDCA;U-dvFeBa$_v)0e zC*H;;!2nY7F-lmRU}FuiE5Qy2kW%Vw>;zb#v%^xo5{@910>T(OsK+Xyc8rZR!NDmKW(HxGQ)d}!F->`fyRCpXT&KTdl&Iq!;`QQg-w z?riGj)wlln*HisPjZc?8`qT?gI`(f{zOL?Ona)vqZ_oy%!%waQuL z@E|^qJS+r}SGUqtOL`Iy4HU32D{&M&DoX<$Z}9CMe^~CDn>3A8ycf6{f1Lx3X?do> zTmC`Q>CTH$Fv&#VkZ1#5255Y1ffME=2sf*}csm(7!iD%=fS^Jcfq-{YhVH1N5uy|*|qRKR~E-=x8V(dqWQi3T6bkdRTXOZ17|uzeQnuVmfFPEayqDYY|B=| z1C~3vjxgA3QER2fP^i!Q^X1Zx!?!AsgvVaGbV`YzAqdGE$V=w08(@X zs(~qj`I9{3lkrT^&aJj&z00l`KA1*_6~~S_eyqr&f-!H(&=h!9x*YC`2B*v8e@$J| zSQmJ!sBs!y4fRzG4!1kN^7rJy0$*}&8w)e0#hQb;sm9b`(uj41bz|~WHMuP^&{NN)Na3i#|NjAE^l$K$vfCp+yVP1Plx`*E&l^R< 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..d0ba02e --- /dev/null +++ b/src/build-tools/gradle.ts @@ -0,0 +1,243 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; +import { CommandError, runCommand } from '../process/exec'; +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); + const target = 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', paths.script, + ...config.buildArgs, + `-DjavaRun.root=${resolve(config.cwd)}`, + `-DjavaRun.target=${target}`, + `-DjavaRun.output=${discovery ? join(resolve(workspace), 'gradle-projects.json') : paths.output}`, + `-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(`${target === ':' ? ':' : `${target}:`}${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: [ + config.build === 'auto' + ? 'Gradle 任务图准备目标项目和运行依赖,不执行测试' + : '不构建源码,要求目标项目和运行依赖已有可用产物', + '主类、运行类路径和 Java 工具链将在执行 Gradle 后解析', + ], + }; +} + +const initScript = String.raw` +import groovy.json.JsonOutput +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 + +gradle.projectsEvaluated { + // init script 也会进入辅助构建,元数据任务只注册到请求的主构建 + def requestedRoot = new File(System.getProperty('javaRun.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(System.getProperty('javaRun.output')) + output.parentFile.mkdirs() + output.setText(JsonOutput.toJson(candidates), 'UTF-8') + } + } + return + } + def requestedPath = System.getProperty('javaRun.target') + def target = gradle.rootProject.findProject(requestedPath) + if (target == null) { + throw new GradleException("找不到 Gradle 项目:" + requestedPath + ",请用 --module 指定实际项目路径") + } + def javaExtension = target.extensions.findByType(JavaPluginExtension) + if (javaExtension == null) { + throw new GradleException("选定项目 " + requestedPath + " 没有 Java 插件,请用 --module 指定 Java 应用项目") + } + 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] + 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("选定项目没有已编译类;请准备产物或使用 --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("本版本不支持 JPMS mainModule;请使用项目的原生运行任务") + } + def mainClass = application == null ? null : application.mainClass.orNull + def jvmArgs = application == null ? [] : application.applicationDefaultJvmArgs.collect { it.toString() } + def output = new File(System.getProperty('javaRun.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') + } + } +} +`.trimStart(); + +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, initScript); + rmSync(paths.output, { force: true }); + const spec = plan.commands[0]!; + console.error(`java-run:${spec.stage}`); + const result = await runCommand(spec, { capture: true }); + if (result.exitCode !== 0) throw new CommandError(spec, result); + return readProject(paths.output); +} + +/** + * 列出可供交互选择的 Gradle Java 项目 + * + * @description 只执行项目配置和列表任务,不编译、解析运行依赖或读取主类 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, initScript); + rmSync(output, { force: true }); + console.error(`java-run:${spec.stage}`); + const result = await runCommand(spec, { capture: true }); + if (result.exitCode !== 0) throw new CommandError(spec, result); + 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..56975fa --- /dev/null +++ b/src/build-tools/maven.ts @@ -0,0 +1,156 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { parseStringPromise } from 'xml2js'; +import { CommandError, runCommand } from '../process/exec'; +import { buildClasspath } from '../core/classpath'; +import type { BuildPlan, CommandSpec, MavenProject, PreparedProject, RunConfig } from '../core/types'; + +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'; + +/** 优先采用项目 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', 'expression', 'includeScope', 'excludeScope', 'skipTests', 'maven.test.skip', 'maven.main.skip', 'maven.install.skip'].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', join(config.cwd, '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 的单目标准备与解析命令,不执行 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')}`, '-Dmdep.regenerateFile=true', '-q'], '解析目标运行依赖')); + return { + tool: 'maven', commands, + notes: [config.module && config.build === 'auto' ? '自动准备使用 Maven install,仅写本地仓库,不执行 deploy' : '构建与依赖模型由 Maven 处理', + '仅解析选定项目,不合并其他模块的类路径;不复用 java-run 的历史依赖缓存'], + }; +} + +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 isAbsolute(text) ? text : 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 async function prepareMaven(config: RunConfig, workspace: string): Promise { + await validateRoot(config); + const plan = planMaven(config, workspace); + for (const spec of plan.commands) { + console.error(`java-run:${spec.stage}`); + const result = await runCommand(spec, { capture: true }); + if (result.exitCode !== 0) throw new CommandError(spec, result); + } + 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 模块候选'); + console.error(`java-run:${spec.stage}`); + const result = await runCommand(spec, { capture: true }); + if (result.exitCode !== 0) throw new CommandError(spec, result); + 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 || '主类待解析,可能是库模块'})` }; + }); +} 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..4f6b23d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,148 +1,60 @@ -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 { createLaunchCommand } 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 tool = detectBuildTool(config); + const previewWorkspace = join(tmpdir(), ''); + const plan = tool === 'maven' ? planMaven(config, previewWorkspace) : planGradle(config, previewWorkspace); + if (config.action === 'plan') { + 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 && process.stdin.isTTY && process.stderr.isTTY) { + const candidates = tool === 'gradle' ? await discoverGradleProjects(config, workspace) + : await needsMavenModule(config) ? await discoverMavenProjects(config, workspace) : []; + if (candidates.length) { + config.module = candidates.length === 1 ? candidates[0]!.value + : await chooseCandidate(candidates, '选择启动项目(库模块可能没有 main)'); + console.error(`java-run:已选择 --module=${config.module},可将 module 保存到 .java-run.json`); + } + } + const project = tool === 'maven' ? await prepareMaven(config, workspace) : await prepareGradle(config, workspace); + const launch = await createLaunchCommand(config, project, workspace); + console.error(`java-run:运行 ${launch.args[launch.args.indexOf('-classpath') + 2]}(${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..64397a1 --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,243 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { isJavaClassName } from '../core/java-class'; +import type { RunConfig } from '../core/types'; + +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; +} + +function moduleSelector(value: unknown, option: string): string { + const selector = requireValue(value, option).trim(); + if (selector.includes(',') || /^[!\-?]/.test(selector)) { + throw new Error(`${option} 必须指定单个模块,不能多选、排除或使用可选选择器`); + } + return selector; +} + +function mainClass(value: unknown, option: string): string { + const name = requireValue(value, option); + if (!isJavaClassName(name)) throw new Error(`${option} 必须是有效的 Java 类全名`); + return name; +} + +function buildTool(value: unknown, option: string): RunConfig['buildTool'] { + if (value !== 'auto' && value !== 'maven' && value !== 'gradle') { + throw new Error(`${option} 仅支持 auto、maven 或 gradle`); + } + return value; +} + +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 => requireValue(item, field)); +} + +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 { + const data: unknown = JSON.parse(source); + 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; + } catch (error) { + throw new Error(`配置无效 ${path}:${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * 解析启动参数并合并项目根目录下的 .java-run.json + * + * @description + * - argv 不包含运行时和脚本路径,通常由 process.argv.slice(2) 提供 + * - 默认执行 run;help 和 version 只解析参数,不读取项目配置 + * - CLI 标量覆盖配置,数组在配置之后追加,-- 后的参数全部传给应用 + * - --cwd 相对 cwd 解析,只读取最终目录中的配置,不向父目录查找 + * - 未知选项、重复标量、无效值或配置抛出 Error,不执行外部命令 + */ +export function parseArgs(argv: string[], cwd = process.cwd()): RunConfig { + const config: RunConfig = { + action: 'run', + 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 '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 '--java': + config.javaCommand = scalar(); + break; + case '--build-command': + config.buildCommand = scalar(); + break; + default: + throw new Error(`未知参数:${argument};使用 --help 查看帮助`); + } + } + + if (requestedCwd !== undefined) config.cwd = resolve(cwd, requestedCwd); + if (hasHelp) config.action = 'help'; + else if (hasVersion) config.action = 'version'; + if (config.action === 'help' || config.action === 'version') return config; + + const projectConfig = readProjectConfig(config.cwd); + const result: RunConfig = { ...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/help.ts b/src/cli/help.ts new file mode 100644 index 0000000..4116e1c --- /dev/null +++ b/src/cli/help.ts @@ -0,0 +1,60 @@ +/** 返回 CLI 帮助文本,不执行命令或修改进程状态 */ +export function getHelpText(): string { + return `java-run — Java 源码工作区运行器 + +用法: + java-run [run] [选项] [-- 应用参数...] + java-run plan [选项] + java-run help | version + +命令: + run 准备并启动应用,默认命令 + 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 + +项目配置: + 从最终项目目录读取 .java-run.json,不向父目录查找。 + 可用字段:buildTool、module、mainClass、jvmArgs、applicationArgs、 + buildArgs、build、includeTests + CLI 标量覆盖配置,数组在配置之后追加。 + 配置使用严格 JSON,不展开环境变量;未知字段和无效类型均报错。 + plan 只预览步骤,主类和运行类路径仍待构建工具解析。 + +交互选择: + 终端中缺少目标或存在多个主类时补充选择,库模块不一定可运行。 + 仅选择目标和入口,不提供参数向导、不自动写配置。 + 非交互环境请用 --module / --main 或项目配置明确指定;取消返回 130。 + +示例: + java-run + java-run --module :app + java-run plan --tool gradle --module :apps:admin-server + 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/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..152c5f8 --- /dev/null +++ b/src/core/classpath.ts @@ -0,0 +1,44 @@ +import { existsSync, statSync } from 'node:fs'; +import { delimiter, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { MavenProject } from './types'; + +/** 保持 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])); +} + +/** 将文件系统路径编码为 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..d82d1e5 --- /dev/null +++ b/src/core/launch.ts @@ -0,0 +1,40 @@ +import { dirname, isAbsolute, join } from 'node:path'; +import { existsSync, statSync, writeFileSync } from 'node:fs'; +import { createManifest } from './classpath'; +import { discoverMainClass, findMainClasses } from './main-class'; +import { chooseCandidate } from '../cli/selection'; +import { isJavaClassName } from './java-class'; +import { CommandError, runCommand } from '../process/exec'; +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; +} + +/** 检查适配器输出并生成独立 Java 进程的启动命令 */ +export async function createLaunchCommand(config: RunConfig, project: PreparedProject, workspace: string): 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) { + const candidates = findMainClasses(classes); + main = candidates.length > 1 && process.stdin.isTTY && process.stderr.isTTY + ? await chooseCandidate(candidates.map(value => ({ value, label: value })), '选择启动主类') + : discoverMainClass(classes); + } + if (!isJavaClassName(main)) throw new Error(`无效的 Java 主类:${main}`); + 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'); + writeFileSync(manifest, createManifest(project.classpath)); + const spec = { command: resolveJar(java), args: ['cfm', classpathJar, manifest], cwd: workspace, stage: '生成运行类路径' }; + const result = await runCommand(spec, { capture: true }); + if (result.exitCode !== 0) throw new CommandError(spec, result); + return { command: java, args: ['-Dfile.encoding=UTF-8', ...project.jvmArgs, ...config.jvmArgs, '-classpath', classpathJar, main, ...config.applicationArgs], cwd: project.directory, stage: '运行 Java 应用' }; +} diff --git a/src/core/main-class.ts b/src/core/main-class.ts new file mode 100644 index 0000000..9c05afb --- /dev/null +++ b/src/core/main-class.ts @@ -0,0 +1,71 @@ +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(); +} + +/** 在目标项目输出中选择唯一主类,多个候选时要求用户显式选择 */ +export function discoverMainClass(directories: string[]): string { + const choices = findMainClasses(directories); + if (choices.length === 1) return choices[0]!; + if (!choices.length) throw new Error('目标项目中未找到 public static main(String[]),请检查构建产物或用 --main 指定入口'); + 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..b36f8b1 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,56 @@ +/** 从源码工作区运行 Java 应用的配置 */ +export interface RunConfig { + action: 'help' | 'version' | 'run' | 'plan'; + 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[]; +} + +/** Maven 选定项目的有效元数据 */ +export interface MavenProject { + pomFile: string; + directory: string; + groupId: string; + artifactId: string; + version: string; + packaging: string; + outputDirectory: string; + testOutputDirectory: string; + mainClass?: 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..d776aa6 --- /dev/null +++ b/src/process/exec.ts @@ -0,0 +1,245 @@ +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 { + /** 捕获 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 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); + 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/tests/exec.test.ts b/tests/exec.test.ts new file mode 100644 index 0000000..8ba0838 --- /dev/null +++ b/tests/exec.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawn } 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('不存在的命令拒绝 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 }); + }); +}); diff --git a/tests/gradle.test.ts b/tests/gradle.test.ts new file mode 100644 index 0000000..320f346 --- /dev/null +++ b/tests/gradle.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, 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 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 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(`-DjavaRun.root=${root}`); + expect(plan.commands[0]?.args).toContain('-DjavaRun.target=:apps:admin-server'); + expect(plan.commands[0]?.args).toContain('-Pfeature=a b'); + expect(plan.commands[0]?.args.at(-1)).toMatch(/^:apps:admin-server: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); + }); +}); + +describe.skipIf(!availableGradle)('真实 Gradle 项目', () => { + 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')); + expect(prepared.directory).toBe(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']); + expect(prepared.directory).toBe(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); + expect((error as CommandError).stderr + (error as CommandError).stdout).toContain('没有 Java 插件'); + expect((error as CommandError).stderr + (error as 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); + expect((error as CommandError).stderr + (error as CommandError).stdout).toContain('没有已编译类'); + 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..6c21651 --- /dev/null +++ b/tests/helpers/process-child.ts @@ -0,0 +1,42 @@ +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 === '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/launch.test.ts b/tests/launch.test.ts new file mode 100644 index 0000000..4d42f06 --- /dev/null +++ b/tests/launch.test.ts @@ -0,0 +1,117 @@ +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 { buildClasspath, 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 { parseArgs } from '../src/cli/args'; +import { detectBuildTool } from '../src/build-tools/detect'; +import { planMaven, readEffectiveProject } from '../src/build-tools/maven'; +import type { MavenProject } from '../src/core/types'; + +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', output, file], { encoding: 'utf8' }); + if (result.status !== 0) throw new Error(result.stderr || result.error?.message); + return output; +} +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 config = parseArgs(['--cwd', directory, '--', '空格 值', 'a=b', 'quote\'"']); + 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('空格 值|a=b|quote\'"'); + }, 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('组合字符构成的合法 Unicode 标识符可以发现并启动', async () => { + const directory = temporary(); + const name = 'Cafe\u0301'; + const classes = compile(directory, name, `public class ${name} { public static void main(String[] args) { System.out.print("UNICODE_MAIN_OK"); } }`); + const config = parseArgs(['--cwd', directory, `--main=${name}`]); + const launch = await createLaunchCommand(config, { directory, classesDirectories: [classes], classpath: [classes], jvmArgs: [] }, directory); + expect(discoverMainClass([classes])).toBe(name); + expect((await runCommand(launch, { capture: true })).stdout).toBe('UNICODE_MAIN_OK'); + expect(() => parseArgs(['--main=Invalid³'])).toThrow('Java 类全名'); + }, 20000); + test('通过 class 方法表发现唯一入口,注释与非 public/static 方法不会被当成入口', () => { + 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(discoverMainClass([classes])).toBe('Entry'); + expect(hasMainMethod(readFileSync(join(classes, 'Other.class')))).toBe(false); + expect(hasMainMethod(Buffer.from('invalid'))).toBe(false); + }, 20000); + test('多个主类要求明确选择并列出候选', () => { + 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) {} }'); + expect(() => discoverMainClass([classes])).toThrow('First\n Second'); + }, 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..a67c039 --- /dev/null +++ b/tests/maven.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'bun:test'; +import { planMaven } from '../src/build-tools/maven'; +import { parseArgs } from '../src/cli/args'; + +const config = parseArgs(['plan']); + +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', '-Dexpression=other', '-DskipTests=false', '-Dmdep.outputFile=other', '-Dexec.mainClass=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..22ce4c7 --- /dev/null +++ b/tests/parse-argvs.test.ts @@ -0,0 +1,274 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { parseArgs } from '../src/cli/args'; +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.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', '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('读取完整中立配置,标量覆盖,数组依次追加', () => { + 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', '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] }, { applicationArgs: [''] }, + { buildArgs: [null] }, { 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']]) { + 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/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 }); + }); +}); From 3005f8c02a67c48ca9cee4a6f6e74dff0e27277f Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Mon, 5 Oct 2026 10:43:00 +0800 Subject: [PATCH 04/26] =?UTF-8?q?ci:=20=E5=A2=9E=E5=8A=A0=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E8=BF=90=E8=A1=8C=E9=AA=8C=E6=94=B6=E4=B8=8E=E5=8F=97?= =?UTF-8?q?=E6=A3=80=E5=8F=91=E5=B8=83=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用隔离 Maven / Gradle 夹具验收本机二进制、参数、测试作用域、资源与依赖变更;配置三种系统与两种 JDK 的原生检查,发布前检查并生成 SHA-256。 --- .github/workflows/check.yaml | 60 ++++ .github/workflows/release.yaml | 232 +++++-------- scripts/smoke.ts | 306 ++++++++++++++++++ tests/fixtures/.gitignore | 3 + tests/fixtures/README.md | 18 ++ tests/fixtures/boot-reactor/app/pom.xml | 37 +++ .../javarun/fixture/ReactorApplication.java | 34 ++ .../src/main/resources/application.properties | 4 + .../javarun/fixture/ReactorTestMarker.java | 7 + tests/fixtures/boot-reactor/lib/pom.xml | 19 ++ .../org/javarun/fixture/LibraryMarker.java | 22 ++ .../lib/src/main/resources/library.properties | 2 + tests/fixtures/boot-reactor/other-app/pom.xml | 26 ++ .../org/javarun/fixture/OtherApplication.java | 9 + tests/fixtures/boot-reactor/pom.xml | 33 ++ tests/fixtures/boot-single/pom.xml | 47 +++ .../javarun/fixture/SingleApplication.java | 36 +++ .../src/main/resources/application.properties | 4 + .../org/javarun/fixture/SingleTestMarker.java | 7 + .../fixtures/gradle-reactor/app/build.gradle | 19 ++ .../javarun/fixture/GradleApplication.java | 42 +++ .../app/src/main/resources/fixture.properties | 1 + .../org/javarun/fixture/GradleTestMarker.java | 7 + tests/fixtures/gradle-reactor/build.gradle | 18 ++ .../fixtures/gradle-reactor/lib/build.gradle | 7 + .../javarun/fixture/GradleLibraryMarker.java | 22 ++ .../lib/src/main/resources/library.properties | 2 + .../gradle-reactor/other-app/build.gradle | 5 + .../fixture/OtherGradleApplication.java | 9 + tests/fixtures/gradle-reactor/settings.gradle | 2 + tests/fixtures/plain/pom.xml | 70 ++++ .../org/javarun/fixture/PlainApplication.java | 41 +++ .../src/main/resources/fixture.properties | 1 + .../org/javarun/fixture/PlainTestMarker.java | 7 + 34 files changed, 1006 insertions(+), 153 deletions(-) create mode 100644 .github/workflows/check.yaml create mode 100644 scripts/smoke.ts create mode 100644 tests/fixtures/.gitignore create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/boot-reactor/app/pom.xml create mode 100644 tests/fixtures/boot-reactor/app/src/main/java/org/javarun/fixture/ReactorApplication.java create mode 100644 tests/fixtures/boot-reactor/app/src/main/resources/application.properties create mode 100644 tests/fixtures/boot-reactor/app/src/test/java/org/javarun/fixture/ReactorTestMarker.java create mode 100644 tests/fixtures/boot-reactor/lib/pom.xml create mode 100644 tests/fixtures/boot-reactor/lib/src/main/java/org/javarun/fixture/LibraryMarker.java create mode 100644 tests/fixtures/boot-reactor/lib/src/main/resources/library.properties create mode 100644 tests/fixtures/boot-reactor/other-app/pom.xml create mode 100644 tests/fixtures/boot-reactor/other-app/src/main/java/org/javarun/fixture/OtherApplication.java create mode 100644 tests/fixtures/boot-reactor/pom.xml create mode 100644 tests/fixtures/boot-single/pom.xml create mode 100644 tests/fixtures/boot-single/src/main/java/org/javarun/fixture/SingleApplication.java create mode 100644 tests/fixtures/boot-single/src/main/resources/application.properties create mode 100644 tests/fixtures/boot-single/src/test/java/org/javarun/fixture/SingleTestMarker.java create mode 100644 tests/fixtures/gradle-reactor/app/build.gradle create mode 100644 tests/fixtures/gradle-reactor/app/src/main/java/org/javarun/fixture/GradleApplication.java create mode 100644 tests/fixtures/gradle-reactor/app/src/main/resources/fixture.properties create mode 100644 tests/fixtures/gradle-reactor/app/src/test/java/org/javarun/fixture/GradleTestMarker.java create mode 100644 tests/fixtures/gradle-reactor/build.gradle create mode 100644 tests/fixtures/gradle-reactor/lib/build.gradle create mode 100644 tests/fixtures/gradle-reactor/lib/src/main/java/org/javarun/fixture/GradleLibraryMarker.java create mode 100644 tests/fixtures/gradle-reactor/lib/src/main/resources/library.properties create mode 100644 tests/fixtures/gradle-reactor/other-app/build.gradle create mode 100644 tests/fixtures/gradle-reactor/other-app/src/main/java/org/javarun/fixture/OtherGradleApplication.java create mode 100644 tests/fixtures/gradle-reactor/settings.gradle create mode 100644 tests/fixtures/plain/pom.xml create mode 100644 tests/fixtures/plain/src/main/java/org/javarun/fixture/PlainApplication.java create mode 100644 tests/fixtures/plain/src/main/resources/fixture.properties create mode 100644 tests/fixtures/plain/src/test/java/org/javarun/fixture/PlainTestMarker.java diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..6b640b7 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,60 @@ +name: Check + +on: + pull_request: + push: + branches: + - '**' + workflow_call: + +permissions: + contents: read + +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@v4 + - name: 安装 JDK + uses: actions/setup-java@v4 + 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@v4 + with: + gradle-version: '8.14' + - name: 缓存隔离的 Maven 仓库 + uses: actions/cache@v4 + 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..3588ccb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,169 +1,95 @@ name: Release -permissions: - contents: write - on: push: tags: - - 'v*' # 监听以 v 开头的 tag + - 'v*' + +permissions: + contents: read jobs: + checks: + uses: ./.github/workflows/check.yaml + build: - name: 构建并发版 + name: 构建 ${{ matrix.name }} + needs: checks runs-on: ubuntu-latest - + strategy: + fail-fast: false + matrix: + include: + - name: windows-x64-baseline + target: bun-windows-x64-baseline + extension: .exe + - name: linux-x64-baseline + target: bun-linux-x64-baseline + extension: '' + - name: linux-arm64 + target: bun-linux-arm64 + extension: '' + - name: darwin-arm64 + target: bun-darwin-arm64 + extension: '' + - name: darwin-x64 + target: bun-darwin-x64 + extension: '' steps: - - name: 检出代码 + - name: 检出事件对应的标签 uses: actions/checkout@v4 - - - 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 - - - name: 获取当前分支名 - id: get_branch_name - run: | - branch_name=$(git rev-parse --abbrev-ref HEAD) - echo "branch_name=$branch_name" >> $GITHUB_OUTPUT - - - name: 发版详情 - id: generate_release_notes - 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" - 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 - - 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 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} - restore-keys: | - ${{ runner.os }}-bun- - - - name: Setup Bun + - name: 安装固定版本 Bun uses: oven-sh/setup-bun@v2 - - - name: Install dependencies - run: bun install - - - name: Build Windows - 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 - 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 - 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 + with: + bun-version-file: .bun-version + - name: 按锁文件安装依赖 + run: bun install --frozen-lockfile + - name: 交叉编译 + shell: bash + env: + RELEASE_TAG: ${{ github.ref_name }} + PLATFORM_NAME: ${{ matrix.name }} + BUN_TARGET: ${{ matrix.target }} + FILE_EXTENSION: ${{ matrix.extension }} 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 + bun build src/cli.ts --compile --minify \ + --target "$BUN_TARGET" \ + --outfile "dist/java-run-${PLATFORM_NAME}-${RELEASE_TAG}${FILE_EXTENSION}" + - name: 上传单个平台产物 + uses: actions/upload-artifact@v4 + with: + name: binary-${{ matrix.name }} + path: dist/* + if-no-files-found: error - - name: Build macOS x64 + release: + name: 校验并发布标签产物 + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: 汇总构建产物 + uses: actions/download-artifact@v4 + with: + pattern: binary-* + merge-multiple: true + path: dist + - name: 生成并核对 SHA-256 + working-directory: dist 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 + sha256sum java-run-* > SHA256SUMS + sha256sum --check SHA256SUMS + - name: 保存已校验产物 + uses: actions/upload-artifact@v4 + with: + name: release-with-checksums + path: dist/* + if-no-files-found: error + - name: 发布当前事件的标签 + uses: softprops/action-gh-release@v2 with: - artifacts: "dist/*" - bodyFile: changelog.md + tag_name: ${{ github.ref_name }} + generate_release_notes: true + files: dist/* + fail_on_unmatched_files: true diff --git a/scripts/smoke.ts b/scripts/smoke.ts new file mode 100644 index 0000000..695aabc --- /dev/null +++ b/scripts/smoke.ts @@ -0,0 +1,306 @@ +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'; + +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[]; + absent?: string[]; +} + +const projectRoot = path.resolve(import.meta.dir, '..'); +const fixtureNames: Fixture[] = ['boot-single', 'boot-reactor', 'plain', 'gradle-reactor']; +const specialValue = 'hello world #中文%'; + +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); + 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 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'})`); + for (const marker of expected.stdout ?? []) { + if (!result.stdout.includes(marker)) violations.push(`缺少 stdout 标记: ${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'], { stdout: [packageVersion] }); + 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', ...mavenMarkers, ...withoutTests], + }); + if (options.suite === 'full') { + const configPath = path.join(projects.get('plain')!, '.java-run.json'); + await writeFile(configPath, JSON.stringify({ + mainClass: 'org.javarun.fixture.PlainApplication', + build: 'none', + buildArgs: mavenBuildArguments, + jvmArgs: ['-Dfixture.jvm=config value #中文%'], + applicationArgs: ['--from-config'], + }, null, 2)); + await check('project-config-default', [], { + stdout: ['[fixture] kind=plain', '[fixture] jvm-value=config value #中文%', '[fixture] arg=--from-config'], + }, projects.get('plain')!); + 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); + 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'); + } + 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/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..139f680 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,18 @@ +# Java 源码启动验证夹具 + +这些项目仅用于回归验证。测试脚本先复制到临时目录,再运行选定的 Maven 或 Gradle,不在夹具源码目录生成 `target`、`build`、`.gradle` 或缓存。Maven 使用单独的 settings 文件和临时本地仓库,`JAVA_RUN_MAVEN_REPOSITORY` 可指向专用缓存以复用下载;Gradle 使用独立的 `GRADLE_USER_HOME`。 + +| 夹具 | 验证内容 | +| --- | --- | +| `boot-single` | 以 Spring Boot 3.4.4 为普通依赖,验证主类发现、正常退出、构建和 JVM、应用参数 | +| `boot-reactor` | `app` 依赖 `lib`,从已安装的库资源读取标记和版本;`other-app` 误启动时输出 `FORBIDDEN_OTHER_APP` 并返回 23 | +| `plain` | 显式主类的直接 Java 启动、测试类路径、退出码和依赖更新 | +| `gradle-reactor` | Gradle `application` 主类配置、`app` 对 `lib` 的依赖、资源、测试类路径和无关应用隔离 | + +夹具统一以 Java 17 为最低版本。Boot 应用禁用 Web 并在打印 `[fixture]` 标记后关闭上下文,避免通过超时或端口探测推测启动结果。 + +`ci` Maven profile 将资源中的 `fixture.maven.profile` 改为 `ci`。Gradle 的 `-PfixtureProfile=ci` 对应资源中的 `fixture.gradle.profile`。Spring profile 通过普通 JVM 系统属性传递,验证构建参数与运行参数的边界。测试作用域中的 `commons-lang3` 与 `src/test/java` 中的标记类分别输出 `test-dependency` 和 `test-class`,默认均应为 `absent`,显式包含测试类路径后均应为 `present`。 + +`plain` 的 `commons.io.version` 默认是 `2.18.0`。测试在临时副本中将其改为 `2.19.0`,随后读取实际加载依赖的实现版本,验证 POM 修改能够影响下一次启动。传入应用参数 `--exit=7` 则打印结果后返回 7。 + +Gradle 夹具不携带 Wrapper 二进制,CI 固定安装 Gradle `8.14`。本机 smoke 使用 PATH 中的 Gradle,`JAVA_RUN_GRADLE_COMMAND` 可覆盖命令;临时 `GRADLE_USER_HOME` 与个人 Gradle 初始化脚本隔离,`JAVA_RUN_GRADLE_HOME` 可指向专用缓存。 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..a0fff70 --- /dev/null +++ b/tests/fixtures/plain/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + org.javarun.fixture + plain + 1.0.0 + + 17 + UTF-8 + 2.18.0 + base + + + + 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..055e42e --- /dev/null +++ b/tests/fixtures/plain/src/main/java/org/javarun/fixture/PlainApplication.java @@ -0,0 +1,41 @@ +package org.javarun.fixture; + +import java.io.InputStream; +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(stream); + } + 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] 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..a16b75a --- /dev/null +++ b/tests/fixtures/plain/src/main/resources/fixture.properties @@ -0,0 +1 @@ +fixture.maven.profile=${fixture.maven.profile} 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() { + } +} From 06d3ce7960ae9ad9e88754b4cf9b31a65dd2e51a Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Mon, 5 Oct 2026 10:43:48 +0800 Subject: [PATCH 05/26] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84=E9=80=9A?= =?UTF-8?q?=E7=94=A8=20CLI=20=E4=BD=BF=E7=94=A8=E8=AF=B4=E6=98=8E=E4=B8=8E?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=AA=8C=E6=94=B6=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 说明模块与主类交互、参数分层、目录职责、旧 CLI 迁移和技术路线,记录完整 smoke 及 java-template 隔离启动结果。 --- CONTRIBUTING.md | 104 ++++++++++++++++++ README.md | 194 +++++++++++++++++++++++++++------ docs/assessment-and-roadmap.md | 21 ++-- docs/implementation-plan.md | 30 ++++- docs/product-design.md | 39 ++++++- 5 files changed, 344 insertions(+), 44 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8c2bc9e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,104 @@ +# 参与 java-run 开发 + +产品契约以 [产品设计](docs/product-design.md) 为准,实施状态记录在 [实施计划](docs/implementation-plan.md)。java-run 的核心职责是为源码工作区解析一个启动目标,并交给 Maven / Gradle 准备后启动 Java;框架特殊逻辑应留在项目和构建工具中。 + +## 环境与日常检查 + +使用 `.bun-version` 固定的 Bun **1.4.2** 和锁文件,不用个人全局依赖替代项目依赖。真实项目验证需要 JDK、Maven 和 Gradle;当前 CI 使用 JDK 17 / 21、Gradle 8.14,目标项目自己的 Wrapper 与工具链优先。 + +```sh +bun install --frozen-lockfile +bun run check +bun run compile +bun run smoke +``` + +`check` 包含类型检查和快速回归。`compile` 构建本机二进制,smoke 随后运行这个产物,并从子进程 PATH 中移除 Bun 所在目录,检验二进制能独立运行。只交叉编译成功不足以说明目标系统能够运行。 + +smoke 默认运行完整夹具集,也可以缩小范围: + +```sh +bun run smoke --suite=quick +bun run smoke --fixture=gradle-reactor +bun run smoke --cli=/path/to/java-run --suite=full --keep +``` + +成功后默认清理临时目录,失败或 `--keep` 时保留项目副本及 stdout / stderr 日志。默认在临时目录中建立隔离的 Maven 仓库、settings 和 Gradle 用户目录,不写入个人依赖仓库或初始化脚本目录。 + +需要复用下载时使用**专用测试缓存**,不要指向个人 `~/.m2` 或 `~/.gradle`: + +```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 +``` + +其他脚本选项可使用 `JAVA_RUN_SMOKE_SUITE`、`JAVA_RUN_SMOKE_FIXTURE`、`JAVA_RUN_SMOKE_KEEP=1` 配置。变量名应与任务或测试域相关,不覆盖 `HOME`、`CODEX_HOME` 等系统变量。 + +## 分支与提交 + +从主分支创建具有单一意图的工作分支,如 `feat/gradle-project-selection` 或 `fix/process-exit-code`。本轮完整重构使用 `feat/open-source-cli`,保留主分支作为旧实现基线。修改前检查工作区,避免覆盖他人的未提交内容;并行工作采用隔离分支或 worktree。 + +提交遵循 Conventional Commits:type 与 scope 使用英文,subject 使用中文,例如: + +```text +feat(cli): 支持缺失主类的终端选择 +fix(maven): 保留选定项目的运行依赖范围 +test(gradle): 验证无关应用不参与编译 +docs: 说明参数分层与旧版本迁移 +``` + +提交按可审查的意图拆分,避免把无关重构、生成产物和依赖缓存混入同一个提交。提交前检查差异并运行相关验证;PR 说明应写清触发场景、最终行为、实际测试结果和未验证的平台。 + +未经明确发布安排,不生成 tag 或推送版本发布。当前重构尚未发布,旧 `0.0.5` Release 不能作为本分支新契约的安装依据。 + +## 模块边界 + +| 模块 | 职责 | +| --- | --- | +| CLI / 配置 | 验证调用契约,合并根目录 `.java-run.json`,处理命令入口 | +| Maven / Gradle 适配器 | 使用真实构建模型,裁决单个目标、源码准备、运行依赖和工具链 | +| 主类发现 / 交互 | 检查目标输出中的 main 方法,仅在终端中补充缺失选择 | +| classpath / 启动 | 编码文件 URL、生成临时 classpath Jar、构造 Java 参数 | +| 进程执行 | 保持参数边界、输出、退出码与信号,清理子进程 | + +新增适配能力时保持 `PreparedProject` 的公共契约,不把框架 profile、应用环境或自定义运行任务写入核心配置。候选模块来自构建模型,候选不等于已经确认有入口;不要先编译所有模块来寻找可运行应用。 + +源码注释和 API 文档使用中文,说明契约、边界和原因,避免复述代码。Markdown 正文按文章语境使用中文标点;同组列表保持语法与标点统一。public API 的说明下沉到所属接口、字段或方法,避免跨层重复维护。 + +## 测试要求 + +对行为修改选择能够验证调用者结果的测试,不堆叠复述实现的断言。适配器修改应有真实项目 smoke 证据,不能只用假 Maven / Gradle 输出宣称兼容。 + +回归重点包括: + +- Maven / Gradle 单项目与多模块目标、上游依赖和无关项目隔离 +- 默认自动准备与 `--build=none`,模型或依赖变化后的重新解析 +- 主类声明、唯一入口、多个入口、无入口和终端取消 +- JVM / 应用 / 构建参数边界,空格、中文、`#`、`%` 等字符 +- 默认测试隔离、显式测试类路径和资源变化 +- 正常与非零退出,Ctrl+C / SIGTERM 转发和子进程清理 +- 帮助、版本与静态 plan 不调用构建工具、不创建项目缓存 + +始终将夹具复制到临时目录后运行。不要在 `tests/fixtures` 下生成 `target`、`build`、`.gradle` 或 `.cache`,也不要修改用户提供的源仓库。临时目录应在成功时清理,失败时留下可追溯日志。 + +交互测试需要验证 EOF / Ctrl+C 不挂起、取消返回 130,以及非 TTY 不提示。环境和信号测试应使用隔离子进程,避免污染同一测试进程中的其他用例。 + +## CI 与 Release + +[Check 工作流](.github/workflows/check.yaml) 在 Linux、macOS、Windows 与 JDK 17 / 21 的矩阵中执行锁文件安装、类型检查、测试、本机二进制编译和完整 smoke。它验证的是运行器实际运行的原生环境;本地未执行过的平台应留待 CI 证明。 + +[Release 工作流](.github/workflows/release.yaml) 由 `v*` tag 触发,先通过 Check,再构建五类产物: + +| 产物 | 编译目标 | +| --- | --- | +| Windows x64 baseline | `bun-windows-x64-baseline` | +| Linux x64 baseline | `bun-linux-x64-baseline` | +| Linux arm64 | `bun-linux-arm64` | +| macOS arm64 | `bun-darwin-arm64` | +| macOS x64 | `bun-darwin-x64` | + +工作流汇总产物后生成并核对 `SHA256SUMS`,发布到触发事件对应的 tag,不用“最近一个 tag”替代事件版本。五种产物的交叉编译与三类系统的原生 smoke 是不同证据,不应混称全部架构都已原生验证。 + +正式发布前需要确定并补齐开源许可证,明确不兼容迁移对应的版本与发布说明,确认上述检查、平台产物和校验和完整。当前阶段不创建发布 tag。 diff --git a/README.md b/README.md index 32b61d4..c748da9 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,182 @@ # java-run -## 项目说明 +java-run 是面向源码工作区的 Java 运行器,为 Maven 和 Gradle 提供统一的开发启动入口:选择一个项目,由构建工具准备源码和运行依赖,再启动独立的 Java 进程。普通 `main`、Spring Boot 和其他基于 classpath 的应用使用同一套契约。定位与边界见 [产品设计](docs/product-design.md)。 -本项目的立项目的是为了脱离对 java 编辑器的重度依赖,而实现的一个 java 项目启动脚本。 +**当前文档对应重构分支,尚未发布。** 仓库中的 `0.0.5` 版本号仍是旧版本标识;现有 Release 不代表本分支已经发布。新命令与旧版参数不兼容,迁移方式见下文。 -起初笔者尝试使用过 bat 或者 shell 编写过脚本,但是考虑到跨平台的需求,加上脚本实现有些困难,故使用 ts 作为脚本书写语言。 +## 快速开始 -本项目使用 [Bun](https://bun.sh) 创建,可直接编译二进制文件,可执行 `bun run compile`,前往 `dist` 目录查看编译后的二进制文件。 +从源码使用需要 [Bun](https://bun.sh) **1.4.2**,版本固定在 `.bun-version`;运行 Java 项目还需要符合该项目要求的 JDK,以及 Maven / Gradle 或项目 Wrapper。JDK 的 `java` 和 `jar` 必须可用。 -## Todo +在 java-run 仓库中安装锁定依赖并编译本机二进制: -> 当前此程序服务于一个 Jeecg 项目,本质上是 Spring Boot 的多模块项目。 -> -> 是否可通用于通用的 Spring Boot 多模块/单模块项目,还需进一步验证。 -> -当前项目的任务与进展: +```sh +bun --version +bun install --frozen-lockfile +bun run compile +``` + +产物为 `dist/java-run`,Windows 使用 `dist/java-run.exe`。把产物放到 PATH 后,在 Java 项目根目录执行: + +```sh +java-run +``` + +编译后的二进制不需要安装 Bun,仍需要 JDK 和目标项目的构建工具。源码调试可以直接使用: -- [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 +bun run src/cli.ts --cwd /path/to/java-project +``` -## Tips +默认优先使用项目根目录的 `mvnw` / `mvnw.cmd` 或 `gradlew` / `gradlew.bat`;没有 Wrapper 时使用 PATH 中的构建工具。Wrapper 存在但运行失败时直接报错,不自动换用系统版本。可以用 `--build-command` 显式指定其他可执行文件。 -1. 使用本脚本程序之前,确保主机环境存在 `java` 和 `maven` 环境。 +## 命令与启动目标 -2. 确保事先执行过 `mvn compile`,或者可以在使用本脚本程序时,加入 `-c` 传参,详情可以查看 cli 的 `help-log`。 +```sh +java-run [run] [选项] [-- 应用参数...] +java-run plan [选项] +java-run help +java-run version +``` -## Other +`run` 是默认命令。`plan` 读取本地配置并预览步骤,不调用构建工具、不生成项目缓存、不发起交互;其中主类、有效项目模型和运行类路径仍未验证。`help` / `--help` / `-h` 和 `version` / `--version` 不读取项目配置。 -To install dependencies: +多模块仓库中的聚合项目、库模块和应用模块各有职责,并非每个模块都能执行。用 `--module` 选择一个目标,用 `--main` 在需要时指定入口: -```bash -bun install +```sh +java-run --module :app +java-run --tool gradle --module :apps:admin-server --main cn.xxb.admin.AdminApplication +java-run plan --module :app ``` -To run: +Maven 接受单个 reactor 选择器,如 `app`、`:artifactId` 或 `groupId:artifactId`;Gradle 接受项目路径,如 `:apps:admin-server`。不接受多个目标、排除选择器或可选选择器。目录同时存在 Maven 与 Gradle 构建文件时,需要通过 `--tool` 明确选择。 + +在 stdin 和 stderr 都连接终端的情况下,未指定模块时可以发现候选:Maven 聚合项目列出有效 reactor 中的 `jar` 项目,Gradle 列出启用了 Java 插件的项目。一个候选自动采用,多个候选通过数字选择。候选表示可以进一步检查的 Java 项目,可能仍是库模块,不代表已经确认存在入口。选定目标准备完成后,优先使用显式主类或构建声明;没有声明时查找目标输出中的传统 `public static void main(String[])`,唯一入口直接采用,多个入口可交互选择。 + +候选发现会执行构建工具配置,可能下载插件、Wrapper 分发包或准备 `buildSrc` 等构建逻辑;不会为了列出候选编译每个候选应用,也不解析它们的运行依赖。非交互环境和 CI 不进行选择,遇到目标或主类歧义时需通过 CLI 或 `.java-run.json` 明确指定。交互只补齐目标和入口,不提供参数向导,也不自动写入配置;Ctrl+C 或 EOF 取消返回 130。 + +## 参数放在哪一层 -```bash -bun run start +| 层次 | 配置方式 | 示例 | 影响 | +| --- | --- | --- | --- | +| 启动目标 | `--cwd`、`--module`、`--main` | `--module=:apps:admin-server` | 决定工作区、选定项目和 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` 加载 | + +Maven `-Pdev` 激活的是 **Maven profile**,与 Spring profile 分属不同层。java-run 没有 Spring 专用参数;Spring profile 使用正常 JVM 属性或应用参数传递。 + +三个参数数组都支持重复。负号开头的值使用等号形式,值中的后续等号、空格和 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 选项。构建参数由适配器校验;不能借它改写启动目标、注入额外任务或覆盖内部元数据输出步骤。 + +完整选项: + +| 选项 | 默认值或行为 | +| --- | --- | +| `--cwd ` | 当前目录,相对调用时的工作目录解析 | +| `--tool ` | `auto`,根据根目录构建文件选择 | +| `--module ` | 单个目标;缺失时按根项目和交互条件处理 | +| `--main ` | 构建声明或目标输出中的唯一传统 main | +| `--jvm-arg=` | 追加一个 JVM 参数,可重复 | +| `--arg=` | 追加一个应用参数,可重复 | +| `--build-arg=` | 追加一个构建工具参数,可重复 | +| `--build ` | `auto` | +| `--include-tests` | 准备并加入测试输出和依赖,默认关闭 | +| `--java ` | 显式覆盖 Java 可执行文件 | +| `--build-command ` | 显式覆盖构建工具可执行文件 | + +未识别的选项、重复标量和无效值均报错。 + +## 保存项目默认值 + +在传给 `--cwd` 的项目根目录保存 `.java-run.json`。例如,`java-template` 可以使用以下配置,随后只执行 `java-run`: + +```json +{ + "buildTool": "gradle", + "module": ":apps:admin-server", + "mainClass": "cn.xxb.admin.AdminApplication", + "jvmArgs": [ + "-Xmx1g", + "-Dspring.profiles.active=dev" + ], + "applicationArgs": [ + "--server.port=8081" + ] +} ``` -This project was created using `bun init` in bun v1.1.39. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. +可用字段为 `buildTool`、`module`、`mainClass`、`jvmArgs`、`applicationArgs`、`buildArgs`、`build`、`includeTests`。配置使用严格 JSON,不支持注释、未知字段、环境变量插值或配置继承。三个参数字段必须是字符串数组,`includeTests` 必须是布尔值。 + +只读取最终项目根目录中的配置,不向父目录搜索。显式 CLI 标量覆盖文件值,未指定时保留文件值;数组在文件数组之后追加。例如,配置已有 `applicationArgs` 时,`--arg` 不清空原参数。`--cwd`、`--java` 和 `--build-command` 仅通过 CLI 设置。 + +## 构建与运行行为 + +默认 `--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`。缺少产物时直接报错。 + +java-run 不维护独立的 classpath 缓存。每次由 Maven / Gradle 重新裁决,下载和增量计算复用构建工具自身缓存。适配器的元数据、Gradle init script 和运行 classpath Jar 放在系统临时目录,请求结束后清理;目标项目的 `target` / `build` 和构建工具缓存正常保留。classpath 使用正确编码的文件 URL,支持空格、中文、`#`、`%` 等路径字符。 + +构建命令在配置的工作区根目录执行,Java 应用的工作目录为选定项目目录。JVM 参数顺序为默认 `-Dfile.encoding=UTF-8`、构建声明的参数、项目配置数组、CLI 追加参数,因此后续同名系统属性可以覆盖默认值。Java 命令优先采用 `--java`,其次采用构建工具提供的工具链,再使用 `JAVA_HOME` 或 PATH。 + +## 与项目原生运行任务的关系 + +已有 Gradle `application` 的项目可以直接使用 `run`,Spring Boot Gradle 插件提供 `bootRun`。java-run 的价值是提供跨 Maven / Gradle 的统一入口,而项目原生任务本身已经能完成常见开发启动。[Gradle Application Plugin](https://docs.gradle.org/current/userguide/application_plugin.html)、[Spring Boot Gradle 运行说明](https://docs.spring.io/spring-boot/gradle-plugin/running.html) + +例如,在 `java-template` 中也可以直接执行: + +```sh +./gradlew :apps:admin-server:bootRun +``` + +java-run 启动独立 Java 进程,不模拟自定义 `JavaExec` / `bootRun` 的全部副作用,也不会自动搬运任务专用环境变量、agents、附加资源或启动前后逻辑。依赖这些设置的项目应使用原生任务,或把所需运行参数明确配置给 java-run。 + +## 从旧版迁移 + +| 旧用法 | 当前用法 | +| --- | --- | +| `start` | 默认执行或使用 `run` | +| `-c` / `compile` | 默认 `--build=auto` 自动准备,不再手动补编译步骤 | +| `main=` | `--main=` 或配置 `mainClass` | +| `active=` | `--jvm-arg=-Dspring.profiles.active=` 或应用参数 | +| `local` | 直接给出项目需要的完整 profile 名称 | +| `no-run` / `not-run` | 查看计划使用 `plan`;没有仅执行准备的旧模式 | +| `-r` / 刷新旧缓存 | 没有 java-run 独立缓存;构建工具选项通过 `--build-arg` 传递 | + +旧版 `.cache` 中的 classpath 文件和 `cp.jar` 不再读取。 + +## 支持范围与验证 + +当前支持 Maven `jar` 项目和启用了 Gradle Java 插件的项目,以及传统 `public static void main(String[])` 的 classpath 启动。主类发现只检查选定项目的已编译输出,不遍历所有依赖 Jar 寻找应用。JPMS、Android、native image、应用守护和热重启、部署不属于当前支持范围。 + +开发与验证命令: + +```sh +bun run check +bun run compile +bun run smoke +``` + +`check` 执行类型检查和快速测试;`compile` 构建本机独立二进制;`smoke` 使用临时项目副本检验真实 Maven / Gradle 运行,需要 JDK、Maven 和 Gradle,也可能下载依赖。测试和本机验证记录见 [实施计划](docs/implementation-plan.md),夹具说明见 [tests/fixtures/README.md](tests/fixtures/README.md),参与开发见 [CONTRIBUTING.md](CONTRIBUTING.md)。 + +开源许可证尚未确定,正式发布前需要补齐许可证与发布检查。本轮重构不创建版本 tag 或发布新版本。 diff --git a/docs/assessment-and-roadmap.md b/docs/assessment-and-roadmap.md index 62592f4..5d2965d 100644 --- a/docs/assessment-and-roadmap.md +++ b/docs/assessment-and-roadmap.md @@ -6,6 +6,9 @@ 建议继续投入,先以 Maven / Spring Boot 开发启动为明确范围。短期修复可复现问题;中期以启动模块为中心,让 Maven 负责模型与依赖裁决;随后用真实项目夹具和原生操作系统测试支撑支持范围。性能优化应在正确性基线建立后进行。 + +> 本文评估重构前的固定代码基线。用户随后允许重新定位并加入 Gradle,本次实施改为框架中立的源码工作区运行器;最终契约见 [产品设计](product-design.md),结果见 [实施记录](implementation-plan.md)。以下源码链接均指向原始基线。 + ## 评估范围与证据 本次阅读了全部 8 个 TypeScript 源文件、README、配置、构建脚本、发布工作流及本地可见的 17 个提交。在隔离副本安装锁定依赖,执行类型检查、本机编译及针对性探针;查阅 Maven、Java、Spring Boot、Bun 和 Exec Maven Plugin 官方资料。 @@ -42,7 +45,7 @@ flowchart TD | 长类路径 | 使用 Manifest 和 `cp.jar` | 方向可保留,路径编码存在已复现问题 | | 分发 | 本机编译,tag 触发五种 OS / 架构产物构建与 Release | 构建目标已有覆盖,目标系统运行尚无验证门禁 | -源码入口:[主流程](../src/cli.ts)、[模块发现](../src/find-maven-modules.ts)、[类路径构建](../src/classpath-builder.ts)、[发布流程](../.github/workflows/release.yaml)。 +源码入口:[主流程](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/cli.ts)、[模块发现](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/find-maven-modules.ts)、[类路径构建](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/classpath-builder.ts)、[发布流程](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/.github/workflows/release.yaml)。 以下语义需要在文档中明确:`active=` 是 Spring profile,不是 Maven `-P`;`no-run` 仍解析依赖并写缓存、生成 Jar,不是无副作用的 dry-run;`-c` 只执行 `mvn compile`,不能据此保证后续逐模块解析能找到未安装的兄弟模块产物。 @@ -50,7 +53,7 @@ flowchart TD ### Java 正常退出后被 CLI 判为失败 -**已复现,稳定版本的阻断项。** [exec.ts](../src/exec.ts) 第 16 行在 `stdout` 不是 Buffer 时直接调用 `.trim()`;[cli.ts](../src/cli.ts) 第 80 行使用 `stdio: 'inherit'`,此时成功结束的子进程没有可捕获的 stdout。 +**已复现,稳定版本的阻断项。** [exec.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/exec.ts) 第 16 行在 `stdout` 不是 Buffer 时直接调用 `.trim()`;[cli.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/cli.ts) 第 80 行使用 `stdio: 'inherit'`,此时成功结束的子进程没有可捕获的 stdout。 本地调用 `executeCommand('java', ['-version'], { stdio: 'inherit' })` 后,Java 正常退出,CLI 却因 `null.trim()` 抛错并返回 1。相同执行路径用于应用运行,因此应用正常关闭后也可能被报告为失败。这不表示 Java 一定无法启动。 @@ -58,25 +61,25 @@ flowchart TD ### Manifest 路径编码会导致类加载失败 -**已复现,稳定版本的阻断项。** [cli.ts](../src/cli.ts) 第 115 行直接将路径拼为 `file://...`,没有 URL 编码。使用同样的 Manifest 生成逻辑、真实 `javac` / `jar` / `java` 验证,普通目录可运行;目录含空格或 `#` 时出现 `ClassNotFoundException`。 +**已复现,稳定版本的阻断项。** [cli.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/cli.ts) 第 115 行直接将路径拼为 `file://...`,没有 URL 编码。使用同样的 Manifest 生成逻辑、真实 `javac` / `jar` / `java` 验证,普通目录可运行;目录含空格或 `#` 时出现 `ClassNotFoundException`。 建议使用标准路径到 URL 的转换,保持目录 URL 的尾部斜杠,并验证最终 Jar。Manifest 的行长限制按 UTF-8 字节计算;当前按字符串字符切行值得修正,但 `jar` 可能重新折行,本次中文目录样例成功,不能据源码直接断言中文必然失败。Windows 盘符及 UNC 路径仍需原生测试。[JAR 规范](https://docs.oracle.com/en/java/javase/21/docs/specs/jar/jar.html)、[pathToFileURL](https://nodejs.org/api/url.html#urlpathtofileurlpath-options) ### 测试类路径开关没有生效 -**测试目录问题已复现,依赖作用域问题由源码和官方文档确认。** [classpath-builder.ts](../src/classpath-builder.ts) 第 21、31 行无条件枚举 `classes` 与 `test-classes`,没有读取 `includeTests`;调用方传入 `false` 仍会包含测试输出。 +**测试目录问题已复现,依赖作用域问题由源码和官方文档确认。** [classpath-builder.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/classpath-builder.ts) 第 21、31 行无条件枚举 `classes` 与 `test-classes`,没有读取 `includeTests`;调用方传入 `false` 仍会包含测试输出。 第 79 行的 Maven 调用没有指定依赖作用域。`dependency:build-classpath` 默认包含所有依赖;普通运行时通常应使用 `-DincludeScope=runtime`。第 82 行的注释参数 `-Dmdep.includeScope=compile,runtime` 不能直接取消注释作为修复。测试输出目录和测试依赖需要同时控制,具体 Boot 开发启动语义则应遵循选定后端的契约。[插件参数](https://maven.apache.org/plugins/maven-dependency-plugin/build-classpath-mojo.html) ### 缓存可能持续返回旧依赖 -**已复现。** [classpath-builder.ts](../src/classpath-builder.ts) 第 69 行只检查缓存文件及模块 `target` 是否存在。探针将 POM 中依赖从 v1 改为 v2,第二次仍读取 v1,模拟 Maven 的调用次数没有增加;强制刷新才得到 v2。 +**已复现。** [classpath-builder.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/classpath-builder.ts) 第 69 行只检查缓存文件及模块 `target` 是否存在。探针将 POM 中依赖从 v1 改为 v2,第二次仍读取 v1,模拟 Maven 的调用次数没有增加;强制刷新才得到 v2。 建议第一步采用保守失效或默认重新解析,先保证结果正确。恢复缓存时,至少区分目标模块、相关 POM / 父模型、Maven profiles、属性、scope、工具链与缓存格式版本,检查引用文件存在性。外部父模型、SNAPSHOT 和 settings 变化需要额外策略;不能承诺一个根 POM 哈希解决全部问题。缓存写入应原子化,失败结果不得覆盖有效记录。 ### 原始 XML 与 Maven 有效模型存在差距 -**多项边界已复现。** [find-maven-modules.ts](../src/find-maven-modules.ts) 第 29 行只读顶层 modules,第 32 行直接读版本文本,第 52 行固定追加 `pom.xml`。探针结果包括: +**多项边界已复现。** [find-maven-modules.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/find-maven-modules.ts) 第 29 行只读顶层 modules,第 32 行直接读版本文本,第 52 行固定追加 `pom.xml`。探针结果包括: - 默认激活 profile 内的模块未被发现,聚合根被当成叶子 - `${revision}` 没有展开,随后参与模块 Jar 排除时匹配失败 @@ -89,7 +92,7 @@ flowchart TD ### 所有叶子模块的并集不能代表一个应用 -**行为由源码确认,冲突后果属于风险推断。** [cli.ts](../src/cli.ts) 第 97 行获取全部叶子模块,[classpath-builder.ts](../src/classpath-builder.ts) 第 29、43、108 行合并所有输出与依赖,只按路径去重。 +**行为由源码确认,冲突后果属于风险推断。** [cli.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/cli.ts) 第 97 行获取全部叶子模块,[classpath-builder.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/classpath-builder.ts) 第 29、43、108 行合并所有输出与依赖,只按路径去重。 例如仓库包含 `app-a` 与 `app-b`,分别依赖某库的 v1 和 v2,启动 `app-a` 时也可能带入 `app-b` 的目录及 v2。两个不同 Jar 路径不会被 Set 去掉;重复类和配置资源的加载顺序可能受到无关模块影响。本次未运行这一完整框架冲突场景。 @@ -107,7 +110,7 @@ Maven 的版本裁决以当前项目的依赖图为依据。因此,通用化 | 错误输出丢失 | executor 主要输出 `result.error`;stderr 被注释,依赖失败主要拼 stdout | 保留命令阶段、cwd、退出码、stderr,并区分找不到程序与命令执行失败 | | 导入即写缓存目录 | classpath 模块顶层执行 `mkdirSync` | 将文件写入移动到显式执行阶段,让帮助和参数验证可以独立运行 | -依据:[参数解析](../src/parse-argvs.ts) 第 17 至 23 行、[类路径构建](../src/classpath-builder.ts) 第 16、89、108 行、[执行器](../src/exec.ts) 第 9 至 16 行。 +依据:[参数解析](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/parse-argvs.ts) 第 17 至 23 行、[类路径构建](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/classpath-builder.ts) 第 16、89、108 行、[执行器](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/exec.ts) 第 9 至 16 行。 ## 工程能力评价 @@ -127,7 +130,7 @@ Maven 的版本裁决以当前项目的依赖图为依据。因此,通用化 当前源码约 493 行,保持简单是优点。改进不需要 DI 框架、插件市场或多层服务架构;明确运行配置、启动计划和进程执行三个边界即可显著降低维护成本。 -发布流程还存在具体改进点:[release.yaml](../.github/workflows/release.yaml) 第 24 行取全仓库最高版本 tag,而非本次触发 tag,重跑旧 tag 可能产生错误版本名或日志范围;第 76 行附近把提交文本直接插入 shell,应改为可靠的数据传递或标准发布说明机制。现有工作流只在 tag 触发,未包含 PR 类型检查、测试或目标系统运行检查。 +发布流程还存在具体改进点:[release.yaml](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/.github/workflows/release.yaml) 第 24 行取全仓库最高版本 tag,而非本次触发 tag,重跑旧 tag 可能产生错误版本名或日志范围;第 76 行附近把提交文本直接插入 shell,应改为可靠的数据传递或标准发布说明机制。现有工作流只在 tag 触发,未包含 PR 类型检查、测试或目标系统运行检查。 已提交 `bun.lockb` 是可复现性的基础,本次 frozen 安装成功。仍应固定 Bun 版本,并让 CI 显式执行锁文件安装、类型检查和测试;仅保留 strict 配置无法覆盖进程输出等运行时契约。 diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 4cce58b..e9f7558 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -25,6 +25,32 @@ - 原生操作系统 CI 运行本机二进制;本地未运行的系统不宣称已经验证 - 用户 java-template 在隔离副本补充验证,不修改原仓库 -## 状态 +## 实施结果 -实施中。完成后记录实际测试结果、提交边界和仍需原生平台验证的限制。 +2026-10-05 已完成运行契约与核心重构、两种构建适配器、配置与交互、真实项目夹具、原生 CI 和发布门禁、README 及迁移说明。源码按职责组织为 `cli`、`build-tools`、`core`、`process`,根目录保留 CLI 入口。旧入口、静态叶子模块并集和独立 classpath 缓存已经移除。 + +本轮本地环境为 macOS ARM64、Bun 1.4.2、Maven 3.9.16、Corretto JDK 21。Windows 专用用例在本地跳过,不能把已配置 CI 当成远端已经通过。 + +| 检查 | 实际结果 | +| --- | --- | +| 锁文件安装与严格类型检查 | 通过 | +| 核心回归 | 85 项通过、2 项 Windows 专用测试跳过,无失败 | +| Gradle 适配 | 8.14 / Java 21 与 9.7 / Java 26 的真实项目验证通过;buildSrc 与 included build 回归均通过 | +| 独立二进制完整 smoke | 同一次 20 项全部通过,82.2 秒;使用 JDK 21、Gradle 8.14 和独立测试缓存 | +| 目录整理后的二进制复验 | 重新类型检查、编译,7 项 quick smoke 全部通过 | +| 原生终端交互 | Maven 与 Gradle 实际列出模型候选,选择目标后正常运行;Maven 目标存在多个 main 时,选择另一入口并成功运行 | +| 用户 java-template | 隔离副本成功启动 AdminApplication,SIGTERM 返回 143,并执行应用关闭钩子 | + +完整 smoke 覆盖 Maven 单项目和 reactor、普通 main 与 Boot main、测试作用域隔离、资源与依赖变更、配置默认值与 CLI 合并、非零退出和 Gradle 项目依赖。候选发现验证了不读取可能触发任务依赖的主类 Provider,也不编译无关应用。主类与 Manifest 使用真实 JDK 验证,包括空依赖、特殊路径和 Unicode 标识符。 + +`java-template` 固定在提交 `f7e463d85e3e527fae974130fb12939523c40ac4` 的归档副本,使用其 Gradle 8.14 Wrapper / Java 21 toolchain,目标为 `:apps:admin-server`,入口为 `cn.xxb.admin.AdminApplication`。验证用随机 Web 端口、H2 内存库和 create-drop,避免读写原仓库的数据文件;启动耗时约 214 秒,应用自身报告启动约 3.4 秒,其余主要为首次构建准备。应用启动后发送 SIGTERM,确认 JVM、Wrapper 与一次性 Gradle daemon 均已结束,原仓库保持干净。本次仅确认应用启动及关闭,不代替业务接口验收。 + +独立审查额外确认了无依赖 Maven 项目可以正常运行,并发现原主类正则拒绝组合字符标识符的问题;已统一校验器并用 javac / java 回归。实际模板验证发现 Gradle init script 会进入 buildSrc,已用主构建规范路径限定修复,并用约定插件及复合构建验证。 + +## 分支与发布边界 + +全部工作留在 `feat/open-source-cli`,`master` 保留原代码基线。提交分别记录评估、产品定位、核心重构、验收与发布流程、用户文档;本次没有推送、合并主分支、创建 tag 或发布版本。 + +核心实现提交为 `95f1b5f`,验收与 CI 提交为 `3005f8c`。评估与初始定位分别记录在 `4ba5822`、`40e87cf`,最终文档以本记录及 README 为准。 + +包版本仍为旧 `0.0.5`,README 明确当前契约尚未发布。下一步正式发布前需确定开源许可证与版本号,实际运行三种原生系统的 CI,并据结果确认支持矩阵。性能、命名运行配置和新增运行模式按 [后续技术路线](product-design.md#后续技术路线) 推进,不以未运行的计划代替验证。 diff --git a/docs/product-design.md b/docs/product-design.md index 1098e6c..bf32d06 100644 --- a/docs/product-design.md +++ b/docs/product-design.md @@ -4,7 +4,9 @@ java-run 是框架中立的 Java 源码工作区运行器。它为 Maven 与 Gra ## 用户需要掌握的内容 -项目中执行 `java-run` 即启动默认应用。多模块项目在 `.java-run.json` 保存启动目标和参数,随后无需重复输入。没有明确目标或有多个主类时,工具给出选择要求,避免启动整个仓库或凭名称猜测应用。 +项目中执行 `java-run` 即启动默认应用。多模块项目在 `.java-run.json` 保存启动目标和参数,随后无需重复输入。终端中缺少模块或存在多个主类时,通过序号菜单补齐目标;非交互环境明确要求配置。菜单只选择模块或主类,不逐项询问运行参数,也不自动写入配置。 + +构建模块不等于可执行应用。Maven 候选为有效 reactor 中的 jar 项目,Gradle 候选为应用 Java 插件的项目;库也可能出现在列表中,因此标注入口尚未确认。主类在选定项目准备之后才最终确定。发现候选会执行构建工具配置,Gradle 的 buildSrc / included builds 可能准备构建逻辑,但不主动编译候选应用或解析其运行依赖。 命令只分为 `run`、`plan`、`help`、`version`。`plan` 读取本地项目配置并预览构建步骤,不执行构建工具,也不生成项目缓存;它明确表示主类和类路径仍待构建工具解析。 @@ -14,7 +16,7 @@ java-run 是框架中立的 Java 源码工作区运行器。它为 Maven 与 Gra Maven 单项目自动编译;选定 reactor 模块时,自动 install 该模块和上游依赖,再只解析选定模块的运行类路径。这里的 install 是 Maven 的本地仓库操作,计划和运行提示都会说明,不会发布到远程仓库。当前选择这个保守方案,是为了避免单独解析模块时读取缺失或陈旧的兄弟模块 Jar。 -Gradle 将 classes 与项目依赖交给任务图,不先单独编译每个模块,也不做 Maven 式的 install。通过临时 init script 获取选定项目的主 source set 和运行依赖;不改用户的构建脚本。`--build=none` 在两种工具中都表示不主动构建源码,缺失产物直接报错。 +Gradle 将 classes 与项目依赖交给任务图,不先单独编译每个模块,也不做 Maven 式的 install。通过临时 init script 获取选定项目的主 source set 和运行依赖;不改用户的构建脚本。初始化逻辑限定在请求的主构建,避免将目标路径套用到 buildSrc 或 included builds;复合构建的正常依赖任务仍由 Gradle 执行。`--build=none` 在两种工具中都表示不主动构建应用源码,缺失产物直接报错。 不会单独执行测试;`--include-tests` 只准备并加入测试输出和测试依赖。默认使用正常运行时依赖。重新解析依赖由构建工具自身缓存加速,java-run 不维护独立的失效不完整的 classpath 缓存。 @@ -28,4 +30,37 @@ Spring Boot、普通 Java main 和其他基于 classpath 的应用共享启动 CLI 解析配置,构建工具模块生成经过裁决的 PreparedProject,主类模块选择入口,启动模块生成 URL 编码正确的 classpath Jar,执行模块负责退出和信号。Maven 与 Gradle 是真实存在的两种适配器,框架不进入核心配置类型。 +源码按职责集中,入口与实现目录如下: + +```text +src/ +├── cli.ts # CLI 请求编排与进程入口 +├── cli/ # 参数、帮助、交互选择 +├── build-tools/ # 工具检测、Maven 与 Gradle 适配 +├── core/ # 运行契约、主类、类路径、Java 启动 +└── process/ # 外部命令、退出状态与信号处理 +``` + +```mermaid +flowchart LR + A[CLI 与项目配置] --> B[选择构建工具及目标] + B --> C[Maven 有效模型与依赖] + B --> D[Gradle source set 与任务图] + C --> E[PreparedProject] + D --> E + E --> F[主类选择与 Manifest] + F --> G[独立 Java 进程] +``` + 验证以同一契约覆盖两种工具:单项目、多模块依赖、无关项目隔离、参数边界、测试隔离、POM 或 Gradle 构建变更、特殊路径、正常和失败退出。用户提供的 java-template 作为 Gradle 实例补充验证,不修改其源仓库。 + +## 后续技术路线 + +| 阶段 | 交付目标 | 进入下一阶段的依据 | +| --- | --- | --- | +| 本轮重构 | 两种构建工具、统一运行契约、交互补选、真实夹具、原生 CI 与发布门禁 | 本地验收与实现记录可复现 | +| 首个通用版本 | 确定许可证和版本号,完成三种原生系统的 CI 验证,确认 Wrapper 与 JDK 支持矩阵 | 原生 CI 实际通过,兼容边界和迁移说明可公开 | +| 使用体验与性能 | 基于真实使用反馈决定命名运行配置、诊断命令,以及减少 Maven 多次启动和 Gradle 配置开销 | 有具体重复操作或可测量耗时,不恢复失效不完整的 classpath 缓存 | +| 扩展运行模式 | 评估 JPMS、现代 main、原生任务集成等独立需求 | 存在可验证的项目样本和明确契约,再扩展适配器 | + +TypeScript / Bun 继续承担 CLI 和进程编排,Gradle 脚本只承担构建模型读取。暂不为框架建立插件系统;当第三种真实运行模式出现时,再根据数据边界设计扩展接口。 From 2ad210c6d215c4fa5a4e1f44fb917cec0ad787f5 Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Mon, 5 Oct 2026 10:45:24 +0800 Subject: [PATCH 06/26] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20VSCode=20?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E6=96=87=E4=BB=B6=E4=BB=A5=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=20TypeScript=20SDK=20=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .vscode/settings.json 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 From 74d5353b00ae9cb7e14590cd725713550056b3cb Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Mon, 5 Oct 2026 11:03:18 +0800 Subject: [PATCH 07/26] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=20CI=20=E7=9A=84=E7=BC=96=E7=A0=81=E4=B8=8E=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=85=BC=E5=AE=B9=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 macOS JDK 17 的 NFD 主类验收,避免源文件名规范化干扰;以 UTF-8 请求和 ASCII 代理任务传递 Gradle 路径,归一化 Maven 模型输出,并在 Windows JDK 原生编码无法表示参数时提前报错,防止静默替换。 补充 Windows 进程树和参数能力验收;本机回归、双 JDK Gradle 验证与完整二进制 smoke 通过。 --- README.md | 2 ++ docs/implementation-plan.md | 10 ++++++ scripts/smoke.ts | 35 ++++++++++++++++-- src/build-tools/gradle.ts | 65 +++++++++++++++++++++++----------- src/build-tools/maven.ts | 15 +++++--- src/core/launch.ts | 9 +++-- src/process/java-arguments.ts | 40 +++++++++++++++++++++ tests/exec.test.ts | 26 +++++++++++++- tests/fixtures/README.md | 2 ++ tests/gradle.test.ts | 34 +++++++++++++++--- tests/helpers/process-child.ts | 12 +++++++ tests/java-arguments.test.ts | 59 ++++++++++++++++++++++++++++++ tests/launch.test.ts | 61 ++++++++++++++++++++++++++----- tests/maven.test.ts | 2 +- 14 files changed, 328 insertions(+), 44 deletions(-) create mode 100644 src/process/java-arguments.ts create mode 100644 tests/java-arguments.test.ts diff --git a/README.md b/README.md index c748da9..61164a8 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ java-run 启动独立 Java 进程,不模拟自定义 `JavaExec` / `bootRun` 当前支持 Maven `jar` 项目和启用了 Gradle Java 插件的项目,以及传统 `public static void main(String[])` 的 classpath 启动。主类发现只检查选定项目的已编译输出,不遍历所有依赖 Jar 寻找应用。JPMS、Android、native image、应用守护和热重启、部署不属于当前支持范围。 +Windows 的 Java 原生启动器按系统代码页转换命令行参数。classpath 文件 URL 可以保留中文路径,但主类名、JVM / 应用参数和传给构建工具的参数仍需能被该编码完整表示。java-run 会检测 JDK 的 `native.encoding` 并提前拒绝无法表示的参数,避免它们静默变成 `?`。需要传递任意 Unicode 参数时,应按系统要求启用 UTF-8 区域设置;`-Dfile.encoding=UTF-8` 控制应用文件编码,不能替代这项设置。自定义构建入口自行切换 JDK 时,还需核对其实际使用的工具链。[JDK Windows 启动器说明](https://www.oracle.com/java/technologies/javase/21-0-9-relnotes.html) + 开发与验证命令: ```sh diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index e9f7558..a4e66bd 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -54,3 +54,13 @@ 核心实现提交为 `95f1b5f`,验收与 CI 提交为 `3005f8c`。评估与初始定位分别记录在 `4ba5822`、`40e87cf`,最终文档以本记录及 README 为准。 包版本仍为旧 `0.0.5`,README 明确当前契约尚未发布。下一步正式发布前需确定开源许可证与版本号,实际运行三种原生系统的 CI,并据结果确认支持矩阵。性能、命名运行配置和新增运行模式按 [后续技术路线](product-design.md#后续技术路线) 推进,不以未运行的计划代替验证。 + +## 首轮 CI 反馈与修复 + +用户推送后的 [首轮 Check](https://github.com/Vanisper/java-run/actions/runs/37256647164) 对应 `2ad210c`。Linux / JDK 17、21 与 macOS / JDK 21 完成全部验收;macOS / JDK 17 的 Unicode 测试及两组 Windows 回归失败。 + +macOS 的问题发生在测试源文件名:JDK 17 将文件路径转为 NFC,而 public 类使用 NFD。改用 ASCII 源文件名承载原有 NFD 类,保留主类发现和实际启动断言,JDK 17、21 都通过。 + +Windows 原生 JDK 将系统代码页无法表示的参数替换为 `?`。修复包括 Gradle 通过临时脚本中的 UTF-8 请求读取目录和模块、使用 ASCII 根代理任务,Java / Jar 使用相对受控路径,以及执行前按真实 JDK 编码检查用户参数。中文 classpath 目录继续保留,无法表示的原始参数以明确错误结束。Maven 有效模型的输出路径统一归一化,依赖元数据输出固定 UTF-8。另增加实际 Windows 批处理进程树清理用例。 + +修复后本地全量回归 91 项通过、3 项 Windows 专用测试跳过;新增 Maven 编码保护后专项 17 项通过。Gradle 8.14 在 JDK 17、21 下各 21 项验证通过,完整独立二进制 smoke 20 项全部通过。后续原生结果以 [分支 Check 运行](https://github.com/Vanisper/java-run/actions/workflows/check.yaml?query=branch%3Afeat%2Fopen-source-cli) 为准,不将本机结果替代 Windows 验收。 diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 695aabc..d37449a 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -2,6 +2,7 @@ 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, readJavaNativeEncoding } from '../src/process/java-arguments'; type Fixture = 'boot-single' | 'boot-reactor' | 'plain' | 'gradle-reactor'; type Suite = 'quick' | 'full'; @@ -24,12 +25,12 @@ interface ProcessResult { interface Expectations { code?: number; stdout?: string[]; + stderr?: string[]; absent?: string[]; } const projectRoot = path.resolve(import.meta.dir, '..'); const fixtureNames: Fixture[] = ['boot-single', 'boot-reactor', 'plain', 'gradle-reactor']; -const specialValue = 'hello world #中文%'; function parseOptions(): SmokeOptions { let executable = path.join(projectRoot, 'dist', process.platform === 'win32' ? 'java-run.exe' : 'java-run'); @@ -130,6 +131,18 @@ async function main(): Promise { 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 Error) || !error.message.includes('无法完整表示')) 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} 空格#中文%`); @@ -151,6 +164,9 @@ async function main(): Promise { 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}`); } @@ -227,17 +243,30 @@ async function main(): Promise { await check('plain', argumentsFor('plain', ...directArguments, '--build-arg=-Pci', ...commonArguments), { stdout: ['[fixture] kind=plain', '[fixture] dependency-version=2.18.0', ...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], + }); + } + } if (options.suite === 'full') { const configPath = path.join(projects.get('plain')!, '.java-run.json'); await writeFile(configPath, JSON.stringify({ mainClass: 'org.javarun.fixture.PlainApplication', build: 'none', buildArgs: mavenBuildArguments, - jvmArgs: ['-Dfixture.jvm=config value #中文%'], + jvmArgs: [`-Dfixture.jvm=${configValue}`], applicationArgs: ['--from-config'], }, null, 2)); await check('project-config-default', [], { - stdout: ['[fixture] kind=plain', '[fixture] jvm-value=config value #中文%', '[fixture] arg=--from-config'], + stdout: ['[fixture] kind=plain', `[fixture] jvm-value=${configValue}`, '[fixture] arg=--from-config'], }, projects.get('plain')!); 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'], diff --git a/src/build-tools/gradle.ts b/src/build-tools/gradle.ts index d0ba02e..ae7a9cb 100644 --- a/src/build-tools/gradle.ts +++ b/src/build-tools/gradle.ts @@ -1,7 +1,8 @@ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { isAbsolute, join, resolve } from 'node:path'; +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']; @@ -52,23 +53,20 @@ function commandSpecification(config: RunConfig, workspace: string, discovery = throw new Error(`找不到 Gradle settings 或 build 文件:${config.cwd}`); } validateBuildArguments(config.buildArgs); - const target = discovery ? ':' : targetPath(config.module); + 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', paths.script, + '--no-daemon', '--console=plain', '--no-configuration-cache', '-I', relative(config.cwd, paths.script), ...config.buildArgs, - `-DjavaRun.root=${resolve(config.cwd)}`, - `-DjavaRun.target=${target}`, - `-DjavaRun.output=${discovery ? join(resolve(workspace), 'gradle-projects.json') : paths.output}`, `-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(`${target === ':' ? ':' : `${target}:`}${task}`); + args.push(`:${task}`); return { command: gradleCommand(config), args, cwd: config.cwd, stage: discovery ? 'Gradle 项目选择' : 'Gradle 项目准备' }; } @@ -82,25 +80,30 @@ export function planGradle(config: RunConfig, workspace: string): BuildPlan { tool: 'gradle', commands: [commandSpecification(config, workspace)], notes: [ + `目标 Gradle 项目:${targetPath(config.module)}`, config.build === 'auto' ? 'Gradle 任务图准备目标项目和运行依赖,不执行测试' : '不构建源码,要求目标项目和运行依赖已有可用产物', '主类、运行类路径和 Java 工具链将在执行 Gradle 后解析', + '项目根、目标模块和输出路径由临时脚本读取 UTF-8 请求,不经 Java 原生命令行编码转换', ], }; } 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(System.getProperty('javaRun.root')).canonicalFile + def requestedRoot = new File(javaRunRequest.root).canonicalFile if (gradle.rootProject.projectDir.canonicalFile != requestedRoot) { return } @@ -113,14 +116,14 @@ gradle.projectsEvaluated { }.sort { a, b -> a.path <=> b.path }.collect { [value: it.path, label: it.path + '(入口待解析)'] } - def output = new File(System.getProperty('javaRun.output')) + def output = new File(javaRunRequest.output) output.parentFile.mkdirs() output.setText(JsonOutput.toJson(candidates), 'UTF-8') } } return } - def requestedPath = System.getProperty('javaRun.target') + def requestedPath = javaRunRequest.target def target = gradle.rootProject.findProject(requestedPath) if (target == null) { throw new GradleException("找不到 Gradle 项目:" + requestedPath + ",请用 --module 指定实际项目路径") @@ -133,7 +136,7 @@ gradle.projectsEvaluated { 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] - target.tasks.register(taskName) { + def metadataTask = target.tasks.register(taskName) { if (System.getProperty('javaRun.build') == 'auto') { dependsOn selected.classesTaskName dependsOn selected.runtimeClasspath.buildDependencies @@ -155,7 +158,7 @@ gradle.projectsEvaluated { } def mainClass = application == null ? null : application.mainClass.orNull def jvmArgs = application == null ? [] : application.applicationDefaultJvmArgs.collect { it.toString() } - def output = new File(System.getProperty('javaRun.output')) + def output = new File(javaRunRequest.output) output.parentFile.mkdirs() output.setText(JsonOutput.toJson([ directory: target.projectDir.absolutePath, @@ -167,9 +170,35 @@ gradle.projectsEvaluated { ]), '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): 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) throw new CommandError(spec, result); +} + function readProject(output: string): PreparedProject { let metadata: unknown; try { @@ -210,12 +239,10 @@ export async function prepareGradle(config: RunConfig, workspace: string): Promi const plan = planGradle(config, workspace); const paths = metadataPaths(workspace); mkdirSync(resolve(workspace), { recursive: true }); - writeFileSync(paths.script, initScript); + writeFileSync(paths.script, initializationScript(config, workspace)); rmSync(paths.output, { force: true }); const spec = plan.commands[0]!; - console.error(`java-run:${spec.stage}`); - const result = await runCommand(spec, { capture: true }); - if (result.exitCode !== 0) throw new CommandError(spec, result); + await executeGradle(spec); return readProject(paths.output); } @@ -229,11 +256,9 @@ export async function discoverGradleProjects(config: RunConfig, workspace: strin const paths = metadataPaths(workspace); const output = join(resolve(workspace), 'gradle-projects.json'); mkdirSync(resolve(workspace), { recursive: true }); - writeFileSync(paths.script, initScript); + writeFileSync(paths.script, initializationScript(config, workspace, true)); rmSync(output, { force: true }); - console.error(`java-run:${spec.stage}`); - const result = await runCommand(spec, { capture: true }); - if (result.exitCode !== 0) throw new CommandError(spec, result); + await executeGradle(spec); 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')) { diff --git a/src/build-tools/maven.ts b/src/build-tools/maven.ts index 56975fa..079eecc 100644 --- a/src/build-tools/maven.ts +++ b/src/build-tools/maven.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { parseStringPromise } from 'xml2js'; import { CommandError, runCommand } from '../process/exec'; +import { assertJavaArguments } from '../process/java-arguments'; import { buildClasspath } from '../core/classpath'; import type { BuildPlan, CommandSpec, MavenProject, PreparedProject, RunConfig } from '../core/types'; @@ -24,7 +25,7 @@ function validateBuildArgs(args: string[]): void { if (flags.has(arg)) continue; if (arg.startsWith('-D') && arg.length > 2) { const name = arg.slice(2).split('=', 1)[0]!; - if (!['output', 'expression', 'includeScope', 'excludeScope', 'skipTests', 'maven.test.skip', 'maven.main.skip', 'maven.install.skip'].includes(name) + if (!['output', 'outputEncoding', 'expression', 'includeScope', 'excludeScope', 'skipTests', 'maven.test.skip', 'maven.main.skip', 'maven.install.skip'].includes(name) && !name.startsWith('mdep.') && !name.startsWith('exec.')) continue; throw new Error(`构建属性 ${name} 由 java-run 管理,请使用对应的启动配置`); } @@ -41,7 +42,7 @@ function validateBuildArgs(args: string[]): void { function command(config: RunConfig, goals: string[], stage: string, alsoMake = false): CommandSpec { validateBuildArgs(config.buildArgs); - const args = ['-B', '-ntp', ...config.buildArgs, '-f', join(config.cwd, 'pom.xml')]; + 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); @@ -58,7 +59,7 @@ export function planMaven(config: RunConfig, workspace: string): BuildPlan { 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')}`, '-Dmdep.regenerateFile=true', '-q'], '解析目标运行依赖')); + `-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 处理', @@ -90,7 +91,7 @@ export async function readEffectiveProject(xml: string, pomFile: string): Promis }; const outputPath = (value: unknown, name: string): string => { const text = scalar(value, name); - return isAbsolute(text) ? text : resolve(directory, text); + return resolve(directory, text); }; const plugins = project.build?.plugins?.plugin; const pluginList = !plugins ? [] : Array.isArray(plugins) ? plugins : [plugins]; @@ -114,6 +115,7 @@ export async function prepareMaven(config: RunConfig, workspace: string): Promis const plan = planMaven(config, workspace); for (const spec of plan.commands) { console.error(`java-run:${spec.stage}`); + await assertJavaArguments(buildJava(), spec.args, config.cwd); const result = await runCommand(spec, { capture: true }); if (result.exitCode !== 0) throw new CommandError(spec, result); } @@ -143,6 +145,7 @@ export async function discoverMavenProjects(config: RunConfig, workspace: string const output = join(workspace, 'module-list.xml'); const spec = command({ ...config, module: undefined }, [`${HELP_PLUGIN}:effective-pom`, `-Doutput=${output}`, '-q'], '读取 Maven 模块候选'); console.error(`java-run:${spec.stage}`); + await assertJavaArguments(buildJava(), spec.args, config.cwd); const result = await runCommand(spec, { capture: true }); if (result.exitCode !== 0) throw new CommandError(spec, result); const document = await parseStringPromise(readFileSync(output, 'utf8'), { explicitArray: false }); @@ -154,3 +157,7 @@ export async function discoverMavenProjects(config: RunConfig, workspace: string 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/core/launch.ts b/src/core/launch.ts index d82d1e5..190cc0b 100644 --- a/src/core/launch.ts +++ b/src/core/launch.ts @@ -1,10 +1,11 @@ -import { dirname, isAbsolute, join } from 'node:path'; +import { dirname, isAbsolute, join, relative } from 'node:path'; import { existsSync, statSync, writeFileSync } from 'node:fs'; import { createManifest } from './classpath'; import { discoverMainClass, findMainClasses } from './main-class'; import { chooseCandidate } from '../cli/selection'; 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 { @@ -32,9 +33,11 @@ export async function createLaunchCommand(config: RunConfig, project: PreparedPr 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', classpathJar, manifest], cwd: workspace, stage: '生成运行类路径' }; + 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: ['-Dfile.encoding=UTF-8', ...project.jvmArgs, ...config.jvmArgs, '-classpath', classpathJar, main, ...config.applicationArgs], cwd: project.directory, stage: '运行 Java 应用' }; + return { command: java, args, cwd: project.directory, stage: '运行 Java 应用' }; } diff --git a/src/process/java-arguments.ts b/src/process/java-arguments.ts new file mode 100644 index 0000000..105c99e --- /dev/null +++ b/src/process/java-arguments.ts @@ -0,0 +1,40 @@ +import { isAbsolute } from 'node:path'; +import iconv from 'iconv-lite'; +import { CommandError, runCommand } from './exec'; + +const javaEncodings = new Map(); + +/** 读取真实 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 原生命令行编码 */ +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 Error(`Windows JDK 的命令行编码 ${encoding} 无法完整表示部分参数;请在系统区域设置中启用“使用 Unicode UTF-8 提供全球语言支持”并按系统要求重启,或改用 ASCII 参数。-Dfile.encoding=UTF-8 不能改变命令行编码`); + } +} + +/** + * 在 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/exec.test.ts b/tests/exec.test.ts index 8ba0838..0831b1f 100644 --- a/tests/exec.test.ts +++ b/tests/exec.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test'; -import { spawn } from 'node:child_process'; +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'; @@ -151,4 +151,28 @@ describe.skipIf(process.platform !== 'win32')('Windows 批处理入口', () => { 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/README.md b/tests/fixtures/README.md index 139f680..196f3e0 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -16,3 +16,5 @@ `plain` 的 `commons.io.version` 默认是 `2.18.0`。测试在临时副本中将其改为 `2.19.0`,随后读取实际加载依赖的实现版本,验证 POM 修改能够影响下一次启动。传入应用参数 `--exit=7` 则打印结果后返回 7。 Gradle 夹具不携带 Wrapper 二进制,CI 固定安装 Gradle `8.14`。本机 smoke 使用 PATH 中的 Gradle,`JAVA_RUN_GRADLE_COMMAND` 可覆盖命令;临时 `GRADLE_USER_HOME` 与个人 Gradle 初始化脚本隔离,`JAVA_RUN_GRADLE_HOME` 可指向专用缓存。 + +smoke 先读取真实 JDK 的原生命令行编码。中文项目目录在所有系统上保留;JVM / 应用参数可表示时完整验证 Unicode,无法表示时改用可表示的参数完成正常运行,并追加两项二进制拒绝验收,要求明确报错且不泄漏参数。Windows 单字节代码页上的完整验收因此包含 22 项,其余环境通常为 20 项。 diff --git a/tests/gradle.test.ts b/tests/gradle.test.ts index 320f346..b12ba0c 100644 --- a/tests/gradle.test.ts +++ b/tests/gradle.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { CommandError } from '../src/process/exec'; @@ -15,6 +15,12 @@ function directory(): string { 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: [], @@ -66,10 +72,11 @@ describe('Gradle 计划契约', () => { 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(`-DjavaRun.root=${root}`); - expect(plan.commands[0]?.args).toContain('-DjavaRun.target=:apps:admin-server'); + 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.commands[0]?.args.at(-1)).toMatch(/^:apps:admin-server:javaRunMetadata_/); + 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"'); }); @@ -104,6 +111,25 @@ describe('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); + expect(prepared.directory).toBe(join(root, module)); + expect(prepared.classpath.every(value => existsSync(value))).toBe(true); + expect(JSON.parse(readFileSync(join(workspace, 'gradle-project.json'), 'utf8')).directory).toBe(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', ` diff --git a/tests/helpers/process-child.ts b/tests/helpers/process-child.ts index 6c21651..68f7db9 100644 --- a/tests/helpers/process-child.ts +++ b/tests/helpers/process-child.ts @@ -23,6 +23,18 @@ if (mode === 'args') { } 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' }); diff --git a/tests/java-arguments.test.ts b/tests/java-arguments.test.ts new file mode 100644 index 0000000..6baedb8 --- /dev/null +++ b/tests/java-arguments.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import iconv from 'iconv-lite'; +import { assertJavaArguments, assertRepresentableArguments } 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?.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('无法完整表示'); + expect(() => assertRepresentableArguments(['中文'], 'unknown-charset')).toThrow('无法验证'); + }); + + 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.toThrow('无法完整表示'); + } + 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 index 4d42f06..7a0feb8 100644 --- a/tests/launch.test.ts +++ b/tests/launch.test.ts @@ -7,6 +7,7 @@ import { buildClasspath, 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 } from '../src/process/java-arguments'; import { parseArgs } from '../src/cli/args'; import { detectBuildTool } from '../src/build-tools/detect'; import { planMaven, readEffectiveProject } from '../src/build-tools/maven'; @@ -23,10 +24,19 @@ function compile(directory: string, name: string, source: string): string { mkdirSync(output, { recursive: true }); const file = join(directory, `${name}.java`); writeFileSync(file, source); - const result = spawnSync('javac', ['-encoding', 'UTF-8', '-d', output, file], { encoding: 'utf8' }); + 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 Error && error.message.includes('无法完整表示')) 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') }; @@ -48,17 +58,43 @@ describe('目标类路径', () => { const directory = temporary(); const p = project(directory); mkdirSync(p.outputDirectory); expect(buildClasspath(p, '', true)).toEqual([p.outputDirectory]); }); - test('真实 JDK 可加载含空格、中文、#、% 的目录并保留应用参数', async () => { + 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 config = parseArgs(['--cwd', directory, '--', '空格 值', 'a=b', 'quote\'"']); + 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('空格 值|a=b|quote\'"'); + 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(); @@ -71,14 +107,23 @@ describe('目标类路径', () => { }); describe('主类选择', () => { - test('组合字符构成的合法 Unicode 标识符可以发现并启动', async () => { + test('组合字符主类可发现,并按原生编码启动或明确拒绝', async () => { const directory = temporary(); const name = 'Cafe\u0301'; - const classes = compile(directory, name, `public class ${name} { public static void main(String[] args) { System.out.print("UNICODE_MAIN_OK"); } }`); + // 源文件名保持 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}`]); - const launch = await createLaunchCommand(config, { directory, classesDirectories: [classes], classpath: [classes], jvmArgs: [] }, directory); expect(discoverMainClass([classes])).toBe(name); - expect((await runCommand(launch, { capture: true })).stdout).toBe('UNICODE_MAIN_OK'); + 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 方法不会被当成入口', () => { diff --git a/tests/maven.test.ts b/tests/maven.test.ts index a67c039..030895b 100644 --- a/tests/maven.test.ts +++ b/tests/maven.test.ts @@ -9,7 +9,7 @@ describe('Maven 构建参数边界', () => { 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', '-Dexpression=other', '-DskipTests=false', '-Dmdep.outputFile=other', '-Dexec.mainClass=Other'])('拒绝覆盖计划或执行额外目标的参数 %s', argument => { + 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'])('拒绝覆盖计划或执行额外目标的参数 %s', argument => { expect(() => planMaven({ ...config, buildArgs: [argument] }, '/tmp/metadata')).toThrow(); }); }); From ea695ef16bbfcbd2efcf5d24b7cd1cbb42070fcc Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Mon, 5 Oct 2026 11:23:39 +0800 Subject: [PATCH 08/26] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=9E=84=E5=BB=BA=E8=AF=8A=E6=96=AD=E4=B8=8E?= =?UTF-8?q?=20Maven=20=E9=85=8D=E7=BD=AE=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + docs/implementation-plan.md | 4 + scripts/smoke.ts | 14 ++- src/build-tools/gradle.ts | 27 ++++-- src/build-tools/maven.ts | 86 +++++++++++++++--- src/process/exec.ts | 16 +++- tests/exec.test.ts | 24 +++++ tests/fixtures/README.md | 4 +- tests/fixtures/plain/pom.xml | 1 + .../org/javarun/fixture/PlainApplication.java | 10 ++- .../src/main/resources/fixture.properties | 2 + tests/gradle.test.ts | 58 ++++++++++-- tests/maven.test.ts | 89 ++++++++++++++++++- 13 files changed, 303 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 61164a8..ae78602 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,8 @@ java-run 启动独立 Java 进程,不模拟自定义 `JavaExec` / `bootRun` Windows 的 Java 原生启动器按系统代码页转换命令行参数。classpath 文件 URL 可以保留中文路径,但主类名、JVM / 应用参数和传给构建工具的参数仍需能被该编码完整表示。java-run 会检测 JDK 的 `native.encoding` 并提前拒绝无法表示的参数,避免它们静默变成 `?`。需要传递任意 Unicode 参数时,应按系统要求启用 UTF-8 区域设置;`-Dfile.encoding=UTF-8` 控制应用文件编码,不能替代这项设置。自定义构建入口自行切换 JDK 时,还需核对其实际使用的工具链。[JDK Windows 启动器说明](https://www.oracle.com/java/technologies/javase/21-0-9-relnotes.html) +Windows 上的 Maven 配置根超出系统代码页时,需要 Maven **3.9.2 或更新版本**。java-run 会检查这一条件,保留 `.mvn` 配置和 POM 中项目根目录属性的绝对路径语义;版本不满足时明确报错。[Maven 3.9.2 命令行属性插值](https://maven.apache.org/docs/3.9.2/release-notes.html) + 开发与验证命令: ```sh diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index a4e66bd..819e4cf 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -64,3 +64,7 @@ macOS 的问题发生在测试源文件名:JDK 17 将文件路径转为 NFC, Windows 原生 JDK 将系统代码页无法表示的参数替换为 `?`。修复包括 Gradle 通过临时脚本中的 UTF-8 请求读取目录和模块、使用 ASCII 根代理任务,Java / Jar 使用相对受控路径,以及执行前按真实 JDK 编码检查用户参数。中文 classpath 目录继续保留,无法表示的原始参数以明确错误结束。Maven 有效模型的输出路径统一归一化,依赖元数据输出固定 UTF-8。另增加实际 Windows 批处理进程树清理用例。 修复后本地全量回归 91 项通过、3 项 Windows 专用测试跳过;新增 Maven 编码保护后专项 17 项通过。Gradle 8.14 在 JDK 17、21 下各 21 项验证通过,完整独立二进制 smoke 20 项全部通过。后续原生结果以 [分支 Check 运行](https://github.com/Vanisper/java-run/actions/workflows/check.yaml?query=branch%3Afeat%2Fopen-source-cli) 为准,不将本机结果替代 Windows 验收。 + +第二轮 Linux、macOS 的 JDK 17 / 21 均通过。Windows 暴露了目录短名与长名的等价性,以及 JDK 17 控制台丢失中文诊断的问题。目录断言改为验证实际文件系统身份;Gradle 的受控错误通过 ASCII 错误码传递,由 CLI 补充中文说明,保留构建工具原始输出和退出码,也不改变项目默认字符集。 + +复核 Maven 启动脚本时发现,它还会自行向 JVM 注入绝对项目根目录。Windows 的配置根无法由原生编码表示时,子进程使用相对 `MAVEN_BASEDIR` 读取 `.mvn`,再通过 Maven 3.9.2 引入的环境属性插值恢复模型中的绝对根目录;保留显式配置及最近 `.mvn` 祖先的含义,不修改父进程环境。可表示的路径保持绝对形式。完整二进制验收新增 `.mvn/maven.config` 属性经过资源过滤后实际生效的检查,覆盖中文项目目录和祖先目录,并验证模型根目录的绝对路径及配置位置;通常为 21 项,原生命令行编码无法表示 Unicode 的 Windows 环境为 23 项。 diff --git a/scripts/smoke.ts b/scripts/smoke.ts index d37449a..5872f93 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -152,6 +152,9 @@ async function main(): Promise { }); 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(); @@ -241,7 +244,8 @@ async function main(): Promise { 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', ...mavenMarkers, ...withoutTests], + 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-中文'; @@ -257,6 +261,14 @@ async function main(): Promise { } } 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'], + }); const configPath = path.join(projects.get('plain')!, '.java-run.json'); await writeFile(configPath, JSON.stringify({ mainClass: 'org.javarun.fixture.PlainApplication', diff --git a/src/build-tools/gradle.ts b/src/build-tools/gradle.ts index ae7a9cb..4f8544b 100644 --- a/src/build-tools/gradle.ts +++ b/src/build-tools/gradle.ts @@ -126,11 +126,11 @@ gradle.projectsEvaluated { def requestedPath = javaRunRequest.target def target = gradle.rootProject.findProject(requestedPath) if (target == null) { - throw new GradleException("找不到 Gradle 项目:" + requestedPath + ",请用 --module 指定实际项目路径") + 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("选定项目 " + requestedPath + " 没有 Java 插件,请用 --module 指定 Java 应用项目") + 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')) @@ -146,7 +146,7 @@ gradle.projectsEvaluated { def classes = included.collectMany { it.output.classesDirs.files as List } .findAll { it.isDirectory() }.collect { it.absolutePath }.unique() if (classes.isEmpty()) { - throw new GradleException("选定项目没有已编译类;请准备产物或使用 --build=auto") + 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() @@ -154,7 +154,7 @@ gradle.projectsEvaluated { def launcher = toolchains.launcherFor(javaExtension.toolchain).get() def application = target.extensions.findByType(JavaApplication) if (application != null && application.mainModule.isPresent()) { - throw new GradleException("本版本不支持 JPMS mainModule;请使用项目的原生运行任务") + 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() } @@ -189,14 +189,25 @@ function initializationScript(config: RunConfig, workspace: string, discovery = return initScript.replace('@JAVA_RUN_REQUEST@', encoded); } -async function executeGradle(spec: CommandSpec): Promise { +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) throw new CommandError(spec, result); + 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 { @@ -242,7 +253,7 @@ export async function prepareGradle(config: RunConfig, workspace: string): Promi writeFileSync(paths.script, initializationScript(config, workspace)); rmSync(paths.output, { force: true }); const spec = plan.commands[0]!; - await executeGradle(spec); + await executeGradle(spec, config); return readProject(paths.output); } @@ -258,7 +269,7 @@ export async function discoverGradleProjects(config: RunConfig, workspace: strin mkdirSync(resolve(workspace), { recursive: true }); writeFileSync(paths.script, initializationScript(config, workspace, true)); rmSync(output, { force: true }); - await executeGradle(spec); + 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')) { diff --git a/src/build-tools/maven.ts b/src/build-tools/maven.ts index 079eecc..97e3f1d 100644 --- a/src/build-tools/maven.ts +++ b/src/build-tools/maven.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from 'node:fs'; -import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { parseStringPromise } from 'xml2js'; import { CommandError, runCommand } from '../process/exec'; import { assertJavaArguments } from '../process/java-arguments'; @@ -8,6 +8,8 @@ import type { BuildPlan, CommandSpec, MavenProject, PreparedProject, RunConfig } 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 { @@ -25,7 +27,7 @@ function validateBuildArgs(args: string[]): void { 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'].includes(name) + 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 管理,请使用对应的启动配置`); } @@ -49,6 +51,76 @@ function command(config: RunConfig, goals: string[], stage: string, alsoMake = f 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; + let execution = spec; + let bridge = false; + const java = buildJava(); + await assertJavaArguments(java, spec.args, spec.cwd); + if (process.platform === 'win32') { + const configuredKey = Object.keys(process.env).find(name => name.toLowerCase() === 'maven_basedir'); + const base = resolveMavenBaseDirectory(spec.cwd, configuredKey ? process.env[configuredKey] : undefined); + const absoluteBase = resolve(spec.cwd, base); + try { + await assertJavaArguments(java, [`-Dmaven.multiModuleProjectDirectory=${absoluteBase}`], spec.cwd); + env = { MAVEN_BASEDIR: absoluteBase }; + } catch (error) { + if (!(error instanceof Error) || !error.message.includes('无法完整表示')) throw error; + await assertJavaArguments(java, [`-Dmaven.multiModuleProjectDirectory=${base}`], spec.cwd); + // 初始化先用可表示的相对根,Maven CLI 再从 Unicode 子环境还原模型中的绝对根 + env = { MAVEN_BASEDIR: base, JAVA_RUN_MAVEN_BASE_DIRECTORY: absoluteBase }; + execution = { ...spec, args: [...spec.args, baseDirectoryProperty] }; + bridge = true; + } + } + if (bridge) await validateWindowsMavenVersion(spec, 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[] = []; @@ -114,10 +186,7 @@ export async function prepareMaven(config: RunConfig, workspace: string): Promis await validateRoot(config); const plan = planMaven(config, workspace); for (const spec of plan.commands) { - console.error(`java-run:${spec.stage}`); - await assertJavaArguments(buildJava(), spec.args, config.cwd); - const result = await runCommand(spec, { capture: true }); - if (result.exitCode !== 0) throw new CommandError(spec, result); + await executeMaven(spec); } const pomFile = readFileSync(join(workspace, 'project-file.txt'), 'utf8').trim(); if (!isAbsolute(pomFile) || !existsSync(pomFile)) throw new Error('Maven 未返回有效的目标 POM 路径'); @@ -144,10 +213,7 @@ export async function needsMavenModule(config: RunConfig): Promise { 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 模块候选'); - console.error(`java-run:${spec.stage}`); - await assertJavaArguments(buildJava(), spec.args, config.cwd); - const result = await runCommand(spec, { capture: true }); - if (result.exitCode !== 0) throw new CommandError(spec, result); + 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] : []; diff --git a/src/process/exec.ts b/src/process/exec.ts index d776aa6..209ef0e 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -46,6 +46,8 @@ export class CommandError extends Error { /** 异步命令的输出捕获和信号转发选项 */ export interface RunCommandOptions { + /** 本次子进程的环境覆盖;未指定的变量继承父环境,undefined 删除变量 */ + env?: NodeJS.ProcessEnv; /** 捕获 stdout 和 stderr;默认直接继承当前终端 */ capture?: boolean; /** 转发 SIGINT、SIGTERM 并在父进程退出时清理子进程;默认启用 */ @@ -75,6 +77,18 @@ function windowsEnvironmentValue(env: NodeJS.ProcessEnv, name: string): string | 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) ? [''] @@ -150,7 +164,7 @@ export function runCommand(spec: CommandSpec, options: RunCommandOptions = {}): const detached = process.platform !== 'win32'; let child: ReturnType; try { - const prepared = prepareCommand(spec.command, spec.args, spec.cwd); + const prepared = prepareCommand(spec.command, spec.args, spec.cwd, childEnvironment(options.env)); child = spawn(prepared.command, prepared.args, { cwd: spec.cwd, env: prepared.env, diff --git a/tests/exec.test.ts b/tests/exec.test.ts index 0831b1f..7cec279 100644 --- a/tests/exec.test.ts +++ b/tests/exec.test.ts @@ -62,6 +62,30 @@ describe('异步执行', () => { 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 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 removed = await runCommand(command(source), { capture: true, env: { [overrideName]: undefined } }); + expect(JSON.parse(removed.stdout)).toEqual({}); + 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: '依赖解析' }); diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 196f3e0..95a2f1e 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -13,8 +13,8 @@ `ci` Maven profile 将资源中的 `fixture.maven.profile` 改为 `ci`。Gradle 的 `-PfixtureProfile=ci` 对应资源中的 `fixture.gradle.profile`。Spring profile 通过普通 JVM 系统属性传递,验证构建参数与运行参数的边界。测试作用域中的 `commons-lang3` 与 `src/test/java` 中的标记类分别输出 `test-dependency` 和 `test-class`,默认均应为 `absent`,显式包含测试类路径后均应为 `present`。 -`plain` 的 `commons.io.version` 默认是 `2.18.0`。测试在临时副本中将其改为 `2.19.0`,随后读取实际加载依赖的实现版本,验证 POM 修改能够影响下一次启动。传入应用参数 `--exit=7` 则打印结果后返回 7。 +`plain` 的 `commons.io.version` 默认是 `2.18.0`。测试在临时副本中将其改为 `2.19.0`,随后读取实际加载依赖的实现版本,验证 POM 修改能够影响下一次启动。临时 `.mvn/maven.config` 的属性经过资源过滤后输出 `maven-config` 标记,分别验证中文项目目录和祖先目录中的配置生效;同时验证 `${maven.multiModuleProjectDirectory}` 保持绝对路径并指向实际配置根。传入应用参数 `--exit=7` 则打印结果后返回 7。 Gradle 夹具不携带 Wrapper 二进制,CI 固定安装 Gradle `8.14`。本机 smoke 使用 PATH 中的 Gradle,`JAVA_RUN_GRADLE_COMMAND` 可覆盖命令;临时 `GRADLE_USER_HOME` 与个人 Gradle 初始化脚本隔离,`JAVA_RUN_GRADLE_HOME` 可指向专用缓存。 -smoke 先读取真实 JDK 的原生命令行编码。中文项目目录在所有系统上保留;JVM / 应用参数可表示时完整验证 Unicode,无法表示时改用可表示的参数完成正常运行,并追加两项二进制拒绝验收,要求明确报错且不泄漏参数。Windows 单字节代码页上的完整验收因此包含 22 项,其余环境通常为 20 项。 +smoke 先读取真实 JDK 的原生命令行编码。中文项目目录在所有系统上保留;JVM / 应用参数可表示时完整验证 Unicode,无法表示时改用可表示的参数完成正常运行,并追加两项二进制拒绝验收,要求明确报错且不泄漏参数。Windows 单字节代码页上的完整验收因此包含 23 项,其余环境通常为 21 项。 diff --git a/tests/fixtures/plain/pom.xml b/tests/fixtures/plain/pom.xml index a0fff70..a0fe65e 100644 --- a/tests/fixtures/plain/pom.xml +++ b/tests/fixtures/plain/pom.xml @@ -10,6 +10,7 @@ UTF-8 2.18.0 base + absent 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 index 055e42e..ae8c186 100644 --- a/tests/fixtures/plain/src/main/java/org/javarun/fixture/PlainApplication.java +++ b/tests/fixtures/plain/src/main/java/org/javarun/fixture/PlainApplication.java @@ -1,6 +1,10 @@ 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; @@ -13,11 +17,15 @@ public static void main(String[] args) throws Exception { if (stream == null) { throw new IllegalStateException("fixture.properties missing"); } - properties.load(stream); + 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) { diff --git a/tests/fixtures/plain/src/main/resources/fixture.properties b/tests/fixtures/plain/src/main/resources/fixture.properties index a16b75a..9d85906 100644 --- a/tests/fixtures/plain/src/main/resources/fixture.properties +++ b/tests/fixtures/plain/src/main/resources/fixture.properties @@ -1 +1,3 @@ fixture.maven.profile=${fixture.maven.profile} +fixture.maven.config=${fixture.maven.config} +fixture.maven.root=${maven.multiModuleProjectDirectory} diff --git a/tests/gradle.test.ts b/tests/gradle.test.ts index b12ba0c..2f7e30d 100644 --- a/tests/gradle.test.ts +++ b/tests/gradle.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +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'; @@ -34,6 +34,16 @@ function file(root: string, name: string, contents: string | Buffer): void { 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"); @@ -108,6 +118,31 @@ describe('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 项目', () => { @@ -123,9 +158,9 @@ describe.skipIf(!availableGradle)('真实 Gradle 项目', () => { 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); - expect(prepared.directory).toBe(join(root, module)); + expectSameDirectory(prepared.directory, join(root, module)); expect(prepared.classpath.every(value => existsSync(value))).toBe(true); - expect(JSON.parse(readFileSync(join(workspace, 'gradle-project.json'), 'utf8')).directory).toBe(join(root, module)); + 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); @@ -159,7 +194,7 @@ public class RunnerConventions implements Plugin { .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')); - expect(prepared.directory).toBe(join(root, 'app')); + 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); @@ -199,7 +234,7 @@ public class RunnerConventions implements Plugin { 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']); - expect(prepared.directory).toBe(join(root, 'app')); + 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); @@ -229,8 +264,11 @@ public class RunnerConventions implements Plugin { throw new Error('预期聚合根准备失败'); } catch (error) { expect(error).toBeInstanceOf(CommandError); - expect((error as CommandError).stderr + (error as CommandError).stdout).toContain('没有 Java 插件'); - expect((error as CommandError).stderr + (error as CommandError).stdout).toContain('--module'); + 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); @@ -241,7 +279,11 @@ public class RunnerConventions implements Plugin { throw new Error('预期缺少已编译产物时失败'); } catch (error) { expect(error).toBeInstanceOf(CommandError); - expect((error as CommandError).stderr + (error as CommandError).stdout).toContain('没有已编译类'); + 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); } diff --git a/tests/maven.test.ts b/tests/maven.test.ts index 030895b..b1b3381 100644 --- a/tests/maven.test.ts +++ b/tests/maven.test.ts @@ -1,15 +1,98 @@ -import { describe, expect, test } from 'bun:test'; -import { planMaven } from '../src/build-tools/maven'; +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 } 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 Error) || !error.message.includes('无法完整表示')) 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'])('拒绝覆盖计划或执行额外目标的参数 %s', argument => { + 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(); }); }); From a58cb336fb77c8756fef64c4e3b0b104706ac52e Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Mon, 5 Oct 2026 11:29:59 +0800 Subject: [PATCH 09/26] =?UTF-8?q?test:=20=E4=BF=AE=E6=AD=A3=20Windows=20?= =?UTF-8?q?=E5=AD=90=E7=8E=AF=E5=A2=83=E7=A7=BB=E9=99=A4=E7=9A=84=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/exec.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/exec.test.ts b/tests/exec.test.ts index 7cec279..72e971c 100644 --- a/tests/exec.test.ts +++ b/tests/exec.test.ts @@ -75,12 +75,24 @@ describe('异步执行', () => { }); 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 removed = await runCommand(command(source), { capture: true, env: { [overrideName]: undefined } }); - expect(JSON.parse(removed.stdout)).toEqual({}); 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 }); From 8acd01f845414d4aa597ef10e1f1e8a05fbc26e8 Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Mon, 5 Oct 2026 11:39:38 +0800 Subject: [PATCH 10/26] =?UTF-8?q?fix:=20=E8=A7=84=E8=8C=83=E5=8C=96=20Wind?= =?UTF-8?q?ows=20Maven=20=E7=9A=84=E6=A8=A1=E5=9D=97=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/implementation-plan.md | 2 ++ src/build-tools/maven.ts | 21 ++++++++++++--------- src/process/exec.ts | 6 +++++- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 819e4cf..a21f13a 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -68,3 +68,5 @@ Windows 原生 JDK 将系统代码页无法表示的参数替换为 `?`。修复 第二轮 Linux、macOS 的 JDK 17 / 21 均通过。Windows 暴露了目录短名与长名的等价性,以及 JDK 17 控制台丢失中文诊断的问题。目录断言改为验证实际文件系统身份;Gradle 的受控错误通过 ASCII 错误码传递,由 CLI 补充中文说明,保留构建工具原始输出和退出码,也不改变项目默认字符集。 复核 Maven 启动脚本时发现,它还会自行向 JVM 注入绝对项目根目录。Windows 的配置根无法由原生编码表示时,子进程使用相对 `MAVEN_BASEDIR` 读取 `.mvn`,再通过 Maven 3.9.2 引入的环境属性插值恢复模型中的绝对根目录;保留显式配置及最近 `.mvn` 祖先的含义,不修改父进程环境。可表示的路径保持绝对形式。完整二进制验收新增 `.mvn/maven.config` 属性经过资源过滤后实际生效的检查,覆盖中文项目目录和祖先目录,并验证模型根目录的绝对路径及配置位置;通常为 21 项,原生命令行编码无法表示 Unicode 的 Windows 环境为 23 项。 + +Windows 二进制验收进一步发现,Maven 对相对模块目录进行路径比较时,CI 临时目录的 8.3 短名无法匹配有效模型中的长名。执行前使用原生 `realpath` 规范化工作目录及已有配置根,保留 `--module app` 的目录选择语义。六个平台的结果仍由上述分支 Check 验证。 diff --git a/src/build-tools/maven.ts b/src/build-tools/maven.ts index 97e3f1d..f982a68 100644 --- a/src/build-tools/maven.ts +++ b/src/build-tools/maven.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { parseStringPromise } from 'xml2js'; import { CommandError, runCommand } from '../process/exec'; @@ -74,27 +74,30 @@ export function resolveMavenBaseDirectory(cwd: string, configuredBase?: string): async function executeMaven(spec: CommandSpec): Promise { let env: NodeJS.ProcessEnv | undefined; - let execution = spec; + // 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, spec.args, spec.cwd); + await assertJavaArguments(java, execution.args, execution.cwd); if (process.platform === 'win32') { const configuredKey = Object.keys(process.env).find(name => name.toLowerCase() === 'maven_basedir'); - const base = resolveMavenBaseDirectory(spec.cwd, configuredKey ? process.env[configuredKey] : undefined); - const absoluteBase = resolve(spec.cwd, base); + 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}`], spec.cwd); + await assertJavaArguments(java, [`-Dmaven.multiModuleProjectDirectory=${absoluteBase}`], execution.cwd); env = { MAVEN_BASEDIR: absoluteBase }; } catch (error) { if (!(error instanceof Error) || !error.message.includes('无法完整表示')) throw error; - await assertJavaArguments(java, [`-Dmaven.multiModuleProjectDirectory=${base}`], spec.cwd); + await assertJavaArguments(java, [`-Dmaven.multiModuleProjectDirectory=${base}`], execution.cwd); // 初始化先用可表示的相对根,Maven CLI 再从 Unicode 子环境还原模型中的绝对根 env = { MAVEN_BASEDIR: base, JAVA_RUN_MAVEN_BASE_DIRECTORY: absoluteBase }; - execution = { ...spec, args: [...spec.args, baseDirectoryProperty] }; + execution = { ...execution, args: [...execution.args, baseDirectoryProperty] }; bridge = true; } } - if (bridge) await validateWindowsMavenVersion(spec, env!); + 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); diff --git a/src/process/exec.ts b/src/process/exec.ts index 209ef0e..3f0a212 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -46,7 +46,11 @@ export class CommandError extends Error { /** 异步命令的输出捕获和信号转发选项 */ export interface RunCommandOptions { - /** 本次子进程的环境覆盖;未指定的变量继承父环境,undefined 删除变量 */ + /** + * 本次子进程的环境覆盖,未指定的变量继承父环境 + * + * @description undefined 删除变量;Windows 运行时可补回 PATH 等必需系统变量 + */ env?: NodeJS.ProcessEnv; /** 捕获 stdout 和 stderr;默认直接继承当前终端 */ capture?: boolean; From 13b17f3043bef1d8758837af6cfc94026a731574 Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 00:53:37 +0800 Subject: [PATCH 11/26] =?UTF-8?q?refactor:=20=E6=94=B6=E6=95=9B=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=A8=A1=E5=9D=97=E8=81=8C=E8=B4=A3=E4=B8=8E=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 183 +++------------------------------- scripts/build.bat | 15 --- scripts/smoke.ts | 6 +- src/build-tools/gradle.ts | 1 - src/build-tools/maven.ts | 6 +- src/cli.ts | 5 +- src/cli/help.ts | 6 +- src/core/launch.ts | 18 ++-- src/core/main-class.ts | 16 ++- src/process/java-arguments.ts | 19 +++- tests/java-arguments.test.ts | 16 ++- tests/launch.test.ts | 39 ++++++-- tests/maven.test.ts | 4 +- tsconfig.json | 14 +-- 14 files changed, 116 insertions(+), 232 deletions(-) delete mode 100644 scripts/build.bat 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/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/smoke.ts b/scripts/smoke.ts index 5872f93..33e47f3 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -2,7 +2,7 @@ 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, readJavaNativeEncoding } from '../src/process/java-arguments'; +import { assertJavaArguments, JavaArgumentEncodingError, readJavaNativeEncoding } from '../src/process/java-arguments'; type Fixture = 'boot-single' | 'boot-reactor' | 'plain' | 'gradle-reactor'; type Suite = 'quick' | 'full'; @@ -103,7 +103,7 @@ async function exists(file: string): Promise { } } -/** 检查静态预览没有生成构建工具输出或旧版缓存 */ +/** 检查静态预览没有生成构建工具输出或项目缓存 */ 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}`); @@ -137,7 +137,7 @@ async function main(): Promise { try { await assertJavaArguments(java, ['hello world #中文%'], workspace); } catch (error) { - if (!(error instanceof Error) || !error.message.includes('无法完整表示')) throw error; + if (!(error instanceof JavaArgumentEncodingError)) throw error; unicodeArguments = false; } const specialValue = unicodeArguments ? 'hello world #中文%' : 'hello world #%'; diff --git a/src/build-tools/gradle.ts b/src/build-tools/gradle.ts index 4f8544b..acbaf8d 100644 --- a/src/build-tools/gradle.ts +++ b/src/build-tools/gradle.ts @@ -85,7 +85,6 @@ export function planGradle(config: RunConfig, workspace: string): BuildPlan { ? 'Gradle 任务图准备目标项目和运行依赖,不执行测试' : '不构建源码,要求目标项目和运行依赖已有可用产物', '主类、运行类路径和 Java 工具链将在执行 Gradle 后解析', - '项目根、目标模块和输出路径由临时脚本读取 UTF-8 请求,不经 Java 原生命令行编码转换', ], }; } diff --git a/src/build-tools/maven.ts b/src/build-tools/maven.ts index f982a68..e6890d1 100644 --- a/src/build-tools/maven.ts +++ b/src/build-tools/maven.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { parseStringPromise } from 'xml2js'; import { CommandError, runCommand } from '../process/exec'; -import { assertJavaArguments } from '../process/java-arguments'; +import { assertJavaArguments, JavaArgumentEncodingError } from '../process/java-arguments'; import { buildClasspath } from '../core/classpath'; import type { BuildPlan, CommandSpec, MavenProject, PreparedProject, RunConfig } from '../core/types'; @@ -89,7 +89,7 @@ async function executeMaven(spec: CommandSpec): Promise { await assertJavaArguments(java, [`-Dmaven.multiModuleProjectDirectory=${absoluteBase}`], execution.cwd); env = { MAVEN_BASEDIR: absoluteBase }; } catch (error) { - if (!(error instanceof Error) || !error.message.includes('无法完整表示')) throw 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 }; @@ -138,7 +138,7 @@ export function planMaven(config: RunConfig, workspace: string): BuildPlan { return { tool: 'maven', commands, notes: [config.module && config.build === 'auto' ? '自动准备使用 Maven install,仅写本地仓库,不执行 deploy' : '构建与依赖模型由 Maven 处理', - '仅解析选定项目,不合并其他模块的类路径;不复用 java-run 的历史依赖缓存'], + '仅解析选定项目,不合并其他模块的类路径'], }; } diff --git a/src/cli.ts b/src/cli.ts index 4f6b23d..eedd4db 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,7 +38,10 @@ export async function main(argv: string[]): Promise { } } const project = tool === 'maven' ? await prepareMaven(config, workspace) : await prepareGradle(config, workspace); - const launch = await createLaunchCommand(config, project, workspace); + const selectMainClass = process.stdin.isTTY && process.stderr.isTTY + ? (candidates: readonly string[]) => chooseCandidate(candidates.map(value => ({ value, label: value })), '选择启动主类') + : undefined; + const launch = await createLaunchCommand(config, project, workspace, selectMainClass); console.error(`java-run:运行 ${launch.args[launch.args.indexOf('-classpath') + 2]}(${tool})`); return (await runCommand(launch)).exitCode; } catch (error) { diff --git a/src/cli/help.ts b/src/cli/help.ts index 4116e1c..7019306 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -14,7 +14,7 @@ export function getHelpText(): string { version, --version 显示版本,不读取项目配置 常用选项: - --cwd 项目根目录,默认当前目录 + --cwd 工作区根目录,默认当前目录 --module 单个 Maven reactor 选择器或 Gradle 项目路径 --main 启动类,默认使用构建声明或唯一的已编译 main 方法 --jvm-arg= JVM 参数,可重复;负号开头的值必须用等号 @@ -31,7 +31,7 @@ export function getHelpText(): string { --build-command 构建工具可执行文件,默认优先使用项目 Wrapper 项目配置: - 从最终项目目录读取 .java-run.json,不向父目录查找。 + 从 --cwd 指定的工作区根目录读取 .java-run.json,不向父目录查找。 可用字段:buildTool、module、mainClass、jvmArgs、applicationArgs、 buildArgs、build、includeTests CLI 标量覆盖配置,数组在配置之后追加。 @@ -46,7 +46,7 @@ export function getHelpText(): string { 示例: java-run java-run --module :app - java-run plan --tool gradle --module :apps:admin-server + 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 `; diff --git a/src/core/launch.ts b/src/core/launch.ts index 190cc0b..96f0f95 100644 --- a/src/core/launch.ts +++ b/src/core/launch.ts @@ -1,8 +1,7 @@ import { dirname, isAbsolute, join, relative } from 'node:path'; import { existsSync, statSync, writeFileSync } from 'node:fs'; import { createManifest } from './classpath'; -import { discoverMainClass, findMainClasses } from './main-class'; -import { chooseCandidate } from '../cli/selection'; +import { discoverMainClass, type MainClassSelector } from './main-class'; import { isJavaClassName } from './java-class'; import { CommandError, runCommand } from '../process/exec'; import { assertJavaArguments } from '../process/java-arguments'; @@ -15,20 +14,19 @@ function resolveJar(java: string): string { return candidate && existsSync(candidate) ? candidate : name; } -/** 检查适配器输出并生成独立 Java 进程的启动命令 */ -export async function createLaunchCommand(config: RunConfig, project: PreparedProject, workspace: string): Promise { +/** + * 检查适配器输出并生成独立 Java 进程的启动命令 + * + * @description 显式主类优先于项目声明;两者都缺失且有多个入口时才调用 selectMainClass + */ +export async function createLaunchCommand(config: RunConfig, project: PreparedProject, workspace: string, 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) { - const candidates = findMainClasses(classes); - main = candidates.length > 1 && process.stdin.isTTY && process.stderr.isTTY - ? await chooseCandidate(candidates.map(value => ({ value, label: value })), '选择启动主类') - : discoverMainClass(classes); - } + if (!main) main = await discoverMainClass(classes, selectMainClass); if (!isJavaClassName(main)) throw new Error(`无效的 Java 主类:${main}`); 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'); diff --git a/src/core/main-class.ts b/src/core/main-class.ts index 9c05afb..6736f62 100644 --- a/src/core/main-class.ts +++ b/src/core/main-class.ts @@ -62,10 +62,22 @@ export function findMainClasses(directories: string[]): string[] { return [...candidates].sort(); } -/** 在目标项目输出中选择唯一主类,多个候选时要求用户显式选择 */ -export function discoverMainClass(directories: string[]): string { +/** + * 从多个已确认入口中选择一个主类 + * + * @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/process/java-arguments.ts b/src/process/java-arguments.ts index 105c99e..eb37bfd 100644 --- a/src/process/java-arguments.ts +++ b/src/process/java-arguments.ts @@ -4,6 +4,17 @@ 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; @@ -19,13 +30,17 @@ export async function readJavaNativeEncoding(javaCommand: string, cwd: string): return encoding; } -/** 检查参数能否完整经过指定的 Java 原生命令行编码 */ +/** + * 检查参数能否完整经过指定的 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 Error(`Windows JDK 的命令行编码 ${encoding} 无法完整表示部分参数;请在系统区域设置中启用“使用 Unicode UTF-8 提供全球语言支持”并按系统要求重启,或改用 ASCII 参数。-Dfile.encoding=UTF-8 不能改变命令行编码`); + throw new JavaArgumentEncodingError(encoding); } } diff --git a/tests/java-arguments.test.ts b/tests/java-arguments.test.ts index 6baedb8..600ccee 100644 --- a/tests/java-arguments.test.ts +++ b/tests/java-arguments.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { join } from 'node:path'; import iconv from 'iconv-lite'; -import { assertJavaArguments, assertRepresentableArguments } from '../src/process/java-arguments'; +import { assertJavaArguments, assertRepresentableArguments, JavaArgumentEncodingError } from '../src/process/java-arguments'; import { runCommand } from '../src/process/exec'; describe('Java 原生命令行编码', () => { @@ -14,6 +14,8 @@ describe('Java 原生命令行编码', () => { } 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); @@ -25,7 +27,15 @@ describe('Java 原生命令行编码', () => { expect(() => assertRepresentableArguments(['中文'], 'gbk')).not.toThrow(); expect(() => assertRepresentableArguments(['中文', 'Cafe\u0305', '😀'], 'UTF-8')).not.toThrow(); expect(() => assertRepresentableArguments(['😀'], 'gbk')).toThrow('无法完整表示'); - expect(() => assertRepresentableArguments(['中文'], 'unknown-charset')).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 () => { @@ -51,7 +61,7 @@ describe('Java 原生命令行编码', () => { if (iconv.decode(iconv.encode(argument, encoding!), encoding!) === argument) { await assertJavaArguments(java, [argument], process.cwd()); } else { - await expect(assertJavaArguments(java, [argument], process.cwd())).rejects.toThrow('无法完整表示'); + 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 index 7a0feb8..a4bcfda 100644 --- a/tests/launch.test.ts +++ b/tests/launch.test.ts @@ -7,7 +7,7 @@ import { buildClasspath, 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 } from '../src/process/java-arguments'; +import { assertJavaArguments, JavaArgumentEncodingError } from '../src/process/java-arguments'; import { parseArgs } from '../src/cli/args'; import { detectBuildTool } from '../src/build-tools/detect'; import { planMaven, readEffectiveProject } from '../src/build-tools/maven'; @@ -33,7 +33,7 @@ async function acceptsJavaArguments(args: string[], cwd: string): Promise { // 源文件名保持 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(discoverMainClass([classes])).toBe(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); @@ -126,19 +126,44 @@ describe('主类选择', () => { } expect(() => parseArgs(['--main=Invalid³'])).toThrow('Java 类全名'); }, 20000); - test('通过 class 方法表发现唯一入口,注释与非 public/static 方法不会被当成入口', () => { + 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(discoverMainClass([classes])).toBe('Entry'); + expect(await discoverMainClass([classes])).toBe('Entry'); expect(hasMainMethod(readFileSync(join(classes, 'Other.class')))).toBe(false); expect(hasMainMethod(Buffer.from('invalid'))).toBe(false); }, 20000); - test('多个主类要求明确选择并列出候选', () => { + 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) {} }'); - expect(() => discoverMainClass([classes])).toThrow('First\n Second'); + 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); }); diff --git a/tests/maven.test.ts b/tests/maven.test.ts index b1b3381..bd8ec79 100644 --- a/tests/maven.test.ts +++ b/tests/maven.test.ts @@ -4,7 +4,7 @@ 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 } from '../src/process/java-arguments'; +import { assertJavaArguments, JavaArgumentEncodingError } from '../src/process/java-arguments'; const config = parseArgs(['plan']); const temporaryDirectories: string[] = []; @@ -75,7 +75,7 @@ describe('Maven 配置根', () => { try { await assertJavaArguments(java, [`-Dmaven.multiModuleProjectDirectory=${project}`], project); } catch (error) { - if (!(error instanceof Error) || !error.message.includes('无法完整表示')) throw error; + if (!(error instanceof JavaArgumentEncodingError)) throw error; requiresBridge = true; } const prepared = prepareMaven(parseArgs(['--cwd', project, '--build-command', batch]), workspace); 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 } } From 7f2cedfe2155adf40be373827fe23860e664ad7b Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 00:53:44 +0800 Subject: [PATCH 12/26] =?UTF-8?q?docs:=20=E6=95=B4=E7=90=86=E7=8E=B0?= =?UTF-8?q?=E8=A1=8C=E8=BF=90=E8=A1=8C=E5=A5=91=E7=BA=A6=E4=B8=8E=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E8=B7=AF=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 102 +++++++------ README.md | 70 +++++---- docs/architecture.md | 111 ++++++++++++++ docs/assessment-and-roadmap.md | 255 --------------------------------- docs/implementation-plan.md | 72 ---------- docs/product-design.md | 66 --------- docs/roadmap.md | 56 ++++++++ tests/fixtures/README.md | 39 +++-- 8 files changed, 294 insertions(+), 477 deletions(-) create mode 100644 docs/architecture.md delete mode 100644 docs/assessment-and-roadmap.md delete mode 100644 docs/implementation-plan.md delete mode 100644 docs/product-design.md create mode 100644 docs/roadmap.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c2bc9e..d3c53e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,16 @@ # 参与 java-run 开发 -产品契约以 [产品设计](docs/product-design.md) 为准,实施状态记录在 [实施计划](docs/implementation-plan.md)。java-run 的核心职责是为源码工作区解析一个启动目标,并交给 Maven / Gradle 准备后启动 Java;框架特殊逻辑应留在项目和构建工具中。 +java-run 从 Java 源码工作区选择一个启动目标,由 Maven 或 Gradle 准备源码和运行依赖,再启动 Java。使用契约见 [README](README.md),模块职责与设计约束见 [架构设计](docs/architecture.md),演进方向见 [技术路线](docs/roadmap.md)。 -## 环境与日常检查 +## 开发环境 -使用 `.bun-version` 固定的 Bun **1.4.2** 和锁文件,不用个人全局依赖替代项目依赖。真实项目验证需要 JDK、Maven 和 Gradle;当前 CI 使用 JDK 17 / 21、Gradle 8.14,目标项目自己的 Wrapper 与工具链优先。 +- 使用 [`.bun-version`](.bun-version) 指定的 Bun,以及仓库中的依赖锁文件 +- JDK 的 `java`、`javac` 和 `jar` 需要在 PATH 中可用;真实项目应使用符合自身要求的 JDK +- 完整验收需要 Maven 和 Gradle,首次运行可能下载插件与依赖 + +CI 使用 JDK 17 / 21 和 Gradle 8.14。夹具以 Java 17 为最低版本;这些版本构成验证环境,其他构建工具或 JDK 版本需要单独验证。目标项目存在 Wrapper 时,运行器优先使用 Wrapper。 + +安装依赖并执行日常检查: ```sh bun install --frozen-lockfile @@ -13,9 +19,25 @@ bun run compile bun run smoke ``` -`check` 包含类型检查和快速回归。`compile` 构建本机二进制,smoke 随后运行这个产物,并从子进程 PATH 中移除 Bun 所在目录,检验二进制能独立运行。只交叉编译成功不足以说明目标系统能够运行。 +| 命令 | 验证内容 | +| --- | --- | +| `bun run typecheck` | TypeScript 类型检查 | +| `bun test` | 参数、配置、适配器、主类、类路径、交互和进程行为回归 | +| `bun run check` | 类型检查与回归测试 | +| `bun run compile` | 构建本机二进制到 `dist` | +| `bun run smoke` | 使用该二进制运行真实 Maven / Gradle 夹具 | + +回归测试中的真实 Gradle 用例使用 PATH 中的 Gradle;找不到时会跳过,不能据此判断 Gradle 适配已经通过验收。可通过 `JAVA_RUN_TEST_GRADLE` 指定测试命令,通过 `GRADLE_USER_HOME` 隔离测试用的 Gradle 缓存: + +```sh +GRADLE_USER_HOME=/tmp/java-run-test-gradle \ +JAVA_RUN_TEST_GRADLE=/path/to/gradle \ +bun run check +``` + +## 二进制验收 -smoke 默认运行完整夹具集,也可以缩小范围: +smoke 会复制夹具到临时目录,使用独立的 Maven settings、本地仓库和 Gradle 用户目录,并从被测二进制的 PATH 中移除当前 Bun 安装目录,验证其运行不依赖 Bun。成功后默认清理临时目录;失败或指定 `--keep` 时保留项目副本与逐项 stdout / stderr 日志,输出中给出保留位置。 ```sh bun run smoke --suite=quick @@ -23,9 +45,9 @@ bun run smoke --fixture=gradle-reactor bun run smoke --cli=/path/to/java-run --suite=full --keep ``` -成功后默认清理临时目录,失败或 `--keep` 时保留项目副本及 stdout / stderr 日志。默认在临时目录中建立隔离的 Maven 仓库、settings 和 Gradle 用户目录,不写入个人依赖仓库或初始化脚本目录。 +`quick` 验证帮助、版本、静态预览和选定夹具的基本启动;`full` 是默认范围,进一步验证测试类路径、配置优先级、资源和依赖更新、非零退出等行为。`--fixture` 限定项目夹具,同时保留帮助、版本和预览检查。具体夹具与观测标记见 [夹具说明](tests/fixtures/README.md),执行项目数以脚本输出为准。 -需要复用下载时使用**专用测试缓存**,不要指向个人 `~/.m2` 或 `~/.gradle`: +需要复用下载时,可以指定专用测试缓存: ```sh JAVA_RUN_MAVEN_REPOSITORY=/tmp/java-run-test-maven \ @@ -34,11 +56,11 @@ JAVA_RUN_GRADLE_COMMAND=/path/to/gradle \ bun run smoke ``` -其他脚本选项可使用 `JAVA_RUN_SMOKE_SUITE`、`JAVA_RUN_SMOKE_FIXTURE`、`JAVA_RUN_SMOKE_KEEP=1` 配置。变量名应与任务或测试域相关,不覆盖 `HOME`、`CODEX_HOME` 等系统变量。 +不要将这些测试缓存指向个人 `~/.m2` 或 `~/.gradle`。`JAVA_RUN_GRADLE_COMMAND` 用于 smoke,`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`。本轮完整重构使用 `feat/open-source-cli`,保留主分支作为旧实现基线。修改前检查工作区,避免覆盖他人的未提交内容;并行工作采用隔离分支或 worktree。 +从主分支创建具有单一意图的工作分支,如 `feat/gradle-project-selection` 或 `fix/process-exit-code`。修改前检查工作区,保留其他人的未提交内容;需要同时处理独立修改时使用隔离分支或 worktree。 提交遵循 Conventional Commits:type 与 scope 使用英文,subject 使用中文,例如: @@ -46,50 +68,35 @@ bun run smoke feat(cli): 支持缺失主类的终端选择 fix(maven): 保留选定项目的运行依赖范围 test(gradle): 验证无关应用不参与编译 -docs: 说明参数分层与旧版本迁移 +docs: 说明参数传递规则 ``` -提交按可审查的意图拆分,避免把无关重构、生成产物和依赖缓存混入同一个提交。提交前检查差异并运行相关验证;PR 说明应写清触发场景、最终行为、实际测试结果和未验证的平台。 +提交按可审查的意图拆分,避免混入无关改动、生成产物和依赖缓存。提交前检查差异并运行相关验证;PR 说明应写清触发场景、最终行为、实际测试结果和未验证的平台。 -未经明确发布安排,不生成 tag 或推送版本发布。当前重构尚未发布,旧 `0.0.5` Release 不能作为本分支新契约的安装依据。 +源码注释使用中文,说明必要的契约、边界和原因,避免复述代码。公开 API 的说明应放在所属接口、字段或方法;架构取舍放入设计文档。Markdown 正文使用正常中文标点,同组列表保持语法与标点统一。 -## 模块边界 +## 验证要求 -| 模块 | 职责 | -| --- | --- | -| CLI / 配置 | 验证调用契约,合并根目录 `.java-run.json`,处理命令入口 | -| Maven / Gradle 适配器 | 使用真实构建模型,裁决单个目标、源码准备、运行依赖和工具链 | -| 主类发现 / 交互 | 检查目标输出中的 main 方法,仅在终端中补充缺失选择 | -| classpath / 启动 | 编码文件 URL、生成临时 classpath Jar、构造 Java 参数 | -| 进程执行 | 保持参数边界、输出、退出码与信号,清理子进程 | - -新增适配能力时保持 `PreparedProject` 的公共契约,不把框架 profile、应用环境或自定义运行任务写入核心配置。候选模块来自构建模型,候选不等于已经确认有入口;不要先编译所有模块来寻找可运行应用。 - -源码注释和 API 文档使用中文,说明契约、边界和原因,避免复述代码。Markdown 正文按文章语境使用中文标点;同组列表保持语法与标点统一。public API 的说明下沉到所属接口、字段或方法,避免跨层重复维护。 - -## 测试要求 - -对行为修改选择能够验证调用者结果的测试,不堆叠复述实现的断言。适配器修改应有真实项目 smoke 证据,不能只用假 Maven / Gradle 输出宣称兼容。 +行为修改应验证调用者可以观察到的结果。构建工具适配的修改需要真实项目验收,不能只依赖模拟 Maven / Gradle 输出。 回归重点包括: -- Maven / Gradle 单项目与多模块目标、上游依赖和无关项目隔离 -- 默认自动准备与 `--build=none`,模型或依赖变化后的重新解析 -- 主类声明、唯一入口、多个入口、无入口和终端取消 -- JVM / 应用 / 构建参数边界,空格、中文、`#`、`%` 等字符 -- 默认测试隔离、显式测试类路径和资源变化 -- 正常与非零退出,Ctrl+C / SIGTERM 转发和子进程清理 -- 帮助、版本与静态 plan 不调用构建工具、不创建项目缓存 - -始终将夹具复制到临时目录后运行。不要在 `tests/fixtures` 下生成 `target`、`build`、`.gradle` 或 `.cache`,也不要修改用户提供的源仓库。临时目录应在成功时清理,失败时留下可追溯日志。 +- 单项目与多模块目标、上游依赖和无关项目隔离 +- 自动准备与 `--build=none`,模型、资源或依赖变化后的重新解析 +- 主类声明、唯一入口、多个入口和无入口 +- JVM、应用和构建参数边界,以及空格、中文、`#`、`%` 等字符 +- 默认测试隔离和显式测试类路径 +- 正常与非零退出、平台对应的信号处理和进程树清理 +- 帮助、版本和静态预览的无构建行为 +- 终端选择、非 TTY 行为、EOF / Ctrl+C 取消和退出码 -交互测试需要验证 EOF / Ctrl+C 不挂起、取消返回 130,以及非 TTY 不提示。环境和信号测试应使用隔离子进程,避免污染同一测试进程中的其他用例。 +测试应在临时项目或夹具副本中运行,不在 `tests/fixtures` 下生成 `target`、`build`、`.gradle` 或缓存,也不修改用户提供的源仓库。环境和信号测试使用隔离子进程,避免污染其他用例。平台专用测试的跳过条件应与实际系统能力对应;跨平台结论以对应系统上的执行结果为依据。 -## CI 与 Release +## CI 与发布 -[Check 工作流](.github/workflows/check.yaml) 在 Linux、macOS、Windows 与 JDK 17 / 21 的矩阵中执行锁文件安装、类型检查、测试、本机二进制编译和完整 smoke。它验证的是运行器实际运行的原生环境;本地未执行过的平台应留待 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) 由 `v*` tag 触发,先通过 Check,再构建五类产物: +[Release 工作流](.github/workflows/release.yaml) 由 `v*` tag 触发,在同一标签提交上先执行 Check,再交叉编译以下产物: | 产物 | 编译目标 | | --- | --- | @@ -99,6 +106,13 @@ docs: 说明参数分层与旧版本迁移 | macOS arm64 | `bun-darwin-arm64` | | macOS x64 | `bun-darwin-x64` | -工作流汇总产物后生成并核对 `SHA256SUMS`,发布到触发事件对应的 tag,不用“最近一个 tag”替代事件版本。五种产物的交叉编译与三类系统的原生 smoke 是不同证据,不应混称全部架构都已原生验证。 +工作流汇总产物后生成并核对 `SHA256SUMS`,发布到触发事件对应的标签。交叉编译证明产物可以构建;原生矩阵证明相应运行环境可以执行,二者不能替代。矩阵没有逐一覆盖五种产物架构。 + +发布准备包括: + +- 确定开源许可证并补齐许可证文件 +- 为源码接口确定版本号,更新 `package.json` 并核对版本输出 +- 说明相对已发布接口的不兼容变化与迁移方法 +- 完成待发布提交的检查,审查平台产物和支持边界 -正式发布前需要确定并补齐开源许可证,明确不兼容迁移对应的版本与发布说明,确认上述检查、平台产物和校验和完整。当前阶段不创建发布 tag。 +`package.json` 的 `0.0.5` 尚未表达当前源码接口的版本变更。发布时应确保 `v<版本>` 标签与包版本一致,再推送标签触发工作流;`bun run version` 提供 bumpp 版本调整入口。发布完成后检查 Release 的标签、产物和校验和,并更新安装说明。 diff --git a/README.md b/README.md index ae78602..f51fcac 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # java-run -java-run 是面向源码工作区的 Java 运行器,为 Maven 和 Gradle 提供统一的开发启动入口:选择一个项目,由构建工具准备源码和运行依赖,再启动独立的 Java 进程。普通 `main`、Spring Boot 和其他基于 classpath 的应用使用同一套契约。定位与边界见 [产品设计](docs/product-design.md)。 +java-run 为 Maven 和 Gradle 源码工作区提供统一的开发启动入口:选择一个 Java 项目,由构建工具准备源码和运行依赖,再启动独立的 Java 进程。普通 Java、Spring Boot 和其他基于 classpath 的应用使用同一套运行契约。 -**当前文档对应重构分支,尚未发布。** 仓库中的 `0.0.5` 版本号仍是旧版本标识;现有 Release 不代表本分支已经发布。新命令与旧版参数不兼容,迁移方式见下文。 +**发布状态:本文描述的命令契约尚未正式发布,请从源码构建。** `package.json` 中的版本号仍为 `0.0.5`,已发布的 `0.0.5` 使用不同的命令格式,升级方式见[命令兼容性](#命令兼容性)。 ## 快速开始 @@ -16,7 +16,7 @@ bun install --frozen-lockfile bun run compile ``` -产物为 `dist/java-run`,Windows 使用 `dist/java-run.exe`。把产物放到 PATH 后,在 Java 项目根目录执行: +产物为 `dist/java-run`,Windows 使用 `dist/java-run.exe`。把产物放到 PATH 后,在 Java 工作区根目录执行: ```sh java-run @@ -45,25 +45,31 @@ java-run version ```sh java-run --module :app -java-run --tool gradle --module :apps:admin-server --main cn.xxb.admin.AdminApplication +java-run --tool gradle --module :app --main com.example.Application java-run plan --module :app ``` -Maven 接受单个 reactor 选择器,如 `app`、`:artifactId` 或 `groupId:artifactId`;Gradle 接受项目路径,如 `:apps:admin-server`。不接受多个目标、排除选择器或可选选择器。目录同时存在 Maven 与 Gradle 构建文件时,需要通过 `--tool` 明确选择。 +Maven 接受单个 reactor 选择器,如 `app`、`:artifactId` 或 `groupId:artifactId`;Gradle 接受项目路径,如 `:app` 或 `:apps:server`。不接受多个目标、排除选择器或可选选择器。目录同时存在 Maven 与 Gradle 构建文件时,需要通过 `--tool` 明确选择。 -在 stdin 和 stderr 都连接终端的情况下,未指定模块时可以发现候选:Maven 聚合项目列出有效 reactor 中的 `jar` 项目,Gradle 列出启用了 Java 插件的项目。一个候选自动采用,多个候选通过数字选择。候选表示可以进一步检查的 Java 项目,可能仍是库模块,不代表已经确认存在入口。选定目标准备完成后,优先使用显式主类或构建声明;没有声明时查找目标输出中的传统 `public static void main(String[])`,唯一入口直接采用,多个入口可交互选择。 +当 stdin 和 stderr 都连接终端时,缺少目标或入口的信息可以通过交互补齐: -候选发现会执行构建工具配置,可能下载插件、Wrapper 分发包或准备 `buildSrc` 等构建逻辑;不会为了列出候选编译每个候选应用,也不解析它们的运行依赖。非交互环境和 CI 不进行选择,遇到目标或主类歧义时需通过 CLI 或 `.java-run.json` 明确指定。交互只补齐目标和入口,不提供参数向导,也不自动写入配置;Ctrl+C 或 EOF 取消返回 130。 +- Maven 聚合项目列出有效 reactor 中的 `jar` 项目,Gradle 列出启用了 Java 插件的项目;一个候选自动采用,多个候选通过数字选择 +- 候选可能是库模块,入口需要在选定目标准备完成后确认 +- 主类优先采用 `--main` / 配置值,其次采用构建声明;没有声明时查找目标输出中的传统 `public static void main(String[])`,唯一入口直接采用,多个入口通过数字选择 + +候选发现会执行构建工具配置,可能下载插件、Wrapper 分发包或准备 `buildSrc` 等构建逻辑。候选应用的编译和运行依赖解析在选定目标后执行。非交互环境和 CI 遇到目标或主类歧义时,需要通过 CLI 或 `.java-run.json` 明确指定。 + +交互只选择目标和入口。运行参数通过选项或配置传入,配置由用户保存;Ctrl+C 或 EOF 取消返回 130。 ## 参数放在哪一层 | 层次 | 配置方式 | 示例 | 影响 | | --- | --- | --- | --- | -| 启动目标 | `--cwd`、`--module`、`--main` | `--module=:apps:admin-server` | 决定工作区、选定项目和 Java 入口 | +| 启动目标 | `--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` 加载 | +| 环境 | 启动 java-run 的 shell 或 CI 环境 | `JAVA_HOME`、`GRADLE_USER_HOME`、应用环境变量 | 构建工具和 Java 子进程继承环境;`.env` 需由 shell 或其他工具加载 | Maven `-Pdev` 激活的是 **Maven profile**,与 Spring profile 分属不同层。java-run 没有 Spring 专用参数;Spring profile 使用正常 JVM 属性或应用参数传递。 @@ -84,7 +90,7 @@ java-run --module :app \ | 选项 | 默认值或行为 | | --- | --- | -| `--cwd ` | 当前目录,相对调用时的工作目录解析 | +| `--cwd ` | 工作区根目录;默认为当前目录,相对调用时的工作目录解析 | | `--tool ` | `auto`,根据根目录构建文件选择 | | `--module ` | 单个目标;缺失时按根项目和交互条件处理 | | `--main ` | 构建声明或目标输出中的唯一传统 main | @@ -100,13 +106,13 @@ java-run --module :app \ ## 保存项目默认值 -在传给 `--cwd` 的项目根目录保存 `.java-run.json`。例如,`java-template` 可以使用以下配置,随后只执行 `java-run`: +在 `--cwd` 指定的工作区根目录保存 `.java-run.json`,随后可以直接执行 `java-run`: ```json { "buildTool": "gradle", - "module": ":apps:admin-server", - "mainClass": "cn.xxb.admin.AdminApplication", + "module": ":app", + "mainClass": "com.example.Application", "jvmArgs": [ "-Xmx1g", "-Dspring.profiles.active=dev" @@ -117,9 +123,9 @@ java-run --module :app \ } ``` -可用字段为 `buildTool`、`module`、`mainClass`、`jvmArgs`、`applicationArgs`、`buildArgs`、`build`、`includeTests`。配置使用严格 JSON,不支持注释、未知字段、环境变量插值或配置继承。三个参数字段必须是字符串数组,`includeTests` 必须是布尔值。 +可用字段为 `buildTool`、`module`、`mainClass`、`jvmArgs`、`applicationArgs`、`buildArgs`、`build`、`includeTests`。配置使用严格 JSON,不支持注释、未知字段、环境变量插值或配置继承。三个参数字段必须是字符串数组,其中的元素不能是空字符串或纯空白字符串;`includeTests` 必须是布尔值。 -只读取最终项目根目录中的配置,不向父目录搜索。显式 CLI 标量覆盖文件值,未指定时保留文件值;数组在文件数组之后追加。例如,配置已有 `applicationArgs` 时,`--arg` 不清空原参数。`--cwd`、`--java` 和 `--build-command` 仅通过 CLI 设置。 +只读取该工作区根目录的配置,不向父目录搜索,也不在选定模块后重新读取。显式 CLI 标量覆盖文件值,未指定时保留文件值;数组在文件数组之后追加。例如,配置已有 `applicationArgs` 时,`--arg` 会追加参数。`--cwd`、`--java` 和 `--build-command` 仅通过 CLI 设置。 ## 构建与运行行为 @@ -135,23 +141,28 @@ Maven reactor 的 `install` 会更新**本地 Maven 仓库**,不执行 `deploy `--build=none` 不主动编译源码,但仍执行模型与运行依赖解析,必要时仍会下载依赖;它要求目标和项目依赖已有可用产物,不能代替 `plan`。缺少产物时直接报错。 -java-run 不维护独立的 classpath 缓存。每次由 Maven / Gradle 重新裁决,下载和增量计算复用构建工具自身缓存。适配器的元数据、Gradle init script 和运行 classpath Jar 放在系统临时目录,请求结束后清理;目标项目的 `target` / `build` 和构建工具缓存正常保留。classpath 使用正确编码的文件 URL,支持空格、中文、`#`、`%` 等路径字符。 +每次启动的运行类路径由 Maven / Gradle 裁决,下载和增量计算复用构建工具自身缓存。java-run 的请求文件和 classpath Jar 放在系统临时目录,请求结束后清理;目标项目的 `target` / `build` 和构建工具缓存正常保留。classpath 支持空格、中文、`#`、`%` 等路径字符。 + +构建命令在配置的工作区根目录执行,Java 应用的工作目录为选定项目目录。JVM 参数顺序为默认 `-Dfile.encoding=UTF-8`、构建声明的参数、项目配置数组、CLI 追加参数,因此后续同名系统属性可以覆盖默认值。 -构建命令在配置的工作区根目录执行,Java 应用的工作目录为选定项目目录。JVM 参数顺序为默认 `-Dfile.encoding=UTF-8`、构建声明的参数、项目配置数组、CLI 追加参数,因此后续同名系统属性可以覆盖默认值。Java 命令优先采用 `--java`,其次采用构建工具提供的工具链,再使用 `JAVA_HOME` 或 PATH。 +Java 命令优先采用 `--java`,其次采用 Gradle 提供的项目工具链,再使用 `JAVA_HOME` 或 PATH。Maven 应用启动暂不读取 Maven toolchains 配置,需要不同 JDK 时使用 `--java` 显式指定。 ## 与项目原生运行任务的关系 已有 Gradle `application` 的项目可以直接使用 `run`,Spring Boot Gradle 插件提供 `bootRun`。java-run 的价值是提供跨 Maven / Gradle 的统一入口,而项目原生任务本身已经能完成常见开发启动。[Gradle Application Plugin](https://docs.gradle.org/current/userguide/application_plugin.html)、[Spring Boot Gradle 运行说明](https://docs.spring.io/spring-boot/gradle-plugin/running.html) -例如,在 `java-template` 中也可以直接执行: +例如,Gradle 应用模块可以使用对应的原生任务: ```sh -./gradlew :apps:admin-server:bootRun +./gradlew :app:run +./gradlew :app:bootRun ``` java-run 启动独立 Java 进程,不模拟自定义 `JavaExec` / `bootRun` 的全部副作用,也不会自动搬运任务专用环境变量、agents、附加资源或启动前后逻辑。依赖这些设置的项目应使用原生任务,或把所需运行参数明确配置给 java-run。 -## 从旧版迁移 +## 命令兼容性 + +已发布的 `0.0.5` 使用以下命令格式。使用本文描述的版本时,需要调整调用脚本: | 旧用法 | 当前用法 | | --- | --- | @@ -163,8 +174,6 @@ java-run 启动独立 Java 进程,不模拟自定义 `JavaExec` / `bootRun` | `no-run` / `not-run` | 查看计划使用 `plan`;没有仅执行准备的旧模式 | | `-r` / 刷新旧缓存 | 没有 java-run 独立缓存;构建工具选项通过 `--build-arg` 传递 | -旧版 `.cache` 中的 classpath 文件和 `cp.jar` 不再读取。 - ## 支持范围与验证 当前支持 Maven `jar` 项目和启用了 Gradle Java 插件的项目,以及传统 `public static void main(String[])` 的 classpath 启动。主类发现只检查选定项目的已编译输出,不遍历所有依赖 Jar 寻找应用。JPMS、Android、native image、应用守护和热重启、部署不属于当前支持范围。 @@ -173,14 +182,15 @@ Windows 的 Java 原生启动器按系统代码页转换命令行参数。classp Windows 上的 Maven 配置根超出系统代码页时,需要 Maven **3.9.2 或更新版本**。java-run 会检查这一条件,保留 `.mvn` 配置和 POM 中项目根目录属性的绝对路径语义;版本不满足时明确报错。[Maven 3.9.2 命令行属性插值](https://maven.apache.org/docs/3.9.2/release-notes.html) -开发与验证命令: +类型检查、快速测试和原生运行验收通过 `bun run check`、`bun run compile`、`bun run smoke` 执行。具体环境要求、CI 和发布流程见[参与开发](CONTRIBUTING.md)。 -```sh -bun run check -bun run compile -bun run smoke -``` +## 项目文档 -`check` 执行类型检查和快速测试;`compile` 构建本机独立二进制;`smoke` 使用临时项目副本检验真实 Maven / Gradle 运行,需要 JDK、Maven 和 Gradle,也可能下载依赖。测试和本机验证记录见 [实施计划](docs/implementation-plan.md),夹具说明见 [tests/fixtures/README.md](tests/fixtures/README.md),参与开发见 [CONTRIBUTING.md](CONTRIBUTING.md)。 +| 文档 | 内容 | +| --- | --- | +| [架构设计](docs/architecture.md) | 产品边界、模块职责、构建适配与跨平台运行契约 | +| [技术路线](docs/roadmap.md) | 已有工程能力、支持缺口和后续工作的验收条件 | +| [参与开发](CONTRIBUTING.md) | 本地开发、验证、分支协作与发布流程 | +| [验收夹具](tests/fixtures/README.md) | 真实项目场景、隔离方式和验证覆盖 | -开源许可证尚未确定,正式发布前需要补齐许可证与发布检查。本轮重构不创建版本 tag 或发布新版本。 +仓库尚未确定开源许可证,正式发布前需要补齐许可证与发布条件。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5fea061 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,111 @@ +# java-run 架构与运行契约 + +java-run 是框架中立的 Java 源码工作区运行器。它把“选择一个项目、准备运行产物、确定入口、启动独立 JVM”组织成统一流程,构建模型、依赖版本和项目任务仍由 Maven 或 Gradle 裁决。 + +统一入口适用于基于 classpath 的 Java 应用,包括普通 main 和 Spring Boot 应用。框架参数沿用 JVM 参数或应用参数的含义;框架类型不进入核心配置。使用方式见 [README](../README.md),工程能力与后续路线见 [路线规划](roadmap.md)。 + +## 运行目标 + +一次请求只启动一个构建项目和一个主类。多模块工作区中的模块可能是应用,也可能是库;存在编译输出或应用 Java 插件都不能证明它可执行。 + +模块选择和主类选择分两步完成: + +1. Maven 从有效 reactor 中列出 jar 项目,Gradle 从主构建中列出应用 Java 插件的项目。 +2. 准备选定项目后,按显式主类、项目声明、已编译入口的顺序确定主类。 + +自动发现只识别目标输出中的传统 `public static void main(String[])`。未找到入口时要求检查产物或显式配置;发现多个入口时要求选择。显式主类会校验类名语法,实际可加载性和方法有效性由 Java 启动器确认。 + +交互用于补齐目标和入口信息。标准输入与标准错误均为终端时,CLI 可以展示模块或主类菜单;唯一候选自动采用。非交互环境遇到目标或入口歧义时,需要通过参数或配置明确指定。菜单不询问运行参数,也不自动修改 `.java-run.json`;取消选择返回 130。 + +候选发现会运行构建工具。Gradle 配置期间可能需要准备 `buildSrc` 或 included build 的构建逻辑,但发现步骤不主动编译候选应用、解析其运行依赖或读取主类 Provider。 + +## 请求、计划与准备结果 + +三个数据契约分别承担不同阶段的职责: + +| 契约 | 含义 | 可确认的内容 | +| --- | --- | --- | +| `RunConfig` | 已解析的用户请求 | 工作目录、目标、参数和构建策略 | +| `BuildPlan` | 构建工具执行前的静态准备计划 | 命令、参数边界、工作目录和准备说明 | +| `PreparedProject` | 构建工具裁决后的运行信息 | 目标输出、按顺序排列的类路径、项目入口、启动 JDK 和 JVM 默认参数 | + +`run` 与 `plan` 读取解析 `--cwd` 后的工作区根目录中的 `.java-run.json`,不向父目录查找,也不在选定模块后重新读取。CLI 标量覆盖文件配置,数组参数追加到文件数组之后。工具路径由 CLI 指定,项目配置保存可复用的运行目标和参数。 + +`plan` 只读取本地构建文件和配置,输出准备步骤及待解析信息。它不执行构建工具、不创建临时目录、不交互,也不验证有效模型、主类或依赖文件。`help`、`version` 只解析 CLI 参数,不读取项目配置。 + +实际运行流程如下: + +```mermaid +flowchart TD + request[CLI 参数与项目配置] --> target[构建工具检测与目标选择] + target --> prepare[Maven / Gradle 准备与元数据解析] + prepare --> project[PreparedProject] + project --> entry[产物检查与主类确定] + entry --> classpath[Manifest 类路径 Jar] + classpath --> java[独立 Java 进程] +``` + +## 模块职责与依赖 + +```text +src/ +├── cli.ts 请求编排、退出结果与临时工作区生命周期 +├── cli/ 参数和配置解析、帮助、终端选择 +├── build-tools/ 构建工具检测、Maven 与 Gradle 适配器 +├── core/ 运行契约、类路径、主类发现与 Java 启动 +└── process/ 外部进程、环境覆盖、编码检查与信号处理 +``` + +CLI 依赖具体构建适配器完成准备,适配器返回同一 `PreparedProject`,Java 启动模块消费该结果。CLI 协调交互选择,核心模块通过调用方提供的选择行为确定入口,不依赖终端菜单实现。核心契约不包含 Spring 等框架字段;进程模块不判断 Maven 或 Gradle 项目语义。 + +Maven 与 Gradle 的差异集中在 `build-tools/`。各适配器提供计划、候选发现和项目准备函数,使调用方不必理解模型读取和任务图实现。新增构建系统或运行模式时,根据项目样本和结果契约调整接口。 + +## 构建工具适配 + +工作目录同时包含 Maven 与 Gradle 构建文件时,必须显式选择工具。构建命令的选择顺序为 CLI 指定命令、工作目录中的 Wrapper、PATH 中的命令。Wrapper 存在但启动失败时保留错误,不回退到另一个版本。 + +| 策略 | Maven | Gradle | +| --- | --- | --- | +| 默认准备 | 单项目执行 `compile`,包含测试时执行 `test-compile`;指定模块时执行 `-pl <目标> -am install -DskipTests` | 元数据任务依赖目标 source set 的 classes 和 runtimeClasspath 构建依赖,由任务图准备产物 | +| 模型来源 | 固定版本 Help Plugin 输出目标 POM 和 effective POM;Dependency Plugin 输出运行依赖 | 临时 init script 读取目标 source set、runtimeClasspath、application 配置和 Java toolchain | +| 类路径范围 | 目标编译输出加 Maven 裁决的运行依赖 | 目标 source set 的 runtimeClasspath | +| 包含测试 | 测试输出优先于主输出,并解析 test 依赖作用域 | 采用 test runtimeClasspath,同时记录 test 与 main 的输出 | +| `build=none` | 不执行编译或 install,继续解析有效模型和依赖 | 元数据任务不依赖源码构建任务,要求运行产物已经存在 | + +Maven 多模块的准备使用本地仓库衔接上游产物。这样,后续单独解析目标依赖时能够读取所选项目及上游模块的构建结果。`install` 可能执行项目绑定到该生命周期的其他插件,只写本地仓库,不执行 `deploy`。当前 Maven 运行目标要求 `packaging=jar`。 + +Maven 适配器读取工具已经展开的模型,不自行计算属性、继承或依赖冲突。主类元数据采用 `exec.mainClass` 属性或 Exec Maven Plugin 的 `mainClass` 配置。应用启动 JDK 由 `--java`、`JAVA_HOME` 或 PATH 决定,适配器不自动从 Maven toolchains 选择应用启动器。 + +Gradle init script 只写入本次临时工作区,不改项目构建脚本。元数据任务仅注册到请求的主构建,避免把目标项目路径套用到 `buildSrc` 或 included builds;正常项目依赖和复合构建依赖仍由 Gradle 任务图处理。Gradle application 插件声明的主类和默认 JVM 参数可以成为启动元数据,目标 Java toolchain 提供启动器;`--java` 可以显式覆盖应用启动器。 + +适配器不主动调用测试目标或测试任务,项目绑定到编译、安装或 classes 的其他行为仍按其构建配置执行。包含测试类路径意味着准备并加载测试输出与依赖。`build=none` 也会执行模型和依赖解析,构建工具可能下载依赖或更新自身缓存;它不等同于静态预览。 + +构建参数只能调整适配器允许的属性、profile、日志、依赖等选项,不能另行指定项目、生命周期目标、任务列表或 init script。适配器管理的元数据输出和相关属性保持受控,确保请求仍指向一个目标。 + +## Java 启动 + +启动模块先检查类路径文件和目标类目录,再确定主类。主类自动发现仅扫描目标项目输出,不把依赖 Jar 或其他应用的入口混入候选。 + +Java 启动通过临时 Manifest Jar 表达长类路径。Manifest 中的文件路径转换为带正确 URL 编码的 `file:` URL,目录保留尾部斜线,按 UTF-8 字节数折行并使用 CRLF 和终止空行。类路径保留构建工具给出的顺序,只去重相同路径。 + +应用工作目录为选定项目目录。JVM 参数的排列为默认 `-Dfile.encoding=UTF-8`、项目声明的默认参数、用户配置及 CLI 参数;应用参数位于主类之后。各参数一直保持数组边界,不由通用 shell 重新拆分。临时脚本、元数据和类路径 Jar 在请求结束时统一清理。 + +框架原生运行任务可能配置额外资源、环境、agent 或自定义 `JavaExec` 行为。通用启动器只使用上述运行契约;依赖这些任务行为的项目应采用原生任务,或明确配置所需启动参数。 + +## 跨平台进程与编码 + +外部命令异步执行。构建与元数据命令捕获输出,Java 应用继承终端。捕获模式保留换行和首尾空白,总输出默认限制为 16 MiB;非零退出保留阶段、工作目录、退出码和诊断。应用退出码直接成为 CLI 退出码,命令不存在时返回 127。 + +POSIX 命令使用独立进程组,转发 `SIGINT`、`SIGTERM`;三秒后升级终止,父进程退出或命令结束时清理该进程树。Windows 批处理入口通过受控 `cmd` 调用保留参数边界,并阻止参数中的 `%NAME%` 被二次展开;终止时用 `taskkill /T /F` 清理进程树,因此不能承诺 POSIX 式的应用优雅关闭。 + +Windows JDK 原生启动器可能使用系统代码页转换参数,`file.encoding=UTF-8` 不会改变这个转换。执行前按真实 JDK 的原生编码验证非 ASCII 参数;无法完整表示时明确失败,错误不回显原始参数。路径可以保留在 UTF-8 元数据和 Manifest 中:Gradle 的项目根、目标和输出路径从临时脚本中的请求读取,Java 的类路径 Jar 参数相对目标项目目录计算,Jar 命令在临时目录中使用相对文件名。 + +Maven 启动脚本还会注入配置根目录。Windows 适配器保留显式 `MAVEN_BASEDIR` 或最近 `.mvn` 祖先的含义,规范化已有目录的原生路径以展开 8.3 别名。配置根无法经 JDK 编码完整传递时,先以相对配置根加载 `.mvn`,再通过子环境恢复模型中的绝对根;此路径要求 Maven 3.9.2 或更新版本。子环境覆盖不改变父进程环境。 + +## 支持范围与演进原则 + +当前契约围绕 Maven jar、Gradle Java source set 和传统 Java main 建立。JPMS、Android、native image、部署和服务守护不在支持范围。Gradle application 声明 `mainModule` 时会明确拒绝。 + +工具不保存独立的类路径缓存;每次请求由构建工具重新裁决,下载缓存和增量构建由其自身维护。性能优化应围绕模型调用次数、配置成本和真实项目测量展开,同时保留依赖、资源和目标隔离的正确性。 + +验证通过参数与配置契约、真实 JDK 启动、Maven / Gradle 项目夹具和原生二进制验收组成。支持范围的扩大以可运行样本和对应验收为依据,构建出某个平台产物本身不等于验证过该平台。 diff --git a/docs/assessment-and-roadmap.md b/docs/assessment-and-roadmap.md deleted file mode 100644 index 5d2965d..0000000 --- a/docs/assessment-and-roadmap.md +++ /dev/null @@ -1,255 +0,0 @@ -# java-run 实现评估与开源技术路线 - -评估日期:2026-10-05。代码基线:`000c23bc2f11ad80224c179cd12e1757a5b5c09b`,包版本 `0.0.5`。目标按本次讨论确定为通用开源 CLI。 - -**当前项目已经完成特定项目启动工具的主流程,并具备自动发布能力,但尚未达到可对陌生 Maven 项目承诺兼容的阶段。** 最值得保留的是 TypeScript / Bun 工具链、独立 Java 进程、长类路径处理思路和已有发布流程。下一步的重点是运行正确性、Maven 语义和可重复验证。 - -建议继续投入,先以 Maven / Spring Boot 开发启动为明确范围。短期修复可复现问题;中期以启动模块为中心,让 Maven 负责模型与依赖裁决;随后用真实项目夹具和原生操作系统测试支撑支持范围。性能优化应在正确性基线建立后进行。 - - -> 本文评估重构前的固定代码基线。用户随后允许重新定位并加入 Gradle,本次实施改为框架中立的源码工作区运行器;最终契约见 [产品设计](product-design.md),结果见 [实施记录](implementation-plan.md)。以下源码链接均指向原始基线。 - -## 评估范围与证据 - -本次阅读了全部 8 个 TypeScript 源文件、README、配置、构建脚本、发布工作流及本地可见的 17 个提交。在隔离副本安装锁定依赖,执行类型检查、本机编译及针对性探针;查阅 Maven、Java、Spring Boot、Bun 和 Exec Maven Plugin 官方资料。 - -文中的“已复现”指本地实际执行,“源码确认”指实现直接体现的行为,“风险推断”指结合实现与官方语义得出的可能后果,“建议”指后续方案。 - -本次没有运行完整 Jeecg 或 Spring Boot 项目,没有执行真实多模块 Maven reactor 集成验证,没有测试 Windows / Linux 二进制,也没有检验线上 Release 产物。对 Maven 依赖缓存的探针使用模拟 `mvn`,只证明本工具的缓存控制行为。不能把这些结果扩大为完整兼容性验收。 - -## 实现现状 - -当前执行链如下;`main=` 只改变启动类,并不选择或收窄 Maven 模块。 - -```mermaid -flowchart TD - A[解析 CLI 参数] --> B{包含 start} - B -->|否| C[输出帮助] - B -->|是| D[可选执行 mvn compile] - D --> E[递归原始 POM 并收集全部叶子模块] - E --> F[逐模块解析依赖或读取缓存] - F --> G[合并全部模块输出目录和依赖] - G --> H[写 Manifest 并生成 cp.jar] - H --> I{包含 no-run} - I -->|是| J[结束] - I -->|否| K[启动独立 Java 进程] -``` - -| 能力 | 当前实现 | 面向通用工具的评价 | -| --- | --- | --- | -| 模块发现 | 递归静态 ``,提取叶子模块;单模块可作为根模块返回 | 基础流程成立,但不是 Maven 有效模型 | -| 依赖解析 | 调用 `dependency:build-classpath`,读取输出文件并去重 | 复用了 Maven,但逐模块解析后取并集丢失了目标应用语义 | -| 工作区输出 | 加入已有 `target/classes` 和 `target/test-classes`,按路径后缀排除模块 Jar | 固定目录和坐标拼接适用于有限结构 | -| 启动与配置 | 独立 Java 进程,支持主类、Spring profile、本地 profile 前缀和可选编译 | 主要入口齐备,仍绑定 Jeecg 默认主类;缺目标模块和通用参数转发 | -| 缓存 | 按模块绝对路径生成文件名,支持 `-r` | 有性能意识,失效条件不足 | -| 长类路径 | 使用 Manifest 和 `cp.jar` | 方向可保留,路径编码存在已复现问题 | -| 分发 | 本机编译,tag 触发五种 OS / 架构产物构建与 Release | 构建目标已有覆盖,目标系统运行尚无验证门禁 | - -源码入口:[主流程](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/cli.ts)、[模块发现](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/find-maven-modules.ts)、[类路径构建](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/classpath-builder.ts)、[发布流程](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/.github/workflows/release.yaml)。 - -以下语义需要在文档中明确:`active=` 是 Spring profile,不是 Maven `-P`;`no-run` 仍解析依赖并写缓存、生成 Jar,不是无副作用的 dry-run;`-c` 只执行 `mvn compile`,不能据此保证后续逐模块解析能找到未安装的兄弟模块产物。 - -## 需要优先处理的实现问题 - -### Java 正常退出后被 CLI 判为失败 - -**已复现,稳定版本的阻断项。** [exec.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/exec.ts) 第 16 行在 `stdout` 不是 Buffer 时直接调用 `.trim()`;[cli.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/cli.ts) 第 80 行使用 `stdio: 'inherit'`,此时成功结束的子进程没有可捕获的 stdout。 - -本地调用 `executeCommand('java', ['-version'], { stdio: 'inherit' })` 后,Java 正常退出,CLI 却因 `null.trim()` 抛错并返回 1。相同执行路径用于应用运行,因此应用正常关闭后也可能被报告为失败。这不表示 Java 一定无法启动。 - -建议把“捕获输出”和“继承终端”作为明确的执行模式,返回结构化结果,包含退出码、信号和可选输出。进程内部工具函数不直接 `process.exit`,由 CLI 顶层决定退出行为,并保留子进程失败原因。 - -### Manifest 路径编码会导致类加载失败 - -**已复现,稳定版本的阻断项。** [cli.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/cli.ts) 第 115 行直接将路径拼为 `file://...`,没有 URL 编码。使用同样的 Manifest 生成逻辑、真实 `javac` / `jar` / `java` 验证,普通目录可运行;目录含空格或 `#` 时出现 `ClassNotFoundException`。 - -建议使用标准路径到 URL 的转换,保持目录 URL 的尾部斜杠,并验证最终 Jar。Manifest 的行长限制按 UTF-8 字节计算;当前按字符串字符切行值得修正,但 `jar` 可能重新折行,本次中文目录样例成功,不能据源码直接断言中文必然失败。Windows 盘符及 UNC 路径仍需原生测试。[JAR 规范](https://docs.oracle.com/en/java/javase/21/docs/specs/jar/jar.html)、[pathToFileURL](https://nodejs.org/api/url.html#urlpathtofileurlpath-options) - -### 测试类路径开关没有生效 - -**测试目录问题已复现,依赖作用域问题由源码和官方文档确认。** [classpath-builder.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/classpath-builder.ts) 第 21、31 行无条件枚举 `classes` 与 `test-classes`,没有读取 `includeTests`;调用方传入 `false` 仍会包含测试输出。 - -第 79 行的 Maven 调用没有指定依赖作用域。`dependency:build-classpath` 默认包含所有依赖;普通运行时通常应使用 `-DincludeScope=runtime`。第 82 行的注释参数 `-Dmdep.includeScope=compile,runtime` 不能直接取消注释作为修复。测试输出目录和测试依赖需要同时控制,具体 Boot 开发启动语义则应遵循选定后端的契约。[插件参数](https://maven.apache.org/plugins/maven-dependency-plugin/build-classpath-mojo.html) - -### 缓存可能持续返回旧依赖 - -**已复现。** [classpath-builder.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/classpath-builder.ts) 第 69 行只检查缓存文件及模块 `target` 是否存在。探针将 POM 中依赖从 v1 改为 v2,第二次仍读取 v1,模拟 Maven 的调用次数没有增加;强制刷新才得到 v2。 - -建议第一步采用保守失效或默认重新解析,先保证结果正确。恢复缓存时,至少区分目标模块、相关 POM / 父模型、Maven profiles、属性、scope、工具链与缓存格式版本,检查引用文件存在性。外部父模型、SNAPSHOT 和 settings 变化需要额外策略;不能承诺一个根 POM 哈希解决全部问题。缓存写入应原子化,失败结果不得覆盖有效记录。 - -### 原始 XML 与 Maven 有效模型存在差距 - -**多项边界已复现。** [find-maven-modules.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/find-maven-modules.ts) 第 29 行只读顶层 modules,第 32 行直接读版本文本,第 52 行固定追加 `pom.xml`。探针结果包括: - -- 默认激活 profile 内的模块未被发现,聚合根被当成叶子 -- `${revision}` 没有展开,随后参与模块 Jar 排除时匹配失败 -- module 指向具体 POM 文件时无法识别 -- 缺失子 POM 被跳过,畸形 XML 被记录后仍返回其余模块 - -最后一项表示模块发现函数无法明确报告不完整结果;真实 CLI 后续 Maven 步骤可能报错,不能推断一定会带着错误模块集启动。 - -建议将原始 XML 解析限于快速发现和提示,把 profile、继承、属性插值、依赖管理交给 Maven。`help:effective-pom` 可帮助获取有效模型,但它不等于解析后的依赖图或 classpath。[Model Builder](https://maven.apache.org/ref/3.9.11/maven-model-builder/)、[effective-pom](https://maven.apache.org/plugins/maven-help-plugin/effective-pom-mojo.html)、[POM 聚合定义](https://maven.apache.org/pom.html#aggregation-or-multi-module) - -### 所有叶子模块的并集不能代表一个应用 - -**行为由源码确认,冲突后果属于风险推断。** [cli.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/cli.ts) 第 97 行获取全部叶子模块,[classpath-builder.ts](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/classpath-builder.ts) 第 29、43、108 行合并所有输出与依赖,只按路径去重。 - -例如仓库包含 `app-a` 与 `app-b`,分别依赖某库的 v1 和 v2,启动 `app-a` 时也可能带入 `app-b` 的目录及 v2。两个不同 Jar 路径不会被 Set 去掉;重复类和配置资源的加载顺序可能受到无关模块影响。本次未运行这一完整框架冲突场景。 - -Maven 的版本裁决以当前项目的依赖图为依据。因此,通用化应先确定启动模块,再取得该模块经 Maven 裁决的依赖闭包和顺序;聚合关系不能替代依赖关系。[Maven 依赖机制](https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html) - -### 参数和诊断契约需要收紧 - -**参数与空依赖问题已复现,其余由源码确认。** - -| 问题 | 证据 | 建议 | -| --- | --- | --- | -| 键名前缀误匹配 | `mainframe=example.App` 被当成 main;`activeX=prod` 被当成 active | 精确匹配参数名,拒绝未知参数,说明重复参数策略 | -| 值被截断 | `main=a=b` 只保留 b;解析器的 separator 参数未使用 | 只按第一个分隔符切分,再验证取值 | -| 空依赖变成 cwd | 空字符串经过 `resolve('')`,意外进入 classpath | 去掉空记录并验证路径类别 | -| 错误输出丢失 | executor 主要输出 `result.error`;stderr 被注释,依赖失败主要拼 stdout | 保留命令阶段、cwd、退出码、stderr,并区分找不到程序与命令执行失败 | -| 导入即写缓存目录 | classpath 模块顶层执行 `mkdirSync` | 将文件写入移动到显式执行阶段,让帮助和参数验证可以独立运行 | - -依据:[参数解析](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/parse-argvs.ts) 第 17 至 23 行、[类路径构建](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/classpath-builder.ts) 第 16、89、108 行、[执行器](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/src/exec.ts) 第 9 至 16 行。 - -## 工程能力评价 - -本评价针对仓库展示出的工程证据。它不能单独用于判断作者的职级、其他项目经验或能力上限,也不以是否使用大型框架作为成熟度标准。 - -| 维度 | 已展示的能力 | 当前需要补足的证据 | -| --- | --- | --- | -| 问题识别与实现 | 从脱离 IDE 的真实需求出发,实现完整启动链 | 独立项目的适用性验证和明确支持范围 | -| 工具集成 | 能组织 Maven、JDK、文件系统、编码和命令行流程 | 外部工具的失败契约与复杂 Maven 语义 | -| 代码组织 | 执行、参数、模块发现和类路径已拆分;启用 strict,本次类型检查通过 | 显式 cwd / 配置边界、少量纯函数、去除全局状态及导入副作用 | -| 交付自动化 | bumpp、版本 tag、自动生成说明、五个二进制目标 | 固定工具链、正确绑定触发 tag、运行验证后发布 | -| 质量保障 | 历史中持续修复编码、路径和错误提示 | 仓库没有测试与 fixture;修复尚未固化为回归保护 | -| 跨平台意识 | 分隔符适配、输出编码处理、多平台构建 | Windows / Linux / macOS 原生启动、退出和路径测试 | -| 开源使用体验 | 有帮助示例,README 坦诚说明 Jeecg 来源与待验证项 | 安装说明、兼容表、故障排查、许可证、贡献入口 | - -**总体判断:已经证明场景驱动的实现与交付能力;通用工具所需的语义建模、边界验证和维护保障尚未形成完整证据。** 发布自动化领先于质量验证,是本项目当前最明显的不平衡。 - -当前源码约 493 行,保持简单是优点。改进不需要 DI 框架、插件市场或多层服务架构;明确运行配置、启动计划和进程执行三个边界即可显著降低维护成本。 - -发布流程还存在具体改进点:[release.yaml](https://github.com/Vanisper/java-run/blob/000c23bc2f11ad80224c179cd12e1757a5b5c09b/.github/workflows/release.yaml) 第 24 行取全仓库最高版本 tag,而非本次触发 tag,重跑旧 tag 可能产生错误版本名或日志范围;第 76 行附近把提交文本直接插入 shell,应改为可靠的数据传递或标准发布说明机制。现有工作流只在 tag 触发,未包含 PR 类型检查、测试或目标系统运行检查。 - -已提交 `bun.lockb` 是可复现性的基础,本次 frozen 安装成功。仍应固定 Bun 版本,并让 CI 显式执行锁文件安装、类型检查和测试;仅保留 strict 配置无法覆盖进程输出等运行时契约。 - -## 技术路线选择 - -### 保留技术栈并缩小首版支持承诺 - -建议保留 TypeScript / Bun。官方已经支持独立可执行文件和跨平台编译,当前也实际完成了本机构建,没有证据表明换成 Go、Rust 或 Java 能直接解决主要问题。[Bun 可执行文件文档](https://bun.com/docs/bundler/executables) - -首个通用 alpha 建议优先支持 **Maven 3 的 Spring Boot 开发启动**,以显式目标模块为入口,再扩展普通 Java main。建议先将 JDK 17 / 21 纳入验证矩阵;这些是拟定范围,不能标成已经支持。Maven 4、旧 JDK、Gradle、JPMS、WAR 容器与复杂 attached artifacts 逐项按需求验证后加入。 - -产品价值应落在更简单的启动配置、Wrapper / 工具链发现、错误解释、可检查的运行计划和可靠的跨平台分发。自有快速启动模式需要用实际数据证明收益;不应把绕过 Maven 本身当成目标。 - -### 先建立 Maven 托管执行的正确性基线 - -| 方案 | 优点 | 主要成本 | 建议 | -| --- | --- | --- | --- | -| 持续扩展当前 XML 解析器 | 延续现有代码与缓存 | Maven 特例会持续增加,依赖正确性难保证 | 不作为通用化主路线 | -| CLI 编排 `spring-boot:run` | 尊重 Boot 项目插件配置,已有 JVM / 应用参数和 profile 契约 | 处理目标模块、构建准备、版本差异与参数映射 | 第一阶段优先验证的正式后端 | -| CLI 编排 `exec:exec` | 普通 Java main、独立 JVM、Maven 生成 classpath | 插件版本、参数引用、长类路径和 reactor 产物准备 | 普通 Java 支持的后续候选 | -| Maven 解析加自有 launcher | 可缓存解析结果、减少重复 Maven 启动、控制工作区输出 | 需要证明依赖顺序、缓存、进程和路径行为一致 | 保留为受限模式,通过对照验收后扩大范围 | -| 新建 Maven 桥接插件 | 可直接取得 MavenProject / session 等信息 | 增加 JVM 组件和发布维护面 | 仅在现有插件无法满足已验证需求时采用 | - -`exec:java` 在 Maven JVM 内执行,其线程和 JVM 参数行为不能简单等同于独立 Java 进程,因此不建议作为长期运行服务的默认后端。`exec:exec` 有 `%classpath` 和长类路径支持,说明当前 cp.jar 思路有成熟先例。[Exec 用法](https://www.mojohaus.org/exec-maven-plugin/usage.html)、[Exec Java 示例](https://www.mojohaus.org/exec-maven-plugin/examples/example-exec-for-java-programs.html)、[Exec 参数](https://www.mojohaus.org/exec-maven-plugin/exec-mojo.html) - -首轮只实现一个正式后端即可。Boot run 使用独立进程并提供应用参数、JVM 参数和测试 classpath 配置,应优先复用这些契约。[Spring Boot run](https://docs.spring.io/spring-boot/maven-plugin/run.html) - -多模块构建与启动应明确分开:先准备目标及其依赖,再只运行目标。不能直接用 `-pl app -am spring-boot:run` 假定只会运行 app;Spring 官方多模块示例先 install,再对 application 模块执行 run。若暂时要求上游模块已安装,应明确说明;若提供准备命令,应把 install 作为显式选项。避免 install 的同会话 reactor 方案需要单独验证。[Spring 多模块指南](https://spring.io/guides/gs/multi-module/)、[Maven reactor](https://maven.apache.org/guides/mini/guide-multiple-modules.html) - -### 建立小而明确的内部边界 - -建议的流程是: - -```mermaid -flowchart LR - A[CLI 输入] --> B[RunConfig] - B --> C[项目和工具链解析] - C --> D[Maven 托管模型与执行语义] - D --> E[LaunchPlan] - E --> F[ProcessRunner] -``` - -- `RunConfig`:明确 cwd、目标模块、运行后端、Spring profiles、Maven profiles、JVM 参数、应用参数及构建策略 -- `LaunchPlan`:列出需要执行的步骤、可执行文件、参数数组、工作目录及解析来源,支持预览和问题复现 -- `ProcessRunner`:统一处理输出模式、失败结果、退出码与信号;平台差异集中在此边界 - -Maven 托管后端可以在执行时解析依赖;计划中应明确这一点。未来自有 launcher 的计划则需要保存经 Maven 裁决的有序 classpath。不能为了统一类型,假装两者都已完成同样的解析。 - -优先使用项目 Maven Wrapper,再回退到显式配置或 PATH 中的 Maven。Wrapper 存在但执行失败时,应报告原因,避免悄悄换版本。Windows 的 `mvnw.cmd` 调用和带空格参数必须在 Windows 上测试。[Maven Wrapper](https://maven.apache.org/tools/wrapper/) - -进程长期运行时宜采用异步 spawn,显式验证 Ctrl-C、终止信号和退出码传播。保留参数数组,避免把用户参数拼接为通用 shell 字符串。启动计划和调试日志需要避免输出敏感属性值。 - -## 分阶段实施计划 - -以下是规划估算,按一名熟悉 TypeScript / Maven 的开发者、可使用三平台 CI、第一版仅完成一个正式后端计算。外部私服、复杂插件和旧 JDK 适配可能增加投入,工期不是交付承诺。 - -| 阶段 | 预计投入 | 主要交付 | 退出条件 | -| --- | --- | --- | --- | -| A 修复并建立回归保护 | 2 至 4 人日 | 执行器、URL 路径、参数、测试目录及 scope、保守缓存策略;typecheck / test / PR 校验 | 本阶段缺陷有回归测试,正常与失败退出正确,Jeecg 场景补实跑;其余模型边界登记为限制,交阶段 B 验收 | -| B 建立通用运行基线 | 5 至 10 人日 | 目标模块、RunConfig / LaunchPlan、Wrapper、Maven 与 Spring profile 分离、一个 Maven 托管后端 | 单模块和 app→lib 正常启动,无关 app 不参与;干净工作区的构建前提明确可复现 | -| C 完成开源 alpha 分发 | 4 至 7 人日 | 原生三平台 smoke、固定工具链、修发布 tag、安装与排障文档、支持表、许可证、产物校验 | 对外声明的每个 OS / 架构有对应证据;无 Bun 环境可运行产物 | -| D 按数据优化快速模式 | 另计 5 至 10 人日 | Maven 权威解析结果缓存、自有 launcher 对照验证、性能基准 | 依赖与资源选择符合约定,缓存失效正确,温启动收益可测量且不损害正确性 | - -前三阶段约 11 至 21 人日,可按 2 至 4 个工程周加必要缓冲规划。阶段 D 不进入首个 alpha 的必要范围。版本号应表达真实支持范围,不以新增参数数量作为发布标准。 - -建议先拆出三个可审查的工作包: - -1. **执行与路径正确性**:修复 inherited stdio、退出结果、特殊路径及参数解析;提交对应回归用例与 CI 校验。 -2. **启动目标与 Maven 基线**:明确支持矩阵,选择启动模块,验证 Boot 后端和多模块准备流程,输出可检查的运行计划。 -3. **跨平台 alpha 发布**:补原生运行矩阵、固定构建环境、修正 tag 来源,完成 README、安装示例、排障和许可证。 - -## 验收矩阵 - -测试应围绕用户可观察的契约,不追求镜像实现或单纯覆盖率数字。最小集如下: - -| 场景 | 必须观察到的结果 | -| --- | --- | -| 单模块 Boot 与原有 Jeecg | 明确主类或插件配置后能启动;原场景无回归 | -| app 依赖 lib | 使用预期版本和最新构建结果;未准备依赖时给明确提示 | -| 同仓库两个独立 app | 启动一个不会加入另一个的类、配置和冲突版本 | -| profile 与属性坐标 | Maven profile、Spring profile 分别生效;`${revision}` 由 Maven 正确处理 | -| 测试隔离 | 默认行为符合后端契约;开启测试模式时同时处理测试输出和依赖 | -| 空格、中文、`#`、`%` 路径 | 主类、依赖和资源可实际加载;Windows 盘符另行覆盖 | -| 正常退出、非零退出、Ctrl-C | CLI 退出结果符合约定;目标支持平台无遗留 Java 子进程 | -| 缺 Maven / Java、错误 POM | 明确失败阶段与可行动提示,不返回不完整的成功结果 | -| POM / 父模型 / profile 变化 | 重新解析或明确使缓存失效;结果可解释 | -| 无 Bun 环境的发布二进制 | 可执行帮助及真实启动样例 | - -自有 launcher 正式启用前,再加入自定义 outputDirectory、classifier、长 classpath、空依赖、缓存并发写入与 SNAPSHOT 策略测试。当前五种二进制构建目标不能直接转成五种已验证支持承诺;无法提供原生验证的目标应明确标注实验状态。 - -性能指标建议分别记录冷解析、温启动、缓存失效后的启动耗时和 Maven 子进程次数,并与选定的官方运行方式对照。本次未做性能基准,现阶段不设没有依据的提速百分比。 - -## 本次验证记录 - -原始代码与依赖安装、编译产物均放在临时副本,项目业务源码未改动。主验证环境为 macOS arm64、Bun 1.4.2、JDK 21.0.12.1;锁定安装得到 TypeScript 5.8.2 和 `@types/bun` 1.2.5。Maven 3.9.16 已检测可用,但本次未完成真实 reactor 集成测试。 - -| 检查 | 结果 | 证明范围 | -| --- | --- | --- | -| `bun install --frozen-lockfile --ignore-scripts` | 通过,安装 53 个包 | 当前环境可按已有锁文件安装 | -| `./node_modules/.bin/tsc --noEmit` | 通过 | 当前静态类型检查通过,不代表运行时契约正确 | -| `bun run compile` | 通过,打包 133 个模块 | 本机可生成独立二进制 | -| `./dist/java-run --help` | 退出 0 | 本机产物的帮助入口可运行 | -| inherited stdio 执行 `java -version` | Java 成功后出现 `null.trim()`,CLI 退出 1 | 已复现执行器结果处理缺陷 | -| 按现有逻辑生成 cp.jar 并运行 Hello | 普通路径、中文路径成功;空格、`#` 路径失败 | 已复现 URL 编码影响类加载 | -| 模块与参数探针 | profile、属性版本、自定义 POM、参数前缀等边界失败 | 已证明工具自身解析行为 | -| 模拟 Maven 的类路径与缓存探针 | `includeTests:false` 仍含测试目录;POM 改动后仍用旧缓存 | 已证明配置与失效逻辑缺陷,不代替真实 Maven 集成 | - -执行器复现命令,在安装依赖后的仓库副本运行: - -```sh -bun -e 'import { executeCommand } from "./src/exec.ts"; executeCommand("java", ["-version"], { stdio: "inherit" });' -``` - -参数复现命令: - -```sh -bun -e 'import p from "./src/parse-argvs.ts"; p.set(["mainframe=example.App", "active=prod=blue"]); console.log(p.key("main"), p.key("active"));' -``` - -当前输出为 `example.App blue`。前者错误接受了非 main 参数,后者丢失了值的前段。 - -本报告是后续实现与验收的基线。随着阶段 A 至 C 完成,应更新已复现问题的状态和兼容矩阵,而不是继续沿用本次评估时的结论。 diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md deleted file mode 100644 index a21f13a..0000000 --- a/docs/implementation-plan.md +++ /dev/null @@ -1,72 +0,0 @@ -# java-run 重构实施计划 - -本轮在 `feat/open-source-cli` 分支完成框架中立的 Java 源码工作区运行器,定位与契约以 [产品设计](product-design.md) 为准。原评估报告保留为旧实现的证据基线,其中优先特殊化 Spring Boot 的路线已经被本设计替代。 - -## 工作包与提交边界 - -1. 确定产品定位、自动准备行为和 Maven / Gradle 的职责 -2. 实现严格的配置解析、统一进程执行、主类选择和 Java 启动 -3. 完成 Maven 与 Gradle 两种构建工具适配,让工具自身裁决依赖和输出目录 -4. 添加真实项目夹具、二进制 smoke、原生系统 CI 和可靠 Release -5. 完成用户文档与实际验证记录,形成可审查的分支提交 - -移除旧 `start`、`-c`、`active=`、`local`、`no-run` 等参数,避免继续维护源于旧脚本的操作模型。默认运行自动准备源码;`--build=none` 是明确的高级覆盖,`plan` 则仅作静态预览。 - -本轮不生成版本 tag,分支内按清晰意图提交;版本发布由检查通过后的 tag 工作流完成。保留原主分支作为重构前的基线。 - -## 验收要求 - -- Maven / Gradle 共用运行契约,Spring 等框架通过普通参数配置 -- 正常与非零退出可追溯,POSIX 信号不遗留应用子进程 -- 帮助、版本和 plan 不触发构建工具或创建项目缓存 -- 多模块只解析选定应用;依赖准备由对应构建工具执行 -- 唯一 main 可以发现,多个主类明确要求选择 -- 测试输出、测试依赖、特殊路径、依赖与资源变更均有验证 -- 原生操作系统 CI 运行本机二进制;本地未运行的系统不宣称已经验证 -- 用户 java-template 在隔离副本补充验证,不修改原仓库 - -## 实施结果 - -2026-10-05 已完成运行契约与核心重构、两种构建适配器、配置与交互、真实项目夹具、原生 CI 和发布门禁、README 及迁移说明。源码按职责组织为 `cli`、`build-tools`、`core`、`process`,根目录保留 CLI 入口。旧入口、静态叶子模块并集和独立 classpath 缓存已经移除。 - -本轮本地环境为 macOS ARM64、Bun 1.4.2、Maven 3.9.16、Corretto JDK 21。Windows 专用用例在本地跳过,不能把已配置 CI 当成远端已经通过。 - -| 检查 | 实际结果 | -| --- | --- | -| 锁文件安装与严格类型检查 | 通过 | -| 核心回归 | 85 项通过、2 项 Windows 专用测试跳过,无失败 | -| Gradle 适配 | 8.14 / Java 21 与 9.7 / Java 26 的真实项目验证通过;buildSrc 与 included build 回归均通过 | -| 独立二进制完整 smoke | 同一次 20 项全部通过,82.2 秒;使用 JDK 21、Gradle 8.14 和独立测试缓存 | -| 目录整理后的二进制复验 | 重新类型检查、编译,7 项 quick smoke 全部通过 | -| 原生终端交互 | Maven 与 Gradle 实际列出模型候选,选择目标后正常运行;Maven 目标存在多个 main 时,选择另一入口并成功运行 | -| 用户 java-template | 隔离副本成功启动 AdminApplication,SIGTERM 返回 143,并执行应用关闭钩子 | - -完整 smoke 覆盖 Maven 单项目和 reactor、普通 main 与 Boot main、测试作用域隔离、资源与依赖变更、配置默认值与 CLI 合并、非零退出和 Gradle 项目依赖。候选发现验证了不读取可能触发任务依赖的主类 Provider,也不编译无关应用。主类与 Manifest 使用真实 JDK 验证,包括空依赖、特殊路径和 Unicode 标识符。 - -`java-template` 固定在提交 `f7e463d85e3e527fae974130fb12939523c40ac4` 的归档副本,使用其 Gradle 8.14 Wrapper / Java 21 toolchain,目标为 `:apps:admin-server`,入口为 `cn.xxb.admin.AdminApplication`。验证用随机 Web 端口、H2 内存库和 create-drop,避免读写原仓库的数据文件;启动耗时约 214 秒,应用自身报告启动约 3.4 秒,其余主要为首次构建准备。应用启动后发送 SIGTERM,确认 JVM、Wrapper 与一次性 Gradle daemon 均已结束,原仓库保持干净。本次仅确认应用启动及关闭,不代替业务接口验收。 - -独立审查额外确认了无依赖 Maven 项目可以正常运行,并发现原主类正则拒绝组合字符标识符的问题;已统一校验器并用 javac / java 回归。实际模板验证发现 Gradle init script 会进入 buildSrc,已用主构建规范路径限定修复,并用约定插件及复合构建验证。 - -## 分支与发布边界 - -全部工作留在 `feat/open-source-cli`,`master` 保留原代码基线。提交分别记录评估、产品定位、核心重构、验收与发布流程、用户文档;本次没有推送、合并主分支、创建 tag 或发布版本。 - -核心实现提交为 `95f1b5f`,验收与 CI 提交为 `3005f8c`。评估与初始定位分别记录在 `4ba5822`、`40e87cf`,最终文档以本记录及 README 为准。 - -包版本仍为旧 `0.0.5`,README 明确当前契约尚未发布。下一步正式发布前需确定开源许可证与版本号,实际运行三种原生系统的 CI,并据结果确认支持矩阵。性能、命名运行配置和新增运行模式按 [后续技术路线](product-design.md#后续技术路线) 推进,不以未运行的计划代替验证。 - -## 首轮 CI 反馈与修复 - -用户推送后的 [首轮 Check](https://github.com/Vanisper/java-run/actions/runs/37256647164) 对应 `2ad210c`。Linux / JDK 17、21 与 macOS / JDK 21 完成全部验收;macOS / JDK 17 的 Unicode 测试及两组 Windows 回归失败。 - -macOS 的问题发生在测试源文件名:JDK 17 将文件路径转为 NFC,而 public 类使用 NFD。改用 ASCII 源文件名承载原有 NFD 类,保留主类发现和实际启动断言,JDK 17、21 都通过。 - -Windows 原生 JDK 将系统代码页无法表示的参数替换为 `?`。修复包括 Gradle 通过临时脚本中的 UTF-8 请求读取目录和模块、使用 ASCII 根代理任务,Java / Jar 使用相对受控路径,以及执行前按真实 JDK 编码检查用户参数。中文 classpath 目录继续保留,无法表示的原始参数以明确错误结束。Maven 有效模型的输出路径统一归一化,依赖元数据输出固定 UTF-8。另增加实际 Windows 批处理进程树清理用例。 - -修复后本地全量回归 91 项通过、3 项 Windows 专用测试跳过;新增 Maven 编码保护后专项 17 项通过。Gradle 8.14 在 JDK 17、21 下各 21 项验证通过,完整独立二进制 smoke 20 项全部通过。后续原生结果以 [分支 Check 运行](https://github.com/Vanisper/java-run/actions/workflows/check.yaml?query=branch%3Afeat%2Fopen-source-cli) 为准,不将本机结果替代 Windows 验收。 - -第二轮 Linux、macOS 的 JDK 17 / 21 均通过。Windows 暴露了目录短名与长名的等价性,以及 JDK 17 控制台丢失中文诊断的问题。目录断言改为验证实际文件系统身份;Gradle 的受控错误通过 ASCII 错误码传递,由 CLI 补充中文说明,保留构建工具原始输出和退出码,也不改变项目默认字符集。 - -复核 Maven 启动脚本时发现,它还会自行向 JVM 注入绝对项目根目录。Windows 的配置根无法由原生编码表示时,子进程使用相对 `MAVEN_BASEDIR` 读取 `.mvn`,再通过 Maven 3.9.2 引入的环境属性插值恢复模型中的绝对根目录;保留显式配置及最近 `.mvn` 祖先的含义,不修改父进程环境。可表示的路径保持绝对形式。完整二进制验收新增 `.mvn/maven.config` 属性经过资源过滤后实际生效的检查,覆盖中文项目目录和祖先目录,并验证模型根目录的绝对路径及配置位置;通常为 21 项,原生命令行编码无法表示 Unicode 的 Windows 环境为 23 项。 - -Windows 二进制验收进一步发现,Maven 对相对模块目录进行路径比较时,CI 临时目录的 8.3 短名无法匹配有效模型中的长名。执行前使用原生 `realpath` 规范化工作目录及已有配置根,保留 `--module app` 的目录选择语义。六个平台的结果仍由上述分支 Check 验证。 diff --git a/docs/product-design.md b/docs/product-design.md deleted file mode 100644 index bf32d06..0000000 --- a/docs/product-design.md +++ /dev/null @@ -1,66 +0,0 @@ -# java-run 产品定位与运行契约 - -java-run 是框架中立的 Java 源码工作区运行器。它为 Maven 与 Gradle 项目提供统一的开发启动入口,并将构建模型、依赖裁决和增量编译保留在构建工具中。本轮以这个定位完成重构,替代原评估报告中优先特殊化 Spring Boot 的实施建议。 - -## 用户需要掌握的内容 - -项目中执行 `java-run` 即启动默认应用。多模块项目在 `.java-run.json` 保存启动目标和参数,随后无需重复输入。终端中缺少模块或存在多个主类时,通过序号菜单补齐目标;非交互环境明确要求配置。菜单只选择模块或主类,不逐项询问运行参数,也不自动写入配置。 - -构建模块不等于可执行应用。Maven 候选为有效 reactor 中的 jar 项目,Gradle 候选为应用 Java 插件的项目;库也可能出现在列表中,因此标注入口尚未确认。主类在选定项目准备之后才最终确定。发现候选会执行构建工具配置,Gradle 的 buildSrc / included builds 可能准备构建逻辑,但不主动编译候选应用或解析其运行依赖。 - -命令只分为 `run`、`plan`、`help`、`version`。`plan` 读取本地项目配置并预览构建步骤,不执行构建工具,也不生成项目缓存;它明确表示主类和类路径仍待构建工具解析。 - -常用选项为 `--cwd`、`--module`、`--main`、`--jvm-arg`、`--arg`。高级用户可以选择 `--tool=maven|gradle`、`--build=none`、`--build-arg`、`--include-tests`、`--java` 和 `--build-command`。没有专用 Spring profile 参数,Spring 参数通过正常 JVM 或应用参数传递。 - -## 自动准备的含义 - -Maven 单项目自动编译;选定 reactor 模块时,自动 install 该模块和上游依赖,再只解析选定模块的运行类路径。这里的 install 是 Maven 的本地仓库操作,计划和运行提示都会说明,不会发布到远程仓库。当前选择这个保守方案,是为了避免单独解析模块时读取缺失或陈旧的兄弟模块 Jar。 - -Gradle 将 classes 与项目依赖交给任务图,不先单独编译每个模块,也不做 Maven 式的 install。通过临时 init script 获取选定项目的主 source set 和运行依赖;不改用户的构建脚本。初始化逻辑限定在请求的主构建,避免将目标路径套用到 buildSrc 或 included builds;复合构建的正常依赖任务仍由 Gradle 执行。`--build=none` 在两种工具中都表示不主动构建应用源码,缺失产物直接报错。 - -不会单独执行测试;`--include-tests` 只准备并加入测试输出和测试依赖。默认使用正常运行时依赖。重新解析依赖由构建工具自身缓存加速,java-run 不维护独立的失效不完整的 classpath 缓存。 - -## 框架与技术栈 - -Spring Boot、普通 Java main 和其他基于 classpath 的应用共享启动机制。Gradle 已有 application 的 mainClass 和默认 JVM 参数可以作为元数据;框架专用 run 任务的额外逻辑不自动模拟。如果用户依赖 bootRun 或自定义 JavaExec 的特殊环境、agents、附加资源,应继续使用该原生任务或显式配置,不宣称通用 runner 与所有任务等价。 - -目前保留 TypeScript / Bun,原因是 CLI、跨平台二进制和进程编排已有可用基础。关键改动在产品契约与构建工具适配,语言切换不能替代这项工作。JPMS、Android、native image、部署与服务守护不属于首版的支持范围。 - -## 内部模块与验证 - -CLI 解析配置,构建工具模块生成经过裁决的 PreparedProject,主类模块选择入口,启动模块生成 URL 编码正确的 classpath Jar,执行模块负责退出和信号。Maven 与 Gradle 是真实存在的两种适配器,框架不进入核心配置类型。 - -源码按职责集中,入口与实现目录如下: - -```text -src/ -├── cli.ts # CLI 请求编排与进程入口 -├── cli/ # 参数、帮助、交互选择 -├── build-tools/ # 工具检测、Maven 与 Gradle 适配 -├── core/ # 运行契约、主类、类路径、Java 启动 -└── process/ # 外部命令、退出状态与信号处理 -``` - -```mermaid -flowchart LR - A[CLI 与项目配置] --> B[选择构建工具及目标] - B --> C[Maven 有效模型与依赖] - B --> D[Gradle source set 与任务图] - C --> E[PreparedProject] - D --> E - E --> F[主类选择与 Manifest] - F --> G[独立 Java 进程] -``` - -验证以同一契约覆盖两种工具:单项目、多模块依赖、无关项目隔离、参数边界、测试隔离、POM 或 Gradle 构建变更、特殊路径、正常和失败退出。用户提供的 java-template 作为 Gradle 实例补充验证,不修改其源仓库。 - -## 后续技术路线 - -| 阶段 | 交付目标 | 进入下一阶段的依据 | -| --- | --- | --- | -| 本轮重构 | 两种构建工具、统一运行契约、交互补选、真实夹具、原生 CI 与发布门禁 | 本地验收与实现记录可复现 | -| 首个通用版本 | 确定许可证和版本号,完成三种原生系统的 CI 验证,确认 Wrapper 与 JDK 支持矩阵 | 原生 CI 实际通过,兼容边界和迁移说明可公开 | -| 使用体验与性能 | 基于真实使用反馈决定命名运行配置、诊断命令,以及减少 Maven 多次启动和 Gradle 配置开销 | 有具体重复操作或可测量耗时,不恢复失效不完整的 classpath 缓存 | -| 扩展运行模式 | 评估 JPMS、现代 main、原生任务集成等独立需求 | 存在可验证的项目样本和明确契约,再扩展适配器 | - -TypeScript / Bun 继续承担 CLI 和进程编排,Gradle 脚本只承担构建模型读取。暂不为框架建立插件系统;当第三种真实运行模式出现时,再根据数据边界设计扩展接口。 diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..e0aec0f --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,56 @@ +# java-run 工程能力与技术路线 + +java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供可复用的启动入口。当前已具备单目标运行、自动准备、项目配置、交互补选和独立 JVM 启动能力;架构契约见 [架构文档](architecture.md),贡献与验证流程见 [贡献指南](../CONTRIBUTING.md)。 + +## 当前能力评估 + +评估依据当前运行契约、实现边界和验证证据。 + +| 能力 | 实现依据 | 工程评价与限制 | +| --- | --- | --- | +| 产品与配置 | `RunConfig` 表达构建工具、单目标、构建策略和三类参数;严格校验 `.java-run.json` | 通用启动模型清楚,框架通过正常参数配置;尚无命名运行配置 | +| 构建语义 | Maven 有效模型与依赖解析、Gradle source set 与任务图 | 构建工具保有依赖裁决权;Maven 多模块准备写入本地仓库,Gradle 配置需要执行项目构建逻辑 | +| 模块设计 | CLI、两种构建适配器、核心启动和进程处理按职责组织 | Maven / Gradle 差异集中,统一准备结果可复用;扩展接口应由新增的真实需求驱动 | +| 启动正确性 | 目标类路径隔离、测试作用域开关、class 文件入口扫描、Manifest URL 编码 | 主类和类路径有直接执行验证;自动发现仅支持传统 main,运行依赖于现有 classpath 契约 | +| 进程管理 | 异步执行、结构化退出状态、子环境覆盖、平台专用进程树清理 | 失败能够定位到阶段,退出码保留;Windows 强制终止不能保证应用关闭钩子执行 | +| 验证 | Bun 回归、真实 JDK、Maven / Gradle 夹具、编译后二进制 smoke | 覆盖正常启动、依赖与资源变化、参数、配置、目标隔离和平台边界;夹具结果不代表所有第三方构建插件兼容 | +| 分发 | 固定 Bun、锁文件安装、原生检查与受检 tag 发布工作流 | 已建立发布门禁和校验和;正式版本仍需许可证、版本及产物验收 | + +## 验证与支持依据 + +[Check 工作流](../.github/workflows/check.yaml) 在 Ubuntu、macOS、Windows 上分别使用 JDK 17 和 21,执行锁文件安装、类型检查、回归测试、本机二进制编译以及完整 Maven / Gradle 验收。Gradle 在 CI 固定为 8.14;Maven 使用运行环境中的命令,不应把 CI 的单一安装版本扩大为整个 Maven 版本系列的兼容承诺。 + +回归围绕以下可观察结果维护: + +- 单项目与 app 依赖 lib 的多模块项目能够启动指定目标 +- 无关应用不进入目标运行类路径,默认不加载测试输出和测试依赖 +- POM、依赖、profile、资源和配置变化影响下一次运行 +- 主类发现、Manifest 和实际 Java 启动覆盖特殊路径与 Unicode 标识符 +- 应用参数、JVM 参数、构建参数和子环境保留各自边界 +- 正常退出、失败、终止和命令缺失产生明确结果,并清理受控进程树 +- 静态计划、帮助和版本遵守各自的副作用契约 +- Windows 编码、Maven 配置根和批处理调用有对应的原生验证 + +[Release 工作流](../.github/workflows/release.yaml) 先执行 Check,再构建 Windows x64、Linux x64 / arm64、macOS x64 / arm64 五种产物并生成 SHA-256。交叉编译产物和原生 runner 的验证范围应分别声明;尚未原生运行的体系结构需要保留验收项。 + +## 技术路线 + +优先级依据当前支持契约和使用证据安排。每个阶段以可检查的交付条件结束,不预设未经测量的提速比例或缺少项目样本的兼容承诺。 + +| 优先级 | 目标 | 交付内容 | 完成条件 | +| --- | --- | --- | --- | +| P0 | 发布可依赖的通用版本 | 确定许可证与版本号;整理支持矩阵;验证 tag 产物的版本输出、安装与校验方式 | 发布文档与实际产物一致,源码检查通过,宣称支持的系统与体系结构有对应运行证据 | +| P1 | 扩展真实项目兼容性 | 增加 Wrapper、自定义输出、构建约定和不同 Maven / Gradle 版本的代表项目;明确 toolchain 与应用 JDK 选择 | 新样本可重复验收,失败边界可解释,既有目标隔离与参数契约保持成立 | +| P1 | 改善重复启动体验 | 根据使用反馈评估命名运行配置、可读诊断和配置辅助;保持命令可直接用于脚本 | 有明确的重复操作或诊断需求,交互与非交互都能完成相同运行目标,配置行为有稳定契约 | +| P2 | 降低启动准备成本 | 测量冷准备、温准备、构建工具启动次数和配置成本;据结果优化 Maven 多次调用或 Gradle 元数据读取 | 在固定样本上有可复现收益,依赖与资源变更回归仍通过,不引入失效条件不完整的独立类路径缓存 | +| 按需 | 扩展运行模型 | 分别评估 JPMS、现代 main、原生任务集成及其他构建系统 | 每种模式先有项目样本、明确的配置与退出契约,再确定适配器接口和支持范围 | + +许可证和正式版本是发布决策;版本号应表达实际运行契约。正式发布时需要共同核对 README、支持声明、版本输出与安装产物。 + +## 架构决策原则 + +TypeScript / Bun 继续承担 CLI、配置和进程编排。现有工具链已经提供独立可执行文件与跨平台构建能力;更换实现语言应由可验证的运行、分发或维护问题驱动。 + +Maven 与 Gradle 适配器保持各自的模型语义,统一的是准备结果和 Java 启动契约。后续扩展不以相似命令行外观代替语义验证,也不将框架专用运行任务默认等同于直接 Java 启动。 + +命名配置、诊断和性能优化优先服务日常启动。新增能力需要同时说明配置来源、参数作用域、构建副作用、失败方式和验证样本;无法通过现有 `PreparedProject` 表达的运行模式,应先确认新的结果契约再设计接口。 diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 95a2f1e..a3f23cc 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -1,20 +1,39 @@ # Java 源码启动验证夹具 -这些项目仅用于回归验证。测试脚本先复制到临时目录,再运行选定的 Maven 或 Gradle,不在夹具源码目录生成 `target`、`build`、`.gradle` 或缓存。Maven 使用单独的 settings 文件和临时本地仓库,`JAVA_RUN_MAVEN_REPOSITORY` 可指向专用缓存以复用下载;Gradle 使用独立的 `GRADLE_USER_HOME`。 +这些项目用于 [smoke 脚本](../../scripts/smoke.ts) 的原生二进制验收,分别覆盖普通 Java、Spring Boot 依赖和 Gradle 多项目构建。运行方式与测试环境见 [开发指南](../../CONTRIBUTING.md#二进制验收)。 + +脚本先复制夹具到临时目录,过滤 `target`、`build`、`.gradle` 和 `.cache` 等产物,再执行构建。Maven 使用独立 settings 和本地仓库,Gradle 使用独立用户目录。夹具源码应始终保持可从未构建状态运行。 + +## 项目与观测点 | 夹具 | 验证内容 | | --- | --- | -| `boot-single` | 以 Spring Boot 3.4.4 为普通依赖,验证主类发现、正常退出、构建和 JVM、应用参数 | -| `boot-reactor` | `app` 依赖 `lib`,从已安装的库资源读取标记和版本;`other-app` 误启动时输出 `FORBIDDEN_OTHER_APP` 并返回 23 | -| `plain` | 显式主类的直接 Java 启动、测试类路径、退出码和依赖更新 | -| `gradle-reactor` | Gradle `application` 主类配置、`app` 对 `lib` 的依赖、资源、测试类路径和无关应用隔离 | +| `boot-single` | Spring Boot 作为普通运行依赖,验证主类发现、正常退出和各层参数 | +| `boot-reactor` | `app` 依赖 `lib`,验证上游库、资源和有效 POM 变化;`other-app` 用于检测无关项目被构建或启动 | +| `plain` | 显式主类、测试类路径、配置优先级、依赖变化、Maven 配置根和非零退出 | +| `gradle-reactor` | `application` 主类声明、`app` 依赖 `lib`、资源与依赖变化、测试类路径和无关项目隔离 | + +夹具以 Java 17 为最低版本。Gradle 夹具不携带 Wrapper,CI 安装固定版本 Gradle;本地可使用 `JAVA_RUN_GRADLE_COMMAND` 指定 smoke 的 Gradle 命令。依赖版本在 POM 和 Gradle 构建文件中固定,用于验证实际加载结果。 + +应用输出 `[fixture]` 标记供脚本断言。Boot 应用禁用 Web,在输出结果后关闭上下文,以正常退出证明启动成功。无关应用若被启动会输出 `FORBIDDEN_OTHER_APP`;脚本同时检查它的构建目录没有生成,避免只依据输出判断目标隔离。 + +Maven 的 `ci` profile 和 Gradle 的 `-PfixtureProfile=ci` 分别改变资源中的构建 profile 标记。Spring profile 通过普通 JVM 系统属性传入,与构建参数分开验证。测试依赖中的 `commons-lang3` 和测试源码中的标记类分别输出 `test-dependency`、`test-class`;默认值应为 `absent`,启用测试类路径后应为 `present`。 + +## 完整验收 + +完整套件在同一临时副本中修改构建声明和资源,随后读取应用真正加载的值,验证下一次运行会重新解析依赖与模型: -夹具统一以 Java 17 为最低版本。Boot 应用禁用 Web 并在打印 `[fixture]` 标记后关闭上下文,避免通过超时或端口探测推测启动结果。 +- Maven reactor 更新库资源标记,验证目标应用读取更新后的上游库 +- `plain` 修改 `commons.io.version`,验证实际依赖版本变化 +- Gradle 修改库资源和应用依赖,分别验证资源准备与依赖重新解析 +- 启用测试类路径后恢复默认运行,验证测试输出与依赖不会遗留在普通启动中 +- `.java-run.json` 的默认值与 CLI 追加或覆盖值,验证配置合并顺序 +- 应用收到 `--exit=7` 后退出,验证运行器保留退出码 -`ci` Maven profile 将资源中的 `fixture.maven.profile` 改为 `ci`。Gradle 的 `-PfixtureProfile=ci` 对应资源中的 `fixture.gradle.profile`。Spring profile 通过普通 JVM 系统属性传递,验证构建参数与运行参数的边界。测试作用域中的 `commons-lang3` 与 `src/test/java` 中的标记类分别输出 `test-dependency` 和 `test-class`,默认均应为 `absent`,显式包含测试类路径后均应为 `present`。 +`plain` 还会在临时副本中创建 `.mvn/maven.config`,检查项目目录和最近祖先配置根的属性实际参与资源过滤,并验证 `${maven.multiModuleProjectDirectory}` 是指向配置根的绝对路径。这些文件只属于测试副本,不作为夹具默认配置提交。 -`plain` 的 `commons.io.version` 默认是 `2.18.0`。测试在临时副本中将其改为 `2.19.0`,随后读取实际加载依赖的实现版本,验证 POM 修改能够影响下一次启动。临时 `.mvn/maven.config` 的属性经过资源过滤后输出 `maven-config` 标记,分别验证中文项目目录和祖先目录中的配置生效;同时验证 `${maven.multiModuleProjectDirectory}` 保持绝对路径并指向实际配置根。传入应用参数 `--exit=7` 则打印结果后返回 7。 +## 参数与平台编码 -Gradle 夹具不携带 Wrapper 二进制,CI 固定安装 Gradle `8.14`。本机 smoke 使用 PATH 中的 Gradle,`JAVA_RUN_GRADLE_COMMAND` 可覆盖命令;临时 `GRADLE_USER_HOME` 与个人 Gradle 初始化脚本隔离,`JAVA_RUN_GRADLE_HOME` 可指向专用缓存。 +临时项目目录包含空格、中文、`#` 和 `%`,所有系统均保留这些路径。脚本检查实际 JDK 的原生命令行编码:能够完整表示 Unicode 参数时验证其往返传递;不能表示时,使用可表示参数完成启动,并追加 JVM 参数与应用参数的拒绝检查,要求明确报错、应用未启动且诊断不泄漏参数值。 -smoke 先读取真实 JDK 的原生命令行编码。中文项目目录在所有系统上保留;JVM / 应用参数可表示时完整验证 Unicode,无法表示时改用可表示的参数完成正常运行,并追加两项二进制拒绝验收,要求明确报错且不泄漏参数。Windows 单字节代码页上的完整验收因此包含 23 项,其余环境通常为 21 项。 +验收项目数取决于套件、选定夹具和实际编码能力,以脚本输出为准。交互、信号转发和进程树清理由对应回归测试验证,smoke 主要验证真实构建模型与原生二进制启动。 From 30ea59f55859015bd1458b532843429419fde85f Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 01:14:47 +0800 Subject: [PATCH 13/26] =?UTF-8?q?docs:=20=E7=A7=BB=E9=99=A4=E6=97=A7?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E4=B8=8E=E7=89=88=E6=9C=AC=E8=BF=87=E6=B8=A1?= =?UTF-8?q?=E5=8F=99=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 3 +-- README.md | 16 +--------------- docs/roadmap.md | 2 +- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3c53e3..3397f62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,7 +112,6 @@ docs: 说明参数传递规则 - 确定开源许可证并补齐许可证文件 - 为源码接口确定版本号,更新 `package.json` 并核对版本输出 -- 说明相对已发布接口的不兼容变化与迁移方法 - 完成待发布提交的检查,审查平台产物和支持边界 -`package.json` 的 `0.0.5` 尚未表达当前源码接口的版本变更。发布时应确保 `v<版本>` 标签与包版本一致,再推送标签触发工作流;`bun run version` 提供 bumpp 版本调整入口。发布完成后检查 Release 的标签、产物和校验和,并更新安装说明。 +发布时应确保 `v<版本>` 标签与包版本一致,再推送标签触发工作流;`bun run version` 提供 bumpp 版本调整入口。发布完成后检查 Release 的标签、产物和校验和,并更新安装说明。 diff --git a/README.md b/README.md index f51fcac..ec7446e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ java-run 为 Maven 和 Gradle 源码工作区提供统一的开发启动入口:选择一个 Java 项目,由构建工具准备源码和运行依赖,再启动独立的 Java 进程。普通 Java、Spring Boot 和其他基于 classpath 的应用使用同一套运行契约。 -**发布状态:本文描述的命令契约尚未正式发布,请从源码构建。** `package.json` 中的版本号仍为 `0.0.5`,已发布的 `0.0.5` 使用不同的命令格式,升级方式见[命令兼容性](#命令兼容性)。 +**发布状态:本文描述的命令契约尚未正式发布,请从源码构建。** ## 快速开始 @@ -160,20 +160,6 @@ Java 命令优先采用 `--java`,其次采用 Gradle 提供的项目工具链 java-run 启动独立 Java 进程,不模拟自定义 `JavaExec` / `bootRun` 的全部副作用,也不会自动搬运任务专用环境变量、agents、附加资源或启动前后逻辑。依赖这些设置的项目应使用原生任务,或把所需运行参数明确配置给 java-run。 -## 命令兼容性 - -已发布的 `0.0.5` 使用以下命令格式。使用本文描述的版本时,需要调整调用脚本: - -| 旧用法 | 当前用法 | -| --- | --- | -| `start` | 默认执行或使用 `run` | -| `-c` / `compile` | 默认 `--build=auto` 自动准备,不再手动补编译步骤 | -| `main=` | `--main=` 或配置 `mainClass` | -| `active=` | `--jvm-arg=-Dspring.profiles.active=` 或应用参数 | -| `local` | 直接给出项目需要的完整 profile 名称 | -| `no-run` / `not-run` | 查看计划使用 `plan`;没有仅执行准备的旧模式 | -| `-r` / 刷新旧缓存 | 没有 java-run 独立缓存;构建工具选项通过 `--build-arg` 传递 | - ## 支持范围与验证 当前支持 Maven `jar` 项目和启用了 Gradle Java 插件的项目,以及传统 `public static void main(String[])` 的 classpath 启动。主类发现只检查选定项目的已编译输出,不遍历所有依赖 Jar 寻找应用。JPMS、Android、native image、应用守护和热重启、部署不属于当前支持范围。 diff --git a/docs/roadmap.md b/docs/roadmap.md index e0aec0f..d4cd122 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -49,7 +49,7 @@ java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供 ## 架构决策原则 -TypeScript / Bun 继续承担 CLI、配置和进程编排。现有工具链已经提供独立可执行文件与跨平台构建能力;更换实现语言应由可验证的运行、分发或维护问题驱动。 +TypeScript / Bun 承担 CLI、配置和进程编排,并提供独立可执行文件与跨平台构建能力。 Maven 与 Gradle 适配器保持各自的模型语义,统一的是准备结果和 Java 启动契约。后续扩展不以相似命令行外观代替语义验证,也不将框架专用运行任务默认等同于直接 Java 启动。 From 8da4793377f989e8ee382ad3005983016736ec40 Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 01:15:31 +0800 Subject: [PATCH 14/26] =?UTF-8?q?docs:=20=E6=B8=85=E7=90=86=20README=20?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E5=8F=91=E5=B8=83=E8=BF=87=E6=B8=A1=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index ec7446e..fe4d21d 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ java-run 为 Maven 和 Gradle 源码工作区提供统一的开发启动入口:选择一个 Java 项目,由构建工具准备源码和运行依赖,再启动独立的 Java 进程。普通 Java、Spring Boot 和其他基于 classpath 的应用使用同一套运行契约。 -**发布状态:本文描述的命令契约尚未正式发布,请从源码构建。** - ## 快速开始 从源码使用需要 [Bun](https://bun.sh) **1.4.2**,版本固定在 `.bun-version`;运行 Java 项目还需要符合该项目要求的 JDK,以及 Maven / Gradle 或项目 Wrapper。JDK 的 `java` 和 `jar` 必须可用。 @@ -178,5 +176,3 @@ Windows 上的 Maven 配置根超出系统代码页时,需要 Maven **3.9.2 | [技术路线](docs/roadmap.md) | 已有工程能力、支持缺口和后续工作的验收条件 | | [参与开发](CONTRIBUTING.md) | 本地开发、验证、分支协作与发布流程 | | [验收夹具](tests/fixtures/README.md) | 真实项目场景、隔离方式和验证覆盖 | - -仓库尚未确定开源许可证,正式发布前需要补齐许可证与发布条件。 From 66dbf29f3f7b56a7d8c39ae081a5b10524853176 Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 01:21:49 +0800 Subject: [PATCH 15/26] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=E5=A4=9A?= =?UTF-8?q?=E4=B8=BB=E7=B1=BB=E5=85=A5=E5=8F=A3=E7=9A=84=E5=8F=AF=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 ++++++++ examples/multiple-main/.gitignore | 1 + examples/multiple-main/README.md | 69 +++++++++++++++++++ examples/multiple-main/pom.xml | 22 ++++++ .../java/com/example/HelloApplication.java | 10 +++ .../java/com/example/ReportApplication.java | 10 +++ 6 files changed, 141 insertions(+) create mode 100644 examples/multiple-main/.gitignore create mode 100644 examples/multiple-main/README.md create mode 100644 examples/multiple-main/pom.xml create mode 100644 examples/multiple-main/src/main/java/com/example/HelloApplication.java create mode 100644 examples/multiple-main/src/main/java/com/example/ReportApplication.java diff --git a/README.md b/README.md index fe4d21d..c1f597d 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,35 @@ Maven 接受单个 reactor 选择器,如 `app`、`:artifactId` 或 `groupId:ar 交互只选择目标和入口。运行参数通过选项或配置传入,配置由用户保存;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` 或构建声明已经确定入口时,直接启动该入口,不显示主类菜单。 + ## 参数放在哪一层 | 层次 | 配置方式 | 示例 | 影响 | 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..b26ee08 --- /dev/null +++ b/examples/multiple-main/README.md @@ -0,0 +1,69 @@ +# 多个 main 入口 + +这是一个 Java 17 的单模块 Maven 项目,包含两个传统 `public static void main(String[])` 入口,未声明默认主类: + +- `com.example.HelloApplication` +- `com.example.ReportApplication` + +两个入口分别输出自己的类名和收到的参数,不依赖业务库。运行需要 JDK 17 或更新版本,以及 Maven;从 java-run 源码运行还需要根目录 [README](../../README.md#快速开始) 指定的 Bun。 + +## 在终端选择入口 + +在 java-run 仓库根目录安装依赖,然后运行示例: + +```sh +bun install --frozen-lockfile +bun run src/cli.ts --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` 是一个参数,终端选择不会改变参数边界。 + +也可以使用编译后的 java-run 二进制。在 java-run 仓库根目录执行: + +```sh +bun run compile +./dist/java-run --cwd examples/multiple-main -- --format=table "hello world" +``` + +Windows 使用 `./dist/java-run.exe`。把二进制放入 PATH 后,可将上述命令中的 `./dist/java-run` 换为 `java-run`。 + +## 明确指定入口 + +脚本、CI 或重定向输入的环境使用 `--main`,无需菜单: + +```sh +bun run src/cli.ts --cwd examples/multiple-main \ + --main=com.example.HelloApplication -- --name=Alice +``` + +输出为: + +```text +entry=com.example.HelloApplication +args=[--name=Alice] +``` + +二进制用法相同: + +```sh +./dist/java-run --cwd examples/multiple-main \ + --main=com.example.ReportApplication -- --format=json +``` + +非交互环境未指定主类时,java-run 会列出两个候选并要求明确入口,避免等待终端输入。 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)); + } +} From d828e32d4623e200c2e6b97942ffe2a820df1ed0 Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 01:41:08 +0800 Subject: [PATCH 16/26] =?UTF-8?q?ci:=20=E5=8F=91=E5=B8=83=E5=B7=B2?= =?UTF-8?q?=E9=80=9A=E8=BF=87=E5=8E=9F=E7=94=9F=E9=AA=8C=E6=94=B6=E7=9A=84?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=9E=84=E5=BB=BA=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 4 + .github/workflows/release.yaml | 203 +++++++++++++++++++++++++-------- CONTRIBUTING.md | 58 ++++++++-- bump.config.ts | 9 +- docs/roadmap.md | 10 +- package.json | 2 +- scripts/compile.ts | 66 +++++++++++ scripts/release.ts | 107 +++++++++++++++++ scripts/smoke.ts | 6 +- tests/compile.test.ts | 61 ++++++++++ tests/release.test.ts | 58 ++++++++++ 11 files changed, 518 insertions(+), 66 deletions(-) create mode 100644 scripts/compile.ts create mode 100644 scripts/release.ts create mode 100644 tests/compile.test.ts create mode 100644 tests/release.test.ts diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 6b640b7..8d5e393 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -10,6 +10,10 @@ on: 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 }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3588ccb..8e5ae1d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -4,92 +4,201 @@ on: push: tags: - 'v*' + branches: + - '**' + paths: + - '.github/workflows/release.yaml' + - '.github/workflows/check.yaml' + - 'scripts/compile.ts' + - 'scripts/release.ts' + - 'scripts/smoke.ts' + - 'tests/compile.test.ts' + - 'tests/release.test.ts' + - 'package.json' + - 'bun.lockb' + - 'bump.config.ts' + - '.bun-version' + workflow_dispatch: permissions: contents: read +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + 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@v4 + - 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: | + if state=$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft); then + if [ "$state" != 'true' ]; then + echo '该标签已经公开发布,请使用新版本标签。' + exit 1 + fi + fi + checks: + needs: metadata + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' uses: ./.github/workflows/check.yaml build: - name: 构建 ${{ matrix.name }} - needs: checks - runs-on: ubuntu-latest + name: 验收 ${{ matrix.name }} + needs: metadata + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 strategy: fail-fast: false - matrix: - include: - - name: windows-x64-baseline - target: bun-windows-x64-baseline - extension: .exe - - name: linux-x64-baseline - target: bun-linux-x64-baseline - extension: '' - - name: linux-arm64 - target: bun-linux-arm64 - extension: '' - - name: darwin-arm64 - target: bun-darwin-arm64 - extension: '' - - name: darwin-x64 - target: bun-darwin-x64 - extension: '' + matrix: ${{ fromJSON(needs.metadata.outputs.matrix) }} + env: + GRADLE_USER_HOME: ${{ github.workspace }}/.cache/gradle + RELEASE_FILE: ${{ matrix.file }} + BUN_TARGET: ${{ matrix.target }} steps: - - name: 检出事件对应的标签 + - name: 检出待验收提交 uses: actions/checkout@v4 - name: 安装固定版本 Bun uses: oven-sh/setup-bun@v2 with: bun-version-file: .bun-version + - name: 安装 JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - name: 安装固定版本 Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.14' + - name: 缓存隔离的 Maven 仓库 + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/java-run-maven-repository + key: ${{ runner.os }}-${{ runner.arch }}-release-maven-${{ hashFiles('tests/fixtures/**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-release-maven- - name: 按锁文件安装依赖 run: bun install --frozen-lockfile - - name: 交叉编译 + - name: 构建待发布二进制 shell: bash - env: - RELEASE_TAG: ${{ github.ref_name }} - PLATFORM_NAME: ${{ matrix.name }} - BUN_TARGET: ${{ matrix.target }} - FILE_EXTENSION: ${{ matrix.extension }} run: | - bun build src/cli.ts --compile --minify \ - --target "$BUN_TARGET" \ - --outfile "dist/java-run-${PLATFORM_NAME}-${RELEASE_TAG}${FILE_EXTENSION}" - - name: 上传单个平台产物 + bun run compile --target="$BUN_TARGET" --outfile="dist/$RELEASE_FILE" + - 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_FILE" --suite=full + - name: 安装 JDK 17 + uses: actions/setup-java@v4 + 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/smoke.ts --cli="dist/$RELEASE_FILE" --suite=quick + - name: 上传已验收文件 uses: actions/upload-artifact@v4 with: name: binary-${{ matrix.name }} - path: dist/* + path: dist/${{ matrix.file }} if-no-files-found: error + overwrite: true - release: - name: 校验并发布标签产物 + artifacts: + name: 汇总与校验已验收产物 needs: build - runs-on: ubuntu-latest - permissions: - contents: write + runs-on: ubuntu-24.04 steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version - name: 汇总构建产物 uses: actions/download-artifact@v4 with: pattern: binary-* merge-multiple: true path: dist - - name: 生成并核对 SHA-256 + - name: 校验五种产物并生成 SHA-256 + run: bun scripts/release.ts checksums + - name: 核对文件校验和 working-directory: dist - run: | - sha256sum java-run-* > SHA256SUMS - sha256sum --check SHA256SUMS + run: sha256sum --check SHA256SUMS - name: 保存已校验产物 uses: actions/upload-artifact@v4 with: name: release-with-checksums path: dist/* if-no-files-found: error - - name: 发布当前事件的标签 - uses: softprops/action-gh-release@v2 + 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@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - uses: actions/download-artifact@v4 with: - tag_name: ${{ github.ref_name }} - generate_release_notes: true - files: dist/* - fail_on_unmatched_files: true + name: release-with-checksums + path: dist + - name: 核对待上传文件 + working-directory: dist + run: sha256sum --check SHA256SUMS + - name: 创建或恢复未公开草稿 + run: | + if state=$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft); then + if [ "$state" != 'true' ]; then + echo '该标签已经公开发布,请使用新版本标签。' + exit 1 + fi + else + gh release create "$RELEASE_TAG" --verify-tag --draft --generate-notes \ + --title "$RELEASE_TAG" --prerelease="$RELEASE_PRERELEASE" + fi + - name: 上传完整资产并核对草稿 + run: | + 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/CONTRIBUTING.md b/CONTRIBUTING.md index 3397f62..e7102e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,9 +24,11 @@ bun run smoke | `bun run typecheck` | TypeScript 类型检查 | | `bun test` | 参数、配置、适配器、主类、类路径、交互和进程行为回归 | | `bun run check` | 类型检查与回归测试 | -| `bun run compile` | 构建本机二进制到 `dist` | +| `bun run compile` | 构建压缩后的本机二进制到 `dist`,与发布产物使用同一入口 | | `bun run smoke` | 使用该二进制运行真实 Maven / Gradle 夹具 | +编译入口支持等号形式的 `--target=<目标>` 和 `--outfile=<路径>`,可用目标与发布平台表一致。默认产物为 `dist/java-run`,Windows 自动使用 `.exe` 扩展名。二进制不自动加载运行目录中的 `.env` 或 `bunfig.toml`,显式继承的环境变量仍然生效。 + 回归测试中的真实 Gradle 用例使用 PATH 中的 Gradle;找不到时会跳过,不能据此判断 Gradle 适配已经通过验收。可通过 `JAVA_RUN_TEST_GRADLE` 指定测试命令,通过 `GRADLE_USER_HOME` 隔离测试用的 Gradle 缓存: ```sh @@ -96,17 +98,51 @@ docs: 说明参数传递规则 [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) 由 `v*` tag 触发,在同一标签提交上先执行 Check,再交叉编译以下产物: +[Release 工作流](.github/workflows/release.yaml) 提供发布产物演练与标签发布两种入口,使用同一组构建和验收步骤。 -| 产物 | 编译目标 | -| --- | --- | -| Windows x64 baseline | `bun-windows-x64-baseline` | -| Linux x64 baseline | `bun-linux-x64-baseline` | -| Linux arm64 | `bun-linux-arm64` | -| macOS arm64 | `bun-darwin-arm64` | -| macOS x64 | `bun-darwin-x64` | +### 产物构建与验收 + +五种产物在对应系统和体系结构的固定原生 runner 上构建: + +| 产物 | 编译目标 | 原生 runner | +| --- | --- | --- | +| Windows x64 baseline | `bun-windows-x64-baseline` | `windows-2025` | +| Linux x64 baseline | `bun-linux-x64-baseline` | `ubuntu-24.04` | +| Linux arm64 | `bun-linux-arm64` | `ubuntu-24.04-arm` | +| macOS arm64 | `bun-darwin-arm64` | `macos-15` | +| macOS x64 | `bun-darwin-x64` | `macos-15-intel` | + +每个任务使用 `bun run compile` 生成一次待分发的压缩二进制,先在 JDK 21 下执行完整 smoke,再切换到 JDK 17,对同一文件执行 quick 启动验收。验收通过后上传该文件,不重新构建。产物名称保留 x64 的 `baseline` 后缀;平台清单和文件名由 [发布脚本](scripts/release.ts) 统一生成。 + +汇总步骤要求恰好包含这五种非空普通文件,拒绝缺失、多余或无效产物,然后生成并核对 `SHA256SUMS`。源码 Check 的双 JDK 完整回归与发布文件的 JDK 21 full / JDK 17 quick 范围分别声明,不能将 quick 扩大为完整验收。 + +### 发布流程演练 + +分支 push 修改发布或检查工作流、编译 / 发布 / smoke 脚本、对应测试、包与版本配置等路径时,会触发五平台构建、原生验收和校验和汇总。完整路径条件以 Release 工作流的 `paths` 为准。分支演练只保存 Actions artifacts,不创建标签或 GitHub Release;日常 Check 独立运行,Release 不重复调用它。 + +`workflow_dispatch` 也是纯演练入口,并额外复用完整 Check。工作流进入默认分支后,可通过 Actions 页面或 GitHub CLI 手动选择分支运行。演练使用所选提交的包版本生成文件名,不代表该版本已经发布。 + +本地可先检查版本和平台清单: + +```sh +bun scripts/release.ts metadata +``` + +### 标签发布与重试 + +任何 `v*` 标签 push 都进入发布校验,不受分支演练的路径条件限制。包版本必须是合法 SemVer,标签必须严格等于 `v`,且仓库需包含非空许可证文件。已经公开的标签版本会被拒绝,不能通过重跑覆盖。 + +元数据校验通过后,同一标签提交的六组 Check 与五平台产物验收并行执行。只有源码检查、产物验收和汇总全部成功,才创建或恢复该标签的未公开草稿,上传五份文件和校验和。公开前还会核对远端资产集合,缺失或额外文件均令流程失败并保留草稿;文件上传与核对全部成功后自动公开。草稿用于承接上传过程和失败重试,不需要额外人工审批。 + +SemVer 包含预发布段的版本自动标记为 prerelease,且不会标记为 latest;稳定版的 latest 选择交给 GitHub 默认规则。同一标签的运行串行执行,不中断正在进行的发布。 + +网络、下载或上传偶发失败时,可以重跑失败任务: + +```sh +gh run rerun --failed +``` -工作流汇总产物后生成并核对 `SHA256SUMS`,发布到触发事件对应的标签。交叉编译证明产物可以构建;原生矩阵证明相应运行环境可以执行,二者不能替代。矩阵没有逐一覆盖五种产物架构。 +重跑仍使用原始提交和 ref。已验收的 Actions artifacts 支持同名覆盖,Release 资产只在未公开草稿中允许替换。若修改了代码,需要运行新提交的验收;若版本已经公开,需要使用新版本,不能重跑发布来替换文件。 发布准备包括: @@ -114,4 +150,4 @@ docs: 说明参数传递规则 - 为源码接口确定版本号,更新 `package.json` 并核对版本输出 - 完成待发布提交的检查,审查平台产物和支持边界 -发布时应确保 `v<版本>` 标签与包版本一致,再推送标签触发工作流;`bun run version` 提供 bumpp 版本调整入口。发布完成后检查 Release 的标签、产物和校验和,并更新安装说明。 +`bun run version` 使用 bumpp 调整 `package.json`,不自动提交、创建标签或推送。版本变更通过 PR 合入主分支后,再创建与包版本一致的标签并推送该标签,触发自动发布。发布完成后检查 Release 的标签、产物和校验和,并更新安装说明。 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/docs/roadmap.md b/docs/roadmap.md index d4cd122..d54fa5f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,7 +14,7 @@ java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供 | 启动正确性 | 目标类路径隔离、测试作用域开关、class 文件入口扫描、Manifest URL 编码 | 主类和类路径有直接执行验证;自动发现仅支持传统 main,运行依赖于现有 classpath 契约 | | 进程管理 | 异步执行、结构化退出状态、子环境覆盖、平台专用进程树清理 | 失败能够定位到阶段,退出码保留;Windows 强制终止不能保证应用关闭钩子执行 | | 验证 | Bun 回归、真实 JDK、Maven / Gradle 夹具、编译后二进制 smoke | 覆盖正常启动、依赖与资源变化、参数、配置、目标隔离和平台边界;夹具结果不代表所有第三方构建插件兼容 | -| 分发 | 固定 Bun、锁文件安装、原生检查与受检 tag 发布工作流 | 已建立发布门禁和校验和;正式版本仍需许可证、版本及产物验收 | +| 分发 | 固定 Bun、统一编译入口、五平台原生产物验收与受检标签发布 | 版本、许可证和产物集合有发布门禁;首次正式交付仍需完成发布说明与安装核对 | ## 验证与支持依据 @@ -31,7 +31,11 @@ java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供 - 静态计划、帮助和版本遵守各自的副作用契约 - Windows 编码、Maven 配置根和批处理调用有对应的原生验证 -[Release 工作流](../.github/workflows/release.yaml) 先执行 Check,再构建 Windows x64、Linux x64 / arm64、macOS x64 / arm64 五种产物并生成 SHA-256。交叉编译产物和原生 runner 的验证范围应分别声明;尚未原生运行的体系结构需要保留验收项。 +[Release 工作流](../.github/workflows/release.yaml) 在固定原生 runner 上构建 Windows x64、Linux x64 / arm64、macOS x64 / arm64 五种压缩二进制。每份文件在 JDK 21 下执行完整 smoke,在 JDK 17 下执行 quick 启动验收,随后上传同一文件。汇总要求五种非空普通文件齐全,并生成和核对 SHA-256。 + +标签发布先校验 SemVer、标签与包版本的一致性、非空许可证文件及发布状态。六组 Check 与五平台产物验收并行执行;全部通过后,上传完整资产并自动公开。预发布版本自动标记为 prerelease,已有公开版本不覆盖。分支路径触发与手动触发仅演练构建验收,不创建 Release;具体入口与重试规则见 [发布指南](../CONTRIBUTING.md#ci-与发布)。 + +验证矩阵表达所用系统、体系结构与 JDK 的实际覆盖:Check 使用两个 JDK 执行完整回归,发布文件在 JDK 17 上采用较小的启动套件。固定 runner 的验收也不构成所有系统版本、CPU 或第三方构建插件的兼容承诺。 ## 技术路线 @@ -39,7 +43,7 @@ java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供 | 优先级 | 目标 | 交付内容 | 完成条件 | | --- | --- | --- | --- | -| P0 | 发布可依赖的通用版本 | 确定许可证与版本号;整理支持矩阵;验证 tag 产物的版本输出、安装与校验方式 | 发布文档与实际产物一致,源码检查通过,宣称支持的系统与体系结构有对应运行证据 | +| P0 | 发布可依赖的通用版本 | 确定许可证与版本号;整理支持矩阵和发布说明;完成流程演练及实际产物的安装、版本与校验核对 | 发布门禁通过,文档与产物一致,宣称支持的系统与体系结构有对应运行证据 | | P1 | 扩展真实项目兼容性 | 增加 Wrapper、自定义输出、构建约定和不同 Maven / Gradle 版本的代表项目;明确 toolchain 与应用 JDK 选择 | 新样本可重复验收,失败边界可解释,既有目标隔离与参数契约保持成立 | | P1 | 改善重复启动体验 | 根据使用反馈评估命名运行配置、可读诊断和配置辅助;保持命令可直接用于脚本 | 有明确的重复操作或诊断需求,交互与非交互都能完成相同运行目标,配置行为有稳定契约 | | P2 | 降低启动准备成本 | 测量冷准备、温准备、构建工具启动次数和配置成本;据结果优化 Maven 多次调用或 Gradle 元数据读取 | 在固定样本上有可复现收益,依赖与资源变更回归仍通过,不引入失效条件不完整的独立类路径缓存 | diff --git a/package.json b/package.json index eeb73ff..5fe8bf2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "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", diff --git a/scripts/compile.ts b/scripts/compile.ts new file mode 100644 index 0000000..155fdec --- /dev/null +++ b/scripts/compile.ts @@ -0,0 +1,66 @@ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +const projectRoot = resolve(import.meta.dir, '..'); +const targets = [ + 'bun-windows-x64-baseline', + 'bun-linux-x64-baseline', + 'bun-linux-arm64', + 'bun-darwin-arm64', + 'bun-darwin-x64', +] as const; + +/** 本机或指定平台的独立二进制构建配置 */ +export interface CompileOptions { + target?: typeof targets[number]; + 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') { + if (!targets.includes(value as typeof targets[number])) throw new Error(`--target 仅支持 ${targets.join('、')}`); + options.target = value as typeof targets[number]; + } else { + options.outfile = resolve(cwd, value); + } + } + return options; +} + +/** 编译独立二进制,构建失败保留 Bun 的退出码 */ +function main(argv: string[]): number { + const options = parseCompileOptions(argv); + 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; + return result.status ?? 1; +} + +if (import.meta.main) { + try { + process.exitCode = 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/release.ts b/scripts/release.ts new file mode 100644 index 0000000..fb26eb0 --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,107 @@ +import { createHash } from 'node:crypto'; +import { createReadStream, appendFileSync, readFileSync } from 'node:fs'; +import { lstat, readdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const projectRoot = resolve(import.meta.dir, '..'); +const platforms = [ + { name: 'windows-x64-baseline', runner: 'windows-2025', target: 'bun-windows-x64-baseline', extension: '.exe' }, + { name: 'linux-x64-baseline', runner: 'ubuntu-24.04', target: 'bun-linux-x64-baseline', extension: '' }, + { name: 'linux-arm64', runner: 'ubuntu-24.04-arm', target: 'bun-linux-arm64', extension: '' }, + { name: 'darwin-arm64', runner: 'macos-15', target: 'bun-darwin-arm64', extension: '' }, + { name: 'darwin-x64', runner: 'macos-15-intel', target: 'bun-darwin-x64', extension: '' }, +] as const; + +/** 发布版本、预发布标识与需要原生验收的文件集合 */ +export interface ReleaseMetadata { + version: string; + tag: string; + prerelease: boolean; + matrix: { include: { name: string; runner: string; target: string; file: 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, + file: `java-run-${platform.name}-${tag}${platform.extension}`, + })) }, + }; +} + +/** 公开前核对远端资产,确保草稿只包含已验收文件与校验和 */ +export function assertReleaseAssets(assets: unknown, version: string): void { + const expected = [...releaseMetadata(version).matrix.include.map(platform => platform.file), '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.file).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}`); + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + lines.push(`${hash.digest('hex')} ${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 !== 1) throw new Error('用法:bun scripts/release.ts metadata | checksums | verify-assets'); + 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') { + const licenses = ['LICENSE', 'LICENSE.md', 'LICENSE.txt']; + if (!licenses.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] === '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 index 33e47f3..652f2ec 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -25,6 +25,7 @@ interface ProcessResult { interface Expectations { code?: number; stdout?: string[]; + stdoutExact?: string; stderr?: string[]; absent?: string[]; } @@ -164,6 +165,9 @@ async function main(): Promise { 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}`); } @@ -199,7 +203,7 @@ async function main(): Promise { 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'], { stdout: [packageVersion] }); + 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=')); diff --git a/tests/compile.test.ts b/tests/compile.test.ts new file mode 100644 index 0000000..3e94bf1 --- /dev/null +++ b/tests/compile.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +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-baseline', 'bun-linux-x64-baseline', '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('实际二进制忽略 cwd 的 dotenv 和 bunfig,保留显式继承的环境及完整版本', () => { + const root = mkdtempSync(join(tmpdir(), 'java-run-compile-')); + try { + const executable = join(root, process.platform === 'win32' ? 'java-run.exe' : 'java-run'); + const compiled = spawnSync(process.execPath, [compileScript, `--outfile=${executable}`], { encoding: 'utf8' }); + expect(compiled.status).toBe(0); + 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); +}); diff --git a/tests/release.test.ts b/tests/release.test.ts new file mode 100644 index 0000000..b60e01e --- /dev/null +++ b/tests/release.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { assertReleaseAssets, releaseMetadata, writeReleaseChecksums } from '../scripts/release'; + +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.file)).size).toBe(5); + expect(stable.matrix.include.find(platform => platform.name === 'windows-x64-baseline')?.file) + .toBe('java-run-windows-x64-baseline-v1.2.3.exe'); + 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.file), 'SHA256SUMS']; + expect(() => assertReleaseAssets(assets.toReversed(), '1.2.3')).not.toThrow(); + expect(() => assertReleaseAssets(assets.slice(1), '1.2.3')).toThrow('资产集合'); + expect(() => assertReleaseAssets([...assets, 'old-binary'], '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.file); + 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 }); } + }); +}); From 01d299c522c732483eb58dd35e1ed2906e708161 Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 02:05:00 +0800 Subject: [PATCH 17/26] =?UTF-8?q?ci(release):=20=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E5=90=8D=E7=A7=B0=E7=9A=84=E4=BA=94=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=20ZIP=20=E5=AE=89=E8=A3=85=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yaml | 37 ++++--- CONTRIBUTING.md | 26 ++--- README.md | 31 ++++-- docs/installation.md | 96 ++++++++++++++++++ docs/roadmap.md | 10 +- scripts/compile.ts | 4 +- scripts/release.ts | 176 +++++++++++++++++++++++++++++---- tests/compile.test.ts | 2 +- tests/release.test.ts | 96 ++++++++++++++++-- 9 files changed, 415 insertions(+), 63 deletions(-) create mode 100644 docs/installation.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8e5ae1d..dd4d32d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -12,6 +12,8 @@ on: - 'scripts/compile.ts' - 'scripts/release.ts' - 'scripts/smoke.ts' + - 'docs/installation.md' + - 'LICENSE*' - 'tests/compile.test.ts' - 'tests/release.test.ts' - 'package.json' @@ -71,7 +73,8 @@ jobs: matrix: ${{ fromJSON(needs.metadata.outputs.matrix) }} env: GRADLE_USER_HOME: ${{ github.workspace }}/.cache/gradle - RELEASE_FILE: ${{ matrix.file }} + RELEASE_BINARY: ${{ matrix.binary }} + RELEASE_PLATFORM: ${{ matrix.name }} BUN_TARGET: ${{ matrix.target }} steps: - name: 检出待验收提交 @@ -101,29 +104,29 @@ jobs: - name: 构建待发布二进制 shell: bash run: | - bun run compile --target="$BUN_TARGET" --outfile="dist/$RELEASE_FILE" + 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_FILE" --suite=full + run: bun scripts/smoke.ts --cli="dist/$RELEASE_BINARY" --suite=full - name: 安装 JDK 17 uses: actions/setup-java@v4 with: distribution: temurin java-version: '17' - - name: JDK 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/smoke.ts --cli="dist/$RELEASE_FILE" --suite=quick - - name: 上传已验收文件 + run: bun scripts/release.ts package "$RELEASE_PLATFORM" + - name: 上传已验收压缩包 uses: actions/upload-artifact@v4 with: - name: binary-${{ matrix.name }} - path: dist/${{ matrix.file }} + name: package-${{ matrix.name }} + path: dist/${{ matrix.archive }} if-no-files-found: error overwrite: true @@ -139,7 +142,7 @@ jobs: - name: 汇总构建产物 uses: actions/download-artifact@v4 with: - pattern: binary-* + pattern: package-* merge-multiple: true path: dist - name: 校验五种产物并生成 SHA-256 @@ -147,6 +150,10 @@ jobs: - name: 核对文件校验和 working-directory: dist run: sha256sum --check SHA256SUMS + - name: 生成发布说明预览 + run: | + bun scripts/release.ts notes > release-notes.md + cat release-notes.md >> "$GITHUB_STEP_SUMMARY" - name: 保存已校验产物 uses: actions/upload-artifact@v4 with: @@ -179,6 +186,11 @@ jobs: - name: 核对待上传文件 working-directory: dist run: sha256sum --check SHA256SUMS + - name: 生成下载与安装说明 + run: | + 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: | if state=$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft); then @@ -186,9 +198,12 @@ jobs: 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 --generate-notes \ - --title "$RELEASE_TAG" --prerelease="$RELEASE_PRERELEASE" + gh release create "$RELEASE_TAG" --verify-tag --draft \ + --notes-file release-notes.md --title "java-run $RELEASE_TAG" \ + --prerelease="$RELEASE_PRERELEASE" fi - name: 上传完整资产并核对草稿 run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7102e6..48c082e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,23 +104,25 @@ docs: 说明参数传递规则 五种产物在对应系统和体系结构的固定原生 runner 上构建: -| 产物 | 编译目标 | 原生 runner | +| ZIP 资产 | 编译目标 | 原生 runner | | --- | --- | --- | -| Windows x64 baseline | `bun-windows-x64-baseline` | `windows-2025` | -| Linux x64 baseline | `bun-linux-x64-baseline` | `ubuntu-24.04` | -| Linux arm64 | `bun-linux-arm64` | `ubuntu-24.04-arm` | -| macOS arm64 | `bun-darwin-arm64` | `macos-15` | -| macOS x64 | `bun-darwin-x64` | `macos-15-intel` | +| `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` | -每个任务使用 `bun run compile` 生成一次待分发的压缩二进制,先在 JDK 21 下执行完整 smoke,再切换到 JDK 17,对同一文件执行 quick 启动验收。验收通过后上传该文件,不重新构建。产物名称保留 x64 的 `baseline` 后缀;平台清单和文件名由 [发布脚本](scripts/release.ts) 统一生成。 +每个任务使用 `bun run compile` 生成一次待分发的压缩二进制,在 JDK 21 下执行完整 smoke。随后将该文件打包,不重新构建:Linux / macOS 使用 `zip`,Windows 使用 PowerShell `Compress-Archive`。实际 ZIP 会在原生环境中解压,核对其中二进制与已验收文件的字节一致,再使用 JDK 17 对解压出的文件执行 quick 启动验收。 -汇总步骤要求恰好包含这五种非空普通文件,拒绝缺失、多余或无效产物,然后生成并核对 `SHA256SUMS`。源码 Check 的双 JDK 完整回归与发布文件的 JDK 21 full / JDK 17 quick 范围分别声明,不能将 quick 扩大为完整验收。 +每份 ZIP 包含一个 `java-run-<平台>/` 目录,目录内为固定名称的 `java-run` 或 `java-run.exe`、从 [安装指南](docs/installation.md) 复制的 `INSTALL.md`,以及存在的许可证文件。平台清单、包内目录和公开资产名称由 [发布脚本](scripts/release.ts) 统一生成。公开名称不带版本号,版本由 `/releases/download/v<版本>/` URL 表达;`/releases/latest/download/` 提供最新稳定版的固定下载入口。 + +汇总步骤要求恰好包含上述五种非空 ZIP,拒绝缺失、多余或无效产物,然后生成并核对覆盖五个 ZIP 的 `SHA256SUMS`。源码 Check 的双 JDK 完整回归与发布文件的 JDK 21 full / JDK 17 quick 范围分别声明,不能将 quick 扩大为完整验收。Linux 验收采用 Ubuntu 24.04 的 glibc 环境,不代表 musl 或其他系统版本已通过验收。 ### 发布流程演练 -分支 push 修改发布或检查工作流、编译 / 发布 / smoke 脚本、对应测试、包与版本配置等路径时,会触发五平台构建、原生验收和校验和汇总。完整路径条件以 Release 工作流的 `paths` 为准。分支演练只保存 Actions artifacts,不创建标签或 GitHub Release;日常 Check 独立运行,Release 不重复调用它。 +分支 push 修改发布或检查工作流、编译 / 发布 / smoke 脚本、对应测试、包与版本配置、安装指南等路径时,会触发五平台构建、原生验收、ZIP 解压验收和校验和汇总。完整路径条件以 Release 工作流的 `paths` 为准。分支演练保存 Actions artifacts,并在 Actions summary 展示下载与安装正文预览,不创建标签或 GitHub Release;日常 Check 独立运行,Release 不重复调用它。 -`workflow_dispatch` 也是纯演练入口,并额外复用完整 Check。工作流进入默认分支后,可通过 Actions 页面或 GitHub CLI 手动选择分支运行。演练使用所选提交的包版本生成文件名,不代表该版本已经发布。 +`workflow_dispatch` 也是纯演练入口,并额外复用完整 Check。工作流进入默认分支后,可通过 Actions 页面或 GitHub CLI 手动选择分支运行。演练使用所选提交的包版本核对二进制和生成正文,不代表该版本已经发布。 本地可先检查版本和平台清单: @@ -132,7 +134,7 @@ bun scripts/release.ts metadata 任何 `v*` 标签 push 都进入发布校验,不受分支演练的路径条件限制。包版本必须是合法 SemVer,标签必须严格等于 `v`,且仓库需包含非空许可证文件。已经公开的标签版本会被拒绝,不能通过重跑覆盖。 -元数据校验通过后,同一标签提交的六组 Check 与五平台产物验收并行执行。只有源码检查、产物验收和汇总全部成功,才创建或恢复该标签的未公开草稿,上传五份文件和校验和。公开前还会核对远端资产集合,缺失或额外文件均令流程失败并保留草稿;文件上传与核对全部成功后自动公开。草稿用于承接上传过程和失败重试,不需要额外人工审批。 +元数据校验通过后,同一标签提交的六组 Check 与五平台产物验收并行执行。只有源码检查、产物验收和汇总全部成功,才创建或恢复该标签的未公开草稿,上传五份 ZIP 和校验和。Release 正文自动包含对应版本的下载表、安装说明与 GitHub 生成的变更说明。公开前还会核对远端资产集合,缺失或额外文件均令流程失败并保留草稿;文件上传与核对全部成功后自动公开。草稿用于承接上传过程和失败重试,不需要额外人工审批。 SemVer 包含预发布段的版本自动标记为 prerelease,且不会标记为 latest;稳定版的 latest 选择交给 GitHub 默认规则。同一标签的运行串行执行,不中断正在进行的发布。 @@ -150,4 +152,4 @@ gh run rerun --failed - 为源码接口确定版本号,更新 `package.json` 并核对版本输出 - 完成待发布提交的检查,审查平台产物和支持边界 -`bun run version` 使用 bumpp 调整 `package.json`,不自动提交、创建标签或推送。版本变更通过 PR 合入主分支后,再创建与包版本一致的标签并推送该标签,触发自动发布。发布完成后检查 Release 的标签、产物和校验和,并更新安装说明。 +`bun run version` 使用 bumpp 调整 `package.json`,不自动提交、创建标签或推送。版本变更通过 PR 合入主分支后,再创建与包版本一致的标签并推送该标签,触发自动发布。发布完成后检查 Release 的标签、下载表、ZIP 内容和校验和,并按安装指南核对下载后的版本输出。 diff --git a/README.md b/README.md index c1f597d..ec9badd 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,37 @@ java-run 为 Maven 和 Gradle 源码工作区提供统一的开发启动入口 ## 快速开始 -从源码使用需要 [Bun](https://bun.sh) **1.4.2**,版本固定在 `.bun-version`;运行 Java 项目还需要符合该项目要求的 JDK,以及 Maven / Gradle 或项目 Wrapper。JDK 的 `java` 和 `jar` 必须可用。 +从 [GitHub Releases](https://github.com/Vanisper/java-run/releases) 下载对应系统和体系结构的 ZIP。预编译版本不需要 Bun;运行 Java 项目仍需要符合该项目要求的 JDK,以及 Maven / Gradle 或项目 Wrapper。JDK 的 `java` 和 `jar` 必须可用。 -在 java-run 仓库中安装锁定依赖并编译本机二进制: +| 系统与体系结构 | 最新稳定版下载 | +| --- | --- | +| 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) | + +macOS / Linux 使用 `uname -m` 查看体系结构:`x86_64` 对应 x64,`arm64` / `aarch64` 对应 arm64。Windows 可在“设置 → 系统 → 关于”查看系统类型,当前提供 x64 版本。Linux 产物在 Ubuntu 24.04 的 glibc 环境验收;musl(如 Alpine)和其他系统版本未纳入发布验收。 + +按照 [安装指南](docs/installation.md) 核对 SHA-256、解压并将可执行文件加入 PATH;ZIP 内也附有独立的 `INSTALL.md`。随后在 Java 工作区根目录执行: ```sh -bun --version -bun install --frozen-lockfile -bun run compile +java-run ``` -产物为 `dist/java-run`,Windows 使用 `dist/java-run.exe`。把产物放到 PATH 后,在 Java 工作区根目录执行: +### 从源码构建 + +源码构建需要 [Bun](https://bun.sh) **1.4.2**,版本固定在 `.bun-version`。 + +在 java-run 仓库中安装锁定依赖并编译本机二进制: ```sh -java-run +bun --version +bun install --frozen-lockfile +bun run compile ``` -编译后的二进制不需要安装 Bun,仍需要 JDK 和目标项目的构建工具。源码调试可以直接使用: +产物为 `dist/java-run`,Windows 使用 `dist/java-run.exe`。将产物放入 PATH 后即可使用上述命令。源码调试可以直接使用: ```sh bun run src/cli.ts --cwd /path/to/java-project @@ -201,6 +215,7 @@ Windows 上的 Maven 配置根超出系统代码页时,需要 Maven **3.9.2 | 文档 | 内容 | | --- | --- | +| [安装指南](docs/installation.md) | 平台选择、下载安装、校验和升级 | | [架构设计](docs/architecture.md) | 产品边界、模块职责、构建适配与跨平台运行契约 | | [技术路线](docs/roadmap.md) | 已有工程能力、支持缺口和后续工作的验收条件 | | [参与开发](CONTRIBUTING.md) | 本地开发、验证、分支协作与发布流程 | diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..9280e2d --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,96 @@ +# 安装 java-run + +java-run 的预编译版本可直接执行,不需要安装 Bun。运行目标项目仍需要符合该项目要求的 JDK,以及 Maven / Gradle 或项目 Wrapper;JDK 的 `java` 和 `jar` 必须可用。项目存在 `mvnw` / `mvnw.cmd` 或 `gradlew` / `gradlew.bat` 时优先使用 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 中。指定版本的下载地址格式为: + +```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 +``` + +将 `vX.Y.Z` 替换为所选 Release 的完整标签;预发布版本也使用完整标签下载。`/releases/latest/download/` 用于最新稳定版。ZIP 解压后只有一个 `java-run-<平台>/` 目录,内含 `java-run` 或 `java-run.exe`、本安装指南 `INSTALL.md`,以及存在时按原文件名附带的许可证。 + +## 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" +chmod +x "java-run-${platform}/java-run" +"./java-run-${platform}/java-run" version +mkdir -p "$HOME/.local/bin" +cp "java-run-${platform}/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` 中 `java-run-windows-x64.zip` 对应的值核对,比较时忽略字母大小写。一致后解压并检查版本: + +```powershell +Expand-Archive -Path .\java-run-windows-x64.zip -DestinationPath .\java-run-download +& .\java-run-download\java-run-windows-x64\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-download\java-run-windows-x64\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 help`。多模块或多个主类的选择、应用参数与项目配置见 [项目 README](https://github.com/Vanisper/java-run#readme)。 + +升级时下载所选版本的 ZIP 与校验和,完成上述核对和版本检查,再替换 PATH 中已有的可执行文件。Windows 先退出正在使用该可执行文件的进程。升级后运行 `java-run version`,确认结果与所选 Release 的版本一致;回退也可按同样步骤安装指定旧版本。 + +从源码构建与参与开发见 [项目仓库](https://github.com/Vanisper/java-run)。 diff --git a/docs/roadmap.md b/docs/roadmap.md index d54fa5f..17e8d62 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,7 +14,7 @@ java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供 | 启动正确性 | 目标类路径隔离、测试作用域开关、class 文件入口扫描、Manifest URL 编码 | 主类和类路径有直接执行验证;自动发现仅支持传统 main,运行依赖于现有 classpath 契约 | | 进程管理 | 异步执行、结构化退出状态、子环境覆盖、平台专用进程树清理 | 失败能够定位到阶段,退出码保留;Windows 强制终止不能保证应用关闭钩子执行 | | 验证 | Bun 回归、真实 JDK、Maven / Gradle 夹具、编译后二进制 smoke | 覆盖正常启动、依赖与资源变化、参数、配置、目标隔离和平台边界;夹具结果不代表所有第三方构建插件兼容 | -| 分发 | 固定 Bun、统一编译入口、五平台原生产物验收与受检标签发布 | 版本、许可证和产物集合有发布门禁;首次正式交付仍需完成发布说明与安装核对 | +| 分发 | 固定 Bun、统一编译入口、五平台 ZIP 原生验收、SHA-256 与受检标签发布 | 版本、许可证和产物集合有发布门禁;固定下载入口和包内安装指南可直接使用,实际发布后仍需核对下载与安装 | ## 验证与支持依据 @@ -31,11 +31,13 @@ java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供 - 静态计划、帮助和版本遵守各自的副作用契约 - Windows 编码、Maven 配置根和批处理调用有对应的原生验证 -[Release 工作流](../.github/workflows/release.yaml) 在固定原生 runner 上构建 Windows x64、Linux x64 / arm64、macOS x64 / arm64 五种压缩二进制。每份文件在 JDK 21 下执行完整 smoke,在 JDK 17 下执行 quick 启动验收,随后上传同一文件。汇总要求五种非空普通文件齐全,并生成和核对 SHA-256。 +[Release 工作流](../.github/workflows/release.yaml) 在固定原生 runner 上构建 Windows x64、Linux x64 / arm64、macOS x64 / arm64 五种压缩二进制。每份文件在 JDK 21 下执行完整 smoke,在 JDK 17 下执行 quick 启动验收,再与独立安装指南、许可证打包为 ZIP。实际 ZIP 经原生解压、二进制字节一致性检查与 quick 启动验收后上传。汇总要求五种非空 ZIP 齐全,并生成和核对 SHA-256。 -标签发布先校验 SemVer、标签与包版本的一致性、非空许可证文件及发布状态。六组 Check 与五平台产物验收并行执行;全部通过后,上传完整资产并自动公开。预发布版本自动标记为 prerelease,已有公开版本不覆盖。分支路径触发与手动触发仅演练构建验收,不创建 Release;具体入口与重试规则见 [发布指南](../CONTRIBUTING.md#ci-与发布)。 +ZIP 名称固定为 `java-run-<平台>.zip`,版本由 GitHub Release 的下载路径表达;每份包内使用固定的可执行文件名。Release 正文自动生成版本下载表、安装说明与 GitHub 变更说明,安装指南同时随 ZIP 分发。[安装指南](installation.md) 说明最新稳定版和指定版本的下载、校验、PATH 配置与升级方式。 -验证矩阵表达所用系统、体系结构与 JDK 的实际覆盖:Check 使用两个 JDK 执行完整回归,发布文件在 JDK 17 上采用较小的启动套件。固定 runner 的验收也不构成所有系统版本、CPU 或第三方构建插件的兼容承诺。 +标签发布先校验 SemVer、标签与包版本的一致性、非空许可证文件及发布状态。六组 Check 与五平台产物验收并行执行;全部通过后,上传完整资产并自动公开。预发布版本自动标记为 prerelease,已有公开版本不覆盖。分支路径触发与手动触发演练构建验收并预览下载与安装正文,不创建 Release;具体入口与重试规则见 [发布指南](../CONTRIBUTING.md#ci-与发布)。 + +验证矩阵表达所用系统、体系结构与 JDK 的实际覆盖:Check 使用两个 JDK 执行完整回归,发布文件在 JDK 17 上采用较小的启动套件。Linux 使用 Ubuntu 24.04 的 glibc 环境,musl 与其他系统版本未纳入发布验收;固定 runner 的验收也不构成所有 CPU 或第三方构建插件的兼容承诺。 ## 技术路线 diff --git a/scripts/compile.ts b/scripts/compile.ts index 155fdec..6abecac 100644 --- a/scripts/compile.ts +++ b/scripts/compile.ts @@ -3,8 +3,8 @@ import { resolve } from 'node:path'; const projectRoot = resolve(import.meta.dir, '..'); const targets = [ - 'bun-windows-x64-baseline', - 'bun-linux-x64-baseline', + 'bun-windows-x64', + 'bun-linux-x64', 'bun-linux-arm64', 'bun-darwin-arm64', 'bun-darwin-x64', diff --git a/scripts/release.ts b/scripts/release.ts index fb26eb0..cab0d5e 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,23 +1,27 @@ import { createHash } from 'node:crypto'; import { createReadStream, appendFileSync, readFileSync } from 'node:fs'; -import { lstat, readdir, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +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'; const projectRoot = resolve(import.meta.dir, '..'); const platforms = [ - { name: 'windows-x64-baseline', runner: 'windows-2025', target: 'bun-windows-x64-baseline', extension: '.exe' }, - { name: 'linux-x64-baseline', runner: 'ubuntu-24.04', target: 'bun-linux-x64-baseline', extension: '' }, - { name: 'linux-arm64', runner: 'ubuntu-24.04-arm', target: 'bun-linux-arm64', extension: '' }, - { name: 'darwin-arm64', runner: 'macos-15', target: 'bun-darwin-arm64', extension: '' }, - { name: 'darwin-x64', runner: 'macos-15-intel', target: 'bun-darwin-x64', extension: '' }, + { 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; +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; file: string }[] }; + matrix: { include: { name: string; runner: string; target: string; binary: string; archive: string }[] }; } /** 校验 SemVer 和可选标签,生成唯一的发布文件清单 */ @@ -34,14 +38,145 @@ export function releaseMetadata(version: string, requestedTag?: string): Release version, tag, prerelease: match[4] !== undefined, matrix: { include: platforms.map(platform => ({ name: platform.name, runner: platform.runner, target: platform.target, - file: `java-run-${platform.name}-${tag}${platform.extension}`, + binary: platform.binary, archive: `java-run-${platform.name}.zip`, })) }, }; } +/** 为当前版本生成固定下载链接、安装要求和升级说明 */ +export function releaseNotes(version: string): string { + const { tag, matrix } = releaseMetadata(version); + const download = `${repository}/releases/download/${encodeURIComponent(tag)}`; + const rows = matrix.include.map((platform, index) => `| ${platforms[index]!.label} | [${platform.archive}](${download}/${platform.archive}) |`); + return `## 下载与安装 + +下载对应系统与处理器的 ZIP 后解压,无需安装 Bun。本机需要满足 Java 项目要求的 JDK;优先使用项目的 Maven / Gradle Wrapper,没有 Wrapper 时需安装对应构建工具。 + +| 平台 | 下载 | +| --- | --- | +${rows.join('\n')} + +每个 ZIP 包含 \`java-run-<平台>/\` 目录,内含 \`java-run\`(Windows 为 \`java-run.exe\`)、\`INSTALL.md\` 和项目许可证。 + +[SHA256 校验和](${download}/SHA256SUMS) · [安装指南](${repository}/blob/${encodeURIComponent(tag)}/docs/installation.md) + +解压后在该目录执行 \`./java-run --version\`,Windows PowerShell 执行 \`.\\java-run.exe --version\`,应输出 \`java-run ${version}\`。按安装指南加入 PATH 后,即可在 Java 项目目录使用 \`java-run\`。 + +升级时下载新版本的对应 ZIP,校验后用其中的可执行文件替换原文件,再执行 \`java-run --version\` 确认版本。 +`; +} + +function platformFor(name: string) { + const platform = platforms.find(platform => platform.name === name); + if (!platform) throw new Error(`不支持的发布平台:${name}`); + return { ...platform, folder: `java-run-${platform.name}`, archive: `java-run-${platform.name}.zip` }; +} + +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 }, + }); +} + +async function digest(path: string): Promise { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +/** 打包当前平台文件,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 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); + 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 expected = [platform.binary, ...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) }, ...documents]) { + const unpacked = join(folder, file.name); + await assertFile(unpacked); + await assertFile(file.source); + if (await digest(unpacked) !== await digest(file.source)) throw new Error(`ZIP 文件内容不一致:${file.name}`); + } + 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.file), 'SHA256SUMS'].sort(); + 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 资产集合与已验收文件不一致,请检查草稿中的缺失或额外资产'); @@ -54,7 +189,7 @@ export function assertReleaseAssets(assets: unknown, version: string): void { * @description 缺失、多余、空文件或非普通文件均失败;允许重新生成已有清单,校验通过前不覆盖它 */ export async function writeReleaseChecksums(directory: string, version: string): Promise { - const expected = releaseMetadata(version).matrix.include.map(platform => platform.file).sort(); + 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('、')}`); @@ -64,9 +199,7 @@ export async function writeReleaseChecksums(directory: string, version: string): const path = resolve(directory, file); const stat = await lstat(path); if (!stat.isFile() || stat.size === 0) throw new Error(`发布产物不是非空普通文件:${file}`); - const hash = createHash('sha256'); - for await (const chunk of createReadStream(path)) hash.update(chunk); - lines.push(`${hash.digest('hex')} ${file}`); + lines.push(`${await digest(path)} ${file}`); } const manifest = `${lines.join('\n')}\n`; await writeFile(resolve(directory, 'SHA256SUMS'), manifest); @@ -76,12 +209,11 @@ export async function writeReleaseChecksums(directory: string, version: string): 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 !== 1) throw new Error('用法:bun scripts/release.ts metadata | checksums | verify-assets'); + 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') { - const licenses = ['LICENSE', 'LICENSE.md', 'LICENSE.txt']; - if (!licenses.some(file => { + if (!licenseNames.some(file => { try { return readFileSync(resolve(projectRoot, file), 'utf8').trim().length > 0; } catch { return false; } })) throw new Error('正式发布需要先补齐 LICENSE 文件'); } @@ -90,6 +222,16 @@ async function main(argv: string[]): Promise { 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') { diff --git a/tests/compile.test.ts b/tests/compile.test.ts index 3e94bf1..2634a93 100644 --- a/tests/compile.test.ts +++ b/tests/compile.test.ts @@ -13,7 +13,7 @@ describe('独立二进制构建', () => { const root = resolve('compile-workspace'); expect(parseCompileOptions([], root)).toEqual({ outfile: join(root, 'dist/java-run') }); for (const target of [ - 'bun-windows-x64-baseline', 'bun-linux-x64-baseline', 'bun-linux-arm64', 'bun-darwin-arm64', 'bun-darwin-x64', + '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') }); diff --git a/tests/release.test.ts b/tests/release.test.ts index b60e01e..f999227 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -1,18 +1,22 @@ import { describe, expect, test } from 'bun:test'; import { createHash } from 'node:crypto'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { assertReleaseAssets, releaseMetadata, writeReleaseChecksums } from '../scripts/release'; +import { spawnSync } from 'node:child_process'; +import { assertReleaseAssets, packageRelease, releaseMetadata, releaseNotes, verifyReleasePackage, writeReleaseChecksums } from '../scripts/release'; 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.file)).size).toBe(5); - expect(stable.matrix.include.find(platform => platform.name === 'windows-x64-baseline')?.file) - .toBe('java-run-windows-x64-baseline-v1.2.3.exe'); + 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('版本不一致'); @@ -22,17 +26,17 @@ describe('发布产物契约', () => { }); test('草稿只允许验收过的文件与校验和,旧资产或缺失文件阻止公开', () => { - const assets = [...releaseMetadata('1.2.3').matrix.include.map(platform => platform.file), 'SHA256SUMS']; + 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, 'old-binary'], '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.file); + 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('产物集合'); @@ -55,4 +59,80 @@ describe('发布产物契约', () => { 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(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 { + 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)); + 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 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 }); } + }); + + 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 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 }); } + }); }); From 98e4c2abb917e67bbd0a77f0017d9a0109415caf Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 02:06:54 +0800 Subject: [PATCH 18/26] =?UTF-8?q?refactor(build):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=BC=96=E8=AF=91=E4=B8=8E=E5=8F=91=E5=B8=83=E7=9A=84=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yaml | 1 + CONTRIBUTING.md | 2 +- scripts/compile.ts | 15 +++++---------- scripts/platforms.ts | 24 ++++++++++++++++++++++++ scripts/release.ts | 20 ++++---------------- 5 files changed, 35 insertions(+), 27 deletions(-) create mode 100644 scripts/platforms.ts diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index dd4d32d..e364fcb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -10,6 +10,7 @@ on: - '.github/workflows/release.yaml' - '.github/workflows/check.yaml' - 'scripts/compile.ts' + - 'scripts/platforms.ts' - 'scripts/release.ts' - 'scripts/smoke.ts' - 'docs/installation.md' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48c082e..24ee3a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -114,7 +114,7 @@ docs: 说明参数传递规则 每个任务使用 `bun run compile` 生成一次待分发的压缩二进制,在 JDK 21 下执行完整 smoke。随后将该文件打包,不重新构建:Linux / macOS 使用 `zip`,Windows 使用 PowerShell `Compress-Archive`。实际 ZIP 会在原生环境中解压,核对其中二进制与已验收文件的字节一致,再使用 JDK 17 对解压出的文件执行 quick 启动验收。 -每份 ZIP 包含一个 `java-run-<平台>/` 目录,目录内为固定名称的 `java-run` 或 `java-run.exe`、从 [安装指南](docs/installation.md) 复制的 `INSTALL.md`,以及存在的许可证文件。平台清单、包内目录和公开资产名称由 [发布脚本](scripts/release.ts) 统一生成。公开名称不带版本号,版本由 `/releases/download/v<版本>/` URL 表达;`/releases/latest/download/` 提供最新稳定版的固定下载入口。 +每份 ZIP 包含一个 `java-run-<平台>/` 目录,目录内为固定名称的 `java-run` 或 `java-run.exe`、从 [安装指南](docs/installation.md) 复制的 `INSTALL.md`,以及存在的许可证文件。编译参数、发布矩阵、包内目录和公开资产名称共用 [平台清单](scripts/platforms.ts)。公开名称不带版本号,版本由 `/releases/download/v<版本>/` URL 表达;`/releases/latest/download/` 提供最新稳定版的固定下载入口。 汇总步骤要求恰好包含上述五种非空 ZIP,拒绝缺失、多余或无效产物,然后生成并核对覆盖五个 ZIP 的 `SHA256SUMS`。源码 Check 的双 JDK 完整回归与发布文件的 JDK 21 full / JDK 17 quick 范围分别声明,不能将 quick 扩大为完整验收。Linux 验收采用 Ubuntu 24.04 的 glibc 环境,不代表 musl 或其他系统版本已通过验收。 diff --git a/scripts/compile.ts b/scripts/compile.ts index 6abecac..b3fed2c 100644 --- a/scripts/compile.ts +++ b/scripts/compile.ts @@ -1,18 +1,12 @@ import { spawnSync } from 'node:child_process'; import { resolve } from 'node:path'; +import { platforms, type BuildTarget } from './platforms'; const projectRoot = resolve(import.meta.dir, '..'); -const targets = [ - 'bun-windows-x64', - 'bun-linux-x64', - 'bun-linux-arm64', - 'bun-darwin-arm64', - 'bun-darwin-x64', -] as const; /** 本机或指定平台的独立二进制构建配置 */ export interface CompileOptions { - target?: typeof targets[number]; + target?: BuildTarget; outfile: string; } @@ -33,8 +27,9 @@ export function parseCompileOptions(argv: readonly string[], cwd = projectRoot): const value = separator < 0 ? '' : argument.slice(separator + 1); if (!value.trim() || /[\r\n\0]/.test(value)) throw new Error(`${name} 必须使用 ${name}= 指定有效值`); if (name === '--target') { - if (!targets.includes(value as typeof targets[number])) throw new Error(`--target 仅支持 ${targets.join('、')}`); - options.target = value as typeof targets[number]; + 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); } 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 index cab0d5e..9863494 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -4,15 +4,9 @@ import { chmod, copyFile, lstat, mkdir, mkdtemp, readdir, rename, rm, writeFile import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; +import { platforms, platformFor } from './platforms'; const projectRoot = resolve(import.meta.dir, '..'); -const platforms = [ - { 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; const licenseNames = ['LICENSE', 'LICENSE.md', 'LICENSE.txt']; const repository = 'https://github.com/Vanisper/java-run'; @@ -38,16 +32,16 @@ export function releaseMetadata(version: string, requestedTag?: string): Release version, tag, prerelease: match[4] !== undefined, matrix: { include: platforms.map(platform => ({ name: platform.name, runner: platform.runner, target: platform.target, - binary: platform.binary, archive: `java-run-${platform.name}.zip`, + binary: platform.binary, archive: platform.archive, })) }, }; } /** 为当前版本生成固定下载链接、安装要求和升级说明 */ export function releaseNotes(version: string): string { - const { tag, matrix } = releaseMetadata(version); + const { tag } = releaseMetadata(version); const download = `${repository}/releases/download/${encodeURIComponent(tag)}`; - const rows = matrix.include.map((platform, index) => `| ${platforms[index]!.label} | [${platform.archive}](${download}/${platform.archive}) |`); + const rows = platforms.map(platform => `| ${platform.label} | [${platform.archive}](${download}/${platform.archive}) |`); return `## 下载与安装 下载对应系统与处理器的 ZIP 后解压,无需安装 Bun。本机需要满足 Java 项目要求的 JDK;优先使用项目的 Maven / Gradle Wrapper,没有 Wrapper 时需安装对应构建工具。 @@ -66,12 +60,6 @@ ${rows.join('\n')} `; } -function platformFor(name: string) { - const platform = platforms.find(platform => platform.name === name); - if (!platform) throw new Error(`不支持的发布平台:${name}`); - return { ...platform, folder: `java-run-${platform.name}`, archive: `java-run-${platform.name}.zip` }; -} - async function assertFile(path: string): Promise { const stat = await lstat(path); if (!stat.isFile() || stat.size === 0) throw new Error(`文件不是非空普通文件:${path}`); From 9ea4a2f2905b860c42b69ccb93bee745ef92c835 Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 02:13:20 +0800 Subject: [PATCH 19/26] =?UTF-8?q?test(release):=20=E4=B8=BA=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=20ZIP=20=E9=AA=8C=E8=AF=81=E8=AE=BE=E7=BD=AE=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/release.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/release.test.ts b/tests/release.test.ts index f999227..a6b5ff1 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -114,7 +114,7 @@ describe('发布产物契约', () => { 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-')); @@ -134,5 +134,5 @@ describe('发布产物契约', () => { 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); }); From 6181efbd0e12eaaf314d2659c059716729bfb1ca Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 03:12:44 +0800 Subject: [PATCH 20/26] =?UTF-8?q?feat(release):=20=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=BA=8C=E8=BF=9B=E5=88=B6=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E5=92=8C=E5=B9=B6=E9=9A=8F=20ZIP=20=E5=88=86=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yaml | 1 + CONTRIBUTING.md | 20 +++++-- README.md | 4 +- docs/installation.md | 42 ++++++++++++--- docs/roadmap.md | 2 +- scripts/checksum.ts | 47 ++++++++++++++++ scripts/compile.ts | 15 ++++-- scripts/release.ts | 33 +++++++----- tests/compile.test.ts | 98 ++++++++++++++++++++++++++++++++-- tests/release.test.ts | 62 +++++++++++++++++++++ 10 files changed, 286 insertions(+), 38 deletions(-) create mode 100644 scripts/checksum.ts diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e364fcb..86b16d0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -10,6 +10,7 @@ on: - '.github/workflows/release.yaml' - '.github/workflows/check.yaml' - 'scripts/compile.ts' + - 'scripts/checksum.ts' - 'scripts/platforms.ts' - 'scripts/release.ts' - 'scripts/smoke.ts' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24ee3a1..c2825c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,10 +24,10 @@ bun run smoke | `bun run typecheck` | TypeScript 类型检查 | | `bun test` | 参数、配置、适配器、主类、类路径、交互和进程行为回归 | | `bun run check` | 类型检查与回归测试 | -| `bun run compile` | 构建压缩后的本机二进制到 `dist`,与发布产物使用同一入口 | +| `bun run compile` | 构建压缩后的本机二进制及其 `.sha256` 校验文件到 `dist`,与发布产物使用同一入口 | | `bun run smoke` | 使用该二进制运行真实 Maven / Gradle 夹具 | -编译入口支持等号形式的 `--target=<目标>` 和 `--outfile=<路径>`,可用目标与发布平台表一致。默认产物为 `dist/java-run`,Windows 自动使用 `.exe` 扩展名。二进制不自动加载运行目录中的 `.env` 或 `bunfig.toml`,显式继承的环境变量仍然生效。 +编译入口支持等号形式的 `--target=<目标>` 和 `--outfile=<路径>`,可用目标与发布平台表一致。默认产物为 `dist/java-run`,Windows 自动使用 `.exe` 扩展名。编译成功后在实际二进制旁生成 `<二进制文件名>.sha256`,例如 `dist/java-run.sha256` 或 `dist/java-run.exe.sha256`;自定义输出路径也使用同样规则。校验文件使用标准 SHA-256 清单格式,文件名相对于其所在目录。二进制不自动加载运行目录中的 `.env` 或 `bunfig.toml`,显式继承的环境变量仍然生效。 回归测试中的真实 Gradle 用例使用 PATH 中的 Gradle;找不到时会跳过,不能据此判断 Gradle 适配已经通过验收。可通过 `JAVA_RUN_TEST_GRADLE` 指定测试命令,通过 `GRADLE_USER_HOME` 隔离测试用的 Gradle 缓存: @@ -112,11 +112,11 @@ docs: 说明参数传递规则 | `java-run-darwin-arm64.zip` | `bun-darwin-arm64` | `macos-15` | | `java-run-darwin-x64.zip` | `bun-darwin-x64` | `macos-15-intel` | -每个任务使用 `bun run compile` 生成一次待分发的压缩二进制,在 JDK 21 下执行完整 smoke。随后将该文件打包,不重新构建:Linux / macOS 使用 `zip`,Windows 使用 PowerShell `Compress-Archive`。实际 ZIP 会在原生环境中解压,核对其中二进制与已验收文件的字节一致,再使用 JDK 17 对解压出的文件执行 quick 启动验收。 +每个任务使用 `bun run compile` 生成一次待分发的压缩二进制和对应校验文件,在 JDK 21 下执行完整 smoke。打包前按编译时的校验文件核对源二进制,再将这两个文件一起打包:Linux / macOS 使用 `zip`,Windows 使用 PowerShell `Compress-Archive`。实际 ZIP 会在原生环境中解压,核对包内二进制及校验文件与源文件一致,并按包内校验文件验证二进制,再使用 JDK 17 对解压出的文件执行 quick 启动验收。 -每份 ZIP 包含一个 `java-run-<平台>/` 目录,目录内为固定名称的 `java-run` 或 `java-run.exe`、从 [安装指南](docs/installation.md) 复制的 `INSTALL.md`,以及存在的许可证文件。编译参数、发布矩阵、包内目录和公开资产名称共用 [平台清单](scripts/platforms.ts)。公开名称不带版本号,版本由 `/releases/download/v<版本>/` URL 表达;`/releases/latest/download/` 提供最新稳定版的固定下载入口。 +每份 ZIP 包含一个 `java-run-<平台>/` 目录,目录内为固定名称的 `java-run` 或 `java-run.exe`、对应的 `java-run.sha256` 或 `java-run.exe.sha256`、从 [安装指南](docs/installation.md) 复制的 `INSTALL.md`,以及存在的许可证文件。编译参数、发布矩阵、包内目录和公开资产名称共用 [平台清单](scripts/platforms.ts)。公开名称不带版本号,版本由 `/releases/download/v<版本>/` URL 表达;`/releases/latest/download/` 提供最新稳定版的固定下载入口。 -汇总步骤要求恰好包含上述五种非空 ZIP,拒绝缺失、多余或无效产物,然后生成并核对覆盖五个 ZIP 的 `SHA256SUMS`。源码 Check 的双 JDK 完整回归与发布文件的 JDK 21 full / JDK 17 quick 范围分别声明,不能将 quick 扩大为完整验收。Linux 验收采用 Ubuntu 24.04 的 glibc 环境,不代表 musl 或其他系统版本已通过验收。 +汇总步骤要求恰好包含上述五种非空 ZIP,拒绝缺失、多余或无效产物,然后生成并核对覆盖五个 ZIP 的 `SHA256SUMS`。包内 `.sha256` 校验二进制内容,Release 单独提供的 `SHA256SUMS` 校验最终下载的 ZIP。源码 Check 的双 JDK 完整回归与发布文件的 JDK 21 full / JDK 17 quick 范围分别声明,不能将 quick 扩大为完整验收。Linux 验收采用 Ubuntu 24.04 的 glibc 环境,不代表 musl 或其他系统版本已通过验收。 ### 发布流程演练 @@ -130,6 +130,16 @@ docs: 说明参数传递规则 bun scripts/release.ts metadata ``` +在原生机器上可构建并验收对应平台的 ZIP,例如 macOS arm64: + +```sh +bun run compile +bun run smoke --suite=full +bun scripts/release.ts package darwin-arm64 +``` + +`package` 使用已有的二进制和校验文件,输出 `dist/java-run-darwin-arm64.zip`。其他平台使用对应的平台名,并在该平台的原生机器上运行。 + ### 标签发布与重试 任何 `v*` 标签 push 都进入发布校验,不受分支演练的路径条件限制。包版本必须是合法 SemVer,标签必须严格等于 `v`,且仓库需包含非空许可证文件。已经公开的标签版本会被拒绝,不能通过重跑覆盖。 diff --git a/README.md b/README.md index ec9badd..aeb71b5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ java-run 为 Maven 和 Gradle 源码工作区提供统一的开发启动入口 macOS / Linux 使用 `uname -m` 查看体系结构:`x86_64` 对应 x64,`arm64` / `aarch64` 对应 arm64。Windows 可在“设置 → 系统 → 关于”查看系统类型,当前提供 x64 版本。Linux 产物在 Ubuntu 24.04 的 glibc 环境验收;musl(如 Alpine)和其他系统版本未纳入发布验收。 -按照 [安装指南](docs/installation.md) 核对 SHA-256、解压并将可执行文件加入 PATH;ZIP 内也附有独立的 `INSTALL.md`。随后在 Java 工作区根目录执行: +按照 [安装指南](docs/installation.md) 核对 ZIP 和包内二进制的 SHA-256,再将可执行文件加入 PATH;ZIP 内附有二进制校验文件和独立的 `INSTALL.md`。随后在 Java 工作区根目录执行: ```sh java-run @@ -34,7 +34,7 @@ bun install --frozen-lockfile bun run compile ``` -产物为 `dist/java-run`,Windows 使用 `dist/java-run.exe`。将产物放入 PATH 后即可使用上述命令。源码调试可以直接使用: +产物为 `dist/java-run` 和 `dist/java-run.sha256`;Windows 使用 `dist/java-run.exe` 和 `dist/java-run.exe.sha256`。校验文件记录二进制的 SHA-256,可按[安装指南](docs/installation.md)中的二进制校验命令在 `dist` 目录核对。将可执行文件放入 PATH 后即可使用上述命令。源码调试可以直接使用: ```sh bun run src/cli.ts --cwd /path/to/java-project diff --git a/docs/installation.md b/docs/installation.md index 9280e2d..947b241 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -25,7 +25,7 @@ https://github.com/Vanisper/java-run/releases/download/vX.Y.Z/java-run-<平台>. https://github.com/Vanisper/java-run/releases/download/vX.Y.Z/SHA256SUMS ``` -将 `vX.Y.Z` 替换为所选 Release 的完整标签;预发布版本也使用完整标签下载。`/releases/latest/download/` 用于最新稳定版。ZIP 解压后只有一个 `java-run-<平台>/` 目录,内含 `java-run` 或 `java-run.exe`、本安装指南 `INSTALL.md`,以及存在时按原文件名附带的许可证。 +将 `vX.Y.Z` 替换为所选 Release 的完整标签;预发布版本也使用完整标签下载。`/releases/latest/download/` 用于最新稳定版。ZIP 解压后只有一个 `java-run-<平台>/` 目录,内含 `java-run` 或 `java-run.exe`、对应的 `java-run.sha256` 或 `java-run.exe.sha256`、本安装指南 `INSTALL.md`,以及存在时按原文件名附带的许可证。Release 的 `SHA256SUMS` 校验下载的 ZIP,包内 `.sha256` 校验解压后的二进制。 ## macOS / Linux @@ -55,10 +55,28 @@ sha256sum "$asset" ```sh unzip "$asset" -chmod +x "java-run-${platform}/java-run" -"./java-run-${platform}/java-run" version +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-${platform}/java-run" "$HOME/.local/bin/java-run" +cp java-run "$HOME/.local/bin/java-run" export PATH="$HOME/.local/bin:$PATH" java-run version ``` @@ -73,14 +91,24 @@ java-run version Get-FileHash -Algorithm SHA256 .\java-run-windows-x64.zip ``` -将结果与 `SHA256SUMS` 中 `java-run-windows-x64.zip` 对应的值核对,比较时忽略字母大小写。一致后解压并检查版本: +将结果与 `SHA256SUMS` 中 `java-run-windows-x64.zip` 对应的值核对,比较时忽略字母大小写。一致后解压并核对二进制: ```powershell Expand-Archive -Path .\java-run-windows-x64.zip -DestinationPath .\java-run-download -& .\java-run-download\java-run-windows-x64\java-run.exe version +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-download\java-run-windows-x64\java-run.exe $installDir +Copy-Item -Force .\java-run.exe $installDir $env:Path = "$installDir;$env:Path" java-run version ``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 17e8d62..7db9fa4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -31,7 +31,7 @@ java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供 - 静态计划、帮助和版本遵守各自的副作用契约 - Windows 编码、Maven 配置根和批处理调用有对应的原生验证 -[Release 工作流](../.github/workflows/release.yaml) 在固定原生 runner 上构建 Windows x64、Linux x64 / arm64、macOS x64 / arm64 五种压缩二进制。每份文件在 JDK 21 下执行完整 smoke,在 JDK 17 下执行 quick 启动验收,再与独立安装指南、许可证打包为 ZIP。实际 ZIP 经原生解压、二进制字节一致性检查与 quick 启动验收后上传。汇总要求五种非空 ZIP 齐全,并生成和核对 SHA-256。 +[Release 工作流](../.github/workflows/release.yaml) 在固定原生 runner 上构建 Windows x64、Linux x64 / arm64、macOS x64 / arm64 五种压缩二进制。编译时生成对应的 `.sha256` 校验文件,打包前核对二进制,再与校验文件、独立安装指南、许可证一起打包为 ZIP。每份二进制在 JDK 21 下执行完整 smoke;实际 ZIP 经原生解压、源文件一致性与包内 SHA-256 校验后,使用 JDK 17 对解压出的文件执行 quick 启动验收。汇总要求五种非空 ZIP 齐全,并生成和核对覆盖 ZIP 的 `SHA256SUMS`。 ZIP 名称固定为 `java-run-<平台>.zip`,版本由 GitHub Release 的下载路径表达;每份包内使用固定的可执行文件名。Release 正文自动生成版本下载表、安装说明与 GitHub 变更说明,安装指南同时随 ZIP 分发。[安装指南](installation.md) 说明最新稳定版和指定版本的下载、校验、PATH 配置与升级方式。 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 index b3fed2c..f6cfac2 100644 --- a/scripts/compile.ts +++ b/scripts/compile.ts @@ -1,5 +1,7 @@ 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, '..'); @@ -37,9 +39,12 @@ export function parseCompileOptions(argv: readonly string[], cwd = projectRoot): return options; } -/** 编译独立二进制,构建失败保留 Bun 的退出码 */ -function main(argv: string[]): number { +/** 编译独立二进制及校验和,构建失败保留 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', @@ -48,12 +53,14 @@ function main(argv: string[]): number { if (options.target) args.push(`--target=${options.target}`); const result = spawnSync(process.execPath, args, { cwd: projectRoot, stdio: 'inherit' }); if (result.error) throw result.error; - return result.status ?? 1; + const status = result.status ?? 1; + if (status === 0) await writeBinaryChecksum(binary); + return status; } if (import.meta.main) { try { - process.exitCode = main(Bun.argv.slice(2)); + 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/release.ts b/scripts/release.ts index 9863494..88919f5 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,10 +1,10 @@ -import { createHash } from 'node:crypto'; -import { createReadStream, appendFileSync, readFileSync } from 'node:fs'; +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']; @@ -50,10 +50,12 @@ export function releaseNotes(version: string): string { | --- | --- | ${rows.join('\n')} -每个 ZIP 包含 \`java-run-<平台>/\` 目录,内含 \`java-run\`(Windows 为 \`java-run.exe\`)、\`INSTALL.md\` 和项目许可证。 +每个 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\` 文件用于校验解压后的二进制。 + 解压后在该目录执行 \`./java-run --version\`,Windows PowerShell 执行 \`.\\java-run.exe --version\`,应输出 \`java-run ${version}\`。按安装指南加入 PATH 后,即可在 Java 项目目录使用 \`java-run\`。 升级时下载新版本的对应 ZIP,校验后用其中的可执行文件替换原文件,再执行 \`java-run --version\` 确认版本。 @@ -90,17 +92,12 @@ function powershell(script: string, environment: NodeJS.ProcessEnv): string { }); } -async function digest(path: string): Promise { - const hash = createHash('sha256'); - for await (const chunk of createReadStream(path)) hash.update(chunk); - return hash.digest('hex'); -} - -/** 打包当前平台文件,ZIP 内包含固定目录、安装指南及已有许可证 */ +/** 校验并打包当前平台文件,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-')); @@ -109,6 +106,8 @@ export async function packageRelease(directory: string, platformName: string, do 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); @@ -129,7 +128,8 @@ export async function verifyReleasePackage(directory: string, platformName: stri const archive = resolve(directory, platform.archive); await assertFile(archive); const documents = await packageDocuments(documentationRoot); - const expected = [platform.binary, ...documents.map(file => file.name)].map(name => `${platform.folder}/${name}`).sort(); + 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'); @@ -149,12 +149,17 @@ export async function verifyReleasePackage(directory: string, platformName: stri 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) }, ...documents]) { + 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 digest(unpacked) !== await digest(file.source)) throw new Error(`ZIP 文件内容不一致:${file.name}`); + 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 未保留可执行权限'); } @@ -187,7 +192,7 @@ export async function writeReleaseChecksums(directory: string, version: string): const path = resolve(directory, file); const stat = await lstat(path); if (!stat.isFile() || stat.size === 0) throw new Error(`发布产物不是非空普通文件:${file}`); - lines.push(`${await digest(path)} ${file}`); + lines.push(`${await sha256(path)} ${file}`); } const manifest = `${lines.join('\n')}\n`; await writeFile(resolve(directory, 'SHA256SUMS'), manifest); diff --git a/tests/compile.test.ts b/tests/compile.test.ts index 2634a93..02a8803 100644 --- a/tests/compile.test.ts +++ b/tests/compile.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { basename, join, resolve } from 'node:path'; +import { sha256, verifyBinaryChecksum, writeBinaryChecksum } from '../scripts/checksum'; import { parseCompileOptions } from '../scripts/compile'; import { version } from '../package.json'; @@ -25,12 +27,21 @@ describe('独立二进制构建', () => { ]) expect(() => parseCompileOptions(args, root)).toThrow(); }); - test('实际二进制忽略 cwd 的 dotenv 和 bunfig,保留显式继承的环境及完整版本', () => { + test('实际构建生成并更新文件名对应的校验和,二进制保留环境与版本契约', async () => { const root = mkdtempSync(join(tmpdir(), 'java-run-compile-')); try { - const executable = join(root, process.platform === 'win32' ? 'java-run.exe' : 'java-run'); - const compiled = spawnSync(process.execPath, [compileScript, `--outfile=${executable}`], { encoding: 'utf8' }); + 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 = ['); @@ -58,4 +69,81 @@ describe('独立二进制构建', () => { 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/release.test.ts b/tests/release.test.ts index a6b5ff1..5fff7f3 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -5,6 +5,28 @@ 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.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('标签必须匹配包版本,稳定版与预发布生成五个独立产物', () => { @@ -72,6 +94,8 @@ describe('发布产物契约', () => { 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/'); }); @@ -88,12 +112,16 @@ describe('发布产物契约', () => { 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') { @@ -107,6 +135,8 @@ describe('发布产物契约', () => { 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 { @@ -124,6 +154,7 @@ describe('发布产物契约', () => { 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'); @@ -135,4 +166,35 @@ describe('发布产物契约', () => { 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); }); From 9414b2c51ad1c538e7d6be2ff5edb5757856d3ed Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 03:18:33 +0800 Subject: [PATCH 21/26] =?UTF-8?q?test(release):=20=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=20Windows=20ZIP=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/release.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/release.test.ts b/tests/release.test.ts index 5fff7f3..5401570 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -12,7 +12,7 @@ function updateArchive(archive: string, entry: string, content?: string): void { try { let result; if (process.platform === 'win32') { - const script = "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() }"; + 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', From 9c160836b30057a7857fd7bb44e6f077e98e4fa3 Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 03:27:22 +0800 Subject: [PATCH 22/26] =?UTF-8?q?ci:=20=E5=B0=86=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=20Actions=20=E8=BF=81=E7=A7=BB=E5=88=B0=20Node.js=202?= =?UTF-8?q?4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 8 ++++---- .github/workflows/release.yaml | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 8d5e393..6a4bc45 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -28,9 +28,9 @@ jobs: GRADLE_USER_HOME: ${{ github.workspace }}/.cache/gradle steps: - name: 检出代码 - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 安装 JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: ${{ matrix.java }} @@ -39,11 +39,11 @@ jobs: with: bun-version-file: .bun-version - name: 安装固定版本 Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 with: gradle-version: '8.14' - name: 缓存隔离的 Maven 仓库 - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ runner.temp }}/java-run-maven-repository key: ${{ runner.os }}-jdk-${{ matrix.java }}-maven-${{ hashFiles('tests/fixtures/**/pom.xml') }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 86b16d0..ba8eb31 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -40,7 +40,7 @@ jobs: prerelease: ${{ steps.metadata.outputs.prerelease }} matrix: ${{ steps.metadata.outputs.matrix }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: oven-sh/setup-bun@v2 with: bun-version-file: .bun-version @@ -80,22 +80,22 @@ jobs: BUN_TARGET: ${{ matrix.target }} steps: - name: 检出待验收提交 - uses: actions/checkout@v4 + 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@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: '21' - name: 安装固定版本 Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 with: gradle-version: '8.14' - name: 缓存隔离的 Maven 仓库 - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ runner.temp }}/java-run-maven-repository key: ${{ runner.os }}-${{ runner.arch }}-release-maven-${{ hashFiles('tests/fixtures/**/pom.xml') }} @@ -114,7 +114,7 @@ jobs: 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@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: '17' @@ -125,7 +125,7 @@ jobs: JAVA_RUN_GRADLE_HOME: ${{ env.GRADLE_USER_HOME }} run: bun scripts/release.ts package "$RELEASE_PLATFORM" - name: 上传已验收压缩包 - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: package-${{ matrix.name }} path: dist/${{ matrix.archive }} @@ -137,12 +137,12 @@ jobs: needs: build runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: oven-sh/setup-bun@v2 with: bun-version-file: .bun-version - name: 汇总构建产物 - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: pattern: package-* merge-multiple: true @@ -157,7 +157,7 @@ jobs: bun scripts/release.ts notes > release-notes.md cat release-notes.md >> "$GITHUB_STEP_SUMMARY" - name: 保存已校验产物 - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: release-with-checksums path: dist/* @@ -177,11 +177,11 @@ jobs: RELEASE_TAG: ${{ needs.metadata.outputs.tag }} RELEASE_PRERELEASE: ${{ needs.metadata.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: oven-sh/setup-bun@v2 with: bun-version-file: .bun-version - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: name: release-with-checksums path: dist From eb3e7de7f7676f301e123e9fcea6be2eb6dd1f5e Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 04:28:50 +0800 Subject: [PATCH 23/26] =?UTF-8?q?feat(cli):=20=E6=94=AF=E6=8C=81=20init=20?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E7=94=9F=E6=88=90=E9=A1=B9=E7=9B=AE=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 3 +- README.md | 30 +++- docs/architecture.md | 20 ++- docs/roadmap.md | 7 +- examples/multiple-main/README.md | 20 ++- scripts/smoke.ts | 53 ++++-- src/cli.ts | 26 ++- src/cli/args.ts | 119 ++----------- src/cli/config.ts | 140 +++++++++++++++ src/cli/help.ts | 14 +- src/cli/init.ts | 51 ++++++ src/core/launch.ts | 12 +- src/core/types.ts | 2 +- tests/fixtures/README.md | 5 +- tests/init.test.ts | 292 +++++++++++++++++++++++++++++++ tests/parse-argvs.test.ts | 111 +++++++++++- 16 files changed, 749 insertions(+), 156 deletions(-) create mode 100644 src/cli/config.ts create mode 100644 src/cli/init.ts create mode 100644 tests/init.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2825c2..e237389 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ bun run smoke | 命令 | 验证内容 | | --- | --- | | `bun run typecheck` | TypeScript 类型检查 | -| `bun test` | 参数、配置、适配器、主类、类路径、交互和进程行为回归 | +| `bun test` | 参数、配置读写、初始化、适配器、主类、类路径、交互和进程行为回归 | | `bun run check` | 类型检查与回归测试 | | `bun run compile` | 构建压缩后的本机二进制及其 `.sha256` 校验文件到 `dist`,与发布产物使用同一入口 | | `bun run smoke` | 使用该二进制运行真实 Maven / Gradle 夹具 | @@ -91,6 +91,7 @@ docs: 说明参数传递规则 - 正常与非零退出、平台对应的信号处理和进程树清理 - 帮助、版本和静态预览的无构建行为 - 终端选择、非 TTY 行为、EOF / Ctrl+C 取消和退出码 +- `init` 保存后重复运行、参数保留、已有配置保护与 `--force` 重新生成,以及失败或取消不写配置 测试应在临时项目或夹具副本中运行,不在 `tests/fixtures` 下生成 `target`、`build`、`.gradle` 或缓存,也不修改用户提供的源仓库。环境和信号测试使用隔离子进程,避免污染其他用例。平台专用测试的跳过条件应与实际系统能力对应;跨平台结论以对应系统上的执行结果为依据。 diff --git a/README.md b/README.md index aeb71b5..8988a08 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,13 @@ bun run src/cli.ts --cwd /path/to/java-project ```sh java-run [run] [选项] [-- 应用参数...] +java-run init [选项] [-- 应用参数...] java-run plan [选项] java-run help java-run version ``` -`run` 是默认命令。`plan` 读取本地配置并预览步骤,不调用构建工具、不生成项目缓存、不发起交互;其中主类、有效项目模型和运行类路径仍未验证。`help` / `--help` / `-h` 和 `version` / `--version` 不读取项目配置。 +`run` 是默认命令。`init` 准备并选择启动目标,将结果保存为项目配置,随后执行 `java-run` 即可复用。`plan` 读取本地配置并预览步骤,不调用构建工具、不生成项目缓存、不发起交互;其中主类、有效项目模型和运行类路径仍未验证。`help` / `--help` / `-h` 和 `version` / `--version` 不读取项目配置。 多模块仓库中的聚合项目、库模块和应用模块各有职责,并非每个模块都能执行。用 `--module` 选择一个目标,用 `--main` 在需要时指定入口: @@ -71,7 +72,7 @@ Maven 接受单个 reactor 选择器,如 `app`、`:artifactId` 或 `groupId:ar 候选发现会执行构建工具配置,可能下载插件、Wrapper 分发包或准备 `buildSrc` 等构建逻辑。候选应用的编译和运行依赖解析在选定目标后执行。非交互环境和 CI 遇到目标或主类歧义时,需要通过 CLI 或 `.java-run.json` 明确指定。 -交互只选择目标和入口。运行参数通过选项或配置传入,配置由用户保存;Ctrl+C 或 EOF 取消返回 130。 +交互只选择目标和入口,运行参数通过选项或配置传入。普通运行中的选择仅用于本次启动;需要保存时使用 `init`。Ctrl+C 或 EOF 取消返回 130。 ### 多入口选择示例 @@ -142,12 +143,33 @@ java-run --module :app \ | `--include-tests` | 准备并加入测试输出和依赖,默认关闭 | | `--java ` | 显式覆盖 Java 可执行文件 | | `--build-command ` | 显式覆盖构建工具可执行文件 | +| `--force` | 仅用于 `init`,忽略已有配置并重新生成 | 未识别的选项、重复标量和无效值均报错。 ## 保存项目默认值 -在 `--cwd` 指定的工作区根目录保存 `.java-run.json`,随后可以直接执行 `java-run`: +在 Java 工作区中执行 `init`,准备项目并选择要保存的模块和主类: + +```sh +java-run init +java-run +``` + +`init` 在 `--cwd` 指定的工作区根目录生成 `.java-run.json`。它复用运行时的构建工具检测、模块选择、产物准备和入口确定流程,但不启动应用。准备可能编译源码、下载依赖、更新构建工具缓存或执行项目构建配置;Maven reactor 的自动准备仍会写入本地 Maven 仓库。只有一个候选时自动采用,多个模块或入口在终端中通过数字选择;非交互环境存在歧义时,使用 `--module` / `--main` 明确指定。 + +运行参数通过同一组选项一起保存,无需逐项回答参数问题。例如,初始化 Maven 工作区中的 `app` 模块并保存内存和应用参数: + +```sh +java-run init --module app --jvm-arg=-Xmx1g -- --server.port=8081 +java-run +``` + +生成的配置记录实际使用的构建工具、选定模块和主类、本次显式提供的三类参数,以及非默认的 `--build` / `--include-tests` 设置。`--cwd`、`--java` 和 `--build-command` 只用于本次定位或准备,不保存到配置。 + +已有 `.java-run.json` 时,`init` 在运行构建工具前报错并保留原文件。需要重新配置时使用 `java-run init --force`;它忽略旧配置、按本次选项重新生成,因此也可替换格式损坏的配置。取消或准备失败时不写入配置。 + +配置也可以直接编辑。例如: ```json { @@ -164,7 +186,7 @@ java-run --module :app \ } ``` -可用字段为 `buildTool`、`module`、`mainClass`、`jvmArgs`、`applicationArgs`、`buildArgs`、`build`、`includeTests`。配置使用严格 JSON,不支持注释、未知字段、环境变量插值或配置继承。三个参数字段必须是字符串数组,其中的元素不能是空字符串或纯空白字符串;`includeTests` 必须是布尔值。 +可用字段为 `buildTool`、`module`、`mainClass`、`jvmArgs`、`applicationArgs`、`buildArgs`、`build`、`includeTests`。配置使用严格 JSON,不支持注释、未知字段、环境变量插值或配置继承。三个参数字段必须是字符串数组;`jvmArgs` 和 `buildArgs` 的元素不能是空字符串或纯空白字符串,`applicationArgs` 原样保留空字符串和空白参数,与 `--` 透传一致。`includeTests` 必须是布尔值。 只读取该工作区根目录的配置,不向父目录搜索,也不在选定模块后重新读取。显式 CLI 标量覆盖文件值,未指定时保留文件值;数组在文件数组之后追加。例如,配置已有 `applicationArgs` 时,`--arg` 会追加参数。`--cwd`、`--java` 和 `--build-command` 仅通过 CLI 设置。 diff --git a/docs/architecture.md b/docs/architecture.md index 5fea061..4780c9c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ java-run 是框架中立的 Java 源码工作区运行器。它把“选择一 ## 运行目标 -一次请求只启动一个构建项目和一个主类。多模块工作区中的模块可能是应用,也可能是库;存在编译输出或应用 Java 插件都不能证明它可执行。 +一次运行只启动一个构建项目和一个主类,`init` 保存的配置也对应这个单一目标。多模块工作区中的模块可能是应用,也可能是库;存在编译输出或应用 Java 插件都不能证明它可执行。 模块选择和主类选择分两步完成: @@ -15,7 +15,7 @@ java-run 是框架中立的 Java 源码工作区运行器。它把“选择一 自动发现只识别目标输出中的传统 `public static void main(String[])`。未找到入口时要求检查产物或显式配置;发现多个入口时要求选择。显式主类会校验类名语法,实际可加载性和方法有效性由 Java 启动器确认。 -交互用于补齐目标和入口信息。标准输入与标准错误均为终端时,CLI 可以展示模块或主类菜单;唯一候选自动采用。非交互环境遇到目标或入口歧义时,需要通过参数或配置明确指定。菜单不询问运行参数,也不自动修改 `.java-run.json`;取消选择返回 130。 +交互用于补齐目标和入口信息。标准输入与标准错误均为终端时,CLI 可以展示模块或主类菜单;唯一候选自动采用。普通运行只使用本次选择,`init` 在准备和选择成功后保存结果。非交互环境遇到目标或入口歧义时,需要通过参数明确指定;普通运行也可读取已保存的配置。菜单不询问运行参数,参数由选项或配置提供;取消选择返回 130。 候选发现会运行构建工具。Gradle 配置期间可能需要准备 `buildSrc` 或 included build 的构建逻辑,但发现步骤不主动编译候选应用、解析其运行依赖或读取主类 Provider。 @@ -31,17 +31,23 @@ java-run 是框架中立的 Java 源码工作区运行器。它把“选择一 `run` 与 `plan` 读取解析 `--cwd` 后的工作区根目录中的 `.java-run.json`,不向父目录查找,也不在选定模块后重新读取。CLI 标量覆盖文件配置,数组参数追加到文件数组之后。工具路径由 CLI 指定,项目配置保存可复用的运行目标和参数。 +`init` 使用本次 CLI 请求准备目标、检查运行产物并确定主类,然后生成根目录的 `.java-run.json`。配置记录实际构建工具、模块和主类、本次显式提供的参数数组,以及非默认的构建策略和测试类路径设置。构建声明的默认 JVM 参数继续由构建工具提供;`--cwd`、`--java` 和 `--build-command` 不写入配置。 + +`init` 在任何外部命令执行前检查配置文件是否已存在,默认拒绝覆盖。`--force` 忽略旧配置并重新生成,不读取或合并旧字段,因此可用于替换损坏的 JSON。取消、准备失败或入口无法确定时不写配置。初始化会执行构建准备,但不创建应用 JVM;实际可加载性与入口方法有效性仍在运行时由 Java 启动器确认。 + `plan` 只读取本地构建文件和配置,输出准备步骤及待解析信息。它不执行构建工具、不创建临时目录、不交互,也不验证有效模型、主类或依赖文件。`help`、`version` 只解析 CLI 参数,不读取项目配置。 -实际运行流程如下: +运行与初始化复用同一准备流程: ```mermaid flowchart TD - request[CLI 参数与项目配置] --> target[构建工具检测与目标选择] + request[CLI 请求] --> config[run 读取配置 / init 检查输出文件] + config --> target[构建工具检测与目标选择] target --> prepare[Maven / Gradle 准备与元数据解析] prepare --> project[PreparedProject] project --> entry[产物检查与主类确定] - entry --> classpath[Manifest 类路径 Jar] + entry -->|init| save[保存 .java-run.json] + entry -->|run| classpath[Manifest 类路径 Jar] classpath --> java[独立 Java 进程] ``` @@ -50,13 +56,13 @@ flowchart TD ```text src/ ├── cli.ts 请求编排、退出结果与临时工作区生命周期 -├── cli/ 参数和配置解析、帮助、终端选择 +├── cli/ 参数和配置读写、帮助、终端选择 ├── build-tools/ 构建工具检测、Maven 与 Gradle 适配器 ├── core/ 运行契约、类路径、主类发现与 Java 启动 └── process/ 外部进程、环境覆盖、编码检查与信号处理 ``` -CLI 依赖具体构建适配器完成准备,适配器返回同一 `PreparedProject`,Java 启动模块消费该结果。CLI 协调交互选择,核心模块通过调用方提供的选择行为确定入口,不依赖终端菜单实现。核心契约不包含 Spring 等框架字段;进程模块不判断 Maven 或 Gradle 项目语义。 +CLI 依赖具体构建适配器完成准备,适配器返回同一 `PreparedProject`。核心模块检查该结果并确定主类,供 `run` 启动 Java 或 `init` 保存配置。CLI 协调交互选择,核心模块通过调用方提供的选择行为确定入口,不依赖终端菜单实现。核心契约不包含 Spring 等框架字段;进程模块不判断 Maven 或 Gradle 项目语义。 Maven 与 Gradle 的差异集中在 `build-tools/`。各适配器提供计划、候选发现和项目准备函数,使调用方不必理解模型读取和任务图实现。新增构建系统或运行模式时,根据项目样本和结果契约调整接口。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 7db9fa4..f8ac0ce 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # java-run 工程能力与技术路线 -java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供可复用的启动入口。当前已具备单目标运行、自动准备、项目配置、交互补选和独立 JVM 启动能力;架构契约见 [架构文档](architecture.md),贡献与验证流程见 [贡献指南](../CONTRIBUTING.md)。 +java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供可复用的启动入口。当前已具备单目标运行、自动准备、项目配置、交互补选、初始化配置生成和独立 JVM 启动能力;架构契约见 [架构文档](architecture.md),贡献与验证流程见 [贡献指南](../CONTRIBUTING.md)。 ## 当前能力评估 @@ -8,7 +8,7 @@ java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供 | 能力 | 实现依据 | 工程评价与限制 | | --- | --- | --- | -| 产品与配置 | `RunConfig` 表达构建工具、单目标、构建策略和三类参数;严格校验 `.java-run.json` | 通用启动模型清楚,框架通过正常参数配置;尚无命名运行配置 | +| 产品与配置 | `RunConfig` 表达构建工具、单目标、构建策略和三类参数;严格校验 `.java-run.json`;`init` 准备并选择目标后保存配置 | 初始化与运行复用目标和入口确定流程,参数可通过 CLI 保存;尚无命名运行配置 | | 构建语义 | Maven 有效模型与依赖解析、Gradle source set 与任务图 | 构建工具保有依赖裁决权;Maven 多模块准备写入本地仓库,Gradle 配置需要执行项目构建逻辑 | | 模块设计 | CLI、两种构建适配器、核心启动和进程处理按职责组织 | Maven / Gradle 差异集中,统一准备结果可复用;扩展接口应由新增的真实需求驱动 | | 启动正确性 | 目标类路径隔离、测试作用域开关、class 文件入口扫描、Manifest URL 编码 | 主类和类路径有直接执行验证;自动发现仅支持传统 main,运行依赖于现有 classpath 契约 | @@ -29,6 +29,7 @@ java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供 - 应用参数、JVM 参数、构建参数和子环境保留各自边界 - 正常退出、失败、终止和命令缺失产生明确结果,并清理受控进程树 - 静态计划、帮助和版本遵守各自的副作用契约 +- 初始化保存目标和参数,已有配置默认保留,取消或准备失败不写配置 - Windows 编码、Maven 配置根和批处理调用有对应的原生验证 [Release 工作流](../.github/workflows/release.yaml) 在固定原生 runner 上构建 Windows x64、Linux x64 / arm64、macOS x64 / arm64 五种压缩二进制。编译时生成对应的 `.sha256` 校验文件,打包前核对二进制,再与校验文件、独立安装指南、许可证一起打包为 ZIP。每份二进制在 JDK 21 下执行完整 smoke;实际 ZIP 经原生解压、源文件一致性与包内 SHA-256 校验后,使用 JDK 17 对解压出的文件执行 quick 启动验收。汇总要求五种非空 ZIP 齐全,并生成和核对覆盖 ZIP 的 `SHA256SUMS`。 @@ -47,7 +48,7 @@ ZIP 名称固定为 `java-run-<平台>.zip`,版本由 GitHub Release 的下载 | --- | --- | --- | --- | | P0 | 发布可依赖的通用版本 | 确定许可证与版本号;整理支持矩阵和发布说明;完成流程演练及实际产物的安装、版本与校验核对 | 发布门禁通过,文档与产物一致,宣称支持的系统与体系结构有对应运行证据 | | P1 | 扩展真实项目兼容性 | 增加 Wrapper、自定义输出、构建约定和不同 Maven / Gradle 版本的代表项目;明确 toolchain 与应用 JDK 选择 | 新样本可重复验收,失败边界可解释,既有目标隔离与参数契约保持成立 | -| P1 | 改善重复启动体验 | 根据使用反馈评估命名运行配置、可读诊断和配置辅助;保持命令可直接用于脚本 | 有明确的重复操作或诊断需求,交互与非交互都能完成相同运行目标,配置行为有稳定契约 | +| P1 | 改善重复启动体验 | 根据 `init` 和日常运行的使用反馈评估命名运行配置、可读诊断和配置编辑辅助;保持命令可直接用于脚本 | 有明确的重复操作或诊断需求,交互与非交互都能完成相同运行目标,配置行为有稳定契约 | | P2 | 降低启动准备成本 | 测量冷准备、温准备、构建工具启动次数和配置成本;据结果优化 Maven 多次调用或 Gradle 元数据读取 | 在固定样本上有可复现收益,依赖与资源变更回归仍通过,不引入失效条件不完整的独立类路径缓存 | | 按需 | 扩展运行模型 | 分别评估 JPMS、现代 main、原生任务集成及其他构建系统 | 每种模式先有项目样本、明确的配置与退出契约,再确定适配器接口和支持范围 | diff --git a/examples/multiple-main/README.md b/examples/multiple-main/README.md index b26ee08..b40aeda 100644 --- a/examples/multiple-main/README.md +++ b/examples/multiple-main/README.md @@ -66,4 +66,22 @@ args=[--name=Alice] --main=com.example.ReportApplication -- --format=json ``` -非交互环境未指定主类时,java-run 会列出两个候选并要求明确入口,避免等待终端输入。 +非交互环境既没有保存主类、也没有传入 `--main` 时,java-run 会列出两个候选并要求明确入口,避免等待终端输入。 + +## 保存入口与参数 + +使用 `init` 将选择结果保存到示例项目的 `.java-run.json`: + +```sh +bun run src/cli.ts init --cwd examples/multiple-main -- --format=table "hello world" +``` + +此命令同样编译项目并显示两个主类。输入 `2` 后保存 `ReportApplication` 和应用参数,此时不运行应用。随后执行: + +```sh +bun run src/cli.ts --cwd examples/multiple-main +``` + +java-run 直接运行保存的入口,输出 `entry=com.example.ReportApplication` 和 `args=[--format=table, hello world]`,不再显示主类菜单。编译后二进制也支持同样的 `init` 命令。 + +重复初始化默认保留已有文件并报错。需要重新选择时使用 `init --force`,原配置中的入口和参数会被本次选择及选项替换;取消或准备失败时保留原文件。 diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 652f2ec..a2af1c4 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -264,6 +264,29 @@ async function main(): Promise { }); } } + 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'); @@ -273,21 +296,6 @@ async function main(): Promise { stdout: ['[fixture] kind=plain', '[fixture] maven-config=ancestor-root', '[fixture] maven-root-absolute=true', '[fixture] maven-root-config=present'], }); - const configPath = path.join(projects.get('plain')!, '.java-run.json'); - await writeFile(configPath, JSON.stringify({ - mainClass: 'org.javarun.fixture.PlainApplication', - build: 'none', - buildArgs: mavenBuildArguments, - jvmArgs: [`-Dfixture.jvm=${configValue}`], - applicationArgs: ['--from-config'], - }, null, 2)); - await check('project-config-default', [], { - stdout: ['[fixture] kind=plain', `[fixture] jvm-value=${configValue}`, '[fixture] arg=--from-config'], - }, projects.get('plain')!); - 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); await check('plain-tests', argumentsFor('plain', ...directArguments, '--include-tests'), { stdout: ['[fixture] kind=plain', ...withTests], }); @@ -314,6 +322,21 @@ async function main(): Promise { 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], diff --git a/src/cli.ts b/src/cli.ts index eedd4db..46d96d3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,7 +7,9 @@ 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 { createLaunchCommand } from './core/launch'; +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'; @@ -18,29 +20,37 @@ export async function main(argv: string[]): Promise { 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); - const previewWorkspace = join(tmpdir(), ''); - const plan = tool === 'maven' ? planMaven(config, previewWorkspace) : planGradle(config, previewWorkspace); 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 && process.stdin.isTTY && process.stderr.isTTY) { - const candidates = tool === 'gradle' ? await discoverGradleProjects(config, workspace) - : await needsMavenModule(config) ? await discoverMavenProjects(config, workspace) : []; - if (candidates.length) { + 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},可将 module 保存到 .java-run.json`); + 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.args[launch.args.indexOf('-classpath') + 2]}(${tool})`); return (await runCommand(launch)).exitCode; diff --git a/src/cli/args.ts b/src/cli/args.ts index 64397a1..48db9df 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,121 +1,31 @@ -import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { isJavaClassName } from '../core/java-class'; import type { RunConfig } from '../core/types'; +import { buildStrategy, buildTool, mainClass, moduleSelector, readProjectConfig } from './config'; -type ProjectConfig = Partial>; - -const CONFIG_KEYS = new Set([ - 'buildTool', 'module', 'mainClass', 'jvmArgs', 'applicationArgs', - 'buildArgs', 'build', 'includeTests', -]); +/** 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; } -function moduleSelector(value: unknown, option: string): string { - const selector = requireValue(value, option).trim(); - if (selector.includes(',') || /^[!\-?]/.test(selector)) { - throw new Error(`${option} 必须指定单个模块,不能多选、排除或使用可选选择器`); - } - return selector; -} - -function mainClass(value: unknown, option: string): string { - const name = requireValue(value, option); - if (!isJavaClassName(name)) throw new Error(`${option} 必须是有效的 Java 类全名`); - return name; -} - -function buildTool(value: unknown, option: string): RunConfig['buildTool'] { - if (value !== 'auto' && value !== 'maven' && value !== 'gradle') { - throw new Error(`${option} 仅支持 auto、maven 或 gradle`); - } - return value; -} - -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 => requireValue(item, field)); -} - -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 { - const data: unknown = JSON.parse(source); - 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; - } catch (error) { - throw new Error(`配置无效 ${path}:${error instanceof Error ? error.message : String(error)}`); - } -} - /** * 解析启动参数并合并项目根目录下的 .java-run.json * * @description * - argv 不包含运行时和脚本路径,通常由 process.argv.slice(2) 提供 - * - 默认执行 run;help 和 version 只解析参数,不读取项目配置 + * - 默认执行 run;init、help 和 version 只解析参数,不读取项目配置 * - CLI 标量覆盖配置,数组在配置之后追加,-- 后的参数全部传给应用 * - --cwd 相对 cwd 解析,只读取最终目录中的配置,不向父目录查找 * - 未知选项、重复标量、无效值或配置抛出 Error,不执行外部命令 */ -export function parseArgs(argv: string[], cwd = process.cwd()): RunConfig { - const config: RunConfig = { +export function parseArgs(argv: string[], cwd = process.cwd()): CliConfig { + const config: CliConfig = { action: 'run', + force: false, cwd: resolve(cwd), buildTool: 'auto', jvmArgs: [], @@ -164,6 +74,7 @@ export function parseArgs(argv: string[], cwd = process.cwd()): RunConfig { } switch (key) { + case 'init': case 'run': case 'plan': case 'help': @@ -210,6 +121,11 @@ export function parseArgs(argv: string[], cwd = process.cwd()): RunConfig { once(key); config.includeTests = true; break; + case '--force': + flag(); + once(key); + config.force = true; + break; case '--java': config.javaCommand = scalar(); break; @@ -221,13 +137,14 @@ export function parseArgs(argv: string[], cwd = process.cwd()): RunConfig { } } + 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 === 'help' || config.action === 'version') return config; + if (config.action === 'init' || config.action === 'help' || config.action === 'version') return config; const projectConfig = readProjectConfig(config.cwd); - const result: RunConfig = { ...config, ...projectConfig }; + const result: CliConfig = { ...config, ...projectConfig }; // 只用显式 CLI 选项覆盖文件配置,避免默认值遮盖用户保存的设置 const scalarFields = [ ['--tool', 'buildTool'], ['--module', 'module'], ['--main', 'mainClass'], 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 index 7019306..c7d5059 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -4,11 +4,13 @@ export function getHelpText(): string { 用法: java-run [run] [选项] [-- 应用参数...] + java-run init [选项] [-- 应用参数...] java-run plan [选项] java-run help | version 命令: run 准备并启动应用,默认命令 + init 准备并选择目标,生成项目启动配置,不启动应用 plan 预览构建步骤,不运行构建工具或写入项目文件 help, --help, -h 显示帮助,不读取项目配置 version, --version 显示版本,不读取项目配置 @@ -29,22 +31,28 @@ export function getHelpText(): string { --include-tests 准备并加入测试输出和测试依赖,不执行测试 --java Java 可执行文件 --build-command 构建工具可执行文件,默认优先使用项目 Wrapper + --force 仅用于 init,忽略已有配置并重新生成 项目配置: - 从 --cwd 指定的工作区根目录读取 .java-run.json,不向父目录查找。 + run / plan 从 --cwd 指定的根目录读取 .java-run.json,不向父目录查找。 可用字段:buildTool、module、mainClass、jvmArgs、applicationArgs、 buildArgs、build、includeTests CLI 标量覆盖配置,数组在配置之后追加。 配置使用严格 JSON,不展开环境变量;未知字段和无效类型均报错。 + init 保存实际构建工具、模块、主类及本次显式参数,准备时可能编译或下载依赖。 + init 遇到已有配置时拒绝;--force 从本次选项重新生成,不合并旧配置。 + --cwd、--java、--build-command 不写入配置;取消或准备失败不写配置。 plan 只预览步骤,主类和运行类路径仍待构建工具解析。 交互选择: 终端中缺少目标或存在多个主类时补充选择,库模块不一定可运行。 - 仅选择目标和入口,不提供参数向导、不自动写配置。 - 非交互环境请用 --module / --main 或项目配置明确指定;取消返回 130。 + 仅选择目标和入口,参数通过选项提供;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 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/core/launch.ts b/src/core/launch.ts index 96f0f95..b5a718b 100644 --- a/src/core/launch.ts +++ b/src/core/launch.ts @@ -15,11 +15,11 @@ function resolveJar(java: string): string { } /** - * 检查适配器输出并生成独立 Java 进程的启动命令 + * 检查准备产物并确定启动主类 * - * @description 显式主类优先于项目声明;两者都缺失且有多个入口时才调用 selectMainClass + * @description 显式主类优先于项目声明;两者都缺失且有多个入口时才调用 selectMainClass,显式或声明的主类只检查名称格式 */ -export async function createLaunchCommand(config: RunConfig, project: PreparedProject, workspace: string, selectMainClass?: MainClassSelector): Promise { +export async function resolveMainClass(config: RunConfig, project: PreparedProject, selectMainClass?: MainClassSelector): Promise { for (const path of project.classpath) { if (!existsSync(path)) throw new Error(`运行类路径缺少产物:${path},请使用默认自动构建模式准备项目`); } @@ -28,6 +28,12 @@ export async function createLaunchCommand(config: RunConfig, project: PreparedPr let main = config.mainClass || project.mainClass; if (!main) main = await discoverMainClass(classes, selectMainClass); if (!isJavaClassName(main)) throw new Error(`无效的 Java 主类:${main}`); + return main; +} + +/** 生成独立 Java 进程的启动命令,缺少产物或无法确定主类时抛出错误 */ +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'); diff --git a/src/core/types.ts b/src/core/types.ts index b36f8b1..35d5058 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,6 +1,6 @@ /** 从源码工作区运行 Java 应用的配置 */ export interface RunConfig { - action: 'help' | 'version' | 'run' | 'plan'; + action: 'help' | 'version' | 'run' | 'plan' | 'init'; cwd: string; buildTool: 'auto' | 'maven' | 'gradle'; /** Maven reactor 选择器或 Gradle 项目路径,只能选择一个项目 */ diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index a3f23cc..e43bb71 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -10,8 +10,8 @@ | --- | --- | | `boot-single` | Spring Boot 作为普通运行依赖,验证主类发现、正常退出和各层参数 | | `boot-reactor` | `app` 依赖 `lib`,验证上游库、资源和有效 POM 变化;`other-app` 用于检测无关项目被构建或启动 | -| `plain` | 显式主类、测试类路径、配置优先级、依赖变化、Maven 配置根和非零退出 | -| `gradle-reactor` | `application` 主类声明、`app` 依赖 `lib`、资源与依赖变化、测试类路径和无关项目隔离 | +| `plain` | 显式主类、初始化保存与配置复用、测试类路径、配置优先级、依赖变化、Maven 配置根和非零退出 | +| `gradle-reactor` | `application` 主类声明、模块配置保存与复用、`app` 依赖 `lib`、资源与依赖变化、测试类路径和无关项目隔离 | 夹具以 Java 17 为最低版本。Gradle 夹具不携带 Wrapper,CI 安装固定版本 Gradle;本地可使用 `JAVA_RUN_GRADLE_COMMAND` 指定 smoke 的 Gradle 命令。依赖版本在 POM 和 Gradle 构建文件中固定,用于验证实际加载结果。 @@ -28,6 +28,7 @@ Maven 的 `ci` profile 和 Gradle 的 `-PfixtureProfile=ci` 分别改变资源 - Gradle 修改库资源和应用依赖,分别验证资源准备与依赖重新解析 - 启用测试类路径后恢复默认运行,验证测试输出与依赖不会遗留在普通启动中 - `.java-run.json` 的默认值与 CLI 追加或覆盖值,验证配置合并顺序 +- Maven 与 Gradle 的 `init` 保存入口和参数后直接运行,验证初始化不启动应用及已有配置保护 - 应用收到 `--exit=7` 后退出,验证运行器保留退出码 `plain` 还会在临时副本中创建 `.mvn/maven.config`,检查项目目录和最近祖先配置根的属性实际参与资源过滤,并验证 `${maven.multiModuleProjectDirectory}` 是指向配置根的绝对路径。这些文件只属于测试副本,不作为夹具默认配置提交。 diff --git a/tests/init.test.ts b/tests/init.test.ts new file mode 100644 index 0000000..256d06a --- /dev/null +++ b/tests/init.test.ts @@ -0,0 +1,292 @@ +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('项目配置写入保护', () => { + 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/parse-argvs.test.ts b/tests/parse-argvs.test.ts index 22ce4c7..edf63c4 100644 --- a/tests/parse-argvs.test.ts +++ b/tests/parse-argvs.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +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; @@ -27,6 +28,7 @@ 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'); @@ -38,8 +40,8 @@ describe('框架中立的启动参数', () => { expect(config.buildArgs).toEqual([]); }); - test('支持四个命令和帮助版本选项', () => { - for (const action of ['run', 'plan', 'help', 'version'] as const) { + test('支持启动、规划、初始化、帮助和版本命令', () => { + for (const action of ['run', 'plan', 'init', 'help', 'version'] as const) { expect(parse([action]).action).toBe(action); } for (const alias of ['--help', '-h']) { @@ -141,6 +143,98 @@ describe('框架中立的启动参数', () => { }); }); +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({ @@ -190,7 +284,7 @@ describe('项目配置', () => { }); test('配置未知字段拒绝,包括旧框架字段和只允许 CLI 的工具路径', () => { - for (const key of ['backend', 'springProfiles', 'properties', 'cwd', 'javaCommand', 'buildCommand', 'action', 'extra']) { + for (const key of ['backend', 'springProfiles', 'properties', 'cwd', 'javaCommand', 'buildCommand', 'action', 'force', 'extra']) { saveConfig({ [key]: 'x' }); expect(() => parse([])).toThrow(`未知配置项:${key}`); } @@ -200,8 +294,8 @@ describe('项目配置', () => { 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] }, { applicationArgs: [''] }, - { buildArgs: [null] }, { build: 'install' }, { includeTests: 'true' }, + { jvmArgs: '-Xmx1g' }, { jvmArgs: [12] }, { jvmArgs: [' '] }, { applicationArgs: [null] }, + { buildArgs: [null] }, { buildArgs: [''] }, { build: 'install' }, { includeTests: 'true' }, ]) { saveConfig(config); expect(() => parse([])).toThrow('配置无效'); @@ -230,7 +324,10 @@ describe('项目配置', () => { describe('帮助与解析副作用', () => { test('帮助和版本不会读取配置或要求项目存在', () => { writeFileSync(join(projectDirectory, '.java-run.json'), '{invalid'); - for (const args of [['help'], ['version'], ['--help'], ['-h'], ['--version'], ['run', '--help']]) { + 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(); } From 9376f3fa8bb12ebe09b9fbbd045b1d67f42aeb0d Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 06:05:30 +0800 Subject: [PATCH 24/26] =?UTF-8?q?refactor(maven):=20=E6=94=B6=E6=8B=A2?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=A8=A1=E5=9E=8B=E4=B8=8E=E7=B1=BB=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/build-tools/maven.ts | 35 ++++++++++++++++++++++++++++++++--- src/core/classpath.ts | 22 ++-------------------- src/core/types.ts | 13 ------------- tests/launch.test.ts | 5 ++--- 4 files changed, 36 insertions(+), 39 deletions(-) diff --git a/src/build-tools/maven.ts b/src/build-tools/maven.ts index e6890d1..bda23f0 100644 --- a/src/build-tools/maven.ts +++ b/src/build-tools/maven.ts @@ -1,10 +1,22 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs'; -import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +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 { buildClasspath } from '../core/classpath'; -import type { BuildPlan, CommandSpec, MavenProject, PreparedProject, RunConfig } from '../core/types'; +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'; @@ -184,6 +196,23 @@ export async function readEffectiveProject(xml: string, pomFile: string): Promis }; } +/** 保持 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); diff --git a/src/core/classpath.ts b/src/core/classpath.ts index 152c5f8..dbf3513 100644 --- a/src/core/classpath.ts +++ b/src/core/classpath.ts @@ -1,24 +1,6 @@ -import { existsSync, statSync } from 'node:fs'; -import { delimiter, resolve, sep } from 'node:path'; +import { statSync } from 'node:fs'; +import { resolve, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { MavenProject } from './types'; - -/** 保持 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])); -} /** 将文件系统路径编码为 Manifest 使用的 URL */ export function classpathUrl(file: string): string { diff --git a/src/core/types.ts b/src/core/types.ts index 35d5058..852d585 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -41,16 +41,3 @@ export interface BuildPlan { commands: CommandSpec[]; notes: string[]; } - -/** Maven 选定项目的有效元数据 */ -export interface MavenProject { - pomFile: string; - directory: string; - groupId: string; - artifactId: string; - version: string; - packaging: string; - outputDirectory: string; - testOutputDirectory: string; - mainClass?: string; -} diff --git a/tests/launch.test.ts b/tests/launch.test.ts index a4bcfda..50f493c 100644 --- a/tests/launch.test.ts +++ b/tests/launch.test.ts @@ -3,15 +3,14 @@ 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 { buildClasspath, createManifest } from '../src/core/classpath'; +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 { planMaven, readEffectiveProject } from '../src/build-tools/maven'; -import type { MavenProject } from '../src/core/types'; +import { buildClasspath, planMaven, readEffectiveProject, type MavenProject } from '../src/build-tools/maven'; const directories: string[] = []; function temporary(): string { From f949ba528947c355f2c548caef13344d83c91fdb Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 06:05:30 +0800 Subject: [PATCH 25/26] =?UTF-8?q?fix(cli):=20=E4=BD=BF=E7=94=A8=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E7=BB=93=E6=9E=9C=E4=B8=AD=E7=9A=84=E4=B8=BB=E7=B1=BB?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 2 +- src/core/launch.ts | 10 +++++++--- tests/init.test.ts | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 46d96d3..07b9c10 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -52,7 +52,7 @@ export async function main(argv: string[]): Promise { return 0; } const launch = await createLaunchCommand(config, project, workspace, selectMainClass); - console.error(`java-run:运行 ${launch.args[launch.args.indexOf('-classpath') + 2]}(${tool})`); + 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; } diff --git a/src/core/launch.ts b/src/core/launch.ts index b5a718b..e58b6af 100644 --- a/src/core/launch.ts +++ b/src/core/launch.ts @@ -31,8 +31,12 @@ export async function resolveMainClass(config: RunConfig, project: PreparedProje return main; } -/** 生成独立 Java 进程的启动命令,缺少产物或无法确定主类时抛出错误 */ -export async function createLaunchCommand(config: RunConfig, project: PreparedProject, workspace: string, selectMainClass?: MainClassSelector): Promise { +/** + * 生成独立 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'); @@ -43,5 +47,5 @@ export async function createLaunchCommand(config: RunConfig, project: PreparedPr 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 应用' }; + return { command: java, args, cwd: project.directory, stage: '运行 Java 应用', mainClass: main }; } diff --git a/tests/init.test.ts b/tests/init.test.ts index 256d06a..f2b336a 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -241,6 +241,20 @@ describe('生成项目启动配置', () => { }, 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(); From dc95699f2a418a7cf26691cadc996ae5a1d97112 Mon Sep 17 00:00:00 2001 From: Vanisper <273266469@qq.com> Date: Tue, 6 Oct 2026 06:06:12 +0800 Subject: [PATCH 26/26] =?UTF-8?q?docs:=20=E6=95=B4=E7=90=86=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=8C=87=E5=8D=97=E4=B8=8E=E6=9E=B6=E6=9E=84=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 188 +++++++++++++++++------- README.md | 164 +++++++++++++++------ docs/architecture.md | 236 ++++++++++++++++++++++++------- docs/installation.md | 70 +++++++-- docs/roadmap.md | 89 ++++++------ examples/multiple-main/README.md | 68 +++++---- scripts/release.ts | 21 ++- src/build-tools/gradle.ts | 4 +- src/cli/help.ts | 2 +- tests/fixtures/README.md | 69 ++++++--- 10 files changed, 658 insertions(+), 253 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e237389..db0ecd3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,16 +1,16 @@ # 参与 java-run 开发 -java-run 从 Java 源码工作区选择一个启动目标,由 Maven 或 Gradle 准备源码和运行依赖,再启动 Java。使用契约见 [README](README.md),模块职责与设计约束见 [架构设计](docs/architecture.md),演进方向见 [技术路线](docs/roadmap.md)。 +java-run 从 Java 源码工作区选择启动目标,由 Maven 或 Gradle 准备源码和运行依赖,再启动 Java。 +使用契约见 [README](README.md),模块职责见 [架构设计](docs/architecture.md),演进方向见 [技术路线](docs/roadmap.md)。 -## 开发环境 +## 开发环境与日常检查 -- 使用 [`.bun-version`](.bun-version) 指定的 Bun,以及仓库中的依赖锁文件 -- JDK 的 `java`、`javac` 和 `jar` 需要在 PATH 中可用;真实项目应使用符合自身要求的 JDK -- 完整验收需要 Maven 和 Gradle,首次运行可能下载插件与依赖 +- 使用 [`.bun-version`](.bun-version) 指定的 Bun,并按仓库锁文件安装依赖 +- 在 PATH 中提供 JDK 的 `java`、`javac` 和 `jar` +- 安装 Maven 和 Gradle,用于真实项目验收;首次运行可能下载插件与依赖 -CI 使用 JDK 17 / 21 和 Gradle 8.14。夹具以 Java 17 为最低版本;这些版本构成验证环境,其他构建工具或 JDK 版本需要单独验证。目标项目存在 Wrapper 时,运行器优先使用 Wrapper。 - -安装依赖并执行日常检查: +CI 使用 JDK 17 / 21 和 Gradle 8.14,夹具以 Java 17 为最低版本。 +目标项目存在 Wrapper 时,运行器优先使用 Wrapper;项目自身的 JDK 和构建工具要求仍需满足。 ```sh bun install --frozen-lockfile @@ -22,14 +22,13 @@ bun run smoke | 命令 | 验证内容 | | --- | --- | | `bun run typecheck` | TypeScript 类型检查 | -| `bun test` | 参数、配置读写、初始化、适配器、主类、类路径、交互和进程行为回归 | +| `bun test` | 参数、配置、初始化、适配器、主类、类路径、交互和进程行为回归 | | `bun run check` | 类型检查与回归测试 | -| `bun run compile` | 构建压缩后的本机二进制及其 `.sha256` 校验文件到 `dist`,与发布产物使用同一入口 | +| `bun run compile` | 构建本机独立二进制及 `.sha256` 校验文件到 `dist` | | `bun run smoke` | 使用该二进制运行真实 Maven / Gradle 夹具 | -编译入口支持等号形式的 `--target=<目标>` 和 `--outfile=<路径>`,可用目标与发布平台表一致。默认产物为 `dist/java-run`,Windows 自动使用 `.exe` 扩展名。编译成功后在实际二进制旁生成 `<二进制文件名>.sha256`,例如 `dist/java-run.sha256` 或 `dist/java-run.exe.sha256`;自定义输出路径也使用同样规则。校验文件使用标准 SHA-256 清单格式,文件名相对于其所在目录。二进制不自动加载运行目录中的 `.env` 或 `bunfig.toml`,显式继承的环境变量仍然生效。 - -回归测试中的真实 Gradle 用例使用 PATH 中的 Gradle;找不到时会跳过,不能据此判断 Gradle 适配已经通过验收。可通过 `JAVA_RUN_TEST_GRADLE` 指定测试命令,通过 `GRADLE_USER_HOME` 隔离测试用的 Gradle 缓存: +回归测试中的真实 Gradle 用例使用 PATH 中的 Gradle,找不到时会跳过。 +可用 `JAVA_RUN_TEST_GRADLE` 指定命令,并用 `GRADLE_USER_HOME` 隔离测试缓存: ```sh GRADLE_USER_HOME=/tmp/java-run-test-gradle \ @@ -37,9 +36,26 @@ 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 安装目录,验证其运行不依赖 Bun。成功后默认清理临时目录;失败或指定 `--keep` 时保留项目副本与逐项 stdout / stderr 日志,输出中给出保留位置。 +smoke 将夹具复制到临时目录,使用独立的 Maven settings、本地仓库和 Gradle 用户目录。 +它从被测二进制的 PATH 中移除当前 Bun 安装目录,验证产物可独立运行。 ```sh bun run smoke --suite=quick @@ -47,9 +63,18 @@ bun run smoke --fixture=gradle-reactor bun run smoke --cli=/path/to/java-run --suite=full --keep ``` -`quick` 验证帮助、版本、静态预览和选定夹具的基本启动;`full` 是默认范围,进一步验证测试类路径、配置优先级、资源和依赖更新、非零退出等行为。`--fixture` 限定项目夹具,同时保留帮助、版本和预览检查。具体夹具与观测标记见 [夹具说明](tests/fixtures/README.md),执行项目数以脚本输出为准。 +| 选项 | 范围 | +| --- | --- | +| `--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 \ @@ -58,28 +83,36 @@ JAVA_RUN_GRADLE_COMMAND=/path/to/gradle \ bun run smoke ``` -不要将这些测试缓存指向个人 `~/.m2` 或 `~/.gradle`。`JAVA_RUN_GRADLE_COMMAND` 用于 smoke,`JAVA_RUN_TEST_GRADLE` 用于回归测试;两者各自控制对应脚本的 Gradle 命令。smoke 也支持 `JAVA_RUN_SMOKE_SUITE`、`JAVA_RUN_SMOKE_FIXTURE`、`JAVA_RUN_SMOKE_KEEP=1`,同名命令行选项优先。 +测试缓存应独立于个人 `~/.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。 +从主分支创建具有单一意图的工作分支,如 `feat/gradle-project-selection` 或 `fix/process-exit-code`。 +修改前检查工作区并保留其他人的未提交内容;同时处理独立修改时使用隔离分支或 worktree。 提交遵循 Conventional Commits:type 与 scope 使用英文,subject 使用中文,例如: ```text -feat(cli): 支持缺失主类的终端选择 +feat(cli): 支持交互生成启动配置 fix(maven): 保留选定项目的运行依赖范围 test(gradle): 验证无关应用不参与编译 docs: 说明参数传递规则 ``` -提交按可审查的意图拆分,避免混入无关改动、生成产物和依赖缓存。提交前检查差异并运行相关验证;PR 说明应写清触发场景、最终行为、实际测试结果和未验证的平台。 +提交按可审查的意图拆分,排除无关改动、生成产物和依赖缓存。 +提交前检查差异并运行相关验证;PR 说明应写清触发场景、最终行为、实际测试结果和未验证的平台。 -源码注释使用中文,说明必要的契约、边界和原因,避免复述代码。公开 API 的说明应放在所属接口、字段或方法;架构取舍放入设计文档。Markdown 正文使用正常中文标点,同组列表保持语法与标点统一。 +源码注释使用中文,说明必要的契约、边界和原因。 +公开 API 的说明放在所属接口、字段或方法,架构取舍放入设计文档。 +Markdown 正文使用正常中文标点,同组列表保持语法与标点统一。 +较长段落按主题拆分,Markdown 源文件按语义换行。 ## 验证要求 -行为修改应验证调用者可以观察到的结果。构建工具适配的修改需要真实项目验收,不能只依赖模拟 Maven / Gradle 输出。 +行为修改应验证调用者可以观察到的结果。 +构建工具适配的修改需要真实项目验收,模拟输出用于覆盖具体边界。 回归重点包括: @@ -91,19 +124,34 @@ docs: 说明参数传递规则 - 正常与非零退出、平台对应的信号处理和进程树清理 - 帮助、版本和静态预览的无构建行为 - 终端选择、非 TTY 行为、EOF / Ctrl+C 取消和退出码 -- `init` 保存后重复运行、参数保留、已有配置保护与 `--force` 重新生成,以及失败或取消不写配置 +- `init` 保存后重复运行、参数保留、已有配置保护与 `--force` 重新生成 +- 初始化失败或取消时保留已有配置 -测试应在临时项目或夹具副本中运行,不在 `tests/fixtures` 下生成 `target`、`build`、`.gradle` 或缓存,也不修改用户提供的源仓库。环境和信号测试使用隔离子进程,避免污染其他用例。平台专用测试的跳过条件应与实际系统能力对应;跨平台结论以对应系统上的执行结果为依据。 +测试在临时项目或夹具副本中运行,保持 `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) 查看。 +[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) 提供发布产物演练与标签发布两种入口,使用同一组构建和验收步骤。 +[Release 工作流](.github/workflows/release.yaml) 共用同一套构建与验收步骤,提供以下入口: -### 产物构建与验收 +| 入口 | 验收与输出 | +| --- | --- | +| 分支 push 命中 `paths` | 五平台产物演练,保存 Actions artifacts 和发布说明预览;Check 独立运行 | +| `workflow_dispatch` | 手动产物演练,并复用完整 Check | +| `v*` 标签 push | 校验版本与许可证,执行完整 Check 和五平台验收,通过后发布 GitHub Release | -五种产物在对应系统和体系结构的固定原生 runner 上构建: +`paths` 限制分支演练的触发范围,包括工作流、发布脚本、相关测试、版本配置和安装指南等,完整清单以工作流为准。 +标签 push 不受此路径条件限制。 +工作流进入默认分支后,可通过 Actions 页面或 GitHub CLI 选择分支手动演练。 + +### 发布平台与产物 + +[平台清单](scripts/platforms.ts) 统一定义编译目标、原生 runner、包内目录和公开资产名称: | ZIP 资产 | 编译目标 | 原生 runner | | --- | --- | --- | @@ -113,54 +161,86 @@ docs: 说明参数传递规则 | `java-run-darwin-arm64.zip` | `bun-darwin-arm64` | `macos-15` | | `java-run-darwin-x64.zip` | `bun-darwin-x64` | `macos-15-intel` | -每个任务使用 `bun run compile` 生成一次待分发的压缩二进制和对应校验文件,在 JDK 21 下执行完整 smoke。打包前按编译时的校验文件核对源二进制,再将这两个文件一起打包:Linux / macOS 使用 `zip`,Windows 使用 PowerShell `Compress-Archive`。实际 ZIP 会在原生环境中解压,核对包内二进制及校验文件与源文件一致,并按包内校验文件验证二进制,再使用 JDK 17 对解压出的文件执行 quick 启动验收。 +每个平台依次执行: -每份 ZIP 包含一个 `java-run-<平台>/` 目录,目录内为固定名称的 `java-run` 或 `java-run.exe`、对应的 `java-run.sha256` 或 `java-run.exe.sha256`、从 [安装指南](docs/installation.md) 复制的 `INSTALL.md`,以及存在的许可证文件。编译参数、发布矩阵、包内目录和公开资产名称共用 [平台清单](scripts/platforms.ts)。公开名称不带版本号,版本由 `/releases/download/v<版本>/` URL 表达;`/releases/latest/download/` 提供最新稳定版的固定下载入口。 +1. 编译一次待分发的二进制及校验文件,在 JDK 21 下执行完整 smoke。 +2. 核对源二进制校验和,并打包 ZIP。Linux / macOS 使用 `zip`,Windows 使用 PowerShell `Compress-Archive`。 +3. 解压实际 ZIP,核对文件集合、内容、二进制校验和及 Unix 可执行权限。 +4. 使用 JDK 17 对解压出的二进制执行 quick 验收,再上传 ZIP。 -汇总步骤要求恰好包含上述五种非空 ZIP,拒绝缺失、多余或无效产物,然后生成并核对覆盖五个 ZIP 的 `SHA256SUMS`。包内 `.sha256` 校验二进制内容,Release 单独提供的 `SHA256SUMS` 校验最终下载的 ZIP。源码 Check 的双 JDK 完整回归与发布文件的 JDK 21 full / JDK 17 quick 范围分别声明,不能将 quick 扩大为完整验收。Linux 验收采用 Ubuntu 24.04 的 glibc 环境,不代表 musl 或其他系统版本已通过验收。 +每份 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` -分支 push 修改发布或检查工作流、编译 / 发布 / smoke 脚本、对应测试、包与版本配置、安装指南等路径时,会触发五平台构建、原生验收、ZIP 解压验收和校验和汇总。完整路径条件以 Release 工作流的 `paths` 为准。分支演练保存 Actions artifacts,并在 Actions summary 展示下载与安装正文预览,不创建标签或 GitHub Release;日常 Check 独立运行,Release 不重复调用它。 +汇总步骤要求恰好包含五种非空 ZIP 普通文件,然后生成并核对 `SHA256SUMS`。 +包内 `.sha256` 校验二进制,Release 单独提供的 `SHA256SUMS` 校验下载的 ZIP。 +资产名称不带版本号,指定版本通过 `/releases/download/v<版本>/` 下载,最新稳定版使用 `/releases/latest/download/`。 -`workflow_dispatch` 也是纯演练入口,并额外复用完整 Check。工作流进入默认分支后,可通过 Actions 页面或 GitHub CLI 手动选择分支运行。演练使用所选提交的包版本核对二进制和生成正文,不代表该版本已经发布。 +源码 Check 在两个 JDK 上均执行完整回归;发布产物在 JDK 21 上执行 full,在 JDK 17 上执行 quick。 +Linux 产物在 Ubuntu 24.04 的 glibc 环境验收,musl 和其他系统版本未纳入验证范围。 -本地可先检查版本和平台清单: +### 本地演练 -```sh -bun scripts/release.ts metadata -``` +`scripts/release.ts` 读取当前 `package.json` 的版本,提供五个子命令: + +| 子命令 | 职责 | +| --- | --- | +| `metadata` | 校验版本,输出标签、预发布标识和平台矩阵;标签环境还校验标签与许可证 | +| `package <平台>` | 打包 `dist` 中已有的二进制与校验文件,解压核对并执行 quick | +| `checksums` | 核对 `dist` 中的五份 ZIP 集合,生成 `SHA256SUMS` | +| `notes` | 向标准输出生成下载与安装说明 | +| `verify-assets` | 从标准输入读取远端资产名称的 JSON 数组,核对五份 ZIP 和 `SHA256SUMS` 是否齐全且无额外文件 | -在原生机器上可构建并验收对应平台的 ZIP,例如 macOS arm64: +在对应平台的原生机器上构建并验收 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`。其他平台使用对应的平台名,并在该平台的原生机器上运行。 +`package` 输出 `dist/java-run-darwin-arm64.zip`,其 quick 验收使用当前环境的 JDK。 +本地复现发布矩阵时,先用 JDK 21 编译和执行 full,再切换到 JDK 17 执行 `package`。 -### 标签发布与重试 +五个平台的 ZIP 汇集到只含这些文件的 `dist` 目录后,可执行 `checksums`。 +编译留下的裸二进制和 `.sha256` 不属于汇总输入。 +分支与手动演练保存产物和说明预览,不创建标签或 GitHub Release。 -任何 `v*` 标签 push 都进入发布校验,不受分支演练的路径条件限制。包版本必须是合法 SemVer,标签必须严格等于 `v`,且仓库需包含非空许可证文件。已经公开的标签版本会被拒绝,不能通过重跑覆盖。 +### 发布版本 -元数据校验通过后,同一标签提交的六组 Check 与五平台产物验收并行执行。只有源码检查、产物验收和汇总全部成功,才创建或恢复该标签的未公开草稿,上传五份 ZIP 和校验和。Release 正文自动包含对应版本的下载表、安装说明与 GitHub 生成的变更说明。公开前还会核对远端资产集合,缺失或额外文件均令流程失败并保留草稿;文件上传与核对全部成功后自动公开。草稿用于承接上传过程和失败重试,不需要额外人工审批。 +发布前完成以下准备: -SemVer 包含预发布段的版本自动标记为 prerelease,且不会标记为 latest;稳定版的 latest 选择交给 GitHub 默认规则。同一标签的运行串行执行,不中断正在进行的发布。 +- 确定开源许可证,添加非空的 `LICENSE`、`LICENSE.md` 或 `LICENSE.txt` +- 确定发布版本,更新 `package.json` 并核对版本输出 +- 完成待发布提交的检查,审查平台产物和支持边界 -网络、下载或上传偶发失败时,可以重跑失败任务: +`bun run version` 使用 bumpp 调整 `package.json`,不自动提交、创建标签或推送。 +版本变更通过 PR 合入主分支后,创建并推送 `v` 标签。 +版本必须符合 SemVer,标签必须与包版本严格一致。 -```sh -gh run rerun --failed -``` +元数据校验通过后,同一标签提交的六组 Check 与五平台产物验收并行执行。 +全部通过后,工作流创建或恢复未公开草稿,上传五份 ZIP 和 `SHA256SUMS`。 +Release 正文包含下载表、安装说明及 GitHub 生成的变更说明。 -重跑仍使用原始提交和 ref。已验收的 Actions artifacts 支持同名覆盖,Release 资产只在未公开草稿中允许替换。若修改了代码,需要运行新提交的验收;若版本已经公开,需要使用新版本,不能重跑发布来替换文件。 +公开前会核对远端资产集合,缺失或额外文件均使流程失败并保留草稿。 +上传和核对成功后自动公开,无需额外人工审批。 +预发布版本标记为 prerelease,且不标记为 latest;稳定版的 latest 选择使用 GitHub 默认规则。 +同一标签的运行串行执行,不中断正在进行的发布。 -发布准备包括: +发布完成后,检查 Release 的标签、下载表、ZIP 内容和校验和,并按安装指南核对下载后的版本输出。 -- 确定开源许可证并补齐许可证文件 -- 为源码接口确定版本号,更新 `package.json` 并核对版本输出 -- 完成待发布提交的检查,审查平台产物和支持边界 +### 失败重试 + +网络、下载或上传偶发失败时,可重跑失败任务: + +```sh +gh run rerun --failed +``` -`bun run version` 使用 bumpp 调整 `package.json`,不自动提交、创建标签或推送。版本变更通过 PR 合入主分支后,再创建与包版本一致的标签并推送该标签,触发自动发布。发布完成后检查 Release 的标签、下载表、ZIP 内容和校验和,并按安装指南核对下载后的版本输出。 +重跑使用原始提交和 ref,已验收的 Actions artifacts 支持同名覆盖。 +Release 资产只允许在未公开草稿中替换;已经公开的标签版本会被拒绝。 +修改代码后应运行新提交的验收,已发布版本的修正通过新版本发布。 diff --git a/README.md b/README.md index 8988a08..cd83a9b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # java-run -java-run 为 Maven 和 Gradle 源码工作区提供统一的开发启动入口:选择一个 Java 项目,由构建工具准备源码和运行依赖,再启动独立的 Java 进程。普通 Java、Spring Boot 和其他基于 classpath 的应用使用同一套运行契约。 +java-run 为 Maven 和 Gradle 源码工作区提供统一的开发启动入口:选择一个 Java 项目,由构建工具准备源码和运行依赖,再启动独立的 Java 进程。 +普通 Java、Spring Boot 和其他基于 classpath 的应用使用同一套运行契约。 ## 快速开始 -从 [GitHub Releases](https://github.com/Vanisper/java-run/releases) 下载对应系统和体系结构的 ZIP。预编译版本不需要 Bun;运行 Java 项目仍需要符合该项目要求的 JDK,以及 Maven / Gradle 或项目 Wrapper。JDK 的 `java` 和 `jar` 必须可用。 +从 [GitHub Releases](https://github.com/Vanisper/java-run/releases) 下载对应系统和体系结构的 ZIP。 +预编译版本不需要 Bun;运行 Java 项目仍需要符合该项目要求的 JDK,以及 Maven / Gradle 或项目 Wrapper。 +JDK 的 `java` 和 `jar` 必须可用。 | 系统与体系结构 | 最新稳定版下载 | | --- | --- | @@ -14,14 +17,23 @@ java-run 为 Maven 和 Gradle 源码工作区提供统一的开发启动入口 | 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) | -macOS / Linux 使用 `uname -m` 查看体系结构:`x86_64` 对应 x64,`arm64` / `aarch64` 对应 arm64。Windows 可在“设置 → 系统 → 关于”查看系统类型,当前提供 x64 版本。Linux 产物在 Ubuntu 24.04 的 glibc 环境验收;musl(如 Alpine)和其他系统版本未纳入发布验收。 +macOS / Linux 使用 `uname -m` 查看体系结构:`x86_64` 对应 x64,`arm64` / `aarch64` 对应 arm64。 +Windows 可在“设置 → 系统 → 关于”查看系统类型,当前提供 x64 版本。 +Linux 产物在 Ubuntu 24.04 的 glibc 环境验收;musl(如 Alpine)和其他系统版本未纳入发布验收。 -按照 [安装指南](docs/installation.md) 核对 ZIP 和包内二进制的 SHA-256,再将可执行文件加入 PATH;ZIP 内附有二进制校验文件和独立的 `INSTALL.md`。随后在 Java 工作区根目录执行: +按照 [安装指南](docs/installation.md) 核对 SHA-256 并将可执行文件加入 PATH。 +ZIP 内附有二进制校验文件和独立的 `INSTALL.md`。 +随后在 Java 工作区根目录执行: ```sh java-run ``` +多个模块或主类可在终端中选择;需要保存选择和启动参数时,执行 [`java-run init`](#保存项目默认值)。 + +默认优先使用工作区根目录的 `mvnw` / `mvnw.cmd` 或 `gradlew` / `gradlew.bat`,没有 Wrapper 时使用 PATH 中的构建工具。 +Wrapper 存在但运行失败时直接报错,不自动换用系统版本;可以用 `--build-command` 显式指定其他可执行文件。 + ### 从源码构建 源码构建需要 [Bun](https://bun.sh) **1.4.2**,版本固定在 `.bun-version`。 @@ -34,14 +46,15 @@ bun install --frozen-lockfile bun run compile ``` -产物为 `dist/java-run` 和 `dist/java-run.sha256`;Windows 使用 `dist/java-run.exe` 和 `dist/java-run.exe.sha256`。校验文件记录二进制的 SHA-256,可按[安装指南](docs/installation.md)中的二进制校验命令在 `dist` 目录核对。将可执行文件放入 PATH 后即可使用上述命令。源码调试可以直接使用: +产物为 `dist/java-run` 和 `dist/java-run.sha256`;Windows 使用 `dist/java-run.exe` 和 `dist/java-run.exe.sha256`。 +可按[安装指南](docs/installation.md)中的二进制校验命令在 `dist` 目录核对,然后将可执行文件放入 PATH。 + +源码调试可以直接使用: ```sh bun run src/cli.ts --cwd /path/to/java-project ``` -默认优先使用项目根目录的 `mvnw` / `mvnw.cmd` 或 `gradlew` / `gradlew.bat`;没有 Wrapper 时使用 PATH 中的构建工具。Wrapper 存在但运行失败时直接报错,不自动换用系统版本。可以用 `--build-command` 显式指定其他可执行文件。 - ## 命令与启动目标 ```sh @@ -52,9 +65,14 @@ java-run help java-run version ``` -`run` 是默认命令。`init` 准备并选择启动目标,将结果保存为项目配置,随后执行 `java-run` 即可复用。`plan` 读取本地配置并预览步骤,不调用构建工具、不生成项目缓存、不发起交互;其中主类、有效项目模型和运行类路径仍未验证。`help` / `--help` / `-h` 和 `version` / `--version` 不读取项目配置。 +- `run`:准备并启动应用,是默认命令。 +- `init`:准备并选择启动目标,将结果保存为项目配置,供后续运行复用。 +- `plan`:读取本地配置并预览步骤,不调用构建工具、不生成项目缓存、不发起交互。主类、有效项目模型和运行类路径仍未验证。 +- `help` / `--help` / `-h`:显示帮助,不读取项目配置。 +- `version` / `--version`:显示版本,不读取项目配置。 -多模块仓库中的聚合项目、库模块和应用模块各有职责,并非每个模块都能执行。用 `--module` 选择一个目标,用 `--main` 在需要时指定入口: +多模块仓库中的聚合项目、库模块和应用模块各有职责,并非每个模块都能执行。 +用 `--module` 选择一个目标,用 `--main` 在需要时指定入口: ```sh java-run --module :app @@ -62,21 +80,32 @@ 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` 明确选择。 +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 聚合项目列出有效 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` 明确指定。 +候选发现会执行构建工具配置,可能下载插件、Wrapper 分发包或准备 `buildSrc` 等构建逻辑。 +选定目标后,再按构建策略准备该项目并解析运行依赖。 +非交互环境和 CI 遇到目标或主类歧义时,需要通过 CLI 或 `.java-run.json` 明确指定。 -交互只选择目标和入口,运行参数通过选项或配置传入。普通运行中的选择仅用于本次启动;需要保存时使用 `init`。Ctrl+C 或 EOF 取消返回 130。 +交互只选择目标和入口,运行参数通过选项或配置传入。 +普通运行中的选择仅用于本次启动;需要保存时使用 `init`。 +Ctrl+C 或 EOF 取消选择返回 130。 ### 多入口选择示例 -[双入口示例项目](examples/multiple-main/README.md)包含 `HelloApplication` 和 `ReportApplication` 两个主类,未配置默认入口。将 java-run 放入 PATH 后,在本仓库根目录执行: +[双入口示例项目](examples/multiple-main/README.md)包含 `HelloApplication` 和 `ReportApplication` 两个主类,未配置默认入口。 +将 java-run 放入 PATH 后,在本仓库根目录执行: ```sh java-run --cwd examples/multiple-main -- --name=demo @@ -91,7 +120,8 @@ java-run --cwd examples/multiple-main -- --name=demo 选择 [1-2]: ``` -输入 `2` 并回车,启动 `ReportApplication`,`--name=demo` 传给它的 `main(String[])`。此例只有一个 Maven 项目,菜单选择的是项目中的入口。 +输入 `2` 并回车,启动 `ReportApplication`,`--name=demo` 传给它的 `main(String[])`。 +此例只有一个 Maven 项目,菜单选择的是项目中的入口。 脚本和 CI 可以直接指定主类: @@ -113,9 +143,11 @@ java-run --cwd examples/multiple-main \ | 应用 | `--arg` 或 `--` 后的参数 | `-- --server.port=8081` | 原样传给 `main(String[])`,由应用解释 | | 环境 | 启动 java-run 的 shell 或 CI 环境 | `JAVA_HOME`、`GRADLE_USER_HOME`、应用环境变量 | 构建工具和 Java 子进程继承环境;`.env` 需由 shell 或其他工具加载 | -Maven `-Pdev` 激活的是 **Maven profile**,与 Spring profile 分属不同层。java-run 没有 Spring 专用参数;Spring profile 使用正常 JVM 属性或应用参数传递。 +Maven `-Pdev` 激活的是 **Maven profile**,与 Spring profile 分属不同层。 +Spring profile 使用正常 JVM 属性或应用参数传递。 -三个参数数组都支持重复。负号开头的值使用等号形式,值中的后续等号、空格和 shell 字符会保留;含空格的整个参数仍需按当前 shell 的规则引用: +`--build-arg`、`--jvm-arg` 和 `--arg` 都可以重复指定。 +负号开头的值使用等号形式,值中的后续等号、空格和 shell 字符会保留;含空格的整个参数仍需按当前 shell 的规则引用: ```sh java-run --module :app \ @@ -126,7 +158,8 @@ java-run --module :app \ -- --server.port=8081 ``` -`--` 后的内容全部属于应用,不再解释为 java-run 选项。构建参数由适配器校验;不能借它改写启动目标、注入额外任务或覆盖内部元数据输出步骤。 +`--` 后的内容全部属于应用,不再解释为 java-run 选项。 +构建参数用于调整属性、profile、日志和依赖选项,不能改写启动目标、加入额外任务或覆盖 java-run 管理的元数据输出。 完整选项: @@ -141,7 +174,7 @@ java-run --module :app \ | `--build-arg=` | 追加一个构建工具参数,可重复 | | `--build ` | `auto` | | `--include-tests` | 准备并加入测试输出和依赖,默认关闭 | -| `--java ` | 显式覆盖 Java 可执行文件 | +| `--java ` | 显式指定应用启动使用的 Java 可执行文件 | | `--build-command ` | 显式覆盖构建工具可执行文件 | | `--force` | 仅用于 `init`,忽略已有配置并重新生成 | @@ -156,18 +189,34 @@ java-run init java-run ``` -`init` 在 `--cwd` 指定的工作区根目录生成 `.java-run.json`。它复用运行时的构建工具检测、模块选择、产物准备和入口确定流程,但不启动应用。准备可能编译源码、下载依赖、更新构建工具缓存或执行项目构建配置;Maven reactor 的自动准备仍会写入本地 Maven 仓库。只有一个候选时自动采用,多个模块或入口在终端中通过数字选择;非交互环境存在歧义时,使用 `--module` / `--main` 明确指定。 +`init` 在 `--cwd` 指定的工作区根目录生成 `.java-run.json`,不启动应用。 +只有一个候选时自动采用,多个模块或入口在终端中通过数字选择;非交互环境存在歧义时,使用 `--module` / `--main` 明确指定。 -运行参数通过同一组选项一起保存,无需逐项回答参数问题。例如,初始化 Maven 工作区中的 `app` 模块并保存内存和应用参数: +初始化采用与运行相同的[构建准备流程](#构建与运行行为),可能编译源码、下载依赖或更新构建工具缓存。 +Maven reactor 的自动准备会写入本地 Maven 仓库。 + +运行参数通过选项一起保存。 +例如,初始化 Maven 工作区中的 `app` 模块并保存内存和应用参数: ```sh java-run init --module app --jvm-arg=-Xmx1g -- --server.port=8081 java-run ``` -生成的配置记录实际使用的构建工具、选定模块和主类、本次显式提供的三类参数,以及非默认的 `--build` / `--include-tests` 设置。`--cwd`、`--java` 和 `--build-command` 只用于本次定位或准备,不保存到配置。 +生成的配置记录以下设置: + +- 实际使用的构建工具、选定模块和主类 +- 本次显式提供的构建参数、JVM 参数和应用参数 +- 非默认的 `--build` / `--include-tests` 设置 -已有 `.java-run.json` 时,`init` 在运行构建工具前报错并保留原文件。需要重新配置时使用 `java-run init --force`;它忽略旧配置、按本次选项重新生成,因此也可替换格式损坏的配置。取消或准备失败时不写入配置。 +`--cwd`、`--java` 和 `--build-command` 不保存到配置。 +自定义工具路径需在调用时通过 CLI 指定;`--java` 只控制应用启动,不改变构建工具使用的 JDK。 + +已有 `.java-run.json` 时,`init` 在运行构建工具前报错并保留原文件。 +需要重新配置时使用 `java-run init --force`;它忽略旧配置,按本次选项重新生成,也可替换格式损坏的配置。 +取消或准备失败时保留原文件。 + +### 配置内容与优先级 配置也可以直接编辑。例如: @@ -186,9 +235,17 @@ java-run } ``` -可用字段为 `buildTool`、`module`、`mainClass`、`jvmArgs`、`applicationArgs`、`buildArgs`、`build`、`includeTests`。配置使用严格 JSON,不支持注释、未知字段、环境变量插值或配置继承。三个参数字段必须是字符串数组;`jvmArgs` 和 `buildArgs` 的元素不能是空字符串或纯空白字符串,`applicationArgs` 原样保留空字符串和空白参数,与 `--` 透传一致。`includeTests` 必须是布尔值。 +可用字段为 `buildTool`、`module`、`mainClass`、`jvmArgs`、`applicationArgs`、`buildArgs`、`build`、`includeTests`。 +配置使用严格 JSON,不支持注释、未知字段、环境变量插值或配置继承。 -只读取该工作区根目录的配置,不向父目录搜索,也不在选定模块后重新读取。显式 CLI 标量覆盖文件值,未指定时保留文件值;数组在文件数组之后追加。例如,配置已有 `applicationArgs` 时,`--arg` 会追加参数。`--cwd`、`--java` 和 `--build-command` 仅通过 CLI 设置。 +- `jvmArgs`、`applicationArgs` 和 `buildArgs` 必须是字符串数组。 +- `jvmArgs` 和 `buildArgs` 的元素不能是空字符串或纯空白字符串。 +- `applicationArgs` 原样保留空字符串和空白参数,与 `--` 透传一致。 +- `includeTests` 必须是布尔值。 + +`run` 和 `plan` 只读取该工作区根目录的配置,不向父目录搜索,也不读取选定模块中的配置。 +显式 CLI 标量覆盖文件值,未指定时保留文件值;数组在文件数组之后追加。 +例如,配置已有 `applicationArgs` 时,`--arg` 会追加参数。 ## 构建与运行行为 @@ -200,38 +257,65 @@ java-run | Maven 已选 reactor 模块 | 对目标及上游执行 `install -DskipTests`,然后仅解析选定目标的运行类路径 | | Gradle Java 项目 | 由任务图执行所需 `classes` / 测试类准备及运行依赖相关任务 | -Maven reactor 的 `install` 会更新**本地 Maven 仓库**,不执行 `deploy`。Maven 生命周期中的测试执行通过 `-DskipTests` 跳过;测试编译可能是 `install` 的一部分,但默认运行类路径仍排除测试输出和测试依赖。`--include-tests` 表示把这些内容加入运行环境,不表示运行测试。 +Maven reactor 的 `install` 会更新**本地 Maven 仓库**,不执行 `deploy`。 +Maven 生命周期中的测试执行通过 `-DskipTests` 跳过;测试编译可能是 `install` 的一部分,但默认运行类路径仍排除测试输出和测试依赖。 +`--include-tests` 表示把这些内容加入运行环境,不表示运行测试。 -`--build=none` 不主动编译源码,但仍执行模型与运行依赖解析,必要时仍会下载依赖;它要求目标和项目依赖已有可用产物,不能代替 `plan`。缺少产物时直接报错。 +`--build=none` 不主动编译源码,但仍执行模型与运行依赖解析,必要时仍会下载依赖。 +它要求目标和项目依赖已有可用产物,缺少产物时直接报错。 +只需静态预览时使用 `plan`。 -每次启动的运行类路径由 Maven / Gradle 裁决,下载和增量计算复用构建工具自身缓存。java-run 的请求文件和 classpath Jar 放在系统临时目录,请求结束后清理;目标项目的 `target` / `build` 和构建工具缓存正常保留。classpath 支持空格、中文、`#`、`%` 等路径字符。 +每次启动的运行类路径由 Maven / Gradle 裁决,下载和增量计算复用构建工具自身缓存。 +java-run 的临时文件在请求结束后清理;目标项目的 `target` / `build` 和构建工具缓存正常保留。 +classpath 支持空格、中文、`#`、`%` 等路径字符。 -构建命令在配置的工作区根目录执行,Java 应用的工作目录为选定项目目录。JVM 参数顺序为默认 `-Dfile.encoding=UTF-8`、构建声明的参数、项目配置数组、CLI 追加参数,因此后续同名系统属性可以覆盖默认值。 +构建命令在配置的工作区根目录执行,Java 应用的工作目录为选定项目目录。 +JVM 参数按以下顺序传递,因此后续同名系统属性可以覆盖默认值: -Java 命令优先采用 `--java`,其次采用 Gradle 提供的项目工具链,再使用 `JAVA_HOME` 或 PATH。Maven 应用启动暂不读取 Maven toolchains 配置,需要不同 JDK 时使用 `--java` 显式指定。 +1. 默认 `-Dfile.encoding=UTF-8` +2. 构建声明的参数 +3. 项目配置中的 `jvmArgs` +4. CLI 追加参数 -## 与项目原生运行任务的关系 +Java 命令优先采用 `--java`,其次采用 Gradle 提供的项目工具链,再使用 `JAVA_HOME` 或 PATH。 +Maven 应用启动不读取 Maven toolchains 配置,需要不同 JDK 时使用 `--java` 显式指定。 -已有 Gradle `application` 的项目可以直接使用 `run`,Spring Boot Gradle 插件提供 `bootRun`。java-run 的价值是提供跨 Maven / Gradle 的统一入口,而项目原生任务本身已经能完成常见开发启动。[Gradle Application Plugin](https://docs.gradle.org/current/userguide/application_plugin.html)、[Spring Boot Gradle 运行说明](https://docs.spring.io/spring-boot/gradle-plugin/running.html) +## 与项目原生运行任务的关系 -例如,Gradle 应用模块可以使用对应的原生任务: +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 ``` -java-run 启动独立 Java 进程,不模拟自定义 `JavaExec` / `bootRun` 的全部副作用,也不会自动搬运任务专用环境变量、agents、附加资源或启动前后逻辑。依赖这些设置的项目应使用原生任务,或把所需运行参数明确配置给 java-run。 +原生任务可以配置专用环境变量、agents、附加资源或启动前后逻辑,这些任务行为不会自动应用到 java-run。 +依赖这些设置的项目应使用原生任务,或把所需运行参数明确配置给 java-run。 ## 支持范围与验证 -当前支持 Maven `jar` 项目和启用了 Gradle Java 插件的项目,以及传统 `public static void main(String[])` 的 classpath 启动。主类发现只检查选定项目的已编译输出,不遍历所有依赖 Jar 寻找应用。JPMS、Android、native image、应用守护和热重启、部署不属于当前支持范围。 +支持 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 的 Java 原生启动器按系统代码页转换命令行参数。classpath 文件 URL 可以保留中文路径,但主类名、JVM / 应用参数和传给构建工具的参数仍需能被该编码完整表示。java-run 会检测 JDK 的 `native.encoding` 并提前拒绝无法表示的参数,避免它们静默变成 `?`。需要传递任意 Unicode 参数时,应按系统要求启用 UTF-8 区域设置;`-Dfile.encoding=UTF-8` 控制应用文件编码,不能替代这项设置。自定义构建入口自行切换 JDK 时,还需核对其实际使用的工具链。[JDK Windows 启动器说明](https://www.oracle.com/java/technologies/javase/21-0-9-relnotes.html) +Windows 上的 Maven 配置根超出系统代码页时,需要 Maven **3.9.2 或更新版本**,否则 java-run 会报错。 +该版本支持所需的[命令行属性插值](https://maven.apache.org/docs/3.9.2/release-notes.html),使 `.mvn` 配置和 POM 中的项目根目录属性保持正确路径。 -Windows 上的 Maven 配置根超出系统代码页时,需要 Maven **3.9.2 或更新版本**。java-run 会检查这一条件,保留 `.mvn` 配置和 POM 中项目根目录属性的绝对路径语义;版本不满足时明确报错。[Maven 3.9.2 命令行属性插值](https://maven.apache.org/docs/3.9.2/release-notes.html) +### 开发与验证 -类型检查、快速测试和原生运行验收通过 `bun run check`、`bun run compile`、`bun run smoke` 执行。具体环境要求、CI 和发布流程见[参与开发](CONTRIBUTING.md)。 +类型检查、快速测试和原生运行验收通过 `bun run check`、`bun run compile`、`bun run smoke` 执行。 +具体环境要求、CI 和发布流程见[参与开发](CONTRIBUTING.md)。 ## 项目文档 diff --git a/docs/architecture.md b/docs/architecture.md index 4780c9c..94fafbe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,43 +1,71 @@ # java-run 架构与运行契约 -java-run 是框架中立的 Java 源码工作区运行器。它把“选择一个项目、准备运行产物、确定入口、启动独立 JVM”组织成统一流程,构建模型、依赖版本和项目任务仍由 Maven 或 Gradle 裁决。 +java-run 是框架中立的 Java 源码工作区运行器。 +它把选择项目、准备产物、确定入口和启动独立 JVM 组织成统一流程。 +构建模型、依赖版本和项目任务由 Maven 或 Gradle 裁决。 -统一入口适用于基于 classpath 的 Java 应用,包括普通 main 和 Spring Boot 应用。框架参数沿用 JVM 参数或应用参数的含义;框架类型不进入核心配置。使用方式见 [README](../README.md),工程能力与后续路线见 [路线规划](roadmap.md)。 +运行契约基于 classpath,适用于普通 main 和 Spring Boot 等 Java 应用。 +框架参数通过 JVM 参数或应用参数传入,框架类型不进入核心配置。 -## 运行目标 +使用方式见 [README](../README.md),验证与发布流程见 [贡献指南](../CONTRIBUTING.md),后续目标见 [技术路线](roadmap.md)。 -一次运行只启动一个构建项目和一个主类,`init` 保存的配置也对应这个单一目标。多模块工作区中的模块可能是应用,也可能是库;存在编译输出或应用 Java 插件都不能证明它可执行。 +## 运行目标与入口 -模块选择和主类选择分两步完成: +一次请求对应一个构建项目和一个主类,`init` 保存的配置也对应这个单一目标。 +多模块工作区中的项目可能是应用,也可能是库。 +存在编译输出或应用了 Java 插件,都不能证明项目具有可运行入口。 -1. Maven 从有效 reactor 中列出 jar 项目,Gradle 从主构建中列出应用 Java 插件的项目。 -2. 准备选定项目后,按显式主类、项目声明、已编译入口的顺序确定主类。 +目标和入口分两步确定: -自动发现只识别目标输出中的传统 `public static void main(String[])`。未找到入口时要求检查产物或显式配置;发现多个入口时要求选择。显式主类会校验类名语法,实际可加载性和方法有效性由 Java 启动器确认。 +1. 发现候选项目:Maven 列出有效 reactor 中的 jar 项目,Gradle 列出主构建中应用了 Java 插件的项目。 +2. 准备选定项目:按显式主类、构建声明、已编译入口的顺序确定主类。 -交互用于补齐目标和入口信息。标准输入与标准错误均为终端时,CLI 可以展示模块或主类菜单;唯一候选自动采用。普通运行只使用本次选择,`init` 在准备和选择成功后保存结果。非交互环境遇到目标或入口歧义时,需要通过参数明确指定;普通运行也可读取已保存的配置。菜单不询问运行参数,参数由选项或配置提供;取消选择返回 130。 +自动发现只扫描目标项目输出,识别传统的 `public static void main(String[])`。 +唯一入口自动采用;多个入口需要选择;没有入口时报告错误。 +显式主类和构建声明的主类会校验名称格式,实际可加载性和方法有效性由 Java 启动器确认。 -候选发现会运行构建工具。Gradle 配置期间可能需要准备 `buildSrc` 或 included build 的构建逻辑,但发现步骤不主动编译候选应用、解析其运行依赖或读取主类 Provider。 +### 交互选择 -## 请求、计划与准备结果 +标准输入与标准错误均为终端时,CLI 可以展示模块或主类菜单。 +唯一候选自动采用,取消选择返回退出码 130。 -三个数据契约分别承担不同阶段的职责: +- `run` 使用本次选择启动应用 +- `init` 在准备和选择成功后保存结果 +- 非交互环境存在歧义时,通过 `--module` / `--main` 明确指定;`run` 也可读取项目配置 -| 契约 | 含义 | 可确认的内容 | -| --- | --- | --- | -| `RunConfig` | 已解析的用户请求 | 工作目录、目标、参数和构建策略 | -| `BuildPlan` | 构建工具执行前的静态准备计划 | 命令、参数边界、工作目录和准备说明 | -| `PreparedProject` | 构建工具裁决后的运行信息 | 目标输出、按顺序排列的类路径、项目入口、启动 JDK 和 JVM 默认参数 | +菜单只补齐目标和入口,运行参数由选项或配置提供。 -`run` 与 `plan` 读取解析 `--cwd` 后的工作区根目录中的 `.java-run.json`,不向父目录查找,也不在选定模块后重新读取。CLI 标量覆盖文件配置,数组参数追加到文件数组之后。工具路径由 CLI 指定,项目配置保存可复用的运行目标和参数。 +候选发现会运行构建工具。 +Gradle 配置期间可能准备 `buildSrc` 或 included build 的构建逻辑。 +发现步骤本身不主动编译候选应用、解析其运行依赖或读取主类 Provider。 -`init` 使用本次 CLI 请求准备目标、检查运行产物并确定主类,然后生成根目录的 `.java-run.json`。配置记录实际构建工具、模块和主类、本次显式提供的参数数组,以及非默认的构建策略和测试类路径设置。构建声明的默认 JVM 参数继续由构建工具提供;`--cwd`、`--java` 和 `--build-command` 不写入配置。 +## 请求与命令流程 -`init` 在任何外部命令执行前检查配置文件是否已存在,默认拒绝覆盖。`--force` 忽略旧配置并重新生成,不读取或合并旧字段,因此可用于替换损坏的 JSON。取消、准备失败或入口无法确定时不写配置。初始化会执行构建准备,但不创建应用 JVM;实际可加载性与入口方法有效性仍在运行时由 Java 启动器确认。 +准备流程使用三个数据契约: -`plan` 只读取本地构建文件和配置,输出准备步骤及待解析信息。它不执行构建工具、不创建临时目录、不交互,也不验证有效模型、主类或依赖文件。`help`、`version` 只解析 CLI 参数,不读取项目配置。 +| 契约 | 职责 | +| --- | --- | +| `RunConfig` | 表达工作目录、运行目标、参数和构建策略 | +| `BuildPlan` | 描述执行构建工具前的静态命令、工作目录和准备说明 | +| `PreparedProject` | 保存构建工具裁决的目标输出、有序类路径、主类、启动 JDK 和默认 JVM 参数 | -运行与初始化复用同一准备流程: +### 配置来源 + +`run` 和 `plan` 读取 `--cwd` 指定的工作区根目录中的 `.java-run.json`。 +配置定位只执行一次,不向父目录查找,也不在选定模块后重新读取。 + +- CLI 标量覆盖文件配置 +- CLI 参数数组追加到文件数组之后 +- 项目配置保存可复用的目标、参数和准备策略 +- `--cwd`、`--java` 和 `--build-command` 由 CLI 指定,不写入项目配置 + +`init` 使用 CLI 请求准备目标、检查产物并确定主类。 +生成的配置记录实际构建工具、模块和主类、显式参数数组,以及非默认的构建策略和测试类路径设置。 +构建声明的默认 JVM 参数仍由构建工具提供。 + +### 执行与保存 + +运行与初始化共用目标选择、项目准备和主类确定流程: ```mermaid flowchart TD @@ -51,6 +79,22 @@ flowchart TD classpath --> java[独立 Java 进程] ``` +初始化写入遵循以下规则: + +- 执行外部命令前检查目标文件,默认拒绝覆盖已有配置 +- `--force` 使用本次选项重新生成,忽略旧配置,也可替换损坏的 JSON +- 取消、准备失败或入口无法确定时保留原文件 +- 保存时再次检查目标,通过同目录临时文件完整写入后发布;目录和符号链接不能作为覆盖目标 + +初始化会执行构建准备,但不启动应用 JVM。 + +### 静态命令 + +`plan` 只读取本地构建文件和配置,输出准备步骤及待解析信息。 +它不执行构建工具、不创建临时目录、不交互,也不验证有效模型、主类或依赖文件。 + +`help` 和 `version` 只解析 CLI 参数,不读取项目配置。 + ## 模块职责与依赖 ```text @@ -62,56 +106,148 @@ src/ └── process/ 外部进程、环境覆盖、编码检查与信号处理 ``` -CLI 依赖具体构建适配器完成准备,适配器返回同一 `PreparedProject`。核心模块检查该结果并确定主类,供 `run` 启动 Java 或 `init` 保存配置。CLI 协调交互选择,核心模块通过调用方提供的选择行为确定入口,不依赖终端菜单实现。核心契约不包含 Spring 等框架字段;进程模块不判断 Maven 或 Gradle 项目语义。 +CLI 调用构建适配器完成准备,适配器返回统一的 `PreparedProject`。 +核心模块检查该结果并确定主类,供 `run` 启动 Java 或 `init` 保存配置。 -Maven 与 Gradle 的差异集中在 `build-tools/`。各适配器提供计划、候选发现和项目准备函数,使调用方不必理解模型读取和任务图实现。新增构建系统或运行模式时,根据项目样本和结果契约调整接口。 +CLI 协调终端交互,核心模块通过调用方提供的选择函数确定入口。 +核心模块不依赖终端菜单,进程模块不判断 Maven 或 Gradle 项目语义。 + +构建适配器提供静态计划、候选发现和项目准备函数。 +模型读取、目标定位和任务图的差异由各适配器处理。 +Maven 的有效模型类型与依赖文本解析位于 Maven 适配器,核心模块只消费准备结果。 +新增构建系统或运行模式时,以真实项目和准备结果契约确定接口。 ## 构建工具适配 -工作目录同时包含 Maven 与 Gradle 构建文件时,必须显式选择工具。构建命令的选择顺序为 CLI 指定命令、工作目录中的 Wrapper、PATH 中的命令。Wrapper 存在但启动失败时保留错误,不回退到另一个版本。 +工作目录同时包含 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`。 -| 策略 | Maven | Gradle | -| --- | --- | --- | -| 默认准备 | 单项目执行 `compile`,包含测试时执行 `test-compile`;指定模块时执行 `-pl <目标> -am install -DskipTests` | 元数据任务依赖目标 source set 的 classes 和 runtimeClasspath 构建依赖,由任务图准备产物 | -| 模型来源 | 固定版本 Help Plugin 输出目标 POM 和 effective POM;Dependency Plugin 输出运行依赖 | 临时 init script 读取目标 source set、runtimeClasspath、application 配置和 Java toolchain | -| 类路径范围 | 目标编译输出加 Maven 裁决的运行依赖 | 目标 source set 的 runtimeClasspath | -| 包含测试 | 测试输出优先于主输出,并解析 test 依赖作用域 | 采用 test runtimeClasspath,同时记录 test 与 main 的输出 | -| `build=none` | 不执行编译或 install,继续解析有效模型和依赖 | 元数据任务不依赖源码构建任务,要求运行产物已经存在 | +主类声明采用 `exec.mainClass` 属性或 Exec Maven Plugin 的 `mainClass` 配置。 +应用启动 JDK 按 `--java`、`JAVA_HOME`、PATH 的顺序选择,不自动读取 Maven toolchains。 -Maven 多模块的准备使用本地仓库衔接上游产物。这样,后续单独解析目标依赖时能够读取所选项目及上游模块的构建结果。`install` 可能执行项目绑定到该生命周期的其他插件,只写本地仓库,不执行 `deploy`。当前 Maven 运行目标要求 `packaging=jar`。 +### Gradle -Maven 适配器读取工具已经展开的模型,不自行计算属性、继承或依赖冲突。主类元数据采用 `exec.mainClass` 属性或 Exec Maven Plugin 的 `mainClass` 配置。应用启动 JDK 由 `--java`、`JAVA_HOME` 或 PATH 决定,适配器不自动从 Maven toolchains 选择应用启动器。 +临时 init script 读取目标的 source set、runtimeClasspath、application 配置和 Java toolchain。 +脚本位于请求的临时工作区,不修改项目构建文件。 -Gradle init script 只写入本次临时工作区,不改项目构建脚本。元数据任务仅注册到请求的主构建,避免把目标项目路径套用到 `buildSrc` 或 included builds;正常项目依赖和复合构建依赖仍由 Gradle 任务图处理。Gradle application 插件声明的主类和默认 JVM 参数可以成为启动元数据,目标 Java toolchain 提供启动器;`--java` 可以显式覆盖应用启动器。 +元数据任务只注册到主构建,目标项目路径不会套用到 `buildSrc` 或 included builds。 +项目依赖和复合构建依赖仍由 Gradle 任务图处理。 -适配器不主动调用测试目标或测试任务,项目绑定到编译、安装或 classes 的其他行为仍按其构建配置执行。包含测试类路径意味着准备并加载测试输出与依赖。`build=none` 也会执行模型和依赖解析,构建工具可能下载依赖或更新自身缓存;它不等同于静态预览。 +| 项目设置 | 准备行为 | +| --- | --- | +| 默认准备 | 元数据任务依赖目标 source set 的 classes 和 runtimeClasspath 构建依赖 | +| 包含测试 | 采用 test runtimeClasspath,并记录 test 与 main 输出 | +| `build=none` | 元数据任务不依赖源码构建任务,要求运行产物已经存在 | -构建参数只能调整适配器允许的属性、profile、日志、依赖等选项,不能另行指定项目、生命周期目标、任务列表或 init script。适配器管理的元数据输出和相关属性保持受控,确保请求仍指向一个目标。 +application 插件提供声明的主类和默认 JVM 参数,Java toolchain 提供应用启动器。 +`--java` 可以显式覆盖该启动器。 +application 声明 `mainModule` 时明确报错,因为它要求 JPMS 启动语义。 + +### 构建参数与副作用 + +适配器不主动调用测试目标或测试任务。 +项目绑定到 compile、install 或 classes 的行为仍按构建配置执行。 +包含测试类路径表示准备并加载测试输出与依赖。 + +`build=none` 仍执行模型和依赖解析,可能下载依赖或更新构建工具缓存。 +需要无构建副作用的预览时使用 `plan`。 + +构建参数用于调整允许的属性、profile、日志和依赖等选项。 +项目选择、生命周期目标、任务列表、init script 和元数据输出由适配器管理,保证一次请求只准备一个运行目标。 ## Java 启动 -启动模块先检查类路径文件和目标类目录,再确定主类。主类自动发现仅扫描目标项目输出,不把依赖 Jar 或其他应用的入口混入候选。 +### 类路径与主类 + +启动模块先检查类路径文件和目标类目录,再确定主类。 +入口候选仅来自目标项目输出,不包含依赖 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 退出码。 -Java 启动通过临时 Manifest Jar 表达长类路径。Manifest 中的文件路径转换为带正确 URL 编码的 `file:` URL,目录保留尾部斜线,按 UTF-8 字节数折行并使用 CRLF 和终止空行。类路径保留构建工具给出的顺序,只去重相同路径。 +POSIX 命令使用独立进程组,接收转发的 `SIGINT` 和 `SIGTERM`,三秒后升级终止。 +父进程退出或命令结束时清理受控进程树。 -应用工作目录为选定项目目录。JVM 参数的排列为默认 `-Dfile.encoding=UTF-8`、项目声明的默认参数、用户配置及 CLI 参数;应用参数位于主类之后。各参数一直保持数组边界,不由通用 shell 重新拆分。临时脚本、元数据和类路径 Jar 在请求结束时统一清理。 +Windows 批处理入口通过受控 `cmd` 调用保留参数边界,并阻止 `%NAME%` 被二次展开。 +终止时使用 `taskkill /T /F` 清理进程树,应用关闭钩子不保证执行。 -框架原生运行任务可能配置额外资源、环境、agent 或自定义 `JavaExec` 行为。通用启动器只使用上述运行契约;依赖这些任务行为的项目应采用原生任务,或明确配置所需启动参数。 +### Windows JDK 参数编码 -## 跨平台进程与编码 +Windows JDK 原生启动器可能使用系统代码页转换参数,`file.encoding=UTF-8` 不改变这个转换。 +执行前按真实 JDK 的原生编码验证非 ASCII 参数;无法完整表示时明确失败,诊断不回显原始参数。 -外部命令异步执行。构建与元数据命令捕获输出,Java 应用继承终端。捕获模式保留换行和首尾空白,总输出默认限制为 16 MiB;非零退出保留阶段、工作目录、退出码和诊断。应用退出码直接成为 CLI 退出码,命令不存在时返回 127。 +项目路径通过以下方式传递: -POSIX 命令使用独立进程组,转发 `SIGINT`、`SIGTERM`;三秒后升级终止,父进程退出或命令结束时清理该进程树。Windows 批处理入口通过受控 `cmd` 调用保留参数边界,并阻止参数中的 `%NAME%` 被二次展开;终止时用 `taskkill /T /F` 清理进程树,因此不能承诺 POSIX 式的应用优雅关闭。 +- Gradle 从临时脚本的 UTF-8 请求读取项目根、目标和输出路径 +- Java 从 Manifest 读取类路径,classpath Jar 参数相对目标项目目录计算 +- Jar 命令在临时目录中使用相对文件名 -Windows JDK 原生启动器可能使用系统代码页转换参数,`file.encoding=UTF-8` 不会改变这个转换。执行前按真实 JDK 的原生编码验证非 ASCII 参数;无法完整表示时明确失败,错误不回显原始参数。路径可以保留在 UTF-8 元数据和 Manifest 中:Gradle 的项目根、目标和输出路径从临时脚本中的请求读取,Java 的类路径 Jar 参数相对目标项目目录计算,Jar 命令在临时目录中使用相对文件名。 +Maven 启动脚本还会注入配置根目录。 +适配器保留显式 `MAVEN_BASEDIR` 或最近 `.mvn` 祖先的含义,并规范化原生目录路径以展开 8.3 别名。 -Maven 启动脚本还会注入配置根目录。Windows 适配器保留显式 `MAVEN_BASEDIR` 或最近 `.mvn` 祖先的含义,规范化已有目录的原生路径以展开 8.3 别名。配置根无法经 JDK 编码完整传递时,先以相对配置根加载 `.mvn`,再通过子环境恢复模型中的绝对根;此路径要求 Maven 3.9.2 或更新版本。子环境覆盖不改变父进程环境。 +配置根无法经 JDK 编码完整传递时,先以相对配置根加载 `.mvn`,再通过子环境恢复模型中的绝对根。 +这条路径要求 Maven 3.9.2 或更新版本。 +子环境覆盖不改变父进程环境。 -## 支持范围与演进原则 +## 支持范围与验证原则 -当前契约围绕 Maven jar、Gradle Java source set 和传统 Java main 建立。JPMS、Android、native image、部署和服务守护不在支持范围。Gradle application 声明 `mainModule` 时会明确拒绝。 +支持契约围绕 Maven jar、Gradle Java source set 和传统 Java main 建立。 +JPMS、Android、native image、部署和服务守护属于不同的运行模型。 -工具不保存独立的类路径缓存;每次请求由构建工具重新裁决,下载缓存和增量构建由其自身维护。性能优化应围绕模型调用次数、配置成本和真实项目测量展开,同时保留依赖、资源和目标隔离的正确性。 +每次请求由构建工具裁决运行信息,下载缓存和增量构建由其自身维护。 +java-run 不维护独立类路径缓存。 +性能优化以模型调用次数、配置成本和真实项目测量为依据。 -验证通过参数与配置契约、真实 JDK 启动、Maven / Gradle 项目夹具和原生二进制验收组成。支持范围的扩大以可运行样本和对应验收为依据,构建出某个平台产物本身不等于验证过该平台。 +验证覆盖配置契约、真实 JDK 启动、Maven / Gradle 项目夹具和原生二进制验收。 +扩大支持范围需要可运行样本和对应验收;仅构建出平台产物不足以证明该平台可用。 diff --git a/docs/installation.md b/docs/installation.md index 947b241..191b7cf 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,6 +1,13 @@ # 安装 java-run -java-run 的预编译版本可直接执行,不需要安装 Bun。运行目标项目仍需要符合该项目要求的 JDK,以及 Maven / Gradle 或项目 Wrapper;JDK 的 `java` 和 `jar` 必须可用。项目存在 `mvnw` / `mvnw.cmd` 或 `gradlew` / `gradlew.bat` 时优先使用 Wrapper,没有时使用 PATH 中的构建工具。发布验收覆盖 JDK 17 和 21,目标项目自身的 Java 版本要求仍需满足。 +java-run 的预编译版本可直接执行,无需安装 Bun。 +运行 Java 项目需要: + +- 符合目标项目要求的 JDK,能够使用其中的 `java` 和 `jar` +- Maven / Gradle 或项目 Wrapper + +项目存在 `mvnw` / `mvnw.cmd` 或 `gradlew` / `gradlew.bat` 时,java-run 优先使用 Wrapper;否则使用 PATH 中的构建工具。 +发布验收覆盖 JDK 17 和 21,目标项目自身的 Java 版本要求仍需满足。 ## 选择下载文件 @@ -14,22 +21,40 @@ java-run 的预编译版本可直接执行,不需要安装 Bun。运行目标 | 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 版本。 +[最新稳定版校验和](https://github.com/Vanisper/java-run/releases/latest/download/SHA256SUMS) 覆盖这五个 ZIP。 +确认体系结构的方法: -Linux 产物在 Ubuntu 24.04 的 glibc 环境验收;musl(如 Alpine)和其他系统版本未纳入发布验收。 +- macOS / Linux:运行 `uname -m`,`x86_64` 对应 x64,`arm64` / `aarch64` 对应 arm64 +- Windows:在“设置 → 系统 → 关于”查看系统类型,当前提供 x64 版本 -资产名称固定,版本位于 URL 中。指定版本的下载地址格式为: +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 ``` -将 `vX.Y.Z` 替换为所选 Release 的完整标签;预发布版本也使用完整标签下载。`/releases/latest/download/` 用于最新稳定版。ZIP 解压后只有一个 `java-run-<平台>/` 目录,内含 `java-run` 或 `java-run.exe`、对应的 `java-run.sha256` 或 `java-run.exe.sha256`、本安装指南 `INSTALL.md`,以及存在时按原文件名附带的许可证。Release 的 `SHA256SUMS` 校验下载的 ZIP,包内 `.sha256` 校验解压后的二进制。 +预发布版本也使用完整标签下载;`/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`: +下面以 macOS arm64 为例,在空的下载目录中执行。 +根据上表调整 `platform`;要固定版本,将 `release_url` 改为对应的 `/releases/download/vX.Y.Z`。 ```sh platform=darwin-arm64 @@ -39,7 +64,8 @@ curl -fL "$release_url/$asset" -o "$asset" curl -fL "$release_url/SHA256SUMS" -o SHA256SUMS ``` -解压前核对 ZIP 的 SHA-256。macOS 使用: +解压前核对 ZIP 的 SHA-256。 +macOS 使用: ```sh shasum -a 256 "$asset" @@ -51,14 +77,16 @@ Linux 使用: sha256sum "$asset" ``` -将输出与 `SHA256SUMS` 中该 ZIP 对应的值核对,一致后再继续;不一致时重新下载同一版本的 ZIP 和校验和。 +将输出与 `SHA256SUMS` 中该 ZIP 对应的值核对,一致后再继续。 +不一致时,重新下载同一版本的 ZIP 和校验和。 ```sh unzip "$asset" cd "java-run-${platform}" ``` -在解压目录中核对二进制。macOS 使用: +在解压目录中核对二进制。 +macOS 使用: ```sh shasum -a 256 -c java-run.sha256 @@ -70,7 +98,7 @@ Linux 使用: sha256sum --check java-run.sha256 ``` -确认输出 `java-run: OK` 后,再检查版本并安装: +确认输出 `java-run: OK` 后,检查版本并安装: ```sh chmod +x java-run @@ -81,7 +109,9 @@ export PATH="$HOME/.local/bin:$PATH" java-run version ``` -`chmod +x` 确保可执行权限。把上述 `export PATH` 加入当前 shell 的配置文件(如 `~/.zshrc` 或 `~/.bashrc`),使之后的终端也能找到命令。这种安装方式只写入当前用户目录,无需 `sudo`。 +`chmod +x` 确保可执行权限。 +把上述 `export PATH` 加入当前 shell 的配置文件(如 `~/.zshrc` 或 `~/.bashrc`),使之后的终端也能找到命令。 +安装位置属于当前用户,无需 `sudo`。 ## Windows @@ -91,7 +121,8 @@ java-run version Get-FileHash -Algorithm SHA256 .\java-run-windows-x64.zip ``` -将结果与 `SHA256SUMS` 中 `java-run-windows-x64.zip` 对应的值核对,比较时忽略字母大小写。一致后解压并核对二进制: +将结果与 `SHA256SUMS` 中该 ZIP 对应的值核对,比较时忽略字母大小写。 +一致后解压并核对二进制: ```powershell Expand-Archive -Path .\java-run-windows-x64.zip -DestinationPath .\java-run-download @@ -113,12 +144,21 @@ $env:Path = "$installDir;$env:Path" java-run version ``` -在“编辑账户的环境变量”中,将 `%LOCALAPPDATA%\Programs\java-run` 加入用户的 `Path`,随后打开新终端验证 `java-run version`。安装到当前用户目录无需管理员权限。 +在“编辑账户的环境变量”中,将 `%LOCALAPPDATA%\Programs\java-run` 加入用户的 `Path`。 +随后打开新终端,执行 `java-run version` 验证安装。 +安装到当前用户目录无需管理员权限。 ## 使用与升级 -在 Java 项目根目录执行 `java-run`;需要查看选项时执行 `java-run help`。多模块或多个主类的选择、应用参数与项目配置见 [项目 README](https://github.com/Vanisper/java-run#readme)。 +在 Java 项目根目录执行 `java-run` 即可启动。 +需要保存模块、主类和启动参数时,执行 `java-run init` 生成项目配置,之后仍使用 `java-run` 启动。 +命令选项见 `java-run help`,交互选择与参数示例见 [项目 README](https://github.com/Vanisper/java-run#readme)。 + +升级步骤: -升级时下载所选版本的 ZIP 与校验和,完成上述核对和版本检查,再替换 PATH 中已有的可执行文件。Windows 先退出正在使用该可执行文件的进程。升级后运行 `java-run version`,确认结果与所选 Release 的版本一致;回退也可按同样步骤安装指定旧版本。 +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 index f8ac0ce..0457d02 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,63 +1,70 @@ # java-run 工程能力与技术路线 -java-run 的产品目标是为 Maven 与 Gradle 的 Java 源码工作区提供可复用的启动入口。当前已具备单目标运行、自动准备、项目配置、交互补选、初始化配置生成和独立 JVM 启动能力;架构契约见 [架构文档](architecture.md),贡献与验证流程见 [贡献指南](../CONTRIBUTING.md)。 +java-run 为 Maven 与 Gradle 的 Java 源码工作区提供统一启动入口。 +它负责选定目标、准备产物、保存启动配置和运行独立 JVM,构建语义由项目使用的构建工具决定。 -## 当前能力评估 +[架构文档](architecture.md) 定义运行契约,[贡献指南](../CONTRIBUTING.md) 说明开发、验收与发布流程。 -评估依据当前运行契约、实现边界和验证证据。 +## 工程能力 -| 能力 | 实现依据 | 工程评价与限制 | +| 能力 | 设计与实现 | 扩展时需要保持的约束 | | --- | --- | --- | -| 产品与配置 | `RunConfig` 表达构建工具、单目标、构建策略和三类参数;严格校验 `.java-run.json`;`init` 准备并选择目标后保存配置 | 初始化与运行复用目标和入口确定流程,参数可通过 CLI 保存;尚无命名运行配置 | -| 构建语义 | Maven 有效模型与依赖解析、Gradle source set 与任务图 | 构建工具保有依赖裁决权;Maven 多模块准备写入本地仓库,Gradle 配置需要执行项目构建逻辑 | -| 模块设计 | CLI、两种构建适配器、核心启动和进程处理按职责组织 | Maven / Gradle 差异集中,统一准备结果可复用;扩展接口应由新增的真实需求驱动 | -| 启动正确性 | 目标类路径隔离、测试作用域开关、class 文件入口扫描、Manifest URL 编码 | 主类和类路径有直接执行验证;自动发现仅支持传统 main,运行依赖于现有 classpath 契约 | -| 进程管理 | 异步执行、结构化退出状态、子环境覆盖、平台专用进程树清理 | 失败能够定位到阶段,退出码保留;Windows 强制终止不能保证应用关闭钩子执行 | -| 验证 | Bun 回归、真实 JDK、Maven / Gradle 夹具、编译后二进制 smoke | 覆盖正常启动、依赖与资源变化、参数、配置、目标隔离和平台边界;夹具结果不代表所有第三方构建插件兼容 | -| 分发 | 固定 Bun、统一编译入口、五平台 ZIP 原生验收、SHA-256 与受检标签发布 | 版本、许可证和产物集合有发布门禁;固定下载入口和包内安装指南可直接使用,实际发布后仍需核对下载与安装 | +| 目标与配置 | 单项目、单主类;严格校验项目配置;`init` 与 `run` 共用准备和入口选择 | 交互与非交互具有一致的配置和参数含义 | +| 构建适配 | Maven 有效模型、Gradle source set 和任务图统一为 `PreparedProject` | 构建工具保有依赖裁决权,目标运行类路径保持隔离 | +| Java 启动 | 编译入口扫描、Manifest 类路径、有序 JVM 与应用参数 | 路径编码、参数边界和依赖顺序保持正确 | +| 进程管理 | 异步执行、结构化错误、子环境覆盖和平台进程树清理 | 退出码可追踪,临时资源和受控进程能够清理 | +| 验证与分发 | 回归测试、真实项目夹具、五平台原生 ZIP 验收和两层 SHA-256 校验 | 支持声明与实际执行证据一致,公开产物通过发布门禁 | -## 验证与支持依据 +这些契约使 Maven / Gradle 的启动差异集中在构建适配层,日常使用保持一套命令。 +`init` 保存可复用的目标和参数,运行时继续由构建工具解析依赖和应用默认值。 -[Check 工作流](../.github/workflows/check.yaml) 在 Ubuntu、macOS、Windows 上分别使用 JDK 17 和 21,执行锁文件安装、类型检查、回归测试、本机二进制编译以及完整 Maven / Gradle 验收。Gradle 在 CI 固定为 8.14;Maven 使用运行环境中的命令,不应把 CI 的单一安装版本扩大为整个 Maven 版本系列的兼容承诺。 +支持范围有明确边界: -回归围绕以下可观察结果维护: +- Maven reactor 准备通过本地仓库衔接上游产物,会执行 install 生命周期 +- Gradle 配置会执行项目构建逻辑,通用启动不复现所有自定义运行任务 +- 主类自动发现基于传统 main 和 classpath,不包含 JPMS 或现代 main 语义 +- Windows 进程树采用强制终止,不能保证应用关闭钩子执行 -- 单项目与 app 依赖 lib 的多模块项目能够启动指定目标 -- 无关应用不进入目标运行类路径,默认不加载测试输出和测试依赖 -- POM、依赖、profile、资源和配置变化影响下一次运行 -- 主类发现、Manifest 和实际 Java 启动覆盖特殊路径与 Unicode 标识符 -- 应用参数、JVM 参数、构建参数和子环境保留各自边界 -- 正常退出、失败、终止和命令缺失产生明确结果,并清理受控进程树 -- 静态计划、帮助和版本遵守各自的副作用契约 -- 初始化保存目标和参数,已有配置默认保留,取消或准备失败不写配置 -- Windows 编码、Maven 配置根和批处理调用有对应的原生验证 +## 验证依据 -[Release 工作流](../.github/workflows/release.yaml) 在固定原生 runner 上构建 Windows x64、Linux x64 / arm64、macOS x64 / arm64 五种压缩二进制。编译时生成对应的 `.sha256` 校验文件,打包前核对二进制,再与校验文件、独立安装指南、许可证一起打包为 ZIP。每份二进制在 JDK 21 下执行完整 smoke;实际 ZIP 经原生解压、源文件一致性与包内 SHA-256 校验后,使用 JDK 17 对解压出的文件执行 quick 启动验收。汇总要求五种非空 ZIP 齐全,并生成和核对覆盖 ZIP 的 `SHA256SUMS`。 +验证以用户可观察的行为为准:目标能否启动、配置能否复用、依赖和资源变化能否生效,以及失败后是否保留诊断并清理资源。 +测试范围与夹具用途见 [夹具说明](../tests/fixtures/README.md)。 -ZIP 名称固定为 `java-run-<平台>.zip`,版本由 GitHub Release 的下载路径表达;每份包内使用固定的可执行文件名。Release 正文自动生成版本下载表、安装说明与 GitHub 变更说明,安装指南同时随 ZIP 分发。[安装指南](installation.md) 说明最新稳定版和指定版本的下载、校验、PATH 配置与升级方式。 +- [Check](../.github/workflows/check.yaml):Ubuntu、macOS、Windows 分别在 JDK 17 和 21 下执行源码回归、二进制编译和完整运行验收 +- [Release](../.github/workflows/release.yaml):五个平台分别构建,JDK 21 完整运行验收,JDK 17 解压产物启动验收,再汇总并校验 ZIP -标签发布先校验 SemVer、标签与包版本的一致性、非空许可证文件及发布状态。六组 Check 与五平台产物验收并行执行;全部通过后,上传完整资产并自动公开。预发布版本自动标记为 prerelease,已有公开版本不覆盖。分支路径触发与手动触发演练构建验收并预览下载与安装正文,不创建 Release;具体入口与重试规则见 [发布指南](../CONTRIBUTING.md#ci-与发布)。 - -验证矩阵表达所用系统、体系结构与 JDK 的实际覆盖:Check 使用两个 JDK 执行完整回归,发布文件在 JDK 17 上采用较小的启动套件。Linux 使用 Ubuntu 24.04 的 glibc 环境,musl 与其他系统版本未纳入发布验收;固定 runner 的验收也不构成所有 CPU 或第三方构建插件的兼容承诺。 +固定环境的验收提供对应系统、体系结构和 JDK 的运行证据。 +其他构建工具版本、第三方插件和系统环境需要代表项目补充验证;平台范围见 [安装指南](installation.md)。 ## 技术路线 -优先级依据当前支持契约和使用证据安排。每个阶段以可检查的交付条件结束,不预设未经测量的提速比例或缺少项目样本的兼容承诺。 +后续工作按使用证据和收益排序。 +每项能力先确定项目样本、运行契约和验收条件,再扩展接口。 + +### 扩展真实项目覆盖 + +优先补充 Wrapper、自定义输出、构建约定和不同 Maven / Gradle 版本的代表项目。 +工具链验证同时覆盖构建 JDK 与应用启动 JDK 的选择。 + +验收条件是样本能够重复运行,失败边界有明确诊断,已有目标隔离与参数契约保持成立。 + +### 改善重复启动体验 + +根据 `init` 和日常运行反馈,评估命名运行配置、配置编辑辅助和更直接的诊断信息。 +新增配置能力需要明确读取位置、覆盖顺序和保存规则,并能直接用于脚本。 + +验收条件是减少有证据的重复操作,交互与非交互方式能够完成相同的运行目标。 -| 优先级 | 目标 | 交付内容 | 完成条件 | -| --- | --- | --- | --- | -| P0 | 发布可依赖的通用版本 | 确定许可证与版本号;整理支持矩阵和发布说明;完成流程演练及实际产物的安装、版本与校验核对 | 发布门禁通过,文档与产物一致,宣称支持的系统与体系结构有对应运行证据 | -| P1 | 扩展真实项目兼容性 | 增加 Wrapper、自定义输出、构建约定和不同 Maven / Gradle 版本的代表项目;明确 toolchain 与应用 JDK 选择 | 新样本可重复验收,失败边界可解释,既有目标隔离与参数契约保持成立 | -| P1 | 改善重复启动体验 | 根据 `init` 和日常运行的使用反馈评估命名运行配置、可读诊断和配置编辑辅助;保持命令可直接用于脚本 | 有明确的重复操作或诊断需求,交互与非交互都能完成相同运行目标,配置行为有稳定契约 | -| P2 | 降低启动准备成本 | 测量冷准备、温准备、构建工具启动次数和配置成本;据结果优化 Maven 多次调用或 Gradle 元数据读取 | 在固定样本上有可复现收益,依赖与资源变更回归仍通过,不引入失效条件不完整的独立类路径缓存 | -| 按需 | 扩展运行模型 | 分别评估 JPMS、现代 main、原生任务集成及其他构建系统 | 每种模式先有项目样本、明确的配置与退出契约,再确定适配器接口和支持范围 | +### 降低启动准备成本 -许可证和正式版本是发布决策;版本号应表达实际运行契约。正式发布时需要共同核对 README、支持声明、版本输出与安装产物。 +先测量冷准备、温准备、构建工具调用次数和配置成本,再优化 Maven 模型调用或 Gradle 元数据读取。 +优先复用构建工具已有的缓存与增量能力。 -## 架构决策原则 +验收条件是在固定样本中取得可复现的收益,同时保持依赖、资源与配置变更即时生效。 -TypeScript / Bun 承担 CLI、配置和进程编排,并提供独立可执行文件与跨平台构建能力。 +### 按需扩展运行模型 -Maven 与 Gradle 适配器保持各自的模型语义,统一的是准备结果和 Java 启动契约。后续扩展不以相似命令行外观代替语义验证,也不将框架专用运行任务默认等同于直接 Java 启动。 +JPMS、现代 main、原生任务集成和其他构建系统分别评估。 +每种模式需要实际项目、明确的配置及退出契约,以及对应的执行验证。 -命名配置、诊断和性能优化优先服务日常启动。新增能力需要同时说明配置来源、参数作用域、构建副作用、失败方式和验证样本;无法通过现有 `PreparedProject` 表达的运行模式,应先确认新的结果契约再设计接口。 +若运行信息无法由 `PreparedProject` 表达,应先确定新的结果契约,再设计适配接口。 diff --git a/examples/multiple-main/README.md b/examples/multiple-main/README.md index b40aeda..91a1189 100644 --- a/examples/multiple-main/README.md +++ b/examples/multiple-main/README.md @@ -5,18 +5,20 @@ - `com.example.HelloApplication` - `com.example.ReportApplication` -两个入口分别输出自己的类名和收到的参数,不依赖业务库。运行需要 JDK 17 或更新版本,以及 Maven;从 java-run 源码运行还需要根目录 [README](../../README.md#快速开始) 指定的 Bun。 +两个入口分别输出自己的类名和收到的参数,不依赖业务库。 +运行需要 JDK 17 或更新版本,以及 Maven。 +按 [安装指南](../../docs/installation.md) 将 java-run 加入 PATH 后,在 java-run 仓库根目录执行下面的命令。 ## 在终端选择入口 -在 java-run 仓库根目录安装依赖,然后运行示例: +运行示例并传入两个应用参数: ```sh -bun install --frozen-lockfile -bun run src/cli.ts --cwd examples/multiple-main -- --format=table "hello world" +java-run --cwd examples/multiple-main -- --format=table "hello world" ``` -java-run 自动编译项目。在标准输入与标准错误均连接终端时,两个入口会按类名排列为候选: +java-run 自动编译项目。 +在标准输入与标准错误均连接终端时,两个入口会按类名排列为候选: ```text 选择启动主类 @@ -32,23 +34,15 @@ entry=com.example.ReportApplication args=[--format=table, hello world] ``` -`--` 后的参数传给所选入口。`hello world` 是一个参数,终端选择不会改变参数边界。 - -也可以使用编译后的 java-run 二进制。在 java-run 仓库根目录执行: - -```sh -bun run compile -./dist/java-run --cwd examples/multiple-main -- --format=table "hello world" -``` - -Windows 使用 `./dist/java-run.exe`。把二进制放入 PATH 后,可将上述命令中的 `./dist/java-run` 换为 `java-run`。 +`--` 后的参数传给所选入口。 +`hello world` 是一个参数,终端选择不会改变参数边界。 ## 明确指定入口 脚本、CI 或重定向输入的环境使用 `--main`,无需菜单: ```sh -bun run src/cli.ts --cwd examples/multiple-main \ +java-run --cwd examples/multiple-main \ --main=com.example.HelloApplication -- --name=Alice ``` @@ -59,13 +53,6 @@ entry=com.example.HelloApplication args=[--name=Alice] ``` -二进制用法相同: - -```sh -./dist/java-run --cwd examples/multiple-main \ - --main=com.example.ReportApplication -- --format=json -``` - 非交互环境既没有保存主类、也没有传入 `--main` 时,java-run 会列出两个候选并要求明确入口,避免等待终端输入。 ## 保存入口与参数 @@ -73,15 +60,42 @@ args=[--name=Alice] 使用 `init` 将选择结果保存到示例项目的 `.java-run.json`: ```sh -bun run src/cli.ts init --cwd examples/multiple-main -- --format=table "hello world" +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] ``` -此命令同样编译项目并显示两个主类。输入 `2` 后保存 `ReportApplication` 和应用参数,此时不运行应用。随后执行: +重复初始化默认保留已有文件并报错。 +需要重新选择时使用 `init --force`,原配置中的入口和参数会被本次选择及选项替换;取消或准备失败时保留原文件。 + +## 从源码运行示例 + +使用仓库中 [`.bun-version`](../../.bun-version) 指定的 Bun 版本,在仓库根目录执行: ```sh +bun install --frozen-lockfile bun run src/cli.ts --cwd examples/multiple-main ``` -java-run 直接运行保存的入口,输出 `entry=com.example.ReportApplication` 和 `args=[--format=table, hello world]`,不再显示主类菜单。编译后二进制也支持同样的 `init` 命令。 +也可以编译后运行: + +```sh +bun run compile +./dist/java-run --cwd examples/multiple-main +``` -重复初始化默认保留已有文件并报错。需要重新选择时使用 `init --force`,原配置中的入口和参数会被本次选择及选项替换;取消或准备失败时保留原文件。 +其他示例的参数保持相同,只需将 `java-run` 换成 `bun run src/cli.ts` 或 `./dist/java-run`。 +Windows 二进制路径使用 `./dist/java-run.exe`。 diff --git a/scripts/release.ts b/scripts/release.ts index 88919f5..d26cc6f 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -44,19 +44,32 @@ export function releaseNotes(version: string): string { const rows = platforms.map(platform => `| ${platform.label} | [${platform.archive}](${download}/${platform.archive}) |`); return `## 下载与安装 -下载对应系统与处理器的 ZIP 后解压,无需安装 Bun。本机需要满足 Java 项目要求的 JDK;优先使用项目的 Maven / Gradle Wrapper,没有 Wrapper 时需安装对应构建工具。 +下载对应系统与处理器的 ZIP 后解压,无需安装 Bun。 + +运行环境: + +- 满足 Java 项目要求的 JDK +- 项目的 Maven / Gradle Wrapper,或本机安装的对应构建工具 | 平台 | 下载 | | --- | --- | ${rows.join('\n')} -每个 ZIP 包含 \`java-run-<平台>/\` 目录,内含 \`java-run\`(Windows 为 \`java-run.exe\`)、同名 \`.sha256\` 校验文件、\`INSTALL.md\` 和项目许可证。 +每个 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\` 文件用于校验解压后的二进制。 +- 包外的 \`SHA256SUMS\` 用于校验 ZIP +- 包内的 \`.sha256\` 文件用于校验解压后的二进制 + +解压后,在该目录检查版本: + +- macOS / Linux:\`./java-run --version\` +- Windows PowerShell:\`.\\java-run.exe --version\` -解压后在该目录执行 \`./java-run --version\`,Windows PowerShell 执行 \`.\\java-run.exe --version\`,应输出 \`java-run ${version}\`。按安装指南加入 PATH 后,即可在 Java 项目目录使用 \`java-run\`。 +输出应为 \`java-run ${version}\`。 +按安装指南加入 PATH 后,即可在 Java 项目目录使用 \`java-run\`。 升级时下载新版本的对应 ZIP,校验后用其中的可执行文件替换原文件,再执行 \`java-run --version\` 确认版本。 `; diff --git a/src/build-tools/gradle.ts b/src/build-tools/gradle.ts index acbaf8d..ac95e03 100644 --- a/src/build-tools/gradle.ts +++ b/src/build-tools/gradle.ts @@ -259,7 +259,9 @@ export async function prepareGradle(config: RunConfig, workspace: string): Promi /** * 列出可供交互选择的 Gradle Java 项目 * - * @description 只执行项目配置和列表任务,不编译、解析运行依赖或读取主类 Provider;候选仍需准备后确认入口 + * @description + * - 执行项目配置和列表任务,构建逻辑可能需要准备 buildSrc 或 included builds + * - 不主动编译候选应用、解析其运行依赖或读取主类 Provider;候选仍需准备后确认入口 */ export async function discoverGradleProjects(config: RunConfig, workspace: string): Promise<{ value: string; label: string }[]> { const spec = commandSpecification(config, workspace, true); diff --git a/src/cli/help.ts b/src/cli/help.ts index c7d5059..e10db92 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -29,7 +29,7 @@ export function getHelpText(): string { none 不主动构建源码,要求产物已经准备好 --build-arg= 构建工具参数,可重复;负号开头的值必须用等号 --include-tests 准备并加入测试输出和测试依赖,不执行测试 - --java Java 可执行文件 + --java 应用启动使用的 Java 可执行文件 --build-command 构建工具可执行文件,默认优先使用项目 Wrapper --force 仅用于 init,忽略已有配置并重新生成 diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index e43bb71..0e52fc2 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -1,40 +1,69 @@ # Java 源码启动验证夹具 -这些项目用于 [smoke 脚本](../../scripts/smoke.ts) 的原生二进制验收,分别覆盖普通 Java、Spring Boot 依赖和 Gradle 多项目构建。运行方式与测试环境见 [开发指南](../../CONTRIBUTING.md#二进制验收)。 +这些项目用于 [smoke 脚本](../../scripts/smoke.ts) 的原生二进制验收,覆盖普通 Java、Spring Boot 依赖和 Gradle 多项目构建。 +运行方式与测试环境见 [开发指南](../../CONTRIBUTING.md#二进制验收)。 -脚本先复制夹具到临时目录,过滤 `target`、`build`、`.gradle` 和 `.cache` 等产物,再执行构建。Maven 使用独立 settings 和本地仓库,Gradle 使用独立用户目录。夹具源码应始终保持可从未构建状态运行。 +脚本先复制夹具到临时目录,过滤 `target`、`build`、`.gradle` 和 `.cache` 等产物,再执行构建。 +Maven 使用独立 settings 和本地仓库,Gradle 使用独立用户目录。 +夹具源码应始终保持可从未构建状态运行。 ## 项目与观测点 | 夹具 | 验证内容 | | --- | --- | -| `boot-single` | Spring Boot 作为普通运行依赖,验证主类发现、正常退出和各层参数 | -| `boot-reactor` | `app` 依赖 `lib`,验证上游库、资源和有效 POM 变化;`other-app` 用于检测无关项目被构建或启动 | -| `plain` | 显式主类、初始化保存与配置复用、测试类路径、配置优先级、依赖变化、Maven 配置根和非零退出 | -| `gradle-reactor` | `application` 主类声明、模块配置保存与复用、`app` 依赖 `lib`、资源与依赖变化、测试类路径和无关项目隔离 | +| `boot-single` | Spring Boot 运行依赖、主类发现、正常退出和各层参数 | +| `boot-reactor` | `app` 依赖 `lib`、上游库与资源更新、有效 POM 变化、无关项目隔离 | +| `plain` | 显式主类、初始化与配置复用、测试类路径、参数优先级、依赖变化、Maven 配置根和非零退出 | +| `gradle-reactor` | `application` 主类声明、模块配置复用、上游库、资源与依赖变化、测试类路径和无关项目隔离 | -夹具以 Java 17 为最低版本。Gradle 夹具不携带 Wrapper,CI 安装固定版本 Gradle;本地可使用 `JAVA_RUN_GRADLE_COMMAND` 指定 smoke 的 Gradle 命令。依赖版本在 POM 和 Gradle 构建文件中固定,用于验证实际加载结果。 +夹具以 Java 17 为最低版本,依赖版本在 POM 和 Gradle 构建文件中固定。 +Gradle 夹具不携带 Wrapper,CI 安装固定版本 Gradle;本地可通过 `JAVA_RUN_GRADLE_COMMAND` 指定 smoke 使用的命令。 -应用输出 `[fixture]` 标记供脚本断言。Boot 应用禁用 Web,在输出结果后关闭上下文,以正常退出证明启动成功。无关应用若被启动会输出 `FORBIDDEN_OTHER_APP`;脚本同时检查它的构建目录没有生成,避免只依据输出判断目标隔离。 +应用输出 `[fixture]` 标记供脚本断言: -Maven 的 `ci` profile 和 Gradle 的 `-PfixtureProfile=ci` 分别改变资源中的构建 profile 标记。Spring profile 通过普通 JVM 系统属性传入,与构建参数分开验证。测试依赖中的 `commons-lang3` 和测试源码中的标记类分别输出 `test-dependency`、`test-class`;默认值应为 `absent`,启用测试类路径后应为 `present`。 +| 观测点 | 判断依据 | +| --- | --- | +| 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` 用例检查保存的入口和参数、初始化期间不启动应用,以及已有配置的覆盖保护。 -- Maven reactor 更新库资源标记,验证目标应用读取更新后的上游库 -- `plain` 修改 `commons.io.version`,验证实际依赖版本变化 +full 套件还会在同一临时副本中修改构建声明与资源,再运行应用验证更新结果: + +- Maven reactor 更新库资源标记,目标应用读取更新后的上游库 +- `plain` 修改 `commons.io.version`,实际加载的依赖版本随之变化 - Gradle 修改库资源和应用依赖,分别验证资源准备与依赖重新解析 -- 启用测试类路径后恢复默认运行,验证测试输出与依赖不会遗留在普通启动中 -- `.java-run.json` 的默认值与 CLI 追加或覆盖值,验证配置合并顺序 -- Maven 与 Gradle 的 `init` 保存入口和参数后直接运行,验证初始化不启动应用及已有配置保护 -- 应用收到 `--exit=7` 后退出,验证运行器保留退出码 +- 启用测试类路径后恢复默认运行,测试输出与依赖不进入普通启动 +- 在 `.java-run.json` 默认值上追加或覆盖 CLI 参数,验证合并顺序 +- 应用收到 `--exit=7` 后退出,运行器保留退出码 + +`plain` 在临时副本中创建 `.mvn/maven.config`,验证配置根的两个位置: -`plain` 还会在临时副本中创建 `.mvn/maven.config`,检查项目目录和最近祖先配置根的属性实际参与资源过滤,并验证 `${maven.multiModuleProjectDirectory}` 是指向配置根的绝对路径。这些文件只属于测试副本,不作为夹具默认配置提交。 +- 项目目录中的配置 +- 最近祖先目录中的配置 + +两种情况均检查属性参与资源过滤,并确认 `${maven.multiModuleProjectDirectory}` 是指向配置根的绝对路径。 +这些配置文件由脚本生成,夹具源码保持独立。 ## 参数与平台编码 -临时项目目录包含空格、中文、`#` 和 `%`,所有系统均保留这些路径。脚本检查实际 JDK 的原生命令行编码:能够完整表示 Unicode 参数时验证其往返传递;不能表示时,使用可表示参数完成启动,并追加 JVM 参数与应用参数的拒绝检查,要求明确报错、应用未启动且诊断不泄漏参数值。 +临时项目目录包含空格、中文、`#` 和 `%`,所有系统均保留这些路径。 +脚本根据实际 JDK 的原生命令行编码选择参数断言: + +- 能够完整表示 Unicode 参数时,验证参数往返传递 +- 无法完整表示时,使用可表示参数完成启动,并检查 JVM 参数和应用参数的拒绝行为 + +拒绝用例要求明确报错、应用未启动,且诊断不泄漏参数值。 +验收项数取决于套件、选定夹具和实际编码能力,以脚本输出为准。 -验收项目数取决于套件、选定夹具和实际编码能力,以脚本输出为准。交互、信号转发和进程树清理由对应回归测试验证,smoke 主要验证真实构建模型与原生二进制启动。 +smoke 验证真实构建模型与原生二进制启动。 +交互、信号转发和进程树清理由对应回归测试验证。