Plan cross-platform JAR path implementation
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user