Plan cross-platform JAR path implementation

This commit is contained in:
张家训
2026-08-03 14:50:15 +08:00
parent d2ffedfa53
commit f1c8232480
@@ -0,0 +1,336 @@
# WorkUtils Cross-Platform JAR Paths Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the WorkUtils Spring Boot JAR build and run consistently on Windows, macOS, and Linux with one runtime-home policy and platform-aware IDEA launching.
**Architecture:** Introduce a small `ApplicationPaths` value object that owns runtime-home resolution and relative path resolution, then inject it into configuration and persistence consumers. Isolate platform command construction in `IdeaLaunchCommands`, keep process execution in `GitReviewService`, and move Windows-only packaging into a Maven OS profile while shipping a portable shell launcher.
**Tech Stack:** Java 17, Spring Boot 2.7, Maven, JUnit 5, Java NIO `Path`, Java `ProcessBuilder`, POSIX shell.
## Global Constraints
- Runtime-home priority is exactly `-Dworkutils.home` → `WORKUTILS_HOME` → `user.dir`; blank values are ignored.
- Preserve current Windows default data location and do not migrate existing data.
- Build and run the executable Spring Boot JAR only; do not create `.app` or `.dmg` artifacts.
- Do not modify Mobile UI Automation source code.
- Support spaces and Chinese characters without shell command concatenation.
- Use JDK 17 for Maven compile, test, and package.
---
### Task 1: Central runtime-home resolver
**Files:**
- Create: `src/main/java/com/example/workutils/system/support/ApplicationPaths.java`
- Create: `src/test/java/com/example/workutils/system/support/ApplicationPathsTest.java`
**Interfaces:**
- Produces: `ApplicationPaths.current()`, public `ApplicationPaths.of(Path home)`, package-visible `ApplicationPaths.resolve(String property, String environment, String userDir)`, `home()`, `resolve(String first, String... more)`, and `resolveConfigured(String configured, String... fallbackSegments)`.
- [ ] **Step 1: Write failing precedence and path tests**
```java
@Test void systemPropertyWins() {
ApplicationPaths paths = ApplicationPaths.resolve(" C:/工作 空间 ", "/env", "/cwd");
assertEquals(Path.of("C:/工作 空间").toAbsolutePath().normalize(), paths.home());
}
@Test void blankValuesFallBackToUserDirectory() {
ApplicationPaths paths = ApplicationPaths.resolve(" ", "", "build/test 工作目录");
assertEquals(Path.of("build/test 工作目录").toAbsolutePath().normalize(), paths.home());
}
@Test void resolvesConfiguredAbsoluteAndRelativePaths() {
ApplicationPaths paths = ApplicationPaths.resolve(null, null, "build/runtime 根目录");
assertEquals(paths.home().resolve(Path.of("data", "plugins.config")),
paths.resolveConfigured("data/plugins.config"));
Path absolute = Path.of(System.getProperty("java.io.tmpdir"), "绝对 配置").toAbsolutePath();
assertEquals(absolute.normalize(), paths.resolveConfigured(absolute.toString()));
}
```
- [ ] **Step 2: Run RED**
Run: `./mvnw -Dtest=ApplicationPathsTest test`
Expected: compilation failure because `ApplicationPaths` does not exist.
- [ ] **Step 3: Implement the resolver**
```java
public final class ApplicationPaths {
public static final String HOME_PROPERTY = "workutils.home";
public static final String HOME_ENV = "WORKUTILS_HOME";
private final Path home;
private ApplicationPaths(Path home) { this.home = home.toAbsolutePath().normalize(); }
public static ApplicationPaths of(Path home) { return new ApplicationPaths(home); }
public static ApplicationPaths current() {
return resolve(System.getProperty(HOME_PROPERTY), System.getenv(HOME_ENV),
System.getProperty("user.dir"));
}
static ApplicationPaths resolve(String property, String environment, String userDir) {
String selected = firstNonBlank(property, environment, userDir);
if (selected == null) throw new IllegalStateException("Application home is unavailable");
return new ApplicationPaths(Path.of(selected.trim()));
}
public Path home() { return home; }
public Path resolve(String first, String... more) { return home.resolve(Path.of(first, more)).normalize(); }
public Path resolveConfigured(String configured, String... fallbackSegments) {
Path value = configured == null || configured.isBlank()
? Path.of(fallbackSegments[0], Arrays.copyOfRange(fallbackSegments, 1, fallbackSegments.length))
: Path.of(configured.trim());
return (value.isAbsolute() ? value : home.resolve(value)).normalize();
}
}
```
- [ ] **Step 4: Run GREEN and commit**
Run: `./mvnw -Dtest=ApplicationPathsTest test`
Expected: PASS.
Commit: `git add src/main/java/com/example/workutils/system/support/ApplicationPaths.java src/test/java/com/example/workutils/system/support/ApplicationPathsTest.java && git commit -m "Add unified application path resolver"`
### Task 2: Route configuration and persistence through application paths
**Files:**
- Modify: `src/main/java/com/example/workutils/system/config/WorkUtilsProperties.java`
- Modify: `src/main/java/com/example/workutils/WorkUtilsApplication.java`
- Modify: `src/main/java/com/example/workutils/system/support/HistoryPersistence.java`
- Modify: `src/main/java/com/example/workutils/biz/gitreview/GitReviewService.java`
- Modify: `src/main/resources/application.yml`
- Modify: `src/main/resources/templates/workutils.config`
- Create: `src/test/java/com/example/workutils/system/config/WorkUtilsPropertiesPathTest.java`
- Create: `src/test/java/com/example/workutils/system/support/HistoryPersistencePathTest.java`
**Interfaces:**
- Consumes: `ApplicationPaths.current()`, `home()`, `resolve(...)`, and `resolveConfigured(...)` from Task 1.
- Produces: `WorkUtilsProperties#setApplicationPathsForTest(ApplicationPaths)` as a package-visible test seam, and package-visible `HistoryPersistence.historyFile(ApplicationPaths, String)`.
- [ ] **Step 1: Write failing configuration tests**
```java
@TempDir Path temp;
@Test void allRelativeFilesUseApplicationHome() {
WorkUtilsProperties properties = new WorkUtilsProperties();
properties.setApplicationPathsForTest(ApplicationPaths.of(temp));
assertEquals(temp.resolve("data/project_paths.txt"), properties.getProjectPathsFilePath());
assertEquals(temp.resolve("data/plugins.config"), properties.getPluginsConfigFilePath());
assertEquals(temp.resolve("export"), properties.getExportBasePath());
}
@Test void absoluteConfiguredFileIsPreserved() {
WorkUtilsProperties properties = new WorkUtilsProperties();
properties.setApplicationPathsForTest(ApplicationPaths.of(temp));
Path absolute = temp.resolve("外部 配置.json").toAbsolutePath();
properties.setDatasourcesFile(absolute.toString());
assertEquals(absolute.normalize(), properties.getDatasourcesFilePath());
}
```
- [ ] **Step 2: Run RED**
Run: `./mvnw -Dtest=WorkUtilsPropertiesPathTest,HistoryPersistencePathTest test`
Expected: compilation failures for the missing test seams and unified history resolver.
- [ ] **Step 3: Replace direct `user.dir` reads**
Add `private ApplicationPaths applicationPaths = ApplicationPaths.current();` to `WorkUtilsProperties`; make every relative getter call one private helper:
```java
private Path resolveDataPath(String configured, String... fallback) {
return applicationPaths.resolveConfigured(configured, fallback);
}
```
Set default base/export directories from `applicationPaths.home()` and `applicationPaths.resolve("export")`. Update startup diagnostics, shared history persistence, and Git Review fallback data directory to use the same component. Update resource comments from `user.dir` to “WorkUtils application home”. Do not touch `biz/mobileuiauto`.
- [ ] **Step 4: Run GREEN and regression tests**
Run: `./mvnw -Dtest=WorkUtilsPropertiesPathTest,HistoryPersistencePathTest,DefaultPluginConfigurationTest test`
Expected: PASS with no files written outside JUnit temporary directories.
- [ ] **Step 5: Commit**
Commit: `git add src/main/java/com/example/workutils/WorkUtilsApplication.java src/main/java/com/example/workutils/system/config/WorkUtilsProperties.java src/main/java/com/example/workutils/system/support/HistoryPersistence.java src/main/java/com/example/workutils/biz/gitreview/GitReviewService.java src/main/resources/application.yml src/main/resources/templates/workutils.config src/test/java/com/example/workutils/system/config/WorkUtilsPropertiesPathTest.java src/test/java/com/example/workutils/system/support/HistoryPersistencePathTest.java && git commit -m "Use application home for runtime data paths"`
### Task 3: Make filesystem path composition platform-neutral
**Files:**
- Modify: `src/main/java/com/example/workutils/system/support/FileOperationHelper.java`
- Modify: `src/main/java/com/example/workutils/biz/apidoc/parser/JavaSourceParser.java`
- Modify: `src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java`
- Modify: `src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayoutPolicy.java`
- Modify: `src/test/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayoutPolicyTest.java`
- Create: `src/test/java/com/example/workutils/system/support/FileOperationHelperPathTest.java`
**Interfaces:**
- Produces: existing public methods with unchanged signatures; only returned filesystem paths become platform-native.
- [ ] **Step 1: Write failing path tests**
```java
@Test void generatedFileUsesPathResolution() {
String result = helper.generateFileName(Path.of("build", "输出 空间").toString(), "report", "html");
assertEquals(Path.of(result), Path.of("build", "输出 空间").resolve(Path.of(result).getFileName()));
}
@Test void defaultsUseSegmentedSourceRoots() {
OutputLayout layout = OutputLayoutPolicy.defaults(Path.of("项目 根目录"));
assertEquals(Path.of("项目 根目录", "src", "main", "java"), layout.modelTarget());
assertEquals(Path.of("项目 根目录", "src", "main", "resources"), layout.xmlTarget());
}
```
- [ ] **Step 2: Run RED**
Run: `./mvnw -Dtest=FileOperationHelperPathTest,OutputLayoutPolicyTest test`
Expected: generated filename assertion fails on non-portable string composition or architecture assertion detects embedded path fragments.
- [ ] **Step 3: Implement segmented NIO paths**
Replace filename concatenation with `Path.of(baseDirectory).resolve(prefix + "_" + timestamp + "." + extension).toString()`. Replace operational `resolve("src/main/java")` and `resolve("src/main/resources")` calls with `resolve(Path.of("src", "main", "java"))` and `resolve(Path.of("src", "main", "resources"))`. Preserve ApiDoc’s normalized display/string parsing logic where `/src/main/java` is a semantic marker.
- [ ] **Step 4: Run GREEN and commit**
Run: `./mvnw -Dtest=FileOperationHelperPathTest,OutputLayoutPolicyTest test`
Expected: PASS.
Commit: `git add src/main/java/com/example/workutils/system/support/FileOperationHelper.java src/main/java/com/example/workutils/biz/apidoc/parser/JavaSourceParser.java src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayoutPolicy.java src/test/java/com/example/workutils/system/support/FileOperationHelperPathTest.java src/test/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayoutPolicyTest.java && git commit -m "Use platform-neutral path composition"`
### Task 4: Add platform-aware IntelliJ IDEA commands
**Files:**
- Create: `src/main/java/com/example/workutils/biz/gitreview/IdeaLaunchCommands.java`
- Modify: `src/main/java/com/example/workutils/biz/gitreview/GitReviewService.java`
- Modify: `src/main/java/com/example/workutils/biz/gitreview/GitReviewWindow.java`
- Create: `src/test/java/com/example/workutils/biz/gitreview/IdeaLaunchCommandsTest.java`
**Interfaces:**
- Produces: `IdeaLaunchCommands.OperatingSystem { WINDOWS, MAC, LINUX }`, `currentOperatingSystem()`, and `candidates(OperatingSystem, List<String> configured, List<String> projectArgs): List<List<String>>`.
- [ ] **Step 1: Write failing command matrix tests**
```java
@Test void macFallsBackToOpenApplication() {
List<List<String>> commands = IdeaLaunchCommands.candidates(MAC, List.of(), List.of("/项目 空间/demo"));
assertEquals(List.of("idea", "/项目 空间/demo"), commands.get(0));
assertEquals(List.of("open", "-a", "IntelliJ IDEA", "/项目 空间/demo"), commands.get(1));
}
@Test void windowsProvidesExeBatAndPathCandidates() {
assertEquals(List.of("idea64.exe", "D:/项目"),
IdeaLaunchCommands.candidates(WINDOWS, List.of(), List.of("D:/项目")).get(0));
}
@Test void configuredCommandAlwaysWins() {
assertEquals(List.of(List.of("/Applications/IntelliJ IDEA.app/Contents/MacOS/idea", "/项目")),
IdeaLaunchCommands.candidates(MAC,
List.of("/Applications/IntelliJ IDEA.app/Contents/MacOS/idea"), List.of("/项目")));
}
```
- [ ] **Step 2: Run RED**
Run: `./mvnw -Dtest=IdeaLaunchCommandsTest test`
Expected: compilation failure because `IdeaLaunchCommands` does not exist.
- [ ] **Step 3: Implement command construction and integration**
Build immutable argument lists without a shell string. `GitReviewService.startIdea` iterates those lists and retains the Windows `.bat/.cmd` wrapper inside `startProcess`. Change the empty-field prompt/placeholder to “自动检测” while treating that display value as no configured command.
- [ ] **Step 4: Run GREEN and commit**
Run: `./mvnw -Dtest=IdeaLaunchCommandsTest test`
Expected: PASS for Windows/macOS/Linux and paths containing spaces/Chinese.
Commit: `git add src/main/java/com/example/workutils/biz/gitreview/IdeaLaunchCommands.java src/main/java/com/example/workutils/biz/gitreview/GitReviewService.java src/main/java/com/example/workutils/biz/gitreview/GitReviewWindow.java src/test/java/com/example/workutils/biz/gitreview/IdeaLaunchCommandsTest.java && git commit -m "Add cross-platform IDEA launch commands"`
### Task 5: Make Maven packaging and launch scripts portable
**Files:**
- Modify: `pom.xml`
- Create: `scripts/start.sh`
- Modify: `scripts/start.bat`
- Create: `src/test/java/com/example/workutils/system/support/BuildPortabilityTest.java`
**Interfaces:**
- Produces: default `target/start.sh`; Windows profile additionally produces `target/start.bat` and `target/workutils.exe`.
- [ ] **Step 1: Write failing POM/script contract tests**
```java
@Test void pomHasNoHardCodedWindowsJdk() throws IOException {
String pom = Files.readString(projectRoot().resolve("pom.xml"));
assertFalse(pom.contains("jdk17.home"));
assertFalse(pom.contains("\\bin\\javac"));
}
@Test void launch4jIsInsideWindowsProfile() throws Exception {
Document pom = parsePom();
assertEquals(0, xpathCount(pom, "/m:project/m:build/m:plugins/m:plugin[m:artifactId='launch4j-maven-plugin']"));
assertEquals(1, xpathCount(pom, "/m:project/m:profiles/m:profile[m:activation/m:os/m:family='Windows']//m:plugin[m:artifactId='launch4j-maven-plugin']"));
}
@Test void shellLauncherUsesStableHomeAndQuotedArguments() throws IOException {
String script = Files.readString(projectRoot().resolve("scripts/start.sh"));
assertTrue(script.contains("-Dworkutils.home=$APP_HOME"));
assertTrue(script.contains("\"$@\""));
}
```
- [ ] **Step 2: Run RED**
Run: `./mvnw -Dtest=BuildPortabilityTest test`
Expected: failures for hardcoded JDK, top-level Launch4j, and missing `start.sh`.
- [ ] **Step 3: Refactor Maven and scripts**
Keep only `<release>17</release>` in compiler configuration. Copy `start.sh` in the default build. Create an OS-activated Windows profile containing Launch4j plus `start.bat` copy. Remove the `jdk17-home-env` profile. Both scripts resolve their own directory, locate `workutils-*.jar`, validate Java 17+, set `-Dworkutils.home` to the release root, and pass arguments without lossy concatenation.
- [ ] **Step 4: Run GREEN, package, and inspect outputs**
Run: `./mvnw -Dtest=BuildPortabilityTest test`
Expected: PASS.
Run: `./mvnw clean package`
Expected on Windows: `target/workutils-1.0.1-SNAPSHOT.jar`, `target/start.sh`, `target/start.bat`, and `target/workutils.exe` exist; all tests pass.
- [ ] **Step 5: Commit**
Commit: `git add pom.xml scripts/start.sh scripts/start.bat src/test/java/com/example/workutils/system/support/BuildPortabilityTest.java && git commit -m "Make JAR packaging portable across desktop systems"`
### Task 6: Full verification and integration readiness
**Files:**
- Modify only if verification finds an in-scope defect; never modify Mobile UI Automation.
**Interfaces:**
- Consumes all deliverables above; produces a clean, merge-ready branch.
- [ ] **Step 1: Verify no excluded source changed**
Run: `git diff master...HEAD --name-only | rg 'mobileuiauto|\.app$|\.dmg$'`
Expected: no output.
- [ ] **Step 2: Run focused and full verification**
Run: `./mvnw -Dtest=ApplicationPathsTest,WorkUtilsPropertiesPathTest,HistoryPersistencePathTest,FileOperationHelperPathTest,OutputLayoutPolicyTest,IdeaLaunchCommandsTest,BuildPortabilityTest test`
Expected: PASS.
Run: `./mvnw clean test`
Expected: all tests pass; only the two existing visual fixture tests may be skipped.
Run: `./mvnw -DskipTests package`
Expected: BUILD SUCCESS and a runnable Spring Boot JAR.
- [ ] **Step 3: Static verification**
Run: `git diff --check && git status --short && git log --oneline master..HEAD`
Expected: no whitespace errors, no unexpected files, and task commits listed.
- [ ] **Step 4: Record verification if corrective edits were needed**
If verification required code changes, commit only those exact files with `git commit -m "Fix cross-platform verification findings"`. Otherwise leave the branch clean and report the verified commands.