Files
homeutils/docs/superpowers/plans/2026-08-03-cross-platform-jar-paths.md
T
2026-08-03 14:50:15 +08:00

337 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.