# 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 configured, List projectArgs): List>`. - [ ] **Step 1: Write failing command matrix tests** ```java @Test void macFallsBackToOpenApplication() { List> 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 `17` 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.