From f1c82324808a4767d18ee174927fb573075c552c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Mon, 3 Aug 2026 14:50:15 +0800 Subject: [PATCH] Plan cross-platform JAR path implementation --- .../2026-08-03-cross-platform-jar-paths.md | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-cross-platform-jar-paths.md diff --git a/docs/superpowers/plans/2026-08-03-cross-platform-jar-paths.md b/docs/superpowers/plans/2026-08-03-cross-platform-jar-paths.md new file mode 100644 index 0000000..d0e2f9a --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-cross-platform-jar-paths.md @@ -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 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.