Skip to content

Commit ea695ef

Browse files
committed
fix: 保留跨平台构建诊断与 Maven 配置语义
1 parent 74d5353 commit ea695ef

13 files changed

Lines changed: 303 additions & 34 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ java-run 启动独立 Java 进程,不模拟自定义 `JavaExec` / `bootRun`
171171

172172
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)
173173

174+
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)
175+
174176
开发与验证命令:
175177

176178
```sh

‎docs/implementation-plan.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,7 @@ macOS 的问题发生在测试源文件名:JDK 17 将文件路径转为 NFC,
6464
Windows 原生 JDK 将系统代码页无法表示的参数替换为 `?`。修复包括 Gradle 通过临时脚本中的 UTF-8 请求读取目录和模块、使用 ASCII 根代理任务,Java / Jar 使用相对受控路径,以及执行前按真实 JDK 编码检查用户参数。中文 classpath 目录继续保留,无法表示的原始参数以明确错误结束。Maven 有效模型的输出路径统一归一化,依赖元数据输出固定 UTF-8。另增加实际 Windows 批处理进程树清理用例。
6565

6666
修复后本地全量回归 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 验收。
67+
68+
第二轮 Linux、macOS 的 JDK 17 / 21 均通过。Windows 暴露了目录短名与长名的等价性,以及 JDK 17 控制台丢失中文诊断的问题。目录断言改为验证实际文件系统身份;Gradle 的受控错误通过 ASCII 错误码传递,由 CLI 补充中文说明,保留构建工具原始输出和退出码,也不改变项目默认字符集。
69+
70+
复核 Maven 启动脚本时发现,它还会自行向 JVM 注入绝对项目根目录。Windows 的配置根无法由原生编码表示时,子进程使用相对 `MAVEN_BASEDIR` 读取 `.mvn`,再通过 Maven 3.9.2 引入的环境属性插值恢复模型中的绝对根目录;保留显式配置及最近 `.mvn` 祖先的含义,不修改父进程环境。可表示的路径保持绝对形式。完整二进制验收新增 `.mvn/maven.config` 属性经过资源过滤后实际生效的检查,覆盖中文项目目录和祖先目录,并验证模型根目录的绝对路径及配置位置;通常为 21 项,原生命令行编码无法表示 Unicode 的 Windows 环境为 23 项。

‎scripts/smoke.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ async function main(): Promise<void> {
152152
});
153153
projects.set(fixture, destination);
154154
}
155+
const plainMavenConfiguration = path.join(projects.get('plain')!, '.mvn');
156+
await mkdir(plainMavenConfiguration);
157+
await writeFile(path.join(plainMavenConfiguration, 'maven.config'), '-Dfixture.maven.config=project-root\n');
155158

156159
const check = async (name: string, args: string[], expected: Expectations = {}, cwd = workspace) => {
157160
const started = Date.now();
@@ -241,7 +244,8 @@ async function main(): Promise<void> {
241244
if (selected('plain')) {
242245
const directArguments = ['--main=org.javarun.fixture.PlainApplication'];
243246
await check('plain', argumentsFor('plain', ...directArguments, '--build-arg=-Pci', ...commonArguments), {
244-
stdout: ['[fixture] kind=plain', '[fixture] dependency-version=2.18.0', ...mavenMarkers, ...withoutTests],
247+
stdout: ['[fixture] kind=plain', '[fixture] dependency-version=2.18.0', '[fixture] maven-config=project-root',
248+
'[fixture] maven-root-absolute=true', '[fixture] maven-root-config=present', ...mavenMarkers, ...withoutTests],
245249
});
246250
if (!unicodeArguments) {
247251
const unsupportedValue = 'token-secret-中文';
@@ -257,6 +261,14 @@ async function main(): Promise<void> {
257261
}
258262
}
259263
if (options.suite === 'full') {
264+
await rm(plainMavenConfiguration, { recursive: true });
265+
const ancestorConfiguration = path.join(workspace, '.mvn');
266+
await mkdir(ancestorConfiguration);
267+
await writeFile(path.join(ancestorConfiguration, 'maven.config'), '-Dfixture.maven.config=ancestor-root\n');
268+
await check('plain-ancestor-maven-config', argumentsFor('plain', ...directArguments), {
269+
stdout: ['[fixture] kind=plain', '[fixture] maven-config=ancestor-root',
270+
'[fixture] maven-root-absolute=true', '[fixture] maven-root-config=present'],
271+
});
260272
const configPath = path.join(projects.get('plain')!, '.java-run.json');
261273
await writeFile(configPath, JSON.stringify({
262274
mainClass: 'org.javarun.fixture.PlainApplication',

‎src/build-tools/gradle.ts‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,11 @@ gradle.projectsEvaluated {
126126
def requestedPath = javaRunRequest.target
127127
def target = gradle.rootProject.findProject(requestedPath)
128128
if (target == null) {
129-
throw new GradleException("找不到 Gradle 项目:" + requestedPath + ",请用 --module 指定实际项目路径")
129+
throw new GradleException("[JAVA_RUN:NO_PROJECT] Project not found; select an existing project with --module")
130130
}
131131
def javaExtension = target.extensions.findByType(JavaPluginExtension)
132132
if (javaExtension == null) {
133-
throw new GradleException("选定项目 " + requestedPath + " 没有 Java 插件,请用 --module 指定 Java 应用项目")
133+
throw new GradleException("[JAVA_RUN:NO_JAVA_PLUGIN] Selected project has no Java plugin; select a Java project with --module")
134134
}
135135
def sourceSets = target.extensions.getByType(SourceSetContainer)
136136
def includeTests = Boolean.parseBoolean(System.getProperty('javaRun.includeTests'))
@@ -146,15 +146,15 @@ gradle.projectsEvaluated {
146146
def classes = included.collectMany { it.output.classesDirs.files as List }
147147
.findAll { it.isDirectory() }.collect { it.absolutePath }.unique()
148148
if (classes.isEmpty()) {
149-
throw new GradleException("选定项目没有已编译类;请准备产物或使用 --build=auto")
149+
throw new GradleException("[JAVA_RUN:NO_CLASSES] No compiled classes; prepare outputs or use --build=auto")
150150
}
151151
def runtime = selected.runtimeClasspath.files.findAll { it.exists() || !allOutputs.contains(it) }
152152
.collect { it.absolutePath }.unique()
153153
def toolchains = target.extensions.getByType(JavaToolchainService)
154154
def launcher = toolchains.launcherFor(javaExtension.toolchain).get()
155155
def application = target.extensions.findByType(JavaApplication)
156156
if (application != null && application.mainModule.isPresent()) {
157-
throw new GradleException("本版本不支持 JPMS mainModule;请使用项目的原生运行任务")
157+
throw new GradleException("[JAVA_RUN:UNSUPPORTED_JPMS] JPMS mainModule is unsupported; use the project's native run task")
158158
}
159159
def mainClass = application == null ? null : application.mainClass.orNull
160160
def jvmArgs = application == null ? [] : application.applicationDefaultJvmArgs.collect { it.toString() }
@@ -189,14 +189,25 @@ function initializationScript(config: RunConfig, workspace: string, discovery =
189189
return initScript.replace('@JAVA_RUN_REQUEST@', encoded);
190190
}
191191

192-
async function executeGradle(spec: CommandSpec): Promise<void> {
192+
async function executeGradle(spec: CommandSpec, config: RunConfig): Promise<void> {
193193
const java = process.env.JAVA_HOME
194194
? join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
195195
: 'java';
196196
await assertJavaArguments(java, spec.args, spec.cwd);
197197
console.error(`java-run:${spec.stage}`);
198198
const result = await runCommand(spec, { capture: true });
199-
if (result.exitCode !== 0) throw new CommandError(spec, result);
199+
if (result.exitCode !== 0) {
200+
// JDK 17 的控制台编码可能丢失中文,受控失败只跨进程传递 ASCII 错误码
201+
const code = /\[JAVA_RUN:(NO_PROJECT|NO_JAVA_PLUGIN|NO_CLASSES|UNSUPPORTED_JPMS)\]/
202+
.exec(result.stderr + result.stdout)?.[1];
203+
const diagnostics: Record<string, string> = {
204+
NO_PROJECT: `找不到 Gradle 项目:${targetPath(config.module)},请用 --module 指定实际项目路径`,
205+
NO_JAVA_PLUGIN: `选定项目 ${targetPath(config.module)} 没有 Java 插件,请用 --module 指定 Java 应用项目`,
206+
NO_CLASSES: '选定项目没有已编译类;请准备产物或使用 --build=auto',
207+
UNSUPPORTED_JPMS: '本版本不支持 JPMS mainModule;请使用项目的原生运行任务',
208+
};
209+
throw new CommandError(spec, result, code ? new Error(diagnostics[code]) : undefined);
210+
}
200211
}
201212

202213
function readProject(output: string): PreparedProject {
@@ -242,7 +253,7 @@ export async function prepareGradle(config: RunConfig, workspace: string): Promi
242253
writeFileSync(paths.script, initializationScript(config, workspace));
243254
rmSync(paths.output, { force: true });
244255
const spec = plan.commands[0]!;
245-
await executeGradle(spec);
256+
await executeGradle(spec, config);
246257
return readProject(paths.output);
247258
}
248259

@@ -258,7 +269,7 @@ export async function discoverGradleProjects(config: RunConfig, workspace: strin
258269
mkdirSync(resolve(workspace), { recursive: true });
259270
writeFileSync(paths.script, initializationScript(config, workspace, true));
260271
rmSync(output, { force: true });
261-
await executeGradle(spec);
272+
await executeGradle(spec, config);
262273
const candidates: unknown = JSON.parse(readFileSync(output, 'utf8'));
263274
if (!Array.isArray(candidates) || candidates.some(candidate =>
264275
!candidate || typeof candidate.value !== 'string' || !candidate.value.startsWith(':') || typeof candidate.label !== 'string')) {

‎src/build-tools/maven.ts‎

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { existsSync, readFileSync } from 'node:fs';
2-
import { dirname, isAbsolute, join, resolve } from 'node:path';
2+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
33
import { parseStringPromise } from 'xml2js';
44
import { CommandError, runCommand } from '../process/exec';
55
import { assertJavaArguments } from '../process/java-arguments';
@@ -8,6 +8,8 @@ import type { BuildPlan, CommandSpec, MavenProject, PreparedProject, RunConfig }
88

99
const HELP_PLUGIN = 'org.apache.maven.plugins:maven-help-plugin:3.5.1';
1010
const DEPENDENCY_PLUGIN = 'org.apache.maven.plugins:maven-dependency-plugin:3.8.1';
11+
const windowsMavenVersions = new Map<string, Promise<void>>();
12+
const baseDirectoryProperty = '-Dmaven.multiModuleProjectDirectory=${env.JAVA_RUN_MAVEN_BASE_DIRECTORY}';
1113

1214
/** 优先采用项目 Wrapper,存在时不回退到其他 Maven 版本 */
1315
function resolveMaven(config: RunConfig): string {
@@ -25,7 +27,7 @@ function validateBuildArgs(args: string[]): void {
2527
if (flags.has(arg)) continue;
2628
if (arg.startsWith('-D') && arg.length > 2) {
2729
const name = arg.slice(2).split('=', 1)[0]!;
28-
if (!['output', 'outputEncoding', 'expression', 'includeScope', 'excludeScope', 'skipTests', 'maven.test.skip', 'maven.main.skip', 'maven.install.skip'].includes(name)
30+
if (!['output', 'outputEncoding', 'expression', 'includeScope', 'excludeScope', 'skipTests', 'maven.test.skip', 'maven.main.skip', 'maven.install.skip', 'maven.multiModuleProjectDirectory'].includes(name)
2931
&& !name.startsWith('mdep.') && !name.startsWith('exec.')) continue;
3032
throw new Error(`构建属性 ${name} 由 java-run 管理,请使用对应的启动配置`);
3133
}
@@ -49,6 +51,76 @@ function command(config: RunConfig, goals: string[], stage: string, alsoMake = f
4951
return { command: resolveMaven(config), args, cwd: config.cwd, stage };
5052
}
5153

54+
/**
55+
* 将 Maven 配置根转换为相对工作目录的路径
56+
*
57+
* @description 显式配置优先,否则采用最近的 .mvn 祖先;找不到时采用工作目录,跨盘路径保持绝对形式
58+
*/
59+
export function resolveMavenBaseDirectory(cwd: string, configuredBase?: string): string {
60+
const directory = resolve(cwd);
61+
let base = configuredBase ? resolve(directory, configuredBase) : directory;
62+
if (!configuredBase) {
63+
while (!existsSync(join(base, '.mvn'))) {
64+
const parent = dirname(base);
65+
if (parent === base) {
66+
base = directory;
67+
break;
68+
}
69+
base = parent;
70+
}
71+
}
72+
return relative(directory, base) || '.';
73+
}
74+
75+
async function executeMaven(spec: CommandSpec): Promise<void> {
76+
let env: NodeJS.ProcessEnv | undefined;
77+
let execution = spec;
78+
let bridge = false;
79+
const java = buildJava();
80+
await assertJavaArguments(java, spec.args, spec.cwd);
81+
if (process.platform === 'win32') {
82+
const configuredKey = Object.keys(process.env).find(name => name.toLowerCase() === 'maven_basedir');
83+
const base = resolveMavenBaseDirectory(spec.cwd, configuredKey ? process.env[configuredKey] : undefined);
84+
const absoluteBase = resolve(spec.cwd, base);
85+
try {
86+
await assertJavaArguments(java, [`-Dmaven.multiModuleProjectDirectory=${absoluteBase}`], spec.cwd);
87+
env = { MAVEN_BASEDIR: absoluteBase };
88+
} catch (error) {
89+
if (!(error instanceof Error) || !error.message.includes('无法完整表示')) throw error;
90+
await assertJavaArguments(java, [`-Dmaven.multiModuleProjectDirectory=${base}`], spec.cwd);
91+
// 初始化先用可表示的相对根,Maven CLI 再从 Unicode 子环境还原模型中的绝对根
92+
env = { MAVEN_BASEDIR: base, JAVA_RUN_MAVEN_BASE_DIRECTORY: absoluteBase };
93+
execution = { ...spec, args: [...spec.args, baseDirectoryProperty] };
94+
bridge = true;
95+
}
96+
}
97+
if (bridge) await validateWindowsMavenVersion(spec, env!);
98+
console.error(`java-run:${spec.stage}`);
99+
const result = await runCommand(execution, { capture: true, env });
100+
if (result.exitCode !== 0) throw new CommandError(execution, result);
101+
}
102+
103+
async function validateWindowsMavenVersion(spec: CommandSpec, env: NodeJS.ProcessEnv): Promise<void> {
104+
const key = `${spec.command}\0${spec.cwd}`;
105+
let checked = windowsMavenVersions.get(key);
106+
if (!checked) {
107+
checked = (async () => {
108+
const versionCommand = { ...spec, args: ['-B', '-ntp', '-version'], stage: '检测 Windows Maven 兼容性' };
109+
const result = await runCommand(versionCommand, { capture: true, env });
110+
if (result.exitCode !== 0) throw new CommandError(versionCommand, result);
111+
const output = `${result.stdout}\n${result.stderr}`.replace(/\x1b\[[0-9;]*m/g, '');
112+
const version = /Apache Maven\s+(\d+)\.(\d+)\.(\d+)/.exec(output);
113+
if (!version) throw new Error('无法确认 Maven 版本;Windows 需要 Maven 3.9.2 或更新版本以保留配置根的 Unicode 路径');
114+
const [, major, minor, patch] = version.map(Number);
115+
if (major! < 3 || (major === 3 && (minor! < 9 || (minor === 9 && patch! < 2)))) {
116+
throw new Error(`Windows 需要 Maven 3.9.2 或更新版本以保留配置根语义,实际版本为 ${version[1]}.${version[2]}.${version[3]}`);
117+
}
118+
})();
119+
windowsMavenVersions.set(key, checked);
120+
}
121+
await checked;
122+
}
123+
52124
/** 预览 Maven 的单目标准备与解析命令,不执行 Maven */
53125
export function planMaven(config: RunConfig, workspace: string): BuildPlan {
54126
const commands: CommandSpec[] = [];
@@ -114,10 +186,7 @@ export async function prepareMaven(config: RunConfig, workspace: string): Promis
114186
await validateRoot(config);
115187
const plan = planMaven(config, workspace);
116188
for (const spec of plan.commands) {
117-
console.error(`java-run:${spec.stage}`);
118-
await assertJavaArguments(buildJava(), spec.args, config.cwd);
119-
const result = await runCommand(spec, { capture: true });
120-
if (result.exitCode !== 0) throw new CommandError(spec, result);
189+
await executeMaven(spec);
121190
}
122191
const pomFile = readFileSync(join(workspace, 'project-file.txt'), 'utf8').trim();
123192
if (!isAbsolute(pomFile) || !existsSync(pomFile)) throw new Error('Maven 未返回有效的目标 POM 路径');
@@ -144,10 +213,7 @@ export async function needsMavenModule(config: RunConfig): Promise<boolean> {
144213
export async function discoverMavenProjects(config: RunConfig, workspace: string): Promise<{ value: string; label: string }[]> {
145214
const output = join(workspace, 'module-list.xml');
146215
const spec = command({ ...config, module: undefined }, [`${HELP_PLUGIN}:effective-pom`, `-Doutput=${output}`, '-q'], '读取 Maven 模块候选');
147-
console.error(`java-run:${spec.stage}`);
148-
await assertJavaArguments(buildJava(), spec.args, config.cwd);
149-
const result = await runCommand(spec, { capture: true });
150-
if (result.exitCode !== 0) throw new CommandError(spec, result);
216+
await executeMaven(spec);
151217
const document = await parseStringPromise(readFileSync(output, 'utf8'), { explicitArray: false });
152218
const value = document.projects?.project ?? document.project;
153219
const projects = Array.isArray(value) ? value : value ? [value] : [];

‎src/process/exec.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export class CommandError extends Error {
4646

4747
/** 异步命令的输出捕获和信号转发选项 */
4848
export interface RunCommandOptions {
49+
/** 本次子进程的环境覆盖;未指定的变量继承父环境,undefined 删除变量 */
50+
env?: NodeJS.ProcessEnv;
4951
/** 捕获 stdout 和 stderr;默认直接继承当前终端 */
5052
capture?: boolean;
5153
/** 转发 SIGINT、SIGTERM 并在父进程退出时清理子进程;默认启用 */
@@ -75,6 +77,18 @@ function windowsEnvironmentValue(env: NodeJS.ProcessEnv, name: string): string |
7577
return key ? env[key] : undefined;
7678
}
7779

80+
function childEnvironment(overrides: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
81+
const env = { ...process.env };
82+
for (const [name, value] of Object.entries(overrides ?? {})) {
83+
const names = process.platform === 'win32'
84+
? Object.keys(env).filter(key => key.toLowerCase() === name.toLowerCase())
85+
: [name];
86+
for (const key of names) delete env[key];
87+
if (value !== undefined) env[name] = value;
88+
}
89+
return env;
90+
}
91+
7892
function resolveWindowsCommand(command: string, cwd: string, env: NodeJS.ProcessEnv): string {
7993
const extensions = path.extname(command)
8094
? ['']
@@ -150,7 +164,7 @@ export function runCommand(spec: CommandSpec, options: RunCommandOptions = {}):
150164
const detached = process.platform !== 'win32';
151165
let child: ReturnType<typeof spawn>;
152166
try {
153-
const prepared = prepareCommand(spec.command, spec.args, spec.cwd);
167+
const prepared = prepareCommand(spec.command, spec.args, spec.cwd, childEnvironment(options.env));
154168
child = spawn(prepared.command, prepared.args, {
155169
cwd: spec.cwd,
156170
env: prepared.env,

0 commit comments

Comments
 (0)