diff --git a/src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java b/src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java index dd119ae..972822a 100644 --- a/src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java +++ b/src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java @@ -17,6 +17,7 @@ import io.flamingock.cli.executor.command.AuditCommand; import io.flamingock.cli.executor.command.ExecuteCommand; +import io.flamingock.cli.executor.command.InstallSkillsCommand; import io.flamingock.cli.executor.command.IssueCommand; import io.flamingock.cli.executor.handler.ExecutorExceptionHandler; import io.flamingock.cli.executor.util.VersionProvider; @@ -45,6 +46,7 @@ "", "@|bold Examples:|@", " flamingock execute apply --jar ./app.jar", + " flamingock install-skills", " flamingock audit list --jar ./app.jar", " flamingock audit fix --jar ./app.jar -c my-change-id -r APPLIED", " flamingock issue list --jar ./app.jar", @@ -59,7 +61,7 @@ "", "For detailed help on any command, use: flamingock --help" }, - subcommands = {ExecuteCommand.class, AuditCommand.class, IssueCommand.class}, + subcommands = {ExecuteCommand.class, AuditCommand.class, IssueCommand.class, InstallSkillsCommand.class}, mixinStandardHelpOptions = true, versionProvider = VersionProvider.class ) diff --git a/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java b/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java new file mode 100644 index 0000000..33c3fe2 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.command; + +import io.flamingock.cli.executor.output.ConsoleFormatter; +import io.flamingock.cli.executor.skills.SkillsInstallationTarget; +import io.flamingock.cli.executor.skills.SkillsInstallationPipeline; +import io.flamingock.cli.executor.skills.SkillsInstallationResult; +import io.flamingock.cli.executor.skills.SkillsInstallationTargetResolver; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.Callable; + +/** + * Installs the official Flamingock AI skills into the current project. + */ +@Command( + name = "install-skills", + description = "Install official Flamingock AI skills into the current project", + mixinStandardHelpOptions = true +) +public class InstallSkillsCommand implements Callable { + + @Option(names = {"-g", "--global"}, description = "Install skills globally (not implemented yet)") + private boolean global; + + private final SkillsInstallationTargetResolver targetResolver; + private final SkillsInstallationPipeline pipeline; + private final Path workingDirectory; + + /** + * Creates a command with the default production collaborators. + */ + public InstallSkillsCommand() { + this(new SkillsInstallationTargetResolver(), new SkillsInstallationPipeline(), Path.of("")); + } + + InstallSkillsCommand( + SkillsInstallationTargetResolver targetResolver, + SkillsInstallationPipeline pipeline, + Path workingDirectory + ) { + this.targetResolver = targetResolver; + this.pipeline = pipeline; + this.workingDirectory = workingDirectory; + } + + /** + * Executes the install-skills command. + * + * @return process exit code + */ + @Override + public Integer call() { + try { + List targets = targetResolver.resolveTargets(workingDirectory.toAbsolutePath().normalize(), global); + SkillsInstallationResult result = pipeline.install(targets); + ConsoleFormatter.printInfo(buildSuccessMessage(result)); + return 0; + } catch (IllegalStateException e) { + ConsoleFormatter.printError(e.getMessage()); + return 1; + } + } + + private String buildSuccessMessage(SkillsInstallationResult result) { + if (result.targets().size() == 1) { + return "Installed " + result.installedSkills().size() + + " Flamingock skill(s) into " + result.destinationSkillsDir(); + } + + return "Installed " + result.installedSkills().size() + " Flamingock skill(s) into " + + result.targets().size() + " destinations."; + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java new file mode 100644 index 0000000..ff8cd66 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java @@ -0,0 +1,139 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.skills; + +import io.flamingock.cli.executor.util.archive.ZipArchiveExtractor; +import io.flamingock.cli.executor.util.filesystem.DirectoryLister; +import io.flamingock.cli.executor.util.filesystem.DirectoryReplacer; +import io.flamingock.cli.executor.util.filesystem.FileSystemUtils; +import io.flamingock.cli.executor.util.filesystem.TemporaryDirectoryFactory; +import io.flamingock.cli.executor.util.http.HttpFileDownloader; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Executes the shared staged pipeline that installs official Flamingock skills. + */ +public class SkillsInstallationPipeline { + + private static final URI OFFICIAL_SKILLS_ARCHIVE_URI = + URI.create("https://github.com/flamingock/flamingock-skills/archive/refs/heads/release.zip"); + private static final String ARCHIVE_FILE_NAME = "flamingock-skills.zip"; + private static final String EXTRACTION_DIRECTORY_NAME = "extracted"; + private static final String SKILL_DIRECTORY_PREFIX = "flamingock-"; + private static final String TEMP_DIRECTORY_PREFIX = "flamingock-skills-"; + private static final String DOWNLOAD_LABEL = "official Flamingock skills"; + private static final String ARCHIVE_DESCRIPTION = "skills archive"; + private static final String USER_AGENT = "Flamingock CLI/1.0 install-skills"; + + private final HttpFileDownloader downloader; + private final ZipArchiveExtractor extractor; + private final DirectoryLister directoryLister; + private final DirectoryReplacer replacer; + private final Supplier workspaceSupplier; + private final Consumer cleanup; + + public SkillsInstallationPipeline() { + this(new HttpFileDownloader(), + new ZipArchiveExtractor(), + new DirectoryLister(), + new DirectoryReplacer(), + () -> TemporaryDirectoryFactory.create(TEMP_DIRECTORY_PREFIX, "skill installation"), + FileSystemUtils::deleteRecursively); + } + + SkillsInstallationPipeline( + HttpFileDownloader downloader, + ZipArchiveExtractor extractor, + DirectoryLister directoryLister, + DirectoryReplacer replacer, + Supplier workspaceSupplier, + Consumer cleanup + ) { + this.downloader = downloader; + this.extractor = extractor; + this.directoryLister = directoryLister; + this.replacer = replacer; + this.workspaceSupplier = workspaceSupplier; + this.cleanup = cleanup; + } + + /** + * Runs the download, extract, enumerate, replace, and cleanup stages. + * + * @param targets resolved installation targets + * @return installation result summary + */ + public SkillsInstallationResult install(List targets) { + Objects.requireNonNull(targets, "targets must not be null"); + if (targets.isEmpty()) { + throw new IllegalStateException("No skills installation targets were resolved. Choose a destination and retry."); + } + + Path workspace = workspaceSupplier.get(); + RuntimeException installationFailure = null; + try { + Path archive = downloader.downloadTo( + workspace, + OFFICIAL_SKILLS_ARCHIVE_URI, + ARCHIVE_FILE_NAME, + USER_AGENT, + DOWNLOAD_LABEL + ); + Path snapshotRoot = extractor.extractSingleRootDirectory( + archive, + workspace, + EXTRACTION_DIRECTORY_NAME, + ARCHIVE_DESCRIPTION + ); + List skillDirectories = directoryLister.listDirectories( + snapshotRoot, + path -> path.getFileName().toString().startsWith(SKILL_DIRECTORY_PREFIX) + ); + List installedSkills = new ArrayList<>(); + for (Path skillDirectory : skillDirectories) { + installedSkills.add(skillDirectory.getFileName().toString()); + } + for (SkillsInstallationTarget target : targets) { + for (Path skillDirectory : skillDirectories) { + Path destinationSkillDirectory = target.destinationSkillsDir().resolve(skillDirectory.getFileName().toString()); + replacer.replaceDirectory(skillDirectory, destinationSkillDirectory); + } + } + return new SkillsInstallationResult(targets, installedSkills); + } catch (IOException e) { + installationFailure = new IllegalStateException("Failed to install Flamingock skills: " + e.getMessage(), e); + throw installationFailure; + } finally { + try { + cleanup.accept(workspace); + } catch (RuntimeException cleanupFailure) { + if (installationFailure != null) { + installationFailure.addSuppressed(cleanupFailure); + } else { + throw cleanupFailure; + } + } + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java new file mode 100644 index 0000000..a98b53c --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.skills; + +import java.nio.file.Path; +import java.util.List; + +/** + * Summary of a completed skills installation. + * + * @param targets installation targets that received the installed skills + * @param installedSkills installed official skill folder names + */ +public record SkillsInstallationResult(List targets, List installedSkills) { + + public SkillsInstallationResult { + targets = List.copyOf(targets); + installedSkills = List.copyOf(installedSkills); + } + + /** + * Returns the single destination directory when the installation resolved to one target. + * + * @return single destination directory + */ + public Path destinationSkillsDir() { + if (targets.size() != 1) { + throw new IllegalStateException("Installation resolved to " + targets.size() + + " targets; destinationSkillsDir() is only available for single-target installs."); + } + return targets.get(0).destinationSkillsDir(); + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTarget.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTarget.java new file mode 100644 index 0000000..0fad640 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTarget.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.skills; + +import java.nio.file.Path; +import java.util.Objects; + +/** + * Resolved destination for a skills installation run. + * + * @param identifier stable identifier for the target + * @param destinationSkillsDir destination directory that will receive the skills + */ +public record SkillsInstallationTarget(String identifier, Path destinationSkillsDir) { + + public SkillsInstallationTarget { + identifier = Objects.requireNonNull(identifier, "identifier must not be null"); + destinationSkillsDir = Objects.requireNonNull(destinationSkillsDir, "destinationSkillsDir must not be null"); + } + + /** + * Creates the current project-local installation target. + * + * @param destinationSkillsDir local destination directory + * @return local installation target + */ + public static SkillsInstallationTarget local(Path destinationSkillsDir) { + return new SkillsInstallationTarget("local", destinationSkillsDir); + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolver.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolver.java new file mode 100644 index 0000000..34ec77d --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolver.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.skills; + +import io.flamingock.cli.executor.util.filesystem.DirectoryResolver; + +import java.nio.file.Path; +import java.util.List; + +/** + * Resolves the install intent into concrete destination targets. + */ +public class SkillsInstallationTargetResolver { + + private static final String[] LOCAL_SKILLS_PATH = {".agents", "skills"}; + private static final String GLOBAL_MODE_NOT_IMPLEMENTED = + "Global skills installation is not implemented yet. Run 'flamingock install-skills' to install into ./.agents/skills."; + + private final DirectoryResolver directoryResolver; + + public SkillsInstallationTargetResolver() { + this(new DirectoryResolver()); + } + + SkillsInstallationTargetResolver(DirectoryResolver directoryResolver) { + this.directoryResolver = directoryResolver; + } + + /** + * Resolves the skills installation targets for the requested mode. + * + * @param workingDirectory current command working directory + * @param global whether global mode was requested + * @return resolved installation targets + */ + public List resolveTargets(Path workingDirectory, boolean global) { + if (global) { + throw new IllegalStateException(GLOBAL_MODE_NOT_IMPLEMENTED); + } + + Path destination = directoryResolver.resolveDirectory(workingDirectory, LOCAL_SKILLS_PATH); + return List.of(SkillsInstallationTarget.local(destination)); + } +} diff --git a/src/main/java/io/flamingock/cli/executor/util/archive/ZipArchiveExtractor.java b/src/main/java/io/flamingock/cli/executor/util/archive/ZipArchiveExtractor.java new file mode 100644 index 0000000..2be99ad --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/util/archive/ZipArchiveExtractor.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.util.archive; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Extracts ZIP archives into a workspace and returns their single root directory. + */ +public class ZipArchiveExtractor { + + /** + * Extracts a ZIP archive and returns the extracted single root directory. + * + * @param archive downloaded ZIP archive + * @param workspace temporary workspace directory + * @param extractionDirectoryName extraction folder name within the workspace + * @param archiveDescription archive label for validation messages + * @return extracted root directory + * @throws IOException if extraction fails + */ + public Path extractSingleRootDirectory(Path archive, Path workspace, String extractionDirectoryName, String archiveDescription) + throws IOException { + Path extractionDir = workspace.resolve(extractionDirectoryName); + Files.createDirectories(extractionDir); + + Set rootDirectories = new LinkedHashSet<>(); + try (InputStream inputStream = Files.newInputStream(archive); + ZipInputStream zipInputStream = new ZipInputStream(inputStream)) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if (entry.isDirectory()) { + Path targetDir = safeResolve(extractionDir, entry.getName(), archiveDescription); + Files.createDirectories(targetDir); + } else { + Path targetFile = safeResolve(extractionDir, entry.getName(), archiveDescription); + Path parent = targetFile.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(zipInputStream, targetFile, StandardCopyOption.REPLACE_EXISTING); + } + rootDirectories.add(firstSegment(entry.getName())); + zipInputStream.closeEntry(); + } + } + + if (rootDirectories.size() != 1 || rootDirectories.contains("")) { + throw new IllegalStateException("Expected " + archiveDescription + " to contain a single root directory."); + } + + return extractionDir.resolve(rootDirectories.iterator().next()); + } + + private String firstSegment(String entryName) { + int separatorIndex = entryName.indexOf('/'); + return separatorIndex >= 0 ? entryName.substring(0, separatorIndex) : ""; + } + + private Path safeResolve(Path extractionDir, String entryName, String archiveDescription) { + Path target = extractionDir.resolve(entryName).normalize(); + if (!target.startsWith(extractionDir)) { + throw new IllegalStateException(archiveDescription + " contains unsafe entry: " + entryName); + } + return target; + } +} diff --git a/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryLister.java b/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryLister.java new file mode 100644 index 0000000..a06d75f --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryLister.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.util.filesystem; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; + +/** + * Lists directories under a root using a caller-provided filter. + */ +public class DirectoryLister { + + /** + * Lists top-level directories matching the supplied filter. + * + * @param root root directory to inspect + * @param filter filter applied to each top-level directory + * @return sorted matching directories + * @throws IOException if listing fails + */ + public List listDirectories(Path root, Predicate filter) throws IOException { + try (Stream children = Files.list(root)) { + return children + .filter(Files::isDirectory) + .filter(filter) + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .toList(); + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryReplacer.java b/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryReplacer.java new file mode 100644 index 0000000..c64cfe8 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryReplacer.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.util.filesystem; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.stream.Stream; + +/** + * Replaces a destination directory with a fresh copy from a source directory. + */ +public class DirectoryReplacer { + + /** + * Deletes the existing destination tree and copies the source tree in its place. + * + * @param sourceDirectory source directory + * @param destinationDirectory destination directory to replace + * @throws IOException if replacement fails + */ + public void replaceDirectory(Path sourceDirectory, Path destinationDirectory) throws IOException { + FileSystemUtils.deleteRecursively(destinationDirectory); + Files.createDirectories(destinationDirectory); + + try (Stream sourceTree = Files.walk(sourceDirectory)) { + for (Path sourcePath : sourceTree.sorted(Comparator.naturalOrder()).toList()) { + Path relative = sourceDirectory.relativize(sourcePath); + Path target = destinationDirectory.resolve(relative.toString()); + if (Files.isDirectory(sourcePath)) { + Files.createDirectories(target); + } else { + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(sourcePath, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryResolver.java b/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryResolver.java new file mode 100644 index 0000000..8c8ce5f --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryResolver.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.util.filesystem; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Resolves nested directory paths under a base directory. + */ +public class DirectoryResolver { + + /** + * Resolves the nested directory under the provided base path, validating each segment. + * + * @param baseDirectory base directory + * @param segments nested path segments to resolve + * @return validated destination directory + */ + public Path resolveDirectory(Path baseDirectory, String... segments) { + Path resolved = baseDirectory; + for (String segment : segments) { + resolved = resolved.resolve(segment); + validateDirectoryPath(resolved); + } + + try { + Files.createDirectories(resolved); + } catch (IOException e) { + throw new IllegalStateException("Unable to create directory '" + resolved + + "'. Check filesystem permissions and try again.", e); + } + + return resolved; + } + + private void validateDirectoryPath(Path path) { + if (Files.exists(path) && !Files.isDirectory(path)) { + throw new IllegalStateException("Invalid destination path: '" + path + "' is not a directory. " + + "Delete or rename that path and run the command again."); + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/util/filesystem/FileSystemUtils.java b/src/main/java/io/flamingock/cli/executor/util/filesystem/FileSystemUtils.java new file mode 100644 index 0000000..cd3d36f --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/util/filesystem/FileSystemUtils.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.util.filesystem; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.stream.Stream; + +/** + * Shared filesystem helpers for CLI operations. + */ +public final class FileSystemUtils { + + private FileSystemUtils() { + } + + public static void deleteRecursively(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + try (Stream tree = Files.walk(path)) { + for (Path current : tree.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(current); + } + } catch (IOException e) { + throw new IllegalStateException("Failed to clean temporary path '" + path + "'.", e); + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/util/filesystem/TemporaryDirectoryFactory.java b/src/main/java/io/flamingock/cli/executor/util/filesystem/TemporaryDirectoryFactory.java new file mode 100644 index 0000000..97cd226 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/util/filesystem/TemporaryDirectoryFactory.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.util.filesystem; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Creates temporary directories with caller-provided naming and purpose context. + */ +public final class TemporaryDirectoryFactory { + + private TemporaryDirectoryFactory() { + } + + public static Path create(String prefix, String purposeDescription) { + try { + return Files.createTempDirectory(prefix); + } catch (IOException e) { + throw new IllegalStateException("Unable to create temporary workspace for " + purposeDescription + ".", e); + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/util/http/HttpFileDownloader.java b/src/main/java/io/flamingock/cli/executor/util/http/HttpFileDownloader.java new file mode 100644 index 0000000..bcff9b3 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/util/http/HttpFileDownloader.java @@ -0,0 +1,101 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.util.http; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +/** + * Downloads a remote file into a workspace using Java-native HTTP support. + */ +public class HttpFileDownloader { + + static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); + static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(30); + + private final DownloadExecutor downloadExecutor; + private final Duration connectTimeout; + private final Duration requestTimeout; + + public HttpFileDownloader() { + this(DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT, (request, target, resolvedConnectTimeout) -> HttpClient.newBuilder() + .connectTimeout(resolvedConnectTimeout) + .build() + .send(request, HttpResponse.BodyHandlers.ofFile(target))); + } + + HttpFileDownloader(Duration connectTimeout, Duration requestTimeout, DownloadExecutor downloadExecutor) { + this.connectTimeout = connectTimeout; + this.requestTimeout = requestTimeout; + this.downloadExecutor = downloadExecutor; + } + + /** + * Downloads a remote file into the provided workspace. + * + * @param workspace temporary workspace + * @param sourceUri source URI + * @param targetFileName target file name inside the workspace + * @param userAgent user agent header value + * @param downloadLabel contextual label for actionable error messages + * @return downloaded file path + * @throws IOException if the download fails + */ + public Path downloadTo(Path workspace, URI sourceUri, String targetFileName, String userAgent, String downloadLabel) + throws IOException { + Files.createDirectories(workspace); + Path targetFile = workspace.resolve(targetFileName); + HttpRequest request = HttpRequest.newBuilder(sourceUri) + .timeout(requestTimeout) + .header("User-Agent", userAgent) + .GET() + .build(); + try { + HttpResponse response = downloadExecutor.download(request, targetFile, connectTimeout); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + deletePartialFile(targetFile); + throw new IOException("Download failed while fetching " + downloadLabel + + " with HTTP " + response.statusCode() + " from " + sourceUri + + ". Check your network connection and retry."); + } + return targetFile; + } catch (IOException e) { + deletePartialFile(targetFile); + throw new IOException("Download failed while fetching " + downloadLabel + + ": " + e.getMessage(), e); + } catch (InterruptedException e) { + deletePartialFile(targetFile); + Thread.currentThread().interrupt(); + throw new IOException("Download interrupted while fetching " + downloadLabel + + ". Retry the command once the interruption is cleared.", e); + } + } + + private void deletePartialFile(Path targetFile) throws IOException { + Files.deleteIfExists(targetFile); + } + + @FunctionalInterface + interface DownloadExecutor { + HttpResponse download(HttpRequest request, Path target, Duration connectTimeout) throws IOException, InterruptedException; + } +} diff --git a/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java b/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java new file mode 100644 index 0000000..8f48eac --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.command; + +import io.flamingock.cli.executor.FlamingockExecutorCli; +import io.flamingock.cli.executor.skills.SkillsInstallationTarget; +import io.flamingock.cli.executor.skills.SkillsInstallationPipeline; +import io.flamingock.cli.executor.skills.SkillsInstallationResult; +import io.flamingock.cli.executor.skills.SkillsInstallationTargetResolver; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InstallSkillsCommandTest { + + @TempDir + Path tempDir; + + @Test + void rootCommand_registersInstallSkillsSubcommand() { + CommandLine commandLine = new CommandLine(new FlamingockExecutorCli()); + + assertTrue(commandLine.getSubcommands().containsKey("install-skills")); + } + + @Test + void call_localInvocationInstallsSkillsIntoResolvedDestination() throws Exception { + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.local(tempDir.resolve(".agents/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( + List.of(resolvedTarget), + List.of("flamingock-core", "flamingock-java") + )); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + int exitCode = new CommandLine(command).execute(); + + assertEquals(0, exitCode); + assertTrue(targetResolver.called); + assertFalse(targetResolver.global); + assertEquals(tempDir, targetResolver.workingDirectory); + assertEquals(List.of(resolvedTarget), pipeline.targets); + } + + @Test + void call_globalFlagPrintsNotImplementedAndDoesNotTouchFilesystem() throws Exception { + FailingTargetResolver targetResolver = new FailingTargetResolver( + new IllegalStateException("Global skills installation is not implemented yet. Run 'flamingock install-skills' to install into ./.agents/skills.") + ); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult(List.of(), List.of())); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + ByteArrayOutputStream errContent = new ByteArrayOutputStream(); + PrintStream originalErr = System.err; + System.setErr(new PrintStream(errContent, true, StandardCharsets.UTF_8)); + try { + int exitCode = new CommandLine(command).execute("--global"); + + assertEquals(1, exitCode); + } finally { + System.setErr(originalErr); + } + + assertTrue(targetResolver.called); + assertTrue(targetResolver.global); + assertFalse(pipeline.called); + assertFalse(Files.exists(tempDir.resolve(".agents"))); + assertTrue(errContent.toString(StandardCharsets.UTF_8).contains("not implemented")); + } + + @Test + void call_localInvocationUsesSharedPipelineAndPrintsInstalledSkillCount() throws Exception { + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.local(tempDir.resolve(".agents/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( + List.of(resolvedTarget), + List.of("flamingock-core") + )); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + PrintStream originalOut = System.out; + System.setOut(new PrintStream(outContent, true, StandardCharsets.UTF_8)); + try { + int exitCode = new CommandLine(command).execute(); + + assertEquals(0, exitCode); + } finally { + System.setOut(originalOut); + } + + assertTrue(pipeline.called); + assertTrue(outContent.toString(StandardCharsets.UTF_8).contains("Installed 1 Flamingock skill(s)")); + } + + @Test + void call_localInvocationPrintsActionableFailureWithoutStackTraceAndReturnsExitCodeOne() throws Exception { + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.local(tempDir.resolve(".agents/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); + FailingPipeline pipeline = new FailingPipeline( + new IllegalStateException("Download timed out while fetching official Flamingock skills. Check your network connection and retry.") + ); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + ByteArrayOutputStream errContent = new ByteArrayOutputStream(); + PrintStream originalErr = System.err; + System.setErr(new PrintStream(errContent, true, StandardCharsets.UTF_8)); + try { + int exitCode = new CommandLine(command).execute(); + + assertEquals(1, exitCode); + } finally { + System.setErr(originalErr); + } + + String stderr = errContent.toString(StandardCharsets.UTF_8); + assertTrue(targetResolver.called); + assertTrue(pipeline.called); + assertTrue(stderr.contains("timed out")); + assertTrue(stderr.contains("retry")); + assertFalse(stderr.contains("IllegalStateException")); + assertFalse(stderr.contains("\tat ")); + } + + private static final class RecordingTargetResolver extends SkillsInstallationTargetResolver { + + private final List targets; + private boolean called; + private Path workingDirectory; + private boolean global; + + private RecordingTargetResolver(List targets) { + this.targets = targets; + } + + @Override + public List resolveTargets(Path workingDirectory, boolean global) { + this.called = true; + this.workingDirectory = workingDirectory; + this.global = global; + return targets; + } + } + + private static final class FailingTargetResolver extends SkillsInstallationTargetResolver { + + private final IllegalStateException failure; + private boolean called; + private boolean global; + + private FailingTargetResolver(IllegalStateException failure) { + this.failure = failure; + } + + @Override + public List resolveTargets(Path workingDirectory, boolean global) { + this.called = true; + this.global = global; + throw failure; + } + } + + private static final class RecordingPipeline extends SkillsInstallationPipeline { + + private final SkillsInstallationResult result; + private boolean called; + private List targets; + + private RecordingPipeline(SkillsInstallationResult result) { + this.result = result; + } + + @Override + public SkillsInstallationResult install(List targets) { + this.called = true; + this.targets = targets; + return result; + } + } + + private static final class FailingPipeline extends SkillsInstallationPipeline { + + private final IllegalStateException failure; + private boolean called; + + private FailingPipeline(IllegalStateException failure) { + this.failure = failure; + } + + @Override + public SkillsInstallationResult install(List targets) { + this.called = true; + throw failure; + } + } +} diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java new file mode 100644 index 0000000..f8a30f6 --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java @@ -0,0 +1,262 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.skills; + +import io.flamingock.cli.executor.util.filesystem.DirectoryReplacer; +import io.flamingock.cli.executor.util.filesystem.FileSystemUtils; +import io.flamingock.cli.executor.util.archive.ZipArchiveExtractor; +import io.flamingock.cli.executor.util.filesystem.DirectoryLister; +import io.flamingock.cli.executor.util.http.HttpFileDownloader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkillsInstallationPipelineTest { + + @TempDir + Path tempDir; + + @Test + void install_runsStagesInOrderAndCleansWorkspace() throws Exception { + List events = new ArrayList<>(); + Path destination = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); + SkillsInstallationTarget target = SkillsInstallationTarget.local(destination); + Path downloadedArchive = tempDir.resolve("downloaded.zip"); + Path snapshotRoot = Files.createDirectories(tempDir.resolve("snapshot")); + Path firstSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-core")); + Path secondSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-java")); + + SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( + new HttpFileDownloader() { + @Override + public Path downloadTo(Path workspace, URI sourceUri, String targetFileName, String userAgent, String downloadLabel) + throws IOException { + events.add("download"); + Files.writeString(downloadedArchive, "zip"); + return downloadedArchive; + } + }, + new ZipArchiveExtractor() { + @Override + public Path extractSingleRootDirectory(Path archive, Path workspace, String extractionDirectoryName, + String archiveDescription) { + events.add("extract"); + return snapshotRoot; + } + }, + new DirectoryLister() { + @Override + public List listDirectories(Path root, java.util.function.Predicate filter) { + events.add("enumerate"); + return List.of(firstSkill, secondSkill); + } + }, + new DirectoryReplacer() { + @Override + public void replaceDirectory(Path sourceSkillDir, Path destinationSkillDir) { + events.add("replace:" + sourceSkillDir.getFileName()); + } + }, + () -> tempDir.resolve("workspace"), + FileSystemUtils::deleteRecursively + ); + + SkillsInstallationResult result = pipeline.install(List.of(target)); + + assertEquals(List.of("download", "extract", "enumerate", "replace:flamingock-core", "replace:flamingock-java"), events); + assertEquals(List.of("flamingock-core", "flamingock-java"), result.installedSkills()); + assertEquals(List.of(target), result.targets()); + assertFalse(Files.exists(tempDir.resolve("workspace"))); + } + + @Test + void install_cleansWorkspaceWhenStageFails() { + SkillsInstallationTarget target = SkillsInstallationTarget.local(tempDir.resolve(".agents").resolve("skills")); + SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( + new HttpFileDownloader() { + @Override + public Path downloadTo(Path workspace, URI sourceUri, String targetFileName, String userAgent, String downloadLabel) { + return tempDir.resolve("downloaded.zip"); + } + }, + new ZipArchiveExtractor() { + @Override + public Path extractSingleRootDirectory(Path archive, Path workspace, String extractionDirectoryName, + String archiveDescription) throws IOException { + throw new IOException("boom"); + } + }, + new DirectoryLister(), + new DirectoryReplacer(), + () -> tempDir.resolve("workspace"), + FileSystemUtils::deleteRecursively + ); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> pipeline.install(List.of(target))); + + assertTrue(exception.getMessage().contains("Failed to install Flamingock skills")); + assertFalse(Files.exists(tempDir.resolve("workspace"))); + } + + @Test + void install_preservesOriginalFailureWhenCleanupAlsoFails() throws Exception { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Files.writeString(workspace.resolve("stubborn.txt"), "keep"); + SkillsInstallationTarget target = SkillsInstallationTarget.local(tempDir.resolve(".agents").resolve("skills")); + + SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( + new HttpFileDownloader() { + @Override + public Path downloadTo(Path workspace, URI sourceUri, String targetFileName, String userAgent, String downloadLabel) { + return tempDir.resolve("downloaded.zip"); + } + }, + new ZipArchiveExtractor() { + @Override + public Path extractSingleRootDirectory(Path archive, Path workspace, String extractionDirectoryName, + String archiveDescription) throws IOException { + throw new IOException("download exploded"); + } + }, + new DirectoryLister(), + new DirectoryReplacer(), + () -> workspace, + path -> { + throw new IllegalStateException("cleanup exploded"); + } + ); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> pipeline.install(List.of(target))); + + assertTrue(exception.getMessage().contains("download exploded")); + assertEquals(1, exception.getSuppressed().length); + assertTrue(exception.getSuppressed()[0].getMessage().contains("cleanup exploded")); + } + + @Test + void install_replacesOnlyEnumeratedOfficialSkillsAndPreservesCustomFolders() throws Exception { + Path destination = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); + SkillsInstallationTarget target = SkillsInstallationTarget.local(destination); + Path existingOfficialSkill = Files.createDirectories(destination.resolve("flamingock-core")); + Files.writeString(existingOfficialSkill.resolve("old.txt"), "old"); + Path customSkill = Files.createDirectories(destination.resolve("my-custom-skill")); + Files.writeString(customSkill.resolve("SKILL.md"), "custom"); + + Path snapshotRoot = Files.createDirectories(tempDir.resolve("snapshot")); + Path officialSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-core")); + Files.writeString(officialSkill.resolve("SKILL.md"), "fresh"); + + SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( + new HttpFileDownloader() { + @Override + public Path downloadTo(Path workspace, URI sourceUri, String targetFileName, String userAgent, String downloadLabel) { + return tempDir.resolve("downloaded.zip"); + } + }, + new ZipArchiveExtractor() { + @Override + public Path extractSingleRootDirectory(Path archive, Path workspace, String extractionDirectoryName, + String archiveDescription) { + return snapshotRoot; + } + }, + new DirectoryLister() { + @Override + public List listDirectories(Path root, java.util.function.Predicate filter) { + return List.of(officialSkill); + } + }, + new DirectoryReplacer(), + () -> tempDir.resolve("workspace"), + FileSystemUtils::deleteRecursively + ); + + SkillsInstallationResult result = pipeline.install(List.of(target)); + + assertEquals(List.of("flamingock-core"), result.installedSkills()); + assertFalse(Files.exists(destination.resolve("flamingock-core").resolve("old.txt"))); + assertEquals("fresh", Files.readString(destination.resolve("flamingock-core").resolve("SKILL.md"))); + assertEquals("custom", Files.readString(destination.resolve("my-custom-skill").resolve("SKILL.md"))); + } + + @Test + void install_downloadsAndExtractsOnceForMultipleTargets() throws Exception { + List events = new ArrayList<>(); + SkillsInstallationTarget firstTarget = new SkillsInstallationTarget("local", Files.createDirectories(tempDir.resolve("project-a/.agents/skills"))); + SkillsInstallationTarget secondTarget = new SkillsInstallationTarget("secondary", Files.createDirectories(tempDir.resolve("project-b/.agents/skills"))); + Path snapshotRoot = Files.createDirectories(tempDir.resolve("snapshot")); + Path firstSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-core")); + Path secondSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-java")); + + SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( + new HttpFileDownloader() { + @Override + public Path downloadTo(Path workspace, URI sourceUri, String targetFileName, String userAgent, String downloadLabel) + throws IOException { + events.add("download"); + return Files.writeString(tempDir.resolve("downloaded.zip"), "zip"); + } + }, + new ZipArchiveExtractor() { + @Override + public Path extractSingleRootDirectory(Path archive, Path workspace, String extractionDirectoryName, + String archiveDescription) { + events.add("extract"); + return snapshotRoot; + } + }, + new DirectoryLister() { + @Override + public List listDirectories(Path root, java.util.function.Predicate filter) { + events.add("enumerate"); + return List.of(firstSkill, secondSkill); + } + }, + new DirectoryReplacer() { + @Override + public void replaceDirectory(Path sourceSkillDir, Path destinationSkillDir) { + events.add(destinationSkillDir.toString()); + } + }, + () -> tempDir.resolve("workspace"), + FileSystemUtils::deleteRecursively + ); + + SkillsInstallationResult result = pipeline.install(List.of(firstTarget, secondTarget)); + + assertEquals(List.of(firstTarget, secondTarget), result.targets()); + assertEquals(List.of("flamingock-core", "flamingock-java"), result.installedSkills()); + assertEquals(List.of("download", "extract", "enumerate"), events.subList(0, 3)); + assertEquals(7, events.size()); + assertTrue(events.contains(firstTarget.destinationSkillsDir().resolve("flamingock-core").toString())); + assertTrue(events.contains(firstTarget.destinationSkillsDir().resolve("flamingock-java").toString())); + assertTrue(events.contains(secondTarget.destinationSkillsDir().resolve("flamingock-core").toString())); + assertTrue(events.contains(secondTarget.destinationSkillsDir().resolve("flamingock-java").toString())); + } +} diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java new file mode 100644 index 0000000..d0d9303 --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.skills; + +import io.flamingock.cli.executor.util.filesystem.DirectoryResolver; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkillsInstallationTargetResolverTest { + + @TempDir + Path tempDir; + + @Test + void resolveTargets_localModeResolvesSingleProjectTarget() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".agents/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + List targets = resolver.resolveTargets(tempDir, false); + + assertTrue(directoryResolver.called); + assertEquals(tempDir, directoryResolver.workingDirectory); + assertArrayEquals(new String[]{".agents", "skills"}, directoryResolver.segments); + assertEquals(List.of(SkillsInstallationTarget.local(tempDir.resolve(".agents/skills"))), targets); + } + + @Test + void resolveTargets_globalModeFailsWithoutCreatingDirectories() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".agents/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> resolver.resolveTargets(tempDir, true)); + + assertFalse(directoryResolver.called); + assertFalse(Files.exists(tempDir.resolve(".agents"))); + assertTrue(exception.getMessage().contains("not implemented yet")); + } + + private static final class RecordingDirectoryResolver extends DirectoryResolver { + + private final Path destination; + private boolean called; + private Path workingDirectory; + private String[] segments; + + private RecordingDirectoryResolver(Path destination) { + this.destination = destination; + } + + @Override + public Path resolveDirectory(Path workingDirectory, String... segments) { + this.called = true; + this.workingDirectory = workingDirectory; + this.segments = segments; + return destination; + } + } +} diff --git a/src/test/java/io/flamingock/cli/executor/util/archive/ZipArchiveExtractorTest.java b/src/test/java/io/flamingock/cli/executor/util/archive/ZipArchiveExtractorTest.java new file mode 100644 index 0000000..2ed43ec --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/util/archive/ZipArchiveExtractorTest.java @@ -0,0 +1,97 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.util.archive; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ZipArchiveExtractorTest { + + @TempDir + Path tempDir; + + private final ZipArchiveExtractor extractor = new ZipArchiveExtractor(); + + @Test + void extractSingleRootDirectory_returnsArchiveRootDirectory() throws Exception { + Path archive = createZip(tempDir.resolve("skills.zip"), + "flamingock-skills-master/README.md", + "flamingock-skills-master/flamingock-core/SKILL.md", + "flamingock-skills-master/flamingock-java/SKILL.md"); + + Path snapshotRoot = extractor.extractSingleRootDirectory( + archive, + tempDir.resolve("workspace"), + "extracted", + "skills archive" + ); + + assertEquals(tempDir.resolve("workspace").resolve("extracted").resolve("flamingock-skills-master"), snapshotRoot); + assertTrue(Files.exists(snapshotRoot.resolve("README.md"))); + assertTrue(Files.exists(snapshotRoot.resolve("flamingock-core").resolve("SKILL.md"))); + } + + @Test + void extractSingleRootDirectory_rejectsArchiveWithoutSingleRootDirectory() throws Exception { + Path archive = createZip(tempDir.resolve("invalid.zip"), + "README.md", + "flamingock-core/SKILL.md"); + + IllegalStateException exception = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> extractor.extractSingleRootDirectory(archive, tempDir.resolve("workspace"), "extracted", "skills archive")); + + assertTrue(exception.getMessage().contains("single root directory")); + } + + @Test + void extractSingleRootDirectory_rejectsZipSlipEntriesOutsideWorkspace() throws Exception { + Path archive = createZip(tempDir.resolve("zip-slip.zip"), + "flamingock-skills-master/flamingock-core/SKILL.md", + "flamingock-skills-master/../../evil.txt"); + + IllegalStateException exception = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> extractor.extractSingleRootDirectory(archive, tempDir.resolve("workspace"), "extracted", "skills archive")); + + assertTrue(exception.getMessage().contains("unsafe")); + assertFalse(Files.exists(tempDir.resolve("evil.txt"))); + } + + private Path createZip(Path zipPath, String... entries) throws IOException { + try (OutputStream outputStream = Files.newOutputStream(zipPath); + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + for (String entry : entries) { + ZipEntry zipEntry = new ZipEntry(entry); + zipOutputStream.putNextEntry(zipEntry); + if (!entry.endsWith("/")) { + zipOutputStream.write("content".getBytes()); + } + zipOutputStream.closeEntry(); + } + } + return zipPath; + } +} diff --git a/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryListerTest.java b/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryListerTest.java new file mode 100644 index 0000000..56f2310 --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryListerTest.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.util.filesystem; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DirectoryListerTest { + + @TempDir + Path tempDir; + + private final DirectoryLister directoryLister = new DirectoryLister(); + + @Test + void listDirectories_returnsOnlyTopLevelMatchingDirectories() throws Exception { + Path snapshotRoot = tempDir.resolve("flamingock-skills-master"); + Files.createDirectories(snapshotRoot.resolve("flamingock-core")); + Files.createDirectories(snapshotRoot.resolve("flamingock-java")); + Files.createDirectories(snapshotRoot.resolve("docs")); + Files.writeString(snapshotRoot.resolve("README.md"), "readme"); + + List skillDirectories = directoryLister.listDirectories( + snapshotRoot, + path -> path.getFileName().toString().startsWith("flamingock-") + ); + + assertEquals(List.of( + snapshotRoot.resolve("flamingock-core"), + snapshotRoot.resolve("flamingock-java") + ), skillDirectories); + } + + @Test + void listDirectories_ignoresNestedMatchingDirectoriesOutsideTopLevel() throws Exception { + Path snapshotRoot = tempDir.resolve("flamingock-skills-master"); + Files.createDirectories(snapshotRoot.resolve("docs").resolve("flamingock-not-a-skill")); + Files.createDirectories(snapshotRoot.resolve("flamingock-core")); + + List skillDirectories = directoryLister.listDirectories( + snapshotRoot, + path -> path.getFileName().toString().startsWith("flamingock-") + ); + + assertEquals(List.of(snapshotRoot.resolve("flamingock-core")), skillDirectories); + } +} diff --git a/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryReplacerTest.java b/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryReplacerTest.java new file mode 100644 index 0000000..1bb78c4 --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryReplacerTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.util.filesystem; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DirectoryReplacerTest { + + @TempDir + Path tempDir; + + private final DirectoryReplacer replacer = new DirectoryReplacer(); + + @Test + void replaceDirectory_deletesExistingDestinationBeforeCopyingFreshTree() throws Exception { + Path destinationDirectory = Files.createDirectories(tempDir.resolve(".agents").resolve("skills").resolve("flamingock-core")); + Files.writeString(destinationDirectory.resolve("old-file.txt"), "old"); + Path sourceDirectory = Files.createDirectories(tempDir.resolve("source").resolve("flamingock-core")); + Files.writeString(sourceDirectory.resolve("SKILL.md"), "new"); + + replacer.replaceDirectory(sourceDirectory, destinationDirectory); + + assertFalse(Files.exists(destinationDirectory.resolve("old-file.txt"))); + assertEquals("new", Files.readString(destinationDirectory.resolve("SKILL.md"))); + } + + @Test + void replaceDirectory_preservesSiblingFoldersOutsideDestination() throws Exception { + Path skillsRoot = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); + Files.createDirectories(skillsRoot.resolve("my-custom-skill")); + Path sourceDirectory = Files.createDirectories(tempDir.resolve("source").resolve("flamingock-java")); + Files.writeString(sourceDirectory.resolve("SKILL.md"), "java"); + + replacer.replaceDirectory(sourceDirectory, skillsRoot.resolve("flamingock-java")); + + assertTrue(Files.isDirectory(skillsRoot.resolve("my-custom-skill"))); + assertTrue(Files.exists(skillsRoot.resolve("flamingock-java").resolve("SKILL.md"))); + } +} diff --git a/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryResolverTest.java b/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryResolverTest.java new file mode 100644 index 0000000..9616bce --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryResolverTest.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.util.filesystem; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DirectoryResolverTest { + + @TempDir + Path tempDir; + + private final DirectoryResolver resolver = new DirectoryResolver(); + + @Test + void resolveDirectory_createsMissingNestedTree() throws Exception { + Path destination = resolver.resolveDirectory(tempDir, ".agents", "skills"); + + assertEquals(tempDir.resolve(".agents").resolve("skills"), destination); + assertTrue(Files.isDirectory(tempDir.resolve(".agents"))); + assertTrue(Files.isDirectory(destination)); + } + + @Test + void resolveDirectory_failsWhenIntermediatePathIsAFile() throws Exception { + Path agentsFile = Files.writeString(tempDir.resolve(".agents"), "not a directory"); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> resolver.resolveDirectory(tempDir, ".agents", "skills")); + + assertTrue(exception.getMessage().contains(agentsFile.toString())); + assertTrue(exception.getMessage().contains("Delete or rename")); + } + + @Test + void resolveDirectory_failsWhenFinalPathIsAFile() throws Exception { + Files.createDirectories(tempDir.resolve(".agents")); + Path skillsFile = Files.writeString(tempDir.resolve(".agents").resolve("skills"), "not a directory"); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> resolver.resolveDirectory(tempDir, ".agents", "skills")); + + assertTrue(exception.getMessage().contains(skillsFile.toString())); + assertTrue(exception.getMessage().contains("Delete or rename")); + } +} diff --git a/src/test/java/io/flamingock/cli/executor/util/http/HttpFileDownloaderTest.java b/src/test/java/io/flamingock/cli/executor/util/http/HttpFileDownloaderTest.java new file mode 100644 index 0000000..b8b87ca --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/util/http/HttpFileDownloaderTest.java @@ -0,0 +1,177 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.util.http; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.net.ssl.SSLSession; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HttpFileDownloaderTest { + + private static final URI SOURCE_URI = URI.create("https://example.com/release.zip"); + private static final String TARGET_FILE_NAME = "download.zip"; + private static final String USER_AGENT = "Flamingock CLI/1.0 install-skills"; + private static final String DOWNLOAD_LABEL = "official Flamingock skills"; + + @TempDir + Path tempDir; + + @Test + void downloadTo_usesProvidedArchiveSettingsAndReturnsDownloadedFile() throws Exception { + RecordingDownloadExecutor executor = new RecordingDownloadExecutor(200); + HttpFileDownloader downloader = new HttpFileDownloader(Duration.ofSeconds(10), Duration.ofSeconds(30), executor); + + Path archive = downloader.downloadTo(tempDir.resolve("workspace"), SOURCE_URI, TARGET_FILE_NAME, USER_AGENT, DOWNLOAD_LABEL); + + assertEquals(SOURCE_URI, executor.lastRequest.uri()); + assertEquals(tempDir.resolve("workspace").resolve(TARGET_FILE_NAME), archive); + assertTrue(Files.exists(archive)); + assertEquals("zip-content", Files.readString(archive)); + } + + @Test + void downloadTo_deletesPartialArchiveWhenServerReturnsFailure() { + RecordingDownloadExecutor executor = new RecordingDownloadExecutor(503); + HttpFileDownloader downloader = new HttpFileDownloader(Duration.ofSeconds(10), Duration.ofSeconds(30), executor); + Path workspace = tempDir.resolve("workspace"); + + IOException exception = assertThrows(IOException.class, + () -> downloader.downloadTo(workspace, SOURCE_URI, TARGET_FILE_NAME, USER_AGENT, DOWNLOAD_LABEL)); + + assertTrue(exception.getMessage().contains(DOWNLOAD_LABEL)); + assertTrue(exception.getMessage().contains("HTTP 503")); + assertFalse(Files.exists(workspace.resolve(TARGET_FILE_NAME))); + } + + @Test + void downloadTo_setsReasonableTimeoutsAndUserAgent() throws Exception { + RecordingDownloadExecutor executor = new RecordingDownloadExecutor(200); + HttpFileDownloader downloader = new HttpFileDownloader(Duration.ofSeconds(10), Duration.ofSeconds(30), executor); + + downloader.downloadTo(tempDir.resolve("workspace"), SOURCE_URI, TARGET_FILE_NAME, USER_AGENT, DOWNLOAD_LABEL); + + assertEquals(Duration.ofSeconds(10), executor.connectTimeout); + assertEquals(Duration.ofSeconds(30), executor.lastRequest.timeout().orElseThrow()); + assertEquals(USER_AGENT, executor.lastRequest.headers().firstValue("User-Agent").orElseThrow()); + } + + @Test + void downloadTo_deletesPartialArchiveWhenDownloadThrowsIoException() { + HttpFileDownloader downloader = new HttpFileDownloader(Duration.ofSeconds(10), Duration.ofSeconds(30), (request, target, connectTimeout) -> { + Files.createDirectories(target.getParent()); + Files.writeString(target, "partial"); + throw new IOException("socket closed"); + }); + Path workspace = tempDir.resolve("workspace"); + + IOException exception = assertThrows(IOException.class, + () -> downloader.downloadTo(workspace, SOURCE_URI, TARGET_FILE_NAME, USER_AGENT, DOWNLOAD_LABEL)); + + assertTrue(exception.getMessage().contains(DOWNLOAD_LABEL)); + assertTrue(exception.getMessage().contains("socket closed")); + assertEquals("socket closed", exception.getCause().getMessage()); + assertFalse(Files.exists(workspace.resolve(TARGET_FILE_NAME))); + } + + @Test + void downloadTo_wrapsInterruptedDownloadsWithActionableMessage() { + HttpFileDownloader downloader = new HttpFileDownloader(Duration.ofSeconds(10), Duration.ofSeconds(30), + (request, target, connectTimeout) -> { + throw new InterruptedException("cancelled"); + }); + + IOException exception = assertThrows(IOException.class, + () -> downloader.downloadTo(tempDir.resolve("workspace"), SOURCE_URI, TARGET_FILE_NAME, USER_AGENT, DOWNLOAD_LABEL)); + + assertTrue(exception.getMessage().contains("official Flamingock skills")); + assertTrue(exception.getMessage().contains("interrupted")); + assertTrue(Thread.currentThread().isInterrupted()); + Thread.interrupted(); + } + + private static final class RecordingDownloadExecutor implements HttpFileDownloader.DownloadExecutor { + + private final int statusCode; + private HttpRequest lastRequest; + private Duration connectTimeout; + + private RecordingDownloadExecutor(int statusCode) { + this.statusCode = statusCode; + } + + @Override + public HttpResponse download(HttpRequest request, Path target, Duration connectTimeout) throws IOException { + this.lastRequest = request; + this.connectTimeout = connectTimeout; + Files.createDirectories(target.getParent()); + Files.writeString(target, "zip-content"); + return new HttpResponseStub(statusCode, target, request); + } + } + + private record HttpResponseStub(int statusCode, Path body, HttpRequest request) implements HttpResponse { + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(java.util.Map.of(), (a, b) -> true); + } + + @Override + public Path body() { + return body; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public java.net.http.HttpClient.Version version() { + return java.net.http.HttpClient.Version.HTTP_1_1; + } + } +}