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

18 KiB
Raw Blame History

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

@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
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

@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:

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

@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

@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

@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.