From a01a24b644e9084c586a058b435e75eb683fc95b Mon Sep 17 00:00:00 2001 From: Crafter_Y <71288761+Crafter-Y@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:47:47 +0000 Subject: [PATCH 01/10] Bump devcontainer to Java 21 --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5e29293e..f30430d6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,7 @@ { "name": "Java", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/java:17", + "image": "mcr.microsoft.com/devcontainers/java:21", "features": { "ghcr.io/devcontainers/features/java:1": { From a00c4250c97b9db488f729d0ab20e184a23e591b Mon Sep 17 00:00:00 2001 From: Crafter-Y Date: Tue, 23 Sep 2025 16:12:35 +0200 Subject: [PATCH 02/10] itzg/bungeecord moved to itzg/docker-mc-proxy --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ea455ca3..197b5498 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![test](https://github.com/itzg/mc-image-helper/actions/workflows/test.yml/badge.svg)](https://github.com/itzg/mc-image-helper/actions/workflows/test.yml) ![LOC](https://img.shields.io/endpoint?url=https%3A%2F%2Fshields-codetab-code-loc-bridge.vercel.app%2Fapi%2Fcodeloc%3Fgithub%3Ditzg%2Fmc-image-helper%26language%3Djava) -This tool does the complicated bits for the [itzg/minecraft-server](https://github.com/itzg/docker-minecraft-server) and [itzg/bungeecord](https://github.com/itzg/docker-bungeecord/) Docker images. +This tool does the complicated bits for the [itzg/minecraft-server](https://github.com/itzg/docker-minecraft-server) and [itzg/docker-mc-proxy](https://github.com/itzg/docker-mc-proxy) Docker images. ## Usage From 7d01a80f6b278015b4ba4f334bc9e3275ca46c21 Mon Sep 17 00:00:00 2001 From: Crafter-Y Date: Tue, 23 Sep 2025 18:21:19 +0200 Subject: [PATCH 03/10] implementing @ delimiter and adding unit tests --- .../itzg/helpers/sync/MulitCopyCommand.java | 87 +++++-- .../helpers/sync/MulitCopyCommandTest.java | 242 ++++++++++++++++++ 2 files changed, 309 insertions(+), 20 deletions(-) diff --git a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java index 206685f4..fca06b24 100644 --- a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java +++ b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java @@ -44,7 +44,7 @@ public class MulitCopyCommand implements Callable { ) String manifestId; - @Option(names = {"--to", "--output-directory"}, required = true) + @Option(names = {"--to", "--output-directory"}) Path dest; @Option(names = "--glob", defaultValue = "*", paramLabel = "GLOB", @@ -70,22 +70,25 @@ public class MulitCopyCommand implements Callable { @Option(names = "--ignore-missing-sources", description = "Don't log or fail exit code when any or all sources are missing") boolean ignoreMissingSources; + @Option(names = "--delimiter", defaultValue = "@", + description = "When using per-file destinations, which symbol should be used to delimit destinationsource" + ) + String stringDelimiter; + @Parameters(split = SPLIT_COMMA_NL, splitSynopsisLabel = SPLIT_SYNOPSIS_COMMA_NL, arity = "1..*", paramLabel = "SRC", description = "Any mix of source file, directory, or URLs." + "%nCan be optionally comma or newline separated." + + "%nPer-file destinations can be assigned by destination@source" ) List sources; @Override public Integer call() throws Exception { - - Files.createDirectories(dest); - Flux.fromIterable(sources) .map(String::trim) .filter(s -> !s.isEmpty()) - .flatMap(source -> processSource(source, fileIsListingOption)) + .flatMap(source -> processSource(source, fileIsListingOption, null)) .collectList() .flatMap(this::cleanupAndSaveManifest) .block(); @@ -113,9 +116,52 @@ private Mono cleanupAndSaveManifest(List paths) { }); } - private Publisher processSource(String source, boolean fileIsListing) { + @SuppressWarnings("BlockingMethodInNonBlockingContext") // idk if that is a good idea + private Publisher processSource(String source, boolean fileIsListing, Path parentDestination) { + Path destination = dest; + + if (parentDestination != null) { + destination = parentDestination; + } + + if (source.contains(stringDelimiter)) { + if (manifestId != null) { + throw new GenericException("Manifests cannot be used with inline destinations"); + } + + String[] split = source.split(stringDelimiter); + destination = Paths.get(split[0]); + source = split[1]; + } + + if (fileIsListing) { + if (Uris.isUri(source)) { + return processRemoteListingFile(source, destination); + } else { + final Path path = Paths.get(source); + if (Files.isDirectory(path)) { + throw new GenericException(String.format("Specified listing file '%s' is a directory", source)); + } + if (!Files.exists(path)) { + throw new GenericException(String.format("Source file '%s' does not exist", source)); + } + return processListingFile(path, destination); + } + } + + if (destination == null) { + // maybe there is a way to print the --to flag documentation + throw new GenericException(String.format("No destination for source '%s' specified. Either use --to flag or prepend destination to the source with '%s' as delimiter", source, stringDelimiter)); + } + + try { + Files.createDirectories(destination); + } catch (IOException e) { + throw new RuntimeException(e); + } + if (Uris.isUri(source)) { - return fileIsListing ? processRemoteListingFile(source) : processRemoteSource(source); + return processRemoteSource(source, destination); } else { final Path path = Paths.get(source); if (!Files.exists(path)) { @@ -123,14 +169,14 @@ private Publisher processSource(String source, boolean fileIsListing) { } if (Files.isDirectory(path)) { - return processDirectory(path); + return processDirectory(path, destination); } else { - return fileIsListing ? processListingFile(path) : processFile(path); + return processFile(path, destination); } } } - private Flux processListingFile(Path listingFile) { + private Flux processListingFile(Path listingFile, Path destination) { return Mono.just(listingFile) .publishOn(Schedulers.boundedElastic()) .flatMapMany(path -> { @@ -141,22 +187,23 @@ private Flux processListingFile(Path listingFile) { .filter(this::isListingLine) .flatMap(src -> processSource(src, // avoid recursive file-listing processing - false)); + false, + destination)); } catch (IOException e) { return Mono.error(new GenericException("Failed to read file listing from " + path)); } }); } - private Mono processFile(Path source) { + private Mono processFile(Path source, Path destination) { return Mono.just(source) .publishOn(Schedulers.boundedElastic()) - .map(path -> processFileImmediate(source, dest)); + .map(path -> processFileImmediate(source, destination)); } /** - * Non-mono version of {@link #processFile(Path)} + * Non-mono version of {@link #processFile(Path, Path)} * * @param scopedDest allows for sub-directory destinations */ @@ -203,7 +250,7 @@ private Path processFileImmediate(Path source, Path scopedDest) { return destFile; } - private Flux processDirectory(Path srcDir) { + private Flux processDirectory(Path srcDir, Path destination) { return Mono.just(srcDir) .publishOn(Schedulers.boundedElastic()) .flatMapMany(path -> { @@ -220,7 +267,7 @@ private Flux processDirectory(Path srcDir) { try (DirectoryStream files = Files.newDirectoryStream(srcDir, fileGlob)) { for (final Path file : files) { //noinspection BlockingMethodInNonBlockingContext because IntelliJ is confused - results.add(processFileImmediate(file, dest)); + results.add(processFileImmediate(file, destination)); } } return Flux.fromIterable(results); @@ -230,10 +277,10 @@ private Flux processDirectory(Path srcDir) { }); } - private Mono processRemoteSource(String source) { + private Mono processRemoteSource(String source, Path destination) { return Fetch.fetch(URI.create(source)) .userAgentCommand("mcopy") - .toDirectory(dest) + .toDirectory(destination) .skipUpToDate(skipUpToDate) .skipExisting(skipExisting) .handleDownloaded((downloaded, uri, size) -> @@ -263,7 +310,7 @@ private Mono processRemoteSource(String source) { .checkpoint("Retrieving " + source, true); } - private Flux processRemoteListingFile(String source) { + private Flux processRemoteListingFile(String source, Path destination) { @SuppressWarnings("resource") // closed on terminate SharedFetch sharedFetch = Fetch.sharedFetch("mcopy", SharedFetch.Options.builder().build()); return Mono.just(source) @@ -273,7 +320,7 @@ private Flux processRemoteListingFile(String source) { .flatMapMany(content -> Flux.just(content.split("\\r?\\n"))) .filter(this::isListingLine) ) - .flatMap(this::processRemoteSource) + .flatMap(url -> processSource(url, false, destination)) .doOnTerminate(sharedFetch::close) .checkpoint("Processing remote listing at " + source, true); } diff --git a/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java b/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java index 4a34aae8..bdf5eb33 100644 --- a/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java +++ b/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java @@ -47,6 +47,22 @@ void one() throws IOException { .hasSameTextualContentAs(srcFile); } + @Test + void oneWithInlineDestination() throws IOException { + final Path srcFile = writeLine(tempDir, "source2.txt", "content"); + final Path destDir = tempDir.resolve("dest"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + destDir + "@" +srcFile.toString() + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir.resolve("source2.txt")) + .exists() + .hasSameTextualContentAs(srcFile); + } + @Test void commaDelimited() throws IOException { final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); @@ -67,6 +83,30 @@ void commaDelimited() throws IOException { assertThat(destDir.resolve("two.jar")) .hasSameTextualContentAs(srcJar); } + + @Test + void commaDelimitedMultipleDest() throws IOException { + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + String.join(",", + destDir1 + "@" + srcTxt.toString(), + destDir2 + "@" + srcJar.toString() + ) + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir1.resolve("one.txt")) + .hasSameTextualContentAs(srcTxt); + assertThat(destDir2.resolve("two.jar")) + .hasSameTextualContentAs(srcJar); + } } @Nested @@ -97,6 +137,32 @@ void justFiles() throws IOException { .hasSameTextualContentAs(srcJar); } + @Test + void justFilesWithDifferentDest() throws IOException { + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + + final Path listing = Files.write(tempDir.resolve("listing.txt"), Arrays.asList( + destDir1 + "@" + srcTxt.toString(), + destDir2 + "@" + srcJar.toString() + )); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--file-is-listing", + listing.toString() + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir1.resolve("one.txt")) + .hasSameTextualContentAs(srcTxt); + assertThat(destDir2.resolve("two.jar")) + .hasSameTextualContentAs(srcJar); + } } @Nested @@ -122,6 +188,26 @@ void noGlob() throws IOException { .hasSameTextualContentAs(srcJar); } + @Test + void toInlineDir() throws IOException { + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + final Path destDir = tempDir.resolve("dest"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + destDir + "@" + srcDir + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir.resolve("one.txt")) + .hasSameTextualContentAs(srcTxt); + assertThat(destDir.resolve("two.jar")) + .hasSameTextualContentAs(srcJar); + } + @Test void handlesUpdatedFile() throws IOException { final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); @@ -157,6 +243,8 @@ void handlesUpdatedFile() throws IOException { .hasContent("updated"); } + // NOTE: Having multiple destinations conflicts with the idea of manifests. + // Therefore, manifests are not supported when using them @Test void managedWithManifest() throws IOException { final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); @@ -236,6 +324,22 @@ void remoteFile(WireMockRuntimeInfo wmInfo) { .hasContent("remote"); } + @Test + void remoteFileWithInlineDest(WireMockRuntimeInfo wmInfo) { + stubRemoteSrc("file.jar", "remote"); + + final Path destDir = tempDir.resolve("dest"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + destDir + "@" + wmInfo.getHttpBaseUrl() + "/file.jar" + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir.resolve("file.jar")) + .hasContent("remote"); + } + @Test void listingOfRemoteFiles(WireMockRuntimeInfo wmInfo) throws IOException { stubRemoteSrc("file1.jar", "one"); @@ -262,6 +366,32 @@ void listingOfRemoteFiles(WireMockRuntimeInfo wmInfo) throws IOException { .hasContent("two"); } + @Test + void listingOfRemoteFilesWithInlineDest(WireMockRuntimeInfo wmInfo) throws IOException { + stubRemoteSrc("file1.jar", "one"); + stubRemoteSrc("file2.jar", "two"); + + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + + final Path listing = Files.write(tempDir.resolve("listing.txt"), Arrays.asList( + destDir1 + "@" + wmInfo.getHttpBaseUrl() + "/file1.jar", + destDir2 + "@" +wmInfo.getHttpBaseUrl() + "/file2.jar" + )); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--file-is-listing", + listing.toString() + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir1.resolve("file1.jar")) + .hasContent("one"); + assertThat(destDir2.resolve("file2.jar")) + .hasContent("two"); + } + @Test void remoteListingOfRemoteFiles(WireMockRuntimeInfo wmInfo) { stubRemoteSrc("listing.txt", @@ -287,6 +417,118 @@ void remoteListingOfRemoteFiles(WireMockRuntimeInfo wmInfo) { .hasContent("two"); } + @Test + void remoteListingOfRemoteFilesWithInlineDest(WireMockRuntimeInfo wmInfo) { + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + + stubRemoteSrc("listing.txt", + destDir1 + "@" + wmInfo.getHttpBaseUrl() + "/file1.jar\n" + + destDir2 + "@" + wmInfo.getHttpBaseUrl() + "/file2.jar\n" + ); + stubRemoteSrc("file1.jar", "one"); + stubRemoteSrc("file2.jar", "two"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--file-is-listing", + wmInfo.getHttpBaseUrl() + "/listing.txt" + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir1.resolve("file1.jar")) + .hasContent("one"); + assertThat(destDir2.resolve("file2.jar")) + .hasContent("two"); + } + + @Test + void remoteListingOfLocalFiles(WireMockRuntimeInfo wmInfo) throws IOException { + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + stubRemoteSrc("listing.txt", + srcTxt + "\n" + + srcJar + "\n" + ); + + final Path destDir = tempDir.resolve("dest"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", destDir.toString(), + "--file-is-listing", + wmInfo.getHttpBaseUrl() + "/listing.txt" + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir.resolve("one.txt")) + .hasContent("one"); + assertThat(destDir.resolve("two.jar")) + .hasContent("two"); + } + + @Test + void remoteListingOfLocalFilesWithInlineDest(WireMockRuntimeInfo wmInfo) throws IOException { + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + stubRemoteSrc("listing.txt", + destDir1 + "@" + srcTxt + "\n" + + destDir2 + "@" + srcJar + "\n" + ); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--file-is-listing", + wmInfo.getHttpBaseUrl() + "/listing.txt" + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir1.resolve("one.txt")) + .hasContent("one"); + assertThat(destDir2.resolve("two.jar")) + .hasContent("two"); + } + + @Test + void overrideDestOrder(WireMockRuntimeInfo wmInfo) throws IOException { + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + final Path destDir3 = tempDir.resolve("dest3"); + + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + final Path srcYaml = writeLine(srcDir, "three.yaml", "three"); + + stubRemoteSrc("listing.txt", + srcTxt + "\n" + + srcJar + "\n" + + destDir3 + "@" + srcYaml + "\n" + ); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", destDir1.toString(), + "--file-is-listing", + destDir2 + "@" + wmInfo.getHttpBaseUrl() + "/listing.txt" + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir2.resolve("one.txt")) + .hasContent("one"); + assertThat(destDir2.resolve("two.jar")) + .hasContent("two"); + assertThat(destDir3.resolve("three.yaml")) + .hasContent("three"); + } + private void stubRemoteSrc(String filename, String content) { stubFor(head(urlPathEqualTo("/" + filename)) .willReturn( From 03ac397bc1446d862d15461271a54b9efcf25b11 Mon Sep 17 00:00:00 2001 From: Crafter-Y Date: Tue, 23 Sep 2025 18:26:53 +0200 Subject: [PATCH 04/10] update documentation --- README.md | 148 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 84 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 197b5498..6a4682e5 100644 --- a/README.md +++ b/README.md @@ -9,71 +9,85 @@ This tool does the complicated bits for the [itzg/minecraft-server](https://gith > **NOTE** The following documentation may not always be up-to-date. Please be sure to use `-h` or `--help` after any subcommand to view the current usage. ``` -Usage: mc-image-helper [-hs] [--debug] [COMMAND] - --debug Enable debug output. Can also set environment variable - DEBUG_HELPER - -h, --help Show this usage and exit - -s, --silent Don't output logs even if there's an error +Usage: mc-image-helper [-hsV] [--debug | --logging=] [COMMAND] + --debug Enable debug output. Can also set environment variables + DEBUG_HELPER or DEBUG + -h, --help Show this usage and exit + --logging= + Set logging to specific level. + Valid values: + -s, --silent Don't output logs even if there's an error + -V, --version Commands: - asciify Converts UTF-8 on stdin to ASCII by escaping - Unicode characters - assert Provides assertion operators for verifying - container setup - compare-versions Used for shell scripting, exits with success(0) - when comparison is satisfied or 1 when not - curseforge-files Download and manage individual mod/plugin files - from CurseForge - find Specialized replacement for GNU's find - get Download a file + asciify Converts UTF-8 on stdin to ASCII by escaping + Unicode characters + assert Provides assertion operators for verifying + container setup + compare-versions Used for shell scripting, exits with success + (0) when comparison is satisfied or 1 when + not + curseforge-files Download and manage individual mod/plugin + files from CurseForge + find Specialized replacement for GNU's find + get Download a file github - hash Outputs an MD5 hash of the standard input - install-curseforge Downloads, installs, and upgrades CurseForge - modpacks - install-fabric-loader Provides a few ways to obtain a Fabric loader with - simple cleanup of previous loader instances - install-forge Downloads and installs a requested version of Forge - install-modrinth-modpack Supports installation of Modrinth modpacks along - with the associated mod loader - install-neoforge Downloads and installs a requested version of - NeoForge - install-paper Installs selected PaperMC - install-purpur Downloads latest or selected version of Purpur - install-quilt Installs Quilt mod loader - interpolate Interpolates existing files in one or more - directories - java-release Outputs the Java release number, such as 8, 11, 17 + hash Outputs an MD5 hash of the standard input + install-curseforge Downloads, installs, and upgrades CurseForge + modpacks + install-fabric-loader Provides a few ways to obtain a Fabric loader + with simple cleanup of previous loader + instances + install-forge Downloads and installs a requested version of + Forge + install-modrinth-modpack Supports installation of Modrinth modpacks + along with the associated mod loader + install-neoforge Downloads and installs a requested version of + NeoForge + install-paper Installs selected PaperMC + install-purpur Downloads latest or selected version of Purpur + install-quilt Installs Quilt mod loader + interpolate Interpolates existing files in one or more + directories + java-release Outputs the Java release number, such as 8, + 11, 17 manage-users - maven-download Downloads a maven artifact from a Maven repository - modrinth Automates downloading of modrinth resources - mcopy Multi-source file copy operation with with managed - cleanup. Supports auto-detected sourcing from - file list, directories, and URLs - network-interfaces Provides simple operations to list network - interface names and check existence - patch Patches one or more existing files using JSON path - based operations - Supports the file formats: - - JSON - - JSON5 - - Yaml - - TOML, but processed output is not pretty - resolve-minecraft-version Resolves and validate latest, snapshot, and - specific versions - set-properties Maps environment variables to a properties file - show-all-subcommand-usage Renders all of the subcommand usage as markdown - sections for README - sync Synchronizes the contents of one directory to - another. - sync-and-interpolate Synchronizes the contents of one directory to - another with conditional variable interpolation. + maven-download Downloads a maven artifact from a Maven + repository + modrinth Automates downloading of modrinth resources + mcopy Multi-source file copy operation with with + managed cleanup. Supports auto-detected + sourcing from file list, directories, and + URLs + network-interfaces Provides simple operations to list network + interface names and check existence + patch Patches one or more existing files using JSON + path based operations + Supports the file formats: + - JSON + - JSON5 + - Yaml + - TOML, but processed output is not pretty + resolve-minecraft-version Resolves and validate latest, snapshot, and + specific versions + set-properties Maps environment variables to a properties + file + show-all-subcommand-usage Renders all of the subcommand usage as + markdown sections for README + sync Synchronizes the contents of one directory to + another. + sync-and-interpolate Synchronizes the contents of one directory to + another with conditional variable + interpolation. test-logging-levels - toml-path Extracts a path from a TOML file using json-path - syntax - yaml-path Extracts a path from a YAML file using json-path - syntax - vanillatweaks Downloads Vanilla Tweaks resource packs, data - packs, or crafting tweaks given a share code or - pack file + toml-path Extracts a path from a TOML file using + json-path syntax + vanillatweaks Downloads Vanilla Tweaks resource packs, data + packs, or crafting tweaks given a share + code or pack file + version-from-modrinth-projects Finds a compatible Minecraft version across + given Modrinth projects + yaml-path Extracts a path from a YAML file using + json-path syntax ``` For [patch](#patch) command [see below](#patch-schemas) for a description of [PatchSet](#patchset) and [PatchDefinition](#patchdefinition) JSON schemas. @@ -853,13 +867,19 @@ Downloads a maven artifact from a Maven repository ``` Usage: mc-image-helper mcopy [-hz] [--file-is-listing] [--ignore-missing-sources] [--quiet-when-skipped] - [--skip-existing] [--glob=GLOB] - [--scope=] --to= SRC[, - |SRC...]... + [--skip-existing] [--delimiter=] + [--glob=GLOB] [--scope=] [--to=] + SRC[,|SRC...]... Multi-source file copy operation with with managed cleanup. Supports auto-detected sourcing from file list, directories, and URLs SRC[,|SRC...]... Any mix of source file, directory, or URLs. Can be optionally comma or newline separated. + Per-file destinations can be assigned by + destination@source + --delimiter= + When using per-file destinations, which symbol + should be used to delimit + destinationsource --file-is-listing Source files or URLs are processed as a line delimited list of sources. For remote listing files, the contents must all be From c317f64dfa619156cb1f05029cf80f65abd29875 Mon Sep 17 00:00:00 2001 From: Crafter-Y Date: Tue, 23 Sep 2025 22:47:43 +0200 Subject: [PATCH 05/10] making --to parameter required again and used as base directory for relative paths --- .../itzg/helpers/sync/MulitCopyCommand.java | 9 ++--- .../helpers/sync/MulitCopyCommandTest.java | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java index fca06b24..e8fc8d15 100644 --- a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java +++ b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java @@ -44,7 +44,7 @@ public class MulitCopyCommand implements Callable { ) String manifestId; - @Option(names = {"--to", "--output-directory"}) + @Option(names = {"--to", "--output-directory"}, required = true) Path dest; @Option(names = "--glob", defaultValue = "*", paramLabel = "GLOB", @@ -130,7 +130,7 @@ private Publisher processSource(String source, boolean fileIsListing, Path } String[] split = source.split(stringDelimiter); - destination = Paths.get(split[0]); + destination = destination.resolve(Paths.get(split[0])); source = split[1]; } @@ -149,11 +149,6 @@ private Publisher processSource(String source, boolean fileIsListing, Path } } - if (destination == null) { - // maybe there is a way to print the --to flag documentation - throw new GenericException(String.format("No destination for source '%s' specified. Either use --to flag or prepend destination to the source with '%s' as delimiter", source, stringDelimiter)); - } - try { Files.createDirectories(destination); } catch (IOException e) { diff --git a/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java b/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java index bdf5eb33..a7e26907 100644 --- a/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java +++ b/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java @@ -54,6 +54,7 @@ void oneWithInlineDestination() throws IOException { final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( + "--to", tempDir.toString(), destDir + "@" +srcFile.toString() ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -95,6 +96,7 @@ void commaDelimitedMultipleDest() throws IOException { final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( + "--to", tempDir.toString(), String.join(",", destDir1 + "@" + srcTxt.toString(), destDir2 + "@" + srcJar.toString() @@ -107,6 +109,31 @@ void commaDelimitedMultipleDest() throws IOException { assertThat(destDir2.resolve("two.jar")) .hasSameTextualContentAs(srcJar); } + + @Test + void commaDelimitedRelativeDest() throws IOException { + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + String.join(",", + "./dest1@" + srcTxt.toString(), + "./dest2@" + srcJar.toString() + ) + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir1.resolve("one.txt")) + .hasSameTextualContentAs(srcTxt); + assertThat(destDir2.resolve("two.jar")) + .hasSameTextualContentAs(srcJar); + } } @Nested @@ -153,6 +180,7 @@ void justFilesWithDifferentDest() throws IOException { final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( + "--to", tempDir.toString(), "--file-is-listing", listing.toString() ); @@ -198,6 +226,7 @@ void toInlineDir() throws IOException { final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( + "--to", tempDir.toString(), destDir + "@" + srcDir ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -332,6 +361,7 @@ void remoteFileWithInlineDest(WireMockRuntimeInfo wmInfo) { final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( + "--to", tempDir.toString(), destDir + "@" + wmInfo.getHttpBaseUrl() + "/file.jar" ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -381,6 +411,7 @@ void listingOfRemoteFilesWithInlineDest(WireMockRuntimeInfo wmInfo) throws IOExc final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( + "--to", tempDir.toString(), "--file-is-listing", listing.toString() ); @@ -431,6 +462,7 @@ void remoteListingOfRemoteFilesWithInlineDest(WireMockRuntimeInfo wmInfo) { final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( + "--to", tempDir.toString(), "--file-is-listing", wmInfo.getHttpBaseUrl() + "/listing.txt" ); @@ -485,6 +517,7 @@ void remoteListingOfLocalFilesWithInlineDest(WireMockRuntimeInfo wmInfo) throws final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( + "--to", tempDir.toString(), "--file-is-listing", wmInfo.getHttpBaseUrl() + "/listing.txt" ); From 90ee1cb5623c59db5142ba9ddd8ec418bb062480 Mon Sep 17 00:00:00 2001 From: Crafter-Y Date: Tue, 23 Sep 2025 22:59:15 +0200 Subject: [PATCH 06/10] making manifests work for different destinations --- .../itzg/helpers/sync/MulitCopyCommand.java | 4 -- .../helpers/sync/MulitCopyCommandTest.java | 46 ++++++++++++++++++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java index e8fc8d15..13203816 100644 --- a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java +++ b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java @@ -125,10 +125,6 @@ private Publisher processSource(String source, boolean fileIsListing, Path } if (source.contains(stringDelimiter)) { - if (manifestId != null) { - throw new GenericException("Manifests cannot be used with inline destinations"); - } - String[] split = source.split(stringDelimiter); destination = destination.resolve(Paths.get(split[0])); source = split[1]; diff --git a/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java b/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java index a7e26907..ba1033d4 100644 --- a/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java +++ b/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java @@ -272,8 +272,6 @@ void handlesUpdatedFile() throws IOException { .hasContent("updated"); } - // NOTE: Having multiple destinations conflicts with the idea of manifests. - // Therefore, manifests are not supported when using them @Test void managedWithManifest() throws IOException { final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); @@ -310,6 +308,50 @@ void managedWithManifest() throws IOException { .doesNotExist(); } + @Test + void managedWithManifestAndMultipleDest() throws IOException { + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + final Path destTxt = destDir1.resolve("one.txt"); + final Path destJar = destDir2.resolve("two.jar"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + "--scope", "managedWithManifest", + destDir1 + "@" + srcTxt + "," + destDir2 + "@" + srcJar + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destTxt) + .hasSameTextualContentAs(srcTxt); + assertThat(destJar) + .hasSameTextualContentAs(srcJar); + assertThat(destDir1.resolve("one.txt")) + .hasSameTextualContentAs(srcTxt); + assertThat(destDir2.resolve("two.jar")) + .hasSameTextualContentAs(srcJar); + + Files.delete(srcTxt); + assertThat( + new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + "--scope", "managedWithManifest", + destDir2 + "@" + srcJar + ) + ).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destTxt) + .doesNotExist(); + assertThat(destDir2.resolve("two.jar")) + .hasSameTextualContentAs(srcJar); + } + @Test void withGlob() throws IOException { final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); From 9701aadbd351537b016097962c75811315995432 Mon Sep 17 00:00:00 2001 From: Crafter-Y Date: Tue, 23 Sep 2025 23:00:53 +0200 Subject: [PATCH 07/10] updated docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6a4682e5..f6de2cd4 100644 --- a/README.md +++ b/README.md @@ -868,7 +868,7 @@ Downloads a maven artifact from a Maven repository Usage: mc-image-helper mcopy [-hz] [--file-is-listing] [--ignore-missing-sources] [--quiet-when-skipped] [--skip-existing] [--delimiter=] - [--glob=GLOB] [--scope=] [--to=] + [--glob=GLOB] [--scope=] --to= SRC[,|SRC...]... Multi-source file copy operation with with managed cleanup. Supports auto-detected sourcing from file list, directories, and URLs From 25dc7071e4fc9f6e35aae5dd820bbd1dd234f687 Mon Sep 17 00:00:00 2001 From: Crafter-Y Date: Thu, 25 Sep 2025 11:00:04 +0200 Subject: [PATCH 08/10] most of the requested changes --- .../itzg/helpers/sync/MulitCopyCommand.java | 18 ++++++-------- .../helpers/sync/MulitCopyCommandTest.java | 24 +++++++++---------- 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java index 13203816..efc3fb7a 100644 --- a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java +++ b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java @@ -73,7 +73,7 @@ public class MulitCopyCommand implements Callable { @Option(names = "--delimiter", defaultValue = "@", description = "When using per-file destinations, which symbol should be used to delimit destinationsource" ) - String stringDelimiter; + String destinationDelimiter; @Parameters(split = SPLIT_COMMA_NL, splitSynopsisLabel = SPLIT_SYNOPSIS_COMMA_NL, arity = "1..*", paramLabel = "SRC", @@ -88,7 +88,7 @@ public Integer call() throws Exception { Flux.fromIterable(sources) .map(String::trim) .filter(s -> !s.isEmpty()) - .flatMap(source -> processSource(source, fileIsListingOption, null)) + .flatMap(source -> processSource(source, fileIsListingOption, dest)) .collectList() .flatMap(this::cleanupAndSaveManifest) .block(); @@ -118,16 +118,12 @@ private Mono cleanupAndSaveManifest(List paths) { @SuppressWarnings("BlockingMethodInNonBlockingContext") // idk if that is a good idea private Publisher processSource(String source, boolean fileIsListing, Path parentDestination) { - Path destination = dest; + Path destination = parentDestination; - if (parentDestination != null) { - destination = parentDestination; - } - - if (source.contains(stringDelimiter)) { - String[] split = source.split(stringDelimiter); - destination = destination.resolve(Paths.get(split[0])); - source = split[1]; + final int delimiterPos = source.indexOf(destinationDelimiter); + if (delimiterPos > 0) { + destination = destination.resolve(Paths.get(source.substring(0, delimiterPos))); + source = source.substring(delimiterPos + 1); } if (fileIsListing) { diff --git a/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java b/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java index ba1033d4..78ce8267 100644 --- a/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java +++ b/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java @@ -55,7 +55,7 @@ void oneWithInlineDestination() throws IOException { final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( "--to", tempDir.toString(), - destDir + "@" +srcFile.toString() + "dest@" +srcFile.toString() ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -98,8 +98,8 @@ void commaDelimitedMultipleDest() throws IOException { .execute( "--to", tempDir.toString(), String.join(",", - destDir1 + "@" + srcTxt.toString(), - destDir2 + "@" + srcJar.toString() + "dest1@" + srcTxt.toString(), + "dest2@" + srcJar.toString() ) ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -227,7 +227,7 @@ void toInlineDir() throws IOException { final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( "--to", tempDir.toString(), - destDir + "@" + srcDir + "dest@" + srcDir ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -323,7 +323,7 @@ void managedWithManifestAndMultipleDest() throws IOException { .execute( "--to", tempDir.toString(), "--scope", "managedWithManifest", - destDir1 + "@" + srcTxt + "," + destDir2 + "@" + srcJar + "dest1@" + srcTxt + "," + "dest2@" + srcJar ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -404,7 +404,7 @@ void remoteFileWithInlineDest(WireMockRuntimeInfo wmInfo) { final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( "--to", tempDir.toString(), - destDir + "@" + wmInfo.getHttpBaseUrl() + "/file.jar" + "dest@" + wmInfo.getHttpBaseUrl() + "/file.jar" ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -447,8 +447,8 @@ void listingOfRemoteFilesWithInlineDest(WireMockRuntimeInfo wmInfo) throws IOExc final Path destDir2 = tempDir.resolve("dest2"); final Path listing = Files.write(tempDir.resolve("listing.txt"), Arrays.asList( - destDir1 + "@" + wmInfo.getHttpBaseUrl() + "/file1.jar", - destDir2 + "@" +wmInfo.getHttpBaseUrl() + "/file2.jar" + "dest1@" + wmInfo.getHttpBaseUrl() + "/file1.jar", + "dest2@" +wmInfo.getHttpBaseUrl() + "/file2.jar" )); final int exitCode = new CommandLine(new MulitCopyCommand()) @@ -496,8 +496,8 @@ void remoteListingOfRemoteFilesWithInlineDest(WireMockRuntimeInfo wmInfo) { final Path destDir2 = tempDir.resolve("dest2"); stubRemoteSrc("listing.txt", - destDir1 + "@" + wmInfo.getHttpBaseUrl() + "/file1.jar\n" + - destDir2 + "@" + wmInfo.getHttpBaseUrl() + "/file2.jar\n" + "dest1@" + wmInfo.getHttpBaseUrl() + "/file1.jar\n" + + "dest2@" + wmInfo.getHttpBaseUrl() + "/file2.jar\n" ); stubRemoteSrc("file1.jar", "one"); stubRemoteSrc("file2.jar", "two"); @@ -553,8 +553,8 @@ void remoteListingOfLocalFilesWithInlineDest(WireMockRuntimeInfo wmInfo) throws final Path srcJar = writeLine(srcDir, "two.jar", "two"); stubRemoteSrc("listing.txt", - destDir1 + "@" + srcTxt + "\n" + - destDir2 + "@" + srcJar + "\n" + "dest1@" + srcTxt + "\n" + + "dest2@" + srcJar + "\n" ); final int exitCode = new CommandLine(new MulitCopyCommand()) From 38a1b93f38ac4dbf59ad5071d59ae6fdf1d0ceae Mon Sep 17 00:00:00 2001 From: Crafter-Y Date: Thu, 25 Sep 2025 23:50:46 +0200 Subject: [PATCH 09/10] implemented < as delimiter --- README.md | 15 +++----- .../itzg/helpers/sync/MulitCopyCommand.java | 9 ++--- .../helpers/sync/MulitCopyCommandTest.java | 38 +++++++++---------- 3 files changed, 28 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index f6de2cd4..b3f9e3c2 100644 --- a/README.md +++ b/README.md @@ -865,21 +865,17 @@ Downloads a maven artifact from a Maven repository ### mcopy ``` -Usage: mc-image-helper mcopy [-hz] [--file-is-listing] +Usage: mc-image-helper mcopy [-hz] [--file-is-listing] [--ignore-missing-sources] [--quiet-when-skipped] - [--skip-existing] [--delimiter=] - [--glob=GLOB] [--scope=] --to= - SRC[,|SRC...]... + [--skip-existing] [--glob=GLOB] + [--scope=] --to= SRC[, + |SRC...]... Multi-source file copy operation with with managed cleanup. Supports auto-detected sourcing from file list, directories, and URLs SRC[,|SRC...]... Any mix of source file, directory, or URLs. Can be optionally comma or newline separated. Per-file destinations can be assigned by - destination@source - --delimiter= - When using per-file destinations, which symbol - should be used to delimit - destinationsource + destination -z, --skip-up-to-date + ``` ### modrinth diff --git a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java index efc3fb7a..8b676ebd 100644 --- a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java +++ b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java @@ -70,19 +70,16 @@ public class MulitCopyCommand implements Callable { @Option(names = "--ignore-missing-sources", description = "Don't log or fail exit code when any or all sources are missing") boolean ignoreMissingSources; - @Option(names = "--delimiter", defaultValue = "@", - description = "When using per-file destinations, which symbol should be used to delimit destinationsource" - ) - String destinationDelimiter; - @Parameters(split = SPLIT_COMMA_NL, splitSynopsisLabel = SPLIT_SYNOPSIS_COMMA_NL, arity = "1..*", paramLabel = "SRC", description = "Any mix of source file, directory, or URLs." + "%nCan be optionally comma or newline separated." - + "%nPer-file destinations can be assigned by destination@source" + + "%nPer-file destinations can be assigned by destination sources; + private final static String destinationDelimiter = "<"; + @Override public Integer call() throws Exception { Flux.fromIterable(sources) diff --git a/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java b/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java index 78ce8267..3672f788 100644 --- a/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java +++ b/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java @@ -55,7 +55,7 @@ void oneWithInlineDestination() throws IOException { final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( "--to", tempDir.toString(), - "dest@" +srcFile.toString() + "dest<" +srcFile.toString() ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -98,8 +98,8 @@ void commaDelimitedMultipleDest() throws IOException { .execute( "--to", tempDir.toString(), String.join(",", - "dest1@" + srcTxt.toString(), - "dest2@" + srcJar.toString() + "dest1<" + srcTxt.toString(), + "dest2<" + srcJar.toString() ) ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -123,8 +123,8 @@ void commaDelimitedRelativeDest() throws IOException { .execute( "--to", tempDir.toString(), String.join(",", - "./dest1@" + srcTxt.toString(), - "./dest2@" + srcJar.toString() + "./dest1<" + srcTxt.toString(), + "./dest2<" + srcJar.toString() ) ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -174,8 +174,8 @@ void justFilesWithDifferentDest() throws IOException { final Path destDir2 = tempDir.resolve("dest2"); final Path listing = Files.write(tempDir.resolve("listing.txt"), Arrays.asList( - destDir1 + "@" + srcTxt.toString(), - destDir2 + "@" + srcJar.toString() + "dest1<" + srcTxt.toString(), + "dest2<" + srcJar.toString() )); final int exitCode = new CommandLine(new MulitCopyCommand()) @@ -227,7 +227,7 @@ void toInlineDir() throws IOException { final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( "--to", tempDir.toString(), - "dest@" + srcDir + "dest<" + srcDir ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -323,7 +323,7 @@ void managedWithManifestAndMultipleDest() throws IOException { .execute( "--to", tempDir.toString(), "--scope", "managedWithManifest", - "dest1@" + srcTxt + "," + "dest2@" + srcJar + "dest1<" + srcTxt + "," + "dest2<" + srcJar ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -342,7 +342,7 @@ void managedWithManifestAndMultipleDest() throws IOException { .execute( "--to", tempDir.toString(), "--scope", "managedWithManifest", - destDir2 + "@" + srcJar + destDir2 + "<" + srcJar ) ).isEqualTo(CommandLine.ExitCode.OK); @@ -404,7 +404,7 @@ void remoteFileWithInlineDest(WireMockRuntimeInfo wmInfo) { final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( "--to", tempDir.toString(), - "dest@" + wmInfo.getHttpBaseUrl() + "/file.jar" + "dest<" + wmInfo.getHttpBaseUrl() + "/file.jar" ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); @@ -447,8 +447,8 @@ void listingOfRemoteFilesWithInlineDest(WireMockRuntimeInfo wmInfo) throws IOExc final Path destDir2 = tempDir.resolve("dest2"); final Path listing = Files.write(tempDir.resolve("listing.txt"), Arrays.asList( - "dest1@" + wmInfo.getHttpBaseUrl() + "/file1.jar", - "dest2@" +wmInfo.getHttpBaseUrl() + "/file2.jar" + "dest1<" + wmInfo.getHttpBaseUrl() + "/file1.jar", + "dest2<" +wmInfo.getHttpBaseUrl() + "/file2.jar" )); final int exitCode = new CommandLine(new MulitCopyCommand()) @@ -496,8 +496,8 @@ void remoteListingOfRemoteFilesWithInlineDest(WireMockRuntimeInfo wmInfo) { final Path destDir2 = tempDir.resolve("dest2"); stubRemoteSrc("listing.txt", - "dest1@" + wmInfo.getHttpBaseUrl() + "/file1.jar\n" + - "dest2@" + wmInfo.getHttpBaseUrl() + "/file2.jar\n" + "dest1<" + wmInfo.getHttpBaseUrl() + "/file1.jar\n" + + "dest2<" + wmInfo.getHttpBaseUrl() + "/file2.jar\n" ); stubRemoteSrc("file1.jar", "one"); stubRemoteSrc("file2.jar", "two"); @@ -553,8 +553,8 @@ void remoteListingOfLocalFilesWithInlineDest(WireMockRuntimeInfo wmInfo) throws final Path srcJar = writeLine(srcDir, "two.jar", "two"); stubRemoteSrc("listing.txt", - "dest1@" + srcTxt + "\n" + - "dest2@" + srcJar + "\n" + "dest1<" + srcTxt + "\n" + + "dest2<" + srcJar + "\n" ); final int exitCode = new CommandLine(new MulitCopyCommand()) @@ -585,14 +585,14 @@ void overrideDestOrder(WireMockRuntimeInfo wmInfo) throws IOException { stubRemoteSrc("listing.txt", srcTxt + "\n" + srcJar + "\n" + - destDir3 + "@" + srcYaml + "\n" + destDir3 + "<" + srcYaml + "\n" ); final int exitCode = new CommandLine(new MulitCopyCommand()) .execute( "--to", destDir1.toString(), "--file-is-listing", - destDir2 + "@" + wmInfo.getHttpBaseUrl() + "/listing.txt" + destDir2 + "<" + wmInfo.getHttpBaseUrl() + "/listing.txt" ); assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); From 86098300b9096e691771b14575844cec8da64ae0 Mon Sep 17 00:00:00 2001 From: Geoff Bourne Date: Thu, 25 Sep 2025 20:25:13 -0500 Subject: [PATCH 10/10] Create destination directory reactively --- .../itzg/helpers/sync/MulitCopyCommand.java | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java index 8b676ebd..1aa7b038 100644 --- a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java +++ b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java @@ -17,6 +17,7 @@ import me.itzg.helpers.errors.GenericException; import me.itzg.helpers.errors.InvalidParameterException; import me.itzg.helpers.files.Manifests; +import me.itzg.helpers.files.ReactiveFileUtils; import me.itzg.helpers.http.FailedRequestException; import me.itzg.helpers.http.Fetch; import me.itzg.helpers.http.SharedFetch; @@ -113,51 +114,52 @@ private Mono cleanupAndSaveManifest(List paths) { }); } - @SuppressWarnings("BlockingMethodInNonBlockingContext") // idk if that is a good idea private Publisher processSource(String source, boolean fileIsListing, Path parentDestination) { - Path destination = parentDestination; + final Path destination; + final String resolvedSource; final int delimiterPos = source.indexOf(destinationDelimiter); if (delimiterPos > 0) { - destination = destination.resolve(Paths.get(source.substring(0, delimiterPos))); - source = source.substring(delimiterPos + 1); + destination = parentDestination.resolve(Paths.get(source.substring(0, delimiterPos))); + resolvedSource = source.substring(delimiterPos + 1); + } + else { + destination = parentDestination; + resolvedSource = source; } if (fileIsListing) { - if (Uris.isUri(source)) { - return processRemoteListingFile(source, destination); + if (Uris.isUri(resolvedSource)) { + return processRemoteListingFile(resolvedSource, destination); } else { - final Path path = Paths.get(source); + final Path path = Paths.get(resolvedSource); if (Files.isDirectory(path)) { - throw new GenericException(String.format("Specified listing file '%s' is a directory", source)); + throw new GenericException(String.format("Specified listing file '%s' is a directory", resolvedSource)); } if (!Files.exists(path)) { - throw new GenericException(String.format("Source file '%s' does not exist", source)); + throw new GenericException(String.format("Source file '%s' does not exist", resolvedSource)); } return processListingFile(path, destination); } } - try { - Files.createDirectories(destination); - } catch (IOException e) { - throw new RuntimeException(e); - } - - if (Uris.isUri(source)) { - return processRemoteSource(source, destination); - } else { - final Path path = Paths.get(source); - if (!Files.exists(path)) { - throw new GenericException(String.format("Source file '%s' does not exist", source)); - } + return ReactiveFileUtils.createDirectories(destination) + .flatMapMany(ignored -> { + if (Uris.isUri(resolvedSource)) { + return processRemoteSource(resolvedSource, destination); + } else { + final Path path = Paths.get(resolvedSource); + if (!Files.exists(path)) { + return Mono.error(new GenericException(String.format("Source file '%s' does not exist", resolvedSource))); + } - if (Files.isDirectory(path)) { - return processDirectory(path, destination); - } else { - return processFile(path, destination); - } - } + if (Files.isDirectory(path)) { + return processDirectory(path, destination); + } else { + return processFile(path, destination); + } + } + }); } private Flux processListingFile(Path listingFile, Path destination) {