From cbe38603b2bcd7af1913f2fc7ba8abdfcd1685ee Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Wed, 29 Jul 2026 14:44:46 +0300 Subject: [PATCH] STX-211 Project repository commands --- .../cli/commands/project/ProjectCommand.java | 2 + .../project/repo/ProjectScopedOptions.java | 25 ++ .../commands/project/repo/RepoCommand.java | 21 ++ .../commands/project/repo/get/GetCommand.java | 63 +++++ .../project/repo/remove/RemoveCommand.java | 36 +++ .../commands/project/repo/set/SetCommand.java | 67 +++++ .../repo/sshkey/GenerateSshKeyCommand.java | 96 ++++++++ .../repo/sshkey/RemoveSshKeyCommand.java | 35 +++ .../project/repo/sshkey/SetSshKeyCommand.java | 56 +++++ .../repo/sshkey/ShowSshKeyCommand.java | 45 ++++ .../project/repo/sshkey/SshKeyCommand.java | 17 ++ .../project/ProjectRepoCommandIT.java | 231 ++++++++++++++++++ 12 files changed, 694 insertions(+) create mode 100644 src/main/java/com/streamx/cli/commands/project/repo/ProjectScopedOptions.java create mode 100644 src/main/java/com/streamx/cli/commands/project/repo/RepoCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/repo/get/GetCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/repo/remove/RemoveCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/repo/set/SetCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/repo/sshkey/GenerateSshKeyCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/repo/sshkey/RemoveSshKeyCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/repo/sshkey/SetSshKeyCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/repo/sshkey/ShowSshKeyCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/repo/sshkey/SshKeyCommand.java create mode 100644 src/test/java/com/streamx/cli/commands/project/ProjectRepoCommandIT.java diff --git a/src/main/java/com/streamx/cli/commands/project/ProjectCommand.java b/src/main/java/com/streamx/cli/commands/project/ProjectCommand.java index 9bd0042e..b5e91094 100644 --- a/src/main/java/com/streamx/cli/commands/project/ProjectCommand.java +++ b/src/main/java/com/streamx/cli/commands/project/ProjectCommand.java @@ -6,6 +6,7 @@ import com.streamx.cli.commands.project.get.GetCommand; import com.streamx.cli.commands.project.list.ListCommand; import com.streamx.cli.commands.project.pendingchanges.PendingChangesCommand; +import com.streamx.cli.commands.project.repo.RepoCommand; import com.streamx.cli.commands.project.status.StatusCommand; import com.streamx.cli.commands.project.update.UpdateCommand; import com.streamx.cli.framework.AbstractCommandGroup; @@ -21,6 +22,7 @@ GetCommand.class, ListCommand.class, PendingChangesCommand.class, + RepoCommand.class, StatusCommand.class, UpdateCommand.class } diff --git a/src/main/java/com/streamx/cli/commands/project/repo/ProjectScopedOptions.java b/src/main/java/com/streamx/cli/commands/project/repo/ProjectScopedOptions.java new file mode 100644 index 00000000..43e6d2a6 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/repo/ProjectScopedOptions.java @@ -0,0 +1,25 @@ +package com.streamx.cli.commands.project.repo; + +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.ProjectIdCompletionCandidates; +import picocli.CommandLine; + +/** The org/project selectors shared by every {@code project repo} subcommand. */ +public class ProjectScopedOptions { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Option( + names = "--project", + paramLabel = "", + description = "Project ID (defaults to the current project)", + completionCandidates = ProjectIdCompletionCandidates.class + ) + public String projectId; +} diff --git a/src/main/java/com/streamx/cli/commands/project/repo/RepoCommand.java b/src/main/java/com/streamx/cli/commands/project/repo/RepoCommand.java new file mode 100644 index 00000000..21bbef0c --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/repo/RepoCommand.java @@ -0,0 +1,21 @@ +package com.streamx.cli.commands.project.repo; + +import com.streamx.cli.commands.project.repo.get.GetCommand; +import com.streamx.cli.commands.project.repo.remove.RemoveCommand; +import com.streamx.cli.commands.project.repo.set.SetCommand; +import com.streamx.cli.commands.project.repo.sshkey.SshKeyCommand; +import com.streamx.cli.framework.AbstractCommandGroup; +import picocli.CommandLine; + +@CommandLine.Command( + name = "repo", + header = "Manage the Git repository connected to a project", + subcommands = { + GetCommand.class, + SetCommand.class, + RemoveCommand.class, + SshKeyCommand.class + } +) +public class RepoCommand extends AbstractCommandGroup { +} diff --git a/src/main/java/com/streamx/cli/commands/project/repo/get/GetCommand.java b/src/main/java/com/streamx/cli/commands/project/repo/get/GetCommand.java new file mode 100644 index 00000000..db37b58f --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/repo/get/GetCommand.java @@ -0,0 +1,63 @@ +package com.streamx.cli.commands.project.repo.get; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.commands.project.repo.ProjectScopedOptions; +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectRepositoryApi; +import com.streamx.cli.platform.generated.model.ProjectRepository; +import java.util.List; +import picocli.CommandLine; + +@CommandLine.Command( + name = "get", + header = "Show the repository connected to a project" +) +public class GetCommand extends AbstractCommand { + + @CommandLine.Mixin + ProjectScopedOptions scope; + + @Override + public String getTextOutput(CommandResult result) { + ProjectRepository repository = result.getData(); + var status = repository.getProjectRepositoryStatus(); + Boolean ready = status == null ? null : status.getReady(); + List errors = status == null || status.getErrorMessages() == null + ? List.of() : status.getErrorMessages(); + return """ + uri = %s + branch = %s + commit = %s + ready = %s + ssh key = %s%s""" + .formatted( + orDash(repository.getUri()), + orDash(repository.getBranch()), + orDash(repository.getCommitId()), + ready == null ? "-" : ready, + Boolean.TRUE.equals(repository.getSshKeyProvided()) + ? msg.sshKeySpecified() : msg.sshKeyNotSpecified(), + errors.isEmpty() ? "" : "\nerrors = " + String.join("; ", errors)); + } + + private static String orDash(String value) { + return value == null ? "-" : value; + } + + @Override + public CommandResult runCommand() { + PlatformContext.OrgProject context = + PlatformContext.orgAndProject(scope.orgId, scope.projectId); + try (PlatformClients client = PlatformClients.fromConfig()) { + return new CommandResult<>(new ProjectRepositoryApi(client) + .get(context.org(), context.project())); + } catch (PlatformClients.NotFoundException e) { + throw new CliException(msg.projectRepoNotConnected(context.project()), e); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/repo/remove/RemoveCommand.java b/src/main/java/com/streamx/cli/commands/project/repo/remove/RemoveCommand.java new file mode 100644 index 00000000..20d0a6a4 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/repo/remove/RemoveCommand.java @@ -0,0 +1,36 @@ +package com.streamx.cli.commands.project.repo.remove; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.commands.project.repo.ProjectScopedOptions; +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectRepositoryApi; +import picocli.CommandLine; + +@CommandLine.Command( + name = "remove", + header = "Disconnect the repository from a project", + description = "Only removes the connection; the Git repository itself is not touched." +) +public class RemoveCommand extends AbstractSilentCommand { + + @CommandLine.Mixin + ProjectScopedOptions scope; + + @Override + public CommandResult runCommand() { + PlatformContext.OrgProject context = + PlatformContext.orgAndProject(scope.orgId, scope.projectId); + try (PlatformClients client = PlatformClients.fromConfig()) { + new ProjectRepositoryApi(client).disconnect(context.org(), context.project()); + } catch (PlatformClients.NotFoundException e) { + throw new CliException(msg.projectRepoNotConnected(context.project()), e); + } + System.out.println(msg.projectRepoRemoved(context.project())); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/repo/set/SetCommand.java b/src/main/java/com/streamx/cli/commands/project/repo/set/SetCommand.java new file mode 100644 index 00000000..77b3766e --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/repo/set/SetCommand.java @@ -0,0 +1,67 @@ +package com.streamx.cli.commands.project.repo.set; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.commands.project.repo.ProjectScopedOptions; +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectRepositoryApi; +import picocli.CommandLine; + +@CommandLine.Command( + name = "set", + header = "Connect a repository to a project, or change its settings", + description = "Connects the repository when the project has none yet, otherwise updates " + + "the connection. Use 'ssh-key set' first (or 'project create --ssh-private-key') " + + "for private repositories." +) +public class SetCommand extends AbstractSilentCommand { + + @CommandLine.Mixin + ProjectScopedOptions scope; + + @CommandLine.Option( + names = "--uri", + required = true, + paramLabel = "", + description = "Git repository URI" + ) + public String uri; + + @CommandLine.Option( + names = "--branch", + required = true, + paramLabel = "", + description = "Git branch the platform deploys from" + ) + public String branch; + + @Override + public CommandResult runCommand() { + PlatformContext.OrgProject context = + PlatformContext.orgAndProject(scope.orgId, scope.projectId); + try (PlatformClients client = PlatformClients.fromConfig()) { + ProjectRepositoryApi api = new ProjectRepositoryApi(client); + if (repositoryExists(api, context)) { + api.update(context.org(), context.project(), uri, branch); + System.out.println(msg.projectRepoUpdated(context.project())); + } else { + api.connect(context.org(), context.project(), uri, branch); + System.out.println(msg.projectRepoConnected(context.project())); + } + } + return new CommandResult<>(null); + } + + private static boolean repositoryExists( + ProjectRepositoryApi api, PlatformContext.OrgProject context) { + try { + api.get(context.org(), context.project()); + return true; + } catch (PlatformClients.NotFoundException e) { + return false; + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/repo/sshkey/GenerateSshKeyCommand.java b/src/main/java/com/streamx/cli/commands/project/repo/sshkey/GenerateSshKeyCommand.java new file mode 100644 index 00000000..a27693bf --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/repo/sshkey/GenerateSshKeyCommand.java @@ -0,0 +1,96 @@ +package com.streamx.cli.commands.project.repo.sshkey; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectRepositoryApi; +import com.streamx.cli.platform.generated.model.PrivatePublicKeyPair; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermissions; +import picocli.CommandLine; + +@CommandLine.Command( + name = "generate", + header = "Generate a new SSH key pair server-side and save it to files", + description = "The private key is written to (mode 600) and the public key to " + + ".pub; the keys are not printed and not stored on the platform. Pass the " + + "private key to 'ssh-key set' or 'project create --ssh-private-key'; add the " + + "public key to the Git hosting's deploy keys." +) +public class GenerateSshKeyCommand extends AbstractSilentCommand { + + @CommandLine.Parameters( + index = "0", + paramLabel = "", + description = "Where to save the private key; the public key goes to .pub" + ) + public Path keyFile; + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @Override + public CommandResult runCommand() { + orgId = PlatformContext.requireOrg(orgId); + Path publicKeyFile = keyFile.resolveSibling(keyFile.getFileName() + ".pub"); + requireAbsent(keyFile); + requireAbsent(publicKeyFile); + + PrivatePublicKeyPair pair; + try (PlatformClients client = PlatformClients.fromConfig()) { + pair = new ProjectRepositoryApi(client).generateKeyPair(orgId); + } + writePrivateKey(keyFile, pair.getPrivateKey()); + writePublicKey(publicKeyFile, pair.getPublicKey()); + System.out.println( + msg.projectSshKeyPairWritten(keyFile.toString(), publicKeyFile.toString())); + return new CommandResult<>(null); + } + + private static void requireAbsent(Path path) { + if (Files.exists(path)) { + throw new CliException(msg.projectSshKeyFileExists(path.toString())); + } + } + + private static void writePrivateKey(Path path, String key) { + try { + try { + Files.createFile(path, PosixFilePermissions.asFileAttribute( + PosixFilePermissions.fromString("rw-------"))); + } catch (UnsupportedOperationException nonPosix) { + Files.createFile(path); + } + Files.writeString(path, withTrailingNewline(key)); + } catch (IOException e) { + throw new CliException( + msg.projectSshKeyFileWriteFailed(path.toString(), e.getMessage()), e); + } + } + + private static void writePublicKey(Path path, String key) { + try { + Files.writeString(path, withTrailingNewline(key), StandardOpenOption.CREATE_NEW); + } catch (IOException e) { + throw new CliException( + msg.projectSshKeyFileWriteFailed(path.toString(), e.getMessage()), e); + } + } + + private static String withTrailingNewline(String key) { + return key.endsWith("\n") ? key : key + "\n"; + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/repo/sshkey/RemoveSshKeyCommand.java b/src/main/java/com/streamx/cli/commands/project/repo/sshkey/RemoveSshKeyCommand.java new file mode 100644 index 00000000..7b01df20 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/repo/sshkey/RemoveSshKeyCommand.java @@ -0,0 +1,35 @@ +package com.streamx.cli.commands.project.repo.sshkey; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.commands.project.repo.ProjectScopedOptions; +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectRepositoryApi; +import picocli.CommandLine; + +@CommandLine.Command( + name = "remove", + header = "Remove the SSH deploy key from the repository connection" +) +public class RemoveSshKeyCommand extends AbstractSilentCommand { + + @CommandLine.Mixin + ProjectScopedOptions scope; + + @Override + public CommandResult runCommand() { + PlatformContext.OrgProject context = + PlatformContext.orgAndProject(scope.orgId, scope.projectId); + try (PlatformClients client = PlatformClients.fromConfig()) { + new ProjectRepositoryApi(client).removeSshKey(context.org(), context.project()); + } catch (PlatformClients.NotFoundException e) { + throw new CliException(msg.projectSshKeyMissing(context.project()), e); + } + System.out.println(msg.projectSshKeyRemoved(context.project())); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/repo/sshkey/SetSshKeyCommand.java b/src/main/java/com/streamx/cli/commands/project/repo/sshkey/SetSshKeyCommand.java new file mode 100644 index 00000000..65216161 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/repo/sshkey/SetSshKeyCommand.java @@ -0,0 +1,56 @@ +package com.streamx.cli.commands.project.repo.sshkey; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.commands.project.repo.ProjectScopedOptions; +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectRepositoryApi; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import picocli.CommandLine; + +@CommandLine.Command( + name = "set", + header = "Set the SSH private key used to access the repository" +) +public class SetSshKeyCommand extends AbstractSilentCommand { + + @CommandLine.Mixin + ProjectScopedOptions scope; + + @CommandLine.Parameters( + index = "0", + paramLabel = "", + description = "SSH private key file (sent base64-encoded)" + ) + public Path keyFile; + + @Override + public CommandResult runCommand() { + String keyBase64 = readKeyBase64(); + PlatformContext.OrgProject context = + PlatformContext.orgAndProject(scope.orgId, scope.projectId); + try (PlatformClients client = PlatformClients.fromConfig()) { + ProjectRepositoryApi api = new ProjectRepositoryApi(client); + boolean create = !api.sshKeyExists(context.org(), context.project()); + api.setSshKey(context.org(), context.project(), keyBase64, create); + } + System.out.println(msg.projectSshKeySet(context.project())); + return new CommandResult<>(null); + } + + private String readKeyBase64() { + try { + return Base64.getEncoder().encodeToString(Files.readAllBytes(keyFile)); + } catch (IOException e) { + throw new CliException( + msg.projectSshKeyFileUnreadable(keyFile.toString(), e.getMessage()), e); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/repo/sshkey/ShowSshKeyCommand.java b/src/main/java/com/streamx/cli/commands/project/repo/sshkey/ShowSshKeyCommand.java new file mode 100644 index 00000000..6423ef70 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/repo/sshkey/ShowSshKeyCommand.java @@ -0,0 +1,45 @@ +package com.streamx.cli.commands.project.repo.sshkey; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.commands.project.repo.ProjectScopedOptions; +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectRepositoryApi; +import picocli.CommandLine; + +@CommandLine.Command( + name = "show", + header = "Print the public key of the configured SSH deploy key", + description = "Add this public key to the Git hosting's deploy keys. " + + "The private key never leaves the platform." +) +public class ShowSshKeyCommand extends AbstractCommand { + + @CommandLine.Mixin + ProjectScopedOptions scope; + + @Override + public String getTextOutput(CommandResult result) { + return result.getData(); + } + + @Override + public CommandResult runCommand() { + PlatformContext.OrgProject context = + PlatformContext.orgAndProject(scope.orgId, scope.projectId); + try (PlatformClients client = PlatformClients.fromConfig()) { + String publicKey = new ProjectRepositoryApi(client) + .publicKey(context.org(), context.project()); + if (publicKey == null) { + throw new CliException(msg.projectSshKeyMissing(context.project())); + } + return new CommandResult<>(publicKey); + } catch (PlatformClients.NotFoundException e) { + throw new CliException(msg.projectSshKeyMissing(context.project()), e); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/repo/sshkey/SshKeyCommand.java b/src/main/java/com/streamx/cli/commands/project/repo/sshkey/SshKeyCommand.java new file mode 100644 index 00000000..b674695d --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/repo/sshkey/SshKeyCommand.java @@ -0,0 +1,17 @@ +package com.streamx.cli.commands.project.repo.sshkey; + +import com.streamx.cli.framework.AbstractCommandGroup; +import picocli.CommandLine; + +@CommandLine.Command( + name = "ssh-key", + header = "Manage the SSH deploy key of the connected repository", + subcommands = { + SetSshKeyCommand.class, + ShowSshKeyCommand.class, + RemoveSshKeyCommand.class, + GenerateSshKeyCommand.class + } +) +public class SshKeyCommand extends AbstractCommandGroup { +} diff --git a/src/test/java/com/streamx/cli/commands/project/ProjectRepoCommandIT.java b/src/test/java/com/streamx/cli/commands/project/ProjectRepoCommandIT.java new file mode 100644 index 00000000..94f08436 --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/project/ProjectRepoCommandIT.java @@ -0,0 +1,231 @@ +package com.streamx.cli.commands.project; + +import static com.streamx.cli.i18n.MessageProvider.msg; +import static org.assertj.core.api.Assertions.assertThat; + +import com.streamx.cli.platform.PlatformConfig; +import com.streamx.cli.test.CliBaseIT; +import io.quarkus.test.junit.QuarkusTest; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.time.Instant; +import java.util.Base64; +import java.util.Properties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class ProjectRepoCommandIT extends CliBaseIT { + + private static final String ORG = "so-testorg"; + private static final String PROJECT = "so-org-web-a1b2c"; + private static final String REPO_PATH = + "/api/v1/organizations/" + ORG + "/projects/" + PROJECT + "/repository"; + + private StubProjectServer platform; + + private Path getCredentialsPath() { + return streamxHome.resolve("contexts/default/config/credentials.json"); + } + + @BeforeEach + void setUp() throws IOException { + platform = new StubProjectServer(); + + Properties properties = new Properties(); + properties.setProperty(PlatformConfig.STREAMX_PLATFORM_URL, platform.getUrl()); + Path configFile = getConfigPath(); + Files.createDirectories(configFile.getParent()); + try (OutputStream out = Files.newOutputStream(configFile)) { + properties.store(out, null); + } + + Path credentials = getCredentialsPath(); + Files.createDirectories(credentials.getParent()); + Files.writeString(credentials, """ + {"access_token":"test-access-token","refresh_token":"test-refresh-token", + "expires_at":%d,"issuer_url":"http://127.0.0.1:1/realms/streamx", + "client_id":"streamx-cli"} + """.formatted(Instant.now().plusSeconds(300).getEpochSecond())); + } + + @AfterEach + void tearDown() throws IOException { + if (platform != null) { + platform.close(); + } + Files.deleteIfExists(getCredentialsPath()); + Files.deleteIfExists(streamxHome.resolve("contexts/default/current-org")); + Files.deleteIfExists(streamxHome.resolve("contexts/default/current-project")); + } + + private ProcessResult repo(String... args) throws Exception { + String[] base = {"project", "repo"}; + String[] full = new String[base.length + args.length + 4]; + System.arraycopy(base, 0, full, 0, base.length); + System.arraycopy(args, 0, full, base.length, args.length); + full[base.length + args.length] = "--org"; + full[base.length + args.length + 1] = ORG; + full[base.length + args.length + 2] = "--project"; + full[base.length + args.length + 3] = PROJECT; + return exec(full); + } + + @Test + void setConnectsWhenNoRepositoryYet() throws Exception { + platform.returnNoRepository(); + + ProcessResult result = repo("set", "--uri", "git@github.com:acme/web.git", + "--branch", "main"); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("GET " + REPO_PATH, "POST " + REPO_PATH); + assertThat(platform.getRequestBodies().get(1)) + .contains("\"uri\":\"git@github.com:acme/web.git\"") + .contains("\"branch\":\"main\""); + assertThat(result.stdout()).contains(msg.projectRepoConnected(PROJECT)); + } + + @Test + void setUpdatesWhenRepositoryExists() throws Exception { + ProcessResult result = repo("set", "--uri", "git@github.com:acme/web.git", + "--branch", "develop"); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("GET " + REPO_PATH, "PATCH " + REPO_PATH); + assertThat(result.stdout()).contains(msg.projectRepoUpdated(PROJECT)); + } + + @Test + void getShowsRepositoryDetails() throws Exception { + ProcessResult result = repo("get"); + + result.assertSuccess(); + assertThat(result.stdout()) + .contains("uri = git@github.com:acme/web.git") + .contains("branch = main") + .contains("commit = abc1234") + .contains("ready = true"); + } + + @Test + void getFailsCleanlyWithoutRepository() throws Exception { + platform.returnNoRepository(); + + ProcessResult result = repo("get"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains("has no repository connected"); + } + + @Test + void removeDisconnectsRepository() throws Exception { + ProcessResult result = repo("remove"); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly("DELETE " + REPO_PATH); + assertThat(result.stdout()).contains(msg.projectRepoRemoved(PROJECT)); + } + + @Test + void sshKeySetCreatesWhenAbsent() throws Exception { + Path keyFile = streamxHome.resolve("deploy-key"); + Files.writeString(keyFile, "KEY BYTES"); + String expected = Base64.getEncoder() + .encodeToString("KEY BYTES".getBytes(StandardCharsets.UTF_8)); + + ProcessResult result = repo("ssh-key", "set", keyFile.toString()); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly( + "GET " + REPO_PATH + "/ssh-key/exists", + "POST " + REPO_PATH + "/ssh-key"); + assertThat(platform.getRequestBodies().get(1)) + .contains("\"privateKeyBase64\":\"" + expected + "\""); + assertThat(result.stdout()).contains(msg.projectSshKeySet(PROJECT)); + } + + @Test + void sshKeySetReplacesWhenPresent() throws Exception { + platform.sshKeyExists(true); + Path keyFile = streamxHome.resolve("deploy-key"); + Files.writeString(keyFile, "KEY BYTES"); + + ProcessResult result = repo("ssh-key", "set", keyFile.toString()); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly( + "GET " + REPO_PATH + "/ssh-key/exists", + "PATCH " + REPO_PATH + "/ssh-key"); + } + + @Test + void sshKeyShowPrintsPublicKey() throws Exception { + platform.sshKeyExists(true); + + ProcessResult result = repo("ssh-key", "show"); + + result.assertSuccess(); + assertThat(result.stdout()).contains("ssh-ed25519 STORED-PUBLIC"); + } + + @Test + void sshKeyShowFailsCleanlyWithoutKey() throws Exception { + ProcessResult result = repo("ssh-key", "show"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains("has no SSH key configured"); + } + + @Test + void sshKeyRemoveDeletesKey() throws Exception { + platform.sshKeyExists(true); + + ProcessResult result = repo("ssh-key", "remove"); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly("DELETE " + REPO_PATH + "/ssh-key"); + assertThat(result.stdout()).contains(msg.projectSshKeyRemoved(PROJECT)); + } + + @Test + void sshKeyGenerateWritesKeyPairFilesWithoutPrintingKeys() throws Exception { + Path keyFile = streamxHome.resolve("generated-key"); + + ProcessResult result = exec( + "project", "repo", "ssh-key", "generate", keyFile.toString(), "--org", ORG); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly( + "POST /api/v1/organizations/" + ORG + "/projects/repository/ssh-key/generate-key-pair"); + Path publicKeyFile = streamxHome.resolve("generated-key.pub"); + assertThat(keyFile).content().isEqualTo("GENERATED-PRIVATE\n"); + assertThat(publicKeyFile).content().isEqualTo("ssh-ed25519 GENERATED-PUBLIC\n"); + assertThat(Files.getPosixFilePermissions(keyFile)) + .isEqualTo(PosixFilePermissions.fromString("rw-------")); + assertThat(result.stdout()) + .contains(msg.projectSshKeyPairWritten(keyFile.toString(), publicKeyFile.toString())) + .doesNotContain("GENERATED-PRIVATE"); + } + + @Test + void sshKeyGenerateRefusesToOverwriteExistingFile() throws Exception { + Path keyFile = streamxHome.resolve("existing-key"); + Files.writeString(keyFile, "old"); + + ProcessResult result = exec( + "project", "repo", "ssh-key", "generate", keyFile.toString(), "--org", ORG); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.projectSshKeyFileExists(keyFile.toString())); + assertThat(platform.getRequests()).isEmpty(); + assertThat(keyFile).content().isEqualTo("old"); + } +}