From b18929fb26ca66b73e544b153099ddcc20398940 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Wed, 29 Jul 2026 14:44:46 +0300 Subject: [PATCH] STX-211 Project CRUD commands --- .../streamx/cli/commands/StreamxCommand.java | 4 + .../completion/CompleteProjectIdsCommand.java | 45 ++ .../completion/ZshCompletionGenerator.java | 8 + .../cli/commands/context/ContextCommand.java | 2 + .../context/project/ProjectCommand.java | 19 + .../project/current/CurrentCommand.java | 28 ++ .../context/project/unset/UnsetCommand.java | 30 ++ .../context/project/use/UseCommand.java | 33 ++ .../cli/commands/project/ProjectCommand.java | 29 ++ .../project/clusters/ClustersCommand.java | 21 + .../clusters/disable/DisableCommand.java | 76 +++ .../clusters/enable/EnableCommand.java | 77 +++ .../project/clusters/list/ListCommand.java | 82 +++ .../project/clusters/set/SetCommand.java | 76 +++ .../project/create/CreateCommand.java | 115 +++++ .../project/delete/DeleteCommand.java | 57 +++ .../cli/commands/project/get/GetCommand.java | 77 +++ .../commands/project/list/ListCommand.java | 113 +++++ .../pendingchanges/PendingChangesCommand.java | 68 +++ .../project/status/StatusCommand.java | 69 +++ .../project/update/UpdateCommand.java | 65 +++ .../ContextProjectIdCompletionCandidates.java | 11 + .../ProjectIdCompletionCandidates.java | 16 + .../cli/platform/ProjectRepositoryApi.java | 74 +++ .../com/streamx/cli/platform/ProjectView.java | 31 ++ .../com/streamx/cli/platform/ProjectsApi.java | 95 ++++ .../streamx/cli/platform/RepositoryView.java | 16 + .../completion/CompletionCommandIT.java | 17 + .../commands/context/ContextCommandIT.java | 36 ++ .../cli/commands/org/OrgCommandIT.java | 33 ++ .../project/ProjectClustersCommandIT.java | 204 ++++++++ .../commands/project/ProjectCommandIT.java | 469 ++++++++++++++++++ .../commands/project/StubProjectServer.java | 262 ++++++++++ 33 files changed, 2358 insertions(+) create mode 100644 src/main/java/com/streamx/cli/commands/completion/CompleteProjectIdsCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/context/project/ProjectCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/context/project/current/CurrentCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/context/project/unset/UnsetCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/context/project/use/UseCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/ProjectCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/clusters/ClustersCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/clusters/disable/DisableCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/clusters/enable/EnableCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/clusters/list/ListCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/clusters/set/SetCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/create/CreateCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/delete/DeleteCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/get/GetCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/list/ListCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/pendingchanges/PendingChangesCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/status/StatusCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/project/update/UpdateCommand.java create mode 100644 src/main/java/com/streamx/cli/platform/ContextProjectIdCompletionCandidates.java create mode 100644 src/main/java/com/streamx/cli/platform/ProjectIdCompletionCandidates.java create mode 100644 src/main/java/com/streamx/cli/platform/ProjectRepositoryApi.java create mode 100644 src/main/java/com/streamx/cli/platform/ProjectView.java create mode 100644 src/main/java/com/streamx/cli/platform/ProjectsApi.java create mode 100644 src/main/java/com/streamx/cli/platform/RepositoryView.java create mode 100644 src/test/java/com/streamx/cli/commands/project/ProjectClustersCommandIT.java create mode 100644 src/test/java/com/streamx/cli/commands/project/ProjectCommandIT.java create mode 100644 src/test/java/com/streamx/cli/commands/project/StubProjectServer.java diff --git a/src/main/java/com/streamx/cli/commands/StreamxCommand.java b/src/main/java/com/streamx/cli/commands/StreamxCommand.java index 87327263..e45b6b21 100644 --- a/src/main/java/com/streamx/cli/commands/StreamxCommand.java +++ b/src/main/java/com/streamx/cli/commands/StreamxCommand.java @@ -7,6 +7,7 @@ import com.streamx.cli.commands.completion.CompleteNonDefaultTemplateIdsCommand; import com.streamx.cli.commands.completion.CompleteOrgIdsCommand; import com.streamx.cli.commands.completion.CompleteOrgMemberIdsCommand; +import com.streamx.cli.commands.completion.CompleteProjectIdsCommand; import com.streamx.cli.commands.completion.CompleteRegisteredTemplateIdsCommand; import com.streamx.cli.commands.completion.CompleteSettingsKeysCommand; import com.streamx.cli.commands.completion.CompleteSettingsSetKeysCommand; @@ -16,6 +17,7 @@ import com.streamx.cli.commands.info.InfoCommand; import com.streamx.cli.commands.local.LocalCommand; import com.streamx.cli.commands.org.OrgCommand; +import com.streamx.cli.commands.project.ProjectCommand; import com.streamx.cli.commands.publish.PublishCommand; import com.streamx.cli.commands.settings.SettingsCommand; import com.streamx.cli.framework.AbstractCommandGroup; @@ -28,6 +30,7 @@ AuthCommand.class, ContextCommand.class, OrgCommand.class, + ProjectCommand.class, LocalCommand.class, SettingsCommand.class, PublishCommand.class, @@ -40,6 +43,7 @@ CompleteSettingsSetKeysCommand.class, CompleteContextNamesCommand.class, CompleteOrgIdsCommand.class, + CompleteProjectIdsCommand.class, CompleteOrgMemberIdsCommand.class, CompleteInvitedEmailsCommand.class, CompleteClusterIdsCommand.class diff --git a/src/main/java/com/streamx/cli/commands/completion/CompleteProjectIdsCommand.java b/src/main/java/com/streamx/cli/commands/completion/CompleteProjectIdsCommand.java new file mode 100644 index 00000000..faf5af68 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/completion/CompleteProjectIdsCommand.java @@ -0,0 +1,45 @@ +package com.streamx.cli.commands.completion; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectsApi; +import com.streamx.cli.platform.generated.model.Project; +import java.util.List; +import java.util.Objects; +import picocli.CommandLine; + +@CommandLine.Command( + name = "__complete-project-ids", + hidden = true, + header = "Internal: list project IDs of an organization for shell completion" +) +public class CompleteProjectIdsCommand extends AbstractCommand> { + + @CommandLine.Parameters(index = "0", arity = "0..1", description = "Organization ID") + public String orgId; + + @Override + public CommandResult> runCommand() { + String org = orgId == null || orgId.isBlank() || orgId.startsWith("-") + ? PlatformContext.effectiveOrg() + : orgId; + if (org == null) { + return new CommandResult<>(List.of()); + } + try (PlatformClients client = PlatformClients.completion()) { + return new CommandResult<>(new ProjectsApi(client).list(org).stream() + .map(Project::getId) + .filter(Objects::nonNull) + .toList()); + } catch (RuntimeException anyFailure) { + return new CommandResult<>(List.of()); + } + } + + @Override + public String getTextOutput(CommandResult> result) { + return String.join("\n", result.getData()); + } +} diff --git a/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java b/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java index ce88717a..5fbfe4d8 100644 --- a/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java +++ b/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java @@ -7,9 +7,11 @@ import com.streamx.cli.commands.settings.eventtemplates.TemplateIdCompletionCandidates; import com.streamx.cli.config.ContextNameCompletionCandidates; import com.streamx.cli.platform.ClusterIdCompletionCandidates; +import com.streamx.cli.platform.ContextProjectIdCompletionCandidates; import com.streamx.cli.platform.InvitedEmailCompletionCandidates; import com.streamx.cli.platform.OrgIdCompletionCandidates; import com.streamx.cli.platform.OrgMemberIdCompletionCandidates; +import com.streamx.cli.platform.ProjectIdCompletionCandidates; import java.io.File; import java.nio.file.Path; import java.util.ArrayList; @@ -223,6 +225,12 @@ private static String getCompletionAction( if (completionCandidates instanceof OrgIdCompletionCandidates) { return "($(streamx __complete-org-ids 2>/dev/null))"; } + if (completionCandidates instanceof ProjectIdCompletionCandidates) { + return "($(streamx __complete-project-ids " + ORG_FROM_WORDS + " 2>/dev/null))"; + } + if (completionCandidates instanceof ContextProjectIdCompletionCandidates) { + return "($(streamx __complete-project-ids 2>/dev/null))"; + } if (completionCandidates instanceof OrgMemberIdCompletionCandidates) { return "($(streamx __complete-org-member-ids " + ORG_FROM_WORDS + " 2>/dev/null))"; } diff --git a/src/main/java/com/streamx/cli/commands/context/ContextCommand.java b/src/main/java/com/streamx/cli/commands/context/ContextCommand.java index 05240a02..7cf29f7e 100644 --- a/src/main/java/com/streamx/cli/commands/context/ContextCommand.java +++ b/src/main/java/com/streamx/cli/commands/context/ContextCommand.java @@ -5,6 +5,7 @@ import com.streamx.cli.commands.context.delete.DeleteCommand; import com.streamx.cli.commands.context.list.ListCommand; import com.streamx.cli.commands.context.org.OrgCommand; +import com.streamx.cli.commands.context.project.ProjectCommand; import com.streamx.cli.commands.context.use.UseCommand; import com.streamx.cli.framework.AbstractCommandGroup; import picocli.CommandLine; @@ -19,6 +20,7 @@ UseCommand.class, CurrentCommand.class, OrgCommand.class, + ProjectCommand.class, DeleteCommand.class } ) diff --git a/src/main/java/com/streamx/cli/commands/context/project/ProjectCommand.java b/src/main/java/com/streamx/cli/commands/context/project/ProjectCommand.java new file mode 100644 index 00000000..06e66d04 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/project/ProjectCommand.java @@ -0,0 +1,19 @@ +package com.streamx.cli.commands.context.project; + +import com.streamx.cli.commands.context.project.current.CurrentCommand; +import com.streamx.cli.commands.context.project.unset.UnsetCommand; +import com.streamx.cli.commands.context.project.use.UseCommand; +import com.streamx.cli.framework.AbstractCommandGroup; +import picocli.CommandLine; + +@CommandLine.Command( + name = "project", + header = "Manage the current project of the active context", + subcommands = { + UseCommand.class, + CurrentCommand.class, + UnsetCommand.class + } +) +public class ProjectCommand extends AbstractCommandGroup { +} diff --git a/src/main/java/com/streamx/cli/commands/context/project/current/CurrentCommand.java b/src/main/java/com/streamx/cli/commands/context/project/current/CurrentCommand.java new file mode 100644 index 00000000..543da427 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/project/current/CurrentCommand.java @@ -0,0 +1,28 @@ +package com.streamx.cli.commands.context.project.current; + +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.PlatformContext; +import picocli.CommandLine; + +@CommandLine.Command( + name = "current", + header = "Print the current project", + description = "The effective value: STREAMX_PROJECT if set, otherwise the active " + + "context's current-project." +) +public class CurrentCommand extends AbstractSilentCommand { + + @Override + public CommandResult runCommand() { + String project = PlatformContext.effectiveProject(); + if (project == null) { + throw new CliException(msg.noCurrentProject()); + } + System.out.println(project); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/context/project/unset/UnsetCommand.java b/src/main/java/com/streamx/cli/commands/context/project/unset/UnsetCommand.java new file mode 100644 index 00000000..4f61a2d6 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/project/unset/UnsetCommand.java @@ -0,0 +1,30 @@ +package com.streamx.cli.commands.context.project.unset; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.config.StreamxHome; +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import java.io.IOException; +import picocli.CommandLine; + +@CommandLine.Command( + name = "unset", + header = "Clear the current project of the active context", + description = "The current organization is kept. Idempotent. A STREAMX_PROJECT " + + "environment variable is not affected." +) +public class UnsetCommand extends AbstractSilentCommand { + + @Override + public CommandResult runCommand() { + try { + StreamxHome.clearCurrentProject(); + } catch (IOException e) { + throw new CliException(e.getMessage(), e); + } + System.out.println(msg.projectUnset()); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/context/project/use/UseCommand.java b/src/main/java/com/streamx/cli/commands/context/project/use/UseCommand.java new file mode 100644 index 00000000..f35a8daa --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/project/use/UseCommand.java @@ -0,0 +1,33 @@ +package com.streamx.cli.commands.context.project.use; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.ContextProjectIdCompletionCandidates; +import com.streamx.cli.platform.PlatformContext; +import picocli.CommandLine; + +@CommandLine.Command( + name = "use", + header = "Set the current project for the active context", + description = "Commands taking a project fall back to it when the argument is omitted. " + + "Requires a current organization (the project is resolved inside it); " + + "STREAMX_PROJECT overrides the stored value for a single invocation." +) +public class UseCommand extends AbstractSilentCommand { + + @CommandLine.Parameters( + index = "0", + description = "Project ID", + completionCandidates = ContextProjectIdCompletionCandidates.class + ) + public String projectId; + + @Override + public CommandResult runCommand() { + PlatformContext.setCurrentProject(projectId.strip()); + System.out.println(msg.projectUseSet(projectId.strip())); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/ProjectCommand.java b/src/main/java/com/streamx/cli/commands/project/ProjectCommand.java new file mode 100644 index 00000000..9bd0042e --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/ProjectCommand.java @@ -0,0 +1,29 @@ +package com.streamx.cli.commands.project; + +import com.streamx.cli.commands.project.clusters.ClustersCommand; +import com.streamx.cli.commands.project.create.CreateCommand; +import com.streamx.cli.commands.project.delete.DeleteCommand; +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.status.StatusCommand; +import com.streamx.cli.commands.project.update.UpdateCommand; +import com.streamx.cli.framework.AbstractCommandGroup; +import picocli.CommandLine; + +@CommandLine.Command( + name = "project", + header = "Manage StreamX projects", + subcommands = { + ClustersCommand.class, + CreateCommand.class, + DeleteCommand.class, + GetCommand.class, + ListCommand.class, + PendingChangesCommand.class, + StatusCommand.class, + UpdateCommand.class + } +) +public class ProjectCommand extends AbstractCommandGroup { +} diff --git a/src/main/java/com/streamx/cli/commands/project/clusters/ClustersCommand.java b/src/main/java/com/streamx/cli/commands/project/clusters/ClustersCommand.java new file mode 100644 index 00000000..d65eb675 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/clusters/ClustersCommand.java @@ -0,0 +1,21 @@ +package com.streamx.cli.commands.project.clusters; + +import com.streamx.cli.commands.project.clusters.disable.DisableCommand; +import com.streamx.cli.commands.project.clusters.enable.EnableCommand; +import com.streamx.cli.commands.project.clusters.list.ListCommand; +import com.streamx.cli.commands.project.clusters.set.SetCommand; +import com.streamx.cli.framework.AbstractCommandGroup; +import picocli.CommandLine; + +@CommandLine.Command( + name = "clusters", + header = "Manage the clusters a project runs on", + subcommands = { + ListCommand.class, + SetCommand.class, + EnableCommand.class, + DisableCommand.class + } +) +public class ClustersCommand extends AbstractCommandGroup { +} diff --git a/src/main/java/com/streamx/cli/commands/project/clusters/disable/DisableCommand.java b/src/main/java/com/streamx/cli/commands/project/clusters/disable/DisableCommand.java new file mode 100644 index 00000000..993ca4ba --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/clusters/disable/DisableCommand.java @@ -0,0 +1,76 @@ +package com.streamx.cli.commands.project.clusters.disable; + +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.Cluster; +import com.streamx.cli.platform.ClusterIdCompletionCandidates; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationClustersApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectIdCompletionCandidates; +import java.util.List; +import java.util.stream.Collectors; +import picocli.CommandLine; + +@CommandLine.Command( + name = "disable", + header = "Disable one cluster for a project" +) +public class DisableCommand extends AbstractSilentCommand { + + @CommandLine.Parameters( + index = "0", + paramLabel = "", + description = "Cluster ID, as shown by 'streamx project clusters list'", + completionCandidates = ClusterIdCompletionCandidates.class + ) + public String clusterId; + + @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; + + @Override + public CommandResult runCommand() { + PlatformContext.OrgProject context = PlatformContext.orgAndProject(orgId, projectId); + try (PlatformClients client = PlatformClients.fromConfig()) { + OrganizationClustersApi clusters = new OrganizationClustersApi(client); + List current = clusters.listForProject(context.org(), context.project()); + + Cluster target = current.stream() + .filter(cluster -> clusterId.equals(cluster.id())) + .findFirst() + .orElseThrow(() -> new CliException(msg.projectClusterUnknown(clusterId, + current.stream().map(Cluster::id).sorted().collect(Collectors.joining(", "))))); + if (!target.enabled()) { + System.out.println(msg.projectClusterAlreadyDisabled(clusterId, context.project())); + return new CommandResult<>(null); + } + + List enabled = current.stream() + .filter(Cluster::enabled) + .map(Cluster::id) + .filter(id -> !clusterId.equals(id)) + .toList(); + clusters.setForProject(context.org(), context.project(), enabled); + } + System.out.println(msg.projectClusterDisabled(clusterId, context.project())); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/clusters/enable/EnableCommand.java b/src/main/java/com/streamx/cli/commands/project/clusters/enable/EnableCommand.java new file mode 100644 index 00000000..fb437630 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/clusters/enable/EnableCommand.java @@ -0,0 +1,77 @@ +package com.streamx.cli.commands.project.clusters.enable; + +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.Cluster; +import com.streamx.cli.platform.ClusterIdCompletionCandidates; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationClustersApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectIdCompletionCandidates; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import picocli.CommandLine; + +@CommandLine.Command( + name = "enable", + header = "Enable one more cluster for a project" +) +public class EnableCommand extends AbstractSilentCommand { + + @CommandLine.Parameters( + index = "0", + paramLabel = "", + description = "Cluster ID, as shown by 'streamx org clusters list'", + completionCandidates = ClusterIdCompletionCandidates.class + ) + public String clusterId; + + @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; + + @Override + public CommandResult runCommand() { + PlatformContext.OrgProject context = PlatformContext.orgAndProject(orgId, projectId); + try (PlatformClients client = PlatformClients.fromConfig()) { + OrganizationClustersApi clusters = new OrganizationClustersApi(client); + List current = clusters.listForProject(context.org(), context.project()); + + Cluster target = current.stream() + .filter(cluster -> clusterId.equals(cluster.id())) + .findFirst() + .orElseThrow(() -> new CliException(msg.projectClusterUnknown(clusterId, + current.stream().map(Cluster::id).sorted().collect(Collectors.joining(", "))))); + if (target.enabled()) { + System.out.println(msg.projectClusterAlreadyEnabled(clusterId, context.project())); + return new CommandResult<>(null); + } + + List enabled = new ArrayList<>(current.stream() + .filter(Cluster::enabled) + .map(Cluster::id) + .toList()); + enabled.add(clusterId); + clusters.setForProject(context.org(), context.project(), enabled); + } + System.out.println(msg.projectClusterEnabled(clusterId, context.project())); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/clusters/list/ListCommand.java b/src/main/java/com/streamx/cli/commands/project/clusters/list/ListCommand.java new file mode 100644 index 00000000..d97b8c9d --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/clusters/list/ListCommand.java @@ -0,0 +1,82 @@ +package com.streamx.cli.commands.project.clusters.list; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.framework.TextTable; +import com.streamx.cli.platform.Cluster; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationClustersApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectIdCompletionCandidates; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import picocli.CommandLine; + +@CommandLine.Command( + name = "list", + header = "List clusters available to a project and whether they are enabled" +) +public class ListCommand extends AbstractCommand> { + + @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; + + @CommandLine.Option( + names = {"-q", "--quiet"}, + description = "Only display cluster IDs, one per line (for piping to xargs)" + ) + public boolean quiet; + + @Override + public String getTextOutput(CommandResult> result) { + List clusters = result.getData(); + + if (quiet) { + return clusters.stream() + .map(Cluster::id) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + } + + if (clusters.isEmpty()) { + return msg.orgClustersListEmpty(); + } + + return TextTable.render( + List.of("ID", "TYPE", "NAME", "ENABLED"), + clusters.stream() + .map(cluster -> Arrays.asList( + cluster.id(), + cluster.type(), + cluster.name(), + String.valueOf(cluster.enabled()))) + .toList()); + } + + @Override + public CommandResult> runCommand() { + PlatformContext.OrgProject context = PlatformContext.orgAndProject(orgId, projectId); + try (PlatformClients client = PlatformClients.fromConfig()) { + return new CommandResult<>( + new OrganizationClustersApi(client).listForProject(context.org(), context.project())); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/clusters/set/SetCommand.java b/src/main/java/com/streamx/cli/commands/project/clusters/set/SetCommand.java new file mode 100644 index 00000000..0091ee16 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/clusters/set/SetCommand.java @@ -0,0 +1,76 @@ +package com.streamx.cli.commands.project.clusters.set; + +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.Cluster; +import com.streamx.cli.platform.ClusterIdCompletionCandidates; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationClustersApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectIdCompletionCandidates; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import picocli.CommandLine; + +@CommandLine.Command( + name = "set", + header = "Set the full list of clusters a project runs on", + description = "Replaces the project's enabled clusters with exactly the given IDs." +) +public class SetCommand extends AbstractSilentCommand { + + @CommandLine.Parameters( + index = "0..*", + arity = "1..*", + paramLabel = "", + description = "Cluster IDs, as shown by 'streamx org clusters list'", + completionCandidates = ClusterIdCompletionCandidates.class + ) + public List clusterIds; + + @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; + + @Override + public CommandResult runCommand() { + PlatformContext.OrgProject context = PlatformContext.orgAndProject(orgId, projectId); + try (PlatformClients client = PlatformClients.fromConfig()) { + OrganizationClustersApi clusters = new OrganizationClustersApi(client); + + Set available = clusters.listForProject(context.org(), context.project()).stream() + .map(Cluster::id) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + for (String clusterId : clusterIds) { + if (!available.contains(clusterId)) { + throw new CliException(msg.projectClusterUnknown( + clusterId, available.stream().sorted().collect(Collectors.joining(", ")))); + } + } + + clusters.setForProject(context.org(), context.project(), clusterIds); + } + System.out.println( + msg.projectClustersSet(context.project(), String.join(", ", clusterIds))); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/create/CreateCommand.java b/src/main/java/com/streamx/cli/commands/project/create/CreateCommand.java new file mode 100644 index 00000000..b30652c6 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/create/CreateCommand.java @@ -0,0 +1,115 @@ +package com.streamx.cli.commands.project.create; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.ClusterIdCompletionCandidates; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectsApi; +import com.streamx.cli.platform.ProjectsApi.RepositorySettings; +import com.streamx.cli.platform.generated.model.Project; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.List; +import picocli.CommandLine; + +@CommandLine.Command( + name = "create", + header = "Create a project", + description = "Optionally connects a Git repository and enables clusters in the same call. " + + "The endpoint is transactional: if any part fails, nothing is created." +) +public class CreateCommand extends AbstractCommand { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Parameters( + index = "0", + description = "Project name" + ) + public String name; + + @CommandLine.Option( + names = {"-d", "--description"}, + description = "Project description" + ) + public String description; + + @CommandLine.ArgGroup(exclusive = false) + public RepositoryOptions repository; + + public static class RepositoryOptions { + + @CommandLine.Option( + names = "--repository-uri", + required = true, + paramLabel = "", + description = "Git repository URI to connect to the project" + ) + public String uri; + + @CommandLine.Option( + names = "--repository-branch", + required = true, + paramLabel = "", + description = "Git branch the platform deploys from" + ) + public String branch; + + @CommandLine.Option( + names = "--ssh-private-key", + paramLabel = "", + description = "SSH private key file for private repositories (sent base64-encoded)" + ) + public Path sshPrivateKey; + } + + @CommandLine.Option( + names = "--cluster", + paramLabel = "", + description = "Cluster to enable for the project (repeatable)", + completionCandidates = ClusterIdCompletionCandidates.class + ) + public List clusters; + + @Override + public String getTextOutput(CommandResult result) { + Project project = result.getData(); + return msg.projectCreated(name, project.getId()); + } + + @Override + public CommandResult runCommand() { + orgId = PlatformContext.requireOrg(orgId); + RepositorySettings repositorySettings = repository == null ? null + : new RepositorySettings(repository.uri, repository.branch, readKeyBase64()); + try (PlatformClients client = PlatformClients.fromConfig()) { + return new CommandResult<>(new ProjectsApi(client) + .create(orgId, name, description, repositorySettings, clusters)); + } + } + + private String readKeyBase64() { + if (repository.sshPrivateKey == null) { + return null; + } + try { + return Base64.getEncoder().encodeToString(Files.readAllBytes(repository.sshPrivateKey)); + } catch (IOException e) { + throw new CliException( + msg.projectSshKeyFileUnreadable(repository.sshPrivateKey.toString(), e.getMessage()), e); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/delete/DeleteCommand.java b/src/main/java/com/streamx/cli/commands/project/delete/DeleteCommand.java new file mode 100644 index 00000000..7437d216 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/delete/DeleteCommand.java @@ -0,0 +1,57 @@ +package com.streamx.cli.commands.project.delete; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.framework.DeleteConfirmation; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectIdCompletionCandidates; +import com.streamx.cli.platform.ProjectsApi; +import picocli.CommandLine; + +@CommandLine.Command( + name = "delete", + header = "Delete a project", + description = "Asks to type the project ID back as confirmation; " + + "--force deletes without asking." +) +public class DeleteCommand extends AbstractSilentCommand { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Parameters( + index = "0", + arity = "0..1", + description = "Project ID (defaults to the current project)", + completionCandidates = ProjectIdCompletionCandidates.class + ) + public String projectId; + + @CommandLine.Option( + names = {"-f", "--force"}, + description = "Skip the confirmation prompt (required in non-interactive environments)" + ) + public boolean force; + + @Override + public CommandResult runCommand() { + PlatformContext.OrgProject context = PlatformContext.orgAndProject(orgId, projectId); + orgId = context.org(); + projectId = context.project(); + DeleteConfirmation.require(force, projectId); + try (PlatformClients client = PlatformClients.fromConfig()) { + new ProjectsApi(client).delete(orgId, projectId); + } + System.out.println(msg.projectDeleted(projectId)); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/get/GetCommand.java b/src/main/java/com/streamx/cli/commands/project/get/GetCommand.java new file mode 100644 index 00000000..039d2832 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/get/GetCommand.java @@ -0,0 +1,77 @@ +package com.streamx.cli.commands.project.get; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractCommand; +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.ProjectIdCompletionCandidates; +import com.streamx.cli.platform.ProjectView; +import com.streamx.cli.platform.ProjectsApi; +import com.streamx.cli.platform.RepositoryView; +import picocli.CommandLine; + +@CommandLine.Command( + name = "get", + header = "Display a project, its clusters and its repository" +) +public class GetCommand extends AbstractCommand { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Parameters( + index = "0", + arity = "0..1", + description = "Project ID (defaults to the current project)", + completionCandidates = ProjectIdCompletionCandidates.class + ) + public String projectId; + + @Override + public String getTextOutput(CommandResult result) { + ProjectView project = result.getData(); + RepositoryView repository = project.repository(); + return """ + id = %s + name = %s + description = %s + state = %s + clusters = %s + repository = %s + branch = %s + ssh key = %s""" + .formatted( + orDash(project.id()), + orDash(project.name()), + orDash(project.description()), + orDash(project.state()), + project.clusters() == null || project.clusters().isEmpty() + ? "-" : String.join(", ", project.clusters()), + repository == null ? msg.repositoryNotConnected() : orDash(repository.uri()), + repository == null ? "-" : orDash(repository.branch()), + repository != null && repository.sshKeyProvided() + ? msg.sshKeySpecified() : msg.sshKeyNotSpecified()); + } + + private static String orDash(String value) { + return value == null ? "-" : value; + } + + @Override + public CommandResult runCommand() { + PlatformContext.OrgProject context = PlatformContext.orgAndProject(orgId, projectId); + orgId = context.org(); + projectId = context.project(); + try (PlatformClients client = PlatformClients.fromConfig()) { + return new CommandResult<>(new ProjectsApi(client).getDetailed(orgId, projectId)); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/list/ListCommand.java b/src/main/java/com/streamx/cli/commands/project/list/ListCommand.java new file mode 100644 index 00000000..fa58a7e6 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/list/ListCommand.java @@ -0,0 +1,113 @@ +package com.streamx.cli.commands.project.list; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.framework.TextTable; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectView; +import com.streamx.cli.platform.ProjectsApi; +import com.streamx.cli.platform.RepositoryView; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import picocli.CommandLine; + +@CommandLine.Command( + name = "list", + header = "List projects in an organization" +) +public class ListCommand extends AbstractCommand> { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Option( + names = {"-q", "--quiet"}, + description = "Only display project IDs, one per line (for piping to xargs)" + ) + public boolean quiet; + + @CommandLine.Option( + names = "--wide", + description = "Also show each project's clusters and repository " + + "(one extra request per project)" + ) + public boolean wide; + + @Override + public String getTextOutput(CommandResult> result) { + List projects = result.getData(); + + if (quiet) { + return projects.stream() + .map(ProjectView::id) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + } + + if (projects.isEmpty()) { + return msg.projectListEmpty(); + } + + if (wide) { + return TextTable.render( + List.of("ID", "NAME", "STATE", "DESCRIPTION", "CLUSTERS", "REPOSITORY", "SSH KEY"), + projects.stream() + .map(project -> Arrays.asList( + project.id(), project.name(), project.state(), project.description(), + clustersCell(project), repositoryCell(project), sshKeyCell(project))) + .toList()); + } + + return TextTable.render( + List.of("ID", "NAME", "STATE", "DESCRIPTION"), + projects.stream() + .map(project -> Arrays.asList( + project.id(), project.name(), project.state(), project.description())) + .toList()); + } + + private static String clustersCell(ProjectView project) { + return project.clusters() == null || project.clusters().isEmpty() + ? "-" : String.join(", ", project.clusters()); + } + + private static String repositoryCell(ProjectView project) { + RepositoryView repository = project.repository(); + return repository == null || repository.uri() == null ? "-" : repository.uri(); + } + + private static String sshKeyCell(ProjectView project) { + RepositoryView repository = project.repository(); + if (repository == null) { + return "-"; + } + return repository.sshKeyProvided() ? msg.sshKeySpecified() : msg.sshKeyNotSpecified(); + } + + @Override + public CommandResult> runCommand() { + orgId = PlatformContext.requireOrg(orgId); + try (PlatformClients client = PlatformClients.fromConfig()) { + ProjectsApi api = new ProjectsApi(client); + if (wide) { + return new CommandResult<>(api.list(orgId).stream() + .map(project -> api.detailed(orgId, project)) + .toList()); + } + return new CommandResult<>(api.list(orgId).stream() + .map(ProjectView::basic) + .toList()); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/pendingchanges/PendingChangesCommand.java b/src/main/java/com/streamx/cli/commands/project/pendingchanges/PendingChangesCommand.java new file mode 100644 index 00000000..676a42f2 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/pendingchanges/PendingChangesCommand.java @@ -0,0 +1,68 @@ +package com.streamx.cli.commands.project.pendingchanges; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractCommand; +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.ProjectIdCompletionCandidates; +import com.streamx.cli.platform.ProjectsApi; +import com.streamx.cli.platform.generated.model.PendingChange; +import java.util.List; +import picocli.CommandLine; + +@CommandLine.Command( + name = "pending-changes", + header = "List changes waiting to be applied to a project" +) +public class PendingChangesCommand extends AbstractCommand> { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Parameters( + index = "0", + arity = "0..1", + description = "Project ID (defaults to the current project)", + completionCandidates = ProjectIdCompletionCandidates.class + ) + public String projectId; + + @Override + public String getTextOutput(CommandResult> result) { + List changes = result.getData(); + if (changes.isEmpty()) { + return msg.projectPendingChangesEmpty(); + } + + StringBuilder output = new StringBuilder(); + for (PendingChange change : changes) { + if (!output.isEmpty()) { + output.append("\n"); + } + output.append("- ").append(change.getMessage()); + List details = change.getDetails() == null ? List.of() : change.getDetails(); + for (String detail : details) { + output.append("\n ").append(detail); + } + } + return output.toString(); + } + + @Override + public CommandResult> runCommand() { + PlatformContext.OrgProject context = PlatformContext.orgAndProject(orgId, projectId); + orgId = context.org(); + projectId = context.project(); + try (PlatformClients client = PlatformClients.fromConfig()) { + return new CommandResult<>(new ProjectsApi(client).pendingChanges(orgId, projectId)); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/status/StatusCommand.java b/src/main/java/com/streamx/cli/commands/project/status/StatusCommand.java new file mode 100644 index 00000000..c80aed70 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/status/StatusCommand.java @@ -0,0 +1,69 @@ +package com.streamx.cli.commands.project.status; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.framework.TextTable; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectIdCompletionCandidates; +import com.streamx.cli.platform.ProjectsApi; +import com.streamx.cli.platform.generated.model.ProjectStatus; +import java.util.Arrays; +import java.util.List; +import picocli.CommandLine; + +@CommandLine.Command( + name = "status", + header = "Show a project's deployment status" +) +public class StatusCommand extends AbstractCommand { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Parameters( + index = "0", + arity = "0..1", + description = "Project ID (defaults to the current project)", + completionCandidates = ProjectIdCompletionCandidates.class + ) + public String projectId; + + @Override + public String getTextOutput(CommandResult result) { + ProjectStatus status = result.getData(); + StringBuilder output = new StringBuilder("state = " + + orDash(status.getState() == null ? null : status.getState().value())); + + if (status.getStatuses() != null && !status.getStatuses().isEmpty()) { + output.append("\n\n").append(TextTable.render( + List.of("STATE", "REASON", "MESSAGE"), + status.getStatuses().stream() + .map(component -> Arrays.asList( + component.getState() == null ? null : component.getState().value(), + component.getReason(), component.getMessage())) + .toList())); + } + return output.toString(); + } + + private static String orDash(String value) { + return value == null ? "-" : value; + } + + @Override + public CommandResult runCommand() { + PlatformContext.OrgProject context = PlatformContext.orgAndProject(orgId, projectId); + orgId = context.org(); + projectId = context.project(); + try (PlatformClients client = PlatformClients.fromConfig()) { + return new CommandResult<>(new ProjectsApi(client).status(orgId, projectId)); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/project/update/UpdateCommand.java b/src/main/java/com/streamx/cli/commands/project/update/UpdateCommand.java new file mode 100644 index 00000000..311d5b3f --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/project/update/UpdateCommand.java @@ -0,0 +1,65 @@ +package com.streamx.cli.commands.project.update; + +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.ProjectIdCompletionCandidates; +import com.streamx.cli.platform.ProjectsApi; +import com.streamx.cli.platform.generated.model.Project; +import picocli.CommandLine; + +@CommandLine.Command( + name = "update", + header = "Update a project's name or description" +) +public class UpdateCommand extends AbstractSilentCommand { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Parameters( + index = "0", + arity = "0..1", + description = "Project ID (defaults to the current project)", + completionCandidates = ProjectIdCompletionCandidates.class + ) + public String projectId; + + @CommandLine.Option(names = {"-n", "--name"}, description = "New project name") + public String name; + + @CommandLine.Option(names = {"-d", "--description"}, description = "New project description") + public String description; + + @Override + public CommandResult runCommand() { + PlatformContext.OrgProject context = PlatformContext.orgAndProject(orgId, projectId); + orgId = context.org(); + projectId = context.project(); + if (name == null && description == null) { + throw new CliException(msg.projectUpdateNothingToDo()); + } + + try (PlatformClients client = PlatformClients.fromConfig()) { + ProjectsApi projects = new ProjectsApi(client); + + Project current = projects.get(orgId, projectId); + String newName = name != null ? name : current.getName(); + String newDescription = description != null ? description : current.getDescription(); + + projects.update(orgId, projectId, newName, newDescription); + } + System.out.println(msg.projectUpdated(projectId)); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/platform/ContextProjectIdCompletionCandidates.java b/src/main/java/com/streamx/cli/platform/ContextProjectIdCompletionCandidates.java new file mode 100644 index 00000000..fff1588c --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/ContextProjectIdCompletionCandidates.java @@ -0,0 +1,11 @@ +package com.streamx.cli.platform; + +import java.util.Collections; +import java.util.Iterator; + +public class ContextProjectIdCompletionCandidates implements Iterable { + @Override + public Iterator iterator() { + return Collections.emptyIterator(); + } +} diff --git a/src/main/java/com/streamx/cli/platform/ProjectIdCompletionCandidates.java b/src/main/java/com/streamx/cli/platform/ProjectIdCompletionCandidates.java new file mode 100644 index 00000000..3bbe6215 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/ProjectIdCompletionCandidates.java @@ -0,0 +1,16 @@ +package com.streamx.cli.platform; + +import java.util.Collections; +import java.util.Iterator; + +/** + * Marker for dynamic project-ID completion. The zsh completion script resolves the values at + * TAB time via the hidden {@code __complete-project-ids } command, passing the + * organization ID already typed on the command line. + */ +public class ProjectIdCompletionCandidates implements Iterable { + @Override + public Iterator iterator() { + return Collections.emptyIterator(); + } +} diff --git a/src/main/java/com/streamx/cli/platform/ProjectRepositoryApi.java b/src/main/java/com/streamx/cli/platform/ProjectRepositoryApi.java new file mode 100644 index 00000000..e8d66742 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/ProjectRepositoryApi.java @@ -0,0 +1,74 @@ +package com.streamx.cli.platform; + +import com.streamx.cli.platform.generated.api.ProjectRepositoryResourceApi; +import com.streamx.cli.platform.generated.api.ProjectRepositorySshKeyResourceApi; +import com.streamx.cli.platform.generated.api.ProjectsResourceApi; +import com.streamx.cli.platform.generated.model.CreateProjectRepositoryRequest; +import com.streamx.cli.platform.generated.model.PrivateKeyRequest; +import com.streamx.cli.platform.generated.model.PrivatePublicKeyPair; +import com.streamx.cli.platform.generated.model.ProjectRepository; +import com.streamx.cli.platform.generated.model.PublicKey; + +public class ProjectRepositoryApi { + + private final PlatformClients clients; + private final ProjectRepositoryResourceApi api; + private final ProjectRepositorySshKeyResourceApi sshKeyApi; + + public ProjectRepositoryApi(PlatformClients clients) { + this.clients = clients; + this.api = clients.api(ProjectRepositoryResourceApi.class); + this.sshKeyApi = clients.api(ProjectRepositorySshKeyResourceApi.class); + } + + public ProjectRepository get(String orgId, String projectId) { + return clients.call( + () -> api.getProjectRepository(orgId, projectId, null, null), ProjectRepository.class); + } + + public void connect(String orgId, String projectId, String uri, String branch) { + clients.call(() -> api.createProjectRepository(orgId, projectId, + new CreateProjectRepositoryRequest().uri(uri).branch(branch), null, null)); + } + + public void update(String orgId, String projectId, String uri, String branch) { + clients.call(() -> api.updateProjectRepository(orgId, projectId, + new CreateProjectRepositoryRequest().uri(uri).branch(branch), null, null)); + } + + public void disconnect(String orgId, String projectId) { + clients.call(() -> api.deleteProjectRepository(orgId, projectId, null, null)); + } + + public boolean sshKeyExists(String orgId, String projectId) { + return Boolean.TRUE.equals(clients.call( + () -> sshKeyApi.hasProjectRepositorySshKey(orgId, projectId, null, null), Boolean.class)); + } + + public void setSshKey(String orgId, String projectId, String privateKeyBase64, boolean create) { + PrivateKeyRequest body = new PrivateKeyRequest().privateKeyBase64(privateKeyBase64); + if (create) { + clients.call( + () -> sshKeyApi.createProjectRepositorySshKey(orgId, projectId, body, null, null)); + } else { + clients.call( + () -> sshKeyApi.updateProjectRepositorySshKey(orgId, projectId, body, null, null)); + } + } + + public void removeSshKey(String orgId, String projectId) { + clients.call(() -> sshKeyApi.deleteProjectRepositorySshKey(orgId, projectId, null, null)); + } + + public String publicKey(String orgId, String projectId) { + PublicKey key = clients.call( + () -> sshKeyApi.getSshPublicKey(orgId, projectId, null, null), PublicKey.class); + return key == null ? null : key.getPublicKey(); + } + + public PrivatePublicKeyPair generateKeyPair(String orgId) { + ProjectsResourceApi projectsApi = clients.api(ProjectsResourceApi.class); + return clients.call( + () -> projectsApi.generateSshKeyPair(orgId, null, null), PrivatePublicKeyPair.class); + } +} diff --git a/src/main/java/com/streamx/cli/platform/ProjectView.java b/src/main/java/com/streamx/cli/platform/ProjectView.java new file mode 100644 index 00000000..d6961457 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/ProjectView.java @@ -0,0 +1,31 @@ +package com.streamx.cli.platform; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.streamx.cli.platform.generated.model.Project; +import io.quarkus.runtime.annotations.RegisterForReflection; +import java.util.List; + +@RegisterForReflection +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ProjectView( + String id, + String name, + String description, + String state, + List clusters, + RepositoryView repository +) { + + public static ProjectView basic(Project project) { + return new ProjectView( + project.getId(), project.getName(), project.getDescription(), project.getState(), + null, null); + } + + public static ProjectView of( + Project project, List clusters, RepositoryView repository) { + return new ProjectView( + project.getId(), project.getName(), project.getDescription(), project.getState(), + clusters, repository); + } +} diff --git a/src/main/java/com/streamx/cli/platform/ProjectsApi.java b/src/main/java/com/streamx/cli/platform/ProjectsApi.java new file mode 100644 index 00000000..e72bf95c --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/ProjectsApi.java @@ -0,0 +1,95 @@ +package com.streamx.cli.platform; + +import com.streamx.cli.platform.generated.api.PendingChangesResourceApi; +import com.streamx.cli.platform.generated.api.ProjectsResourceApi; +import com.streamx.cli.platform.generated.api.StatusResourceApi; +import com.streamx.cli.platform.generated.model.CreateProjectRequest; +import com.streamx.cli.platform.generated.model.CreateProjectRequestRepository; +import com.streamx.cli.platform.generated.model.PendingChange; +import com.streamx.cli.platform.generated.model.Project; +import com.streamx.cli.platform.generated.model.ProjectRequest; +import com.streamx.cli.platform.generated.model.ProjectStatus; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public class ProjectsApi { + + private final PlatformClients clients; + private final ProjectsResourceApi api; + + public ProjectsApi(PlatformClients clients) { + this.clients = clients; + this.api = clients.api(ProjectsResourceApi.class); + } + + public record RepositorySettings(String uri, String branch, String sshPrivateKeyBase64) { + } + + public List list(String orgId) { + return clients.callList(() -> api.listProjects(orgId, null, null), Project.class).stream() + .sorted(Comparator.comparing(Project::getId, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + public Project get(String orgId, String projectId) { + return clients.call(() -> api.getProject(orgId, projectId, null, null), Project.class); + } + + public ProjectView getDetailed(String orgId, String projectId) { + return detailed(orgId, get(orgId, projectId)); + } + + public ProjectView detailed(String orgId, Project project) { + List clusters = new OrganizationClustersApi(clients) + .listForProject(orgId, project.getId()).stream() + .filter(Cluster::enabled) + .map(Cluster::id) + .filter(Objects::nonNull) + .toList(); + RepositoryView repository = null; + try { + repository = RepositoryView.from( + new ProjectRepositoryApi(clients).get(orgId, project.getId())); + } catch (PlatformClients.NotFoundException notConnected) { + repository = null; + } + return ProjectView.of(project, clusters, repository); + } + + public Project create(String orgId, String name, String description, + RepositorySettings repository, List clusters) { + CreateProjectRequest request = new CreateProjectRequest().name(name).description(description); + if (repository != null) { + request.repository(new CreateProjectRequestRepository() + .uri(repository.uri()) + .branch(repository.branch()) + .sshPrivateKeyBase64(repository.sshPrivateKeyBase64())); + } + if (clusters != null && !clusters.isEmpty()) { + request.clusters(clusters); + } + return clients.call(() -> api.createProject(orgId, request, null, null), Project.class); + } + + public Project update(String orgId, String projectId, String name, String description) { + ProjectRequest request = new ProjectRequest().name(name).description(description); + return clients.call( + () -> api.updateProject(orgId, projectId, request, null, null), Project.class); + } + + public void delete(String orgId, String projectId) { + clients.call(() -> api.deleteProject(orgId, projectId, null, null)); + } + + public ProjectStatus status(String orgId, String projectId) { + StatusResourceApi statusApi = clients.api(StatusResourceApi.class); + return clients.call(() -> statusApi.status(orgId, projectId, null, null), ProjectStatus.class); + } + + public List pendingChanges(String orgId, String projectId) { + PendingChangesResourceApi changesApi = clients.api(PendingChangesResourceApi.class); + return clients.callList( + () -> changesApi.getPendingChanges(orgId, projectId, null, null), PendingChange.class); + } +} diff --git a/src/main/java/com/streamx/cli/platform/RepositoryView.java b/src/main/java/com/streamx/cli/platform/RepositoryView.java new file mode 100644 index 00000000..e31fa112 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/RepositoryView.java @@ -0,0 +1,16 @@ +package com.streamx.cli.platform; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.streamx.cli.platform.generated.model.ProjectRepository; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RepositoryView(String uri, String branch, boolean sshKeyProvided) { + + public static RepositoryView from(ProjectRepository repository) { + return new RepositoryView( + repository.getUri(), repository.getBranch(), + Boolean.TRUE.equals(repository.getSshKeyProvided())); + } +} diff --git a/src/test/java/com/streamx/cli/commands/completion/CompletionCommandIT.java b/src/test/java/com/streamx/cli/commands/completion/CompletionCommandIT.java index be49ac1a..74de8c49 100644 --- a/src/test/java/com/streamx/cli/commands/completion/CompletionCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/completion/CompletionCommandIT.java @@ -53,6 +53,23 @@ void shouldEmitDynamicContextNameCompletion() throws Exception { .contains("$(streamx __complete-context-names 2>/dev/null)"); } + @Test + void shouldEmitDynamicOrgAndProjectIdCompletion() throws Exception { + ProcessResult result = exec("completion", "zsh"); + result.assertSuccess(); + assertThat(result.stdout()) + .contains("$(streamx __complete-org-ids 2>/dev/null)") + .contains("]::($(streamx __complete-org-ids 2>/dev/null))") + .contains( + "$(streamx __complete-project-ids \"${words[${words[(i)--org]}+1]}\" 2>/dev/null)") + .contains( + "$(streamx __complete-org-member-ids \"${words[${words[(i)--org]}+1]}\" 2>/dev/null)") + .contains( + "$(streamx __complete-invited-emails \"${words[${words[(i)--org]}+1]}\" 2>/dev/null)") + .contains( + "$(streamx __complete-cluster-ids \"${words[${words[(i)--org]}+1]}\" 2>/dev/null)"); + } + @Test void shouldHideInternalCompleteTemplateIdsCommandFromZshSubcommands() throws Exception { ProcessResult result = exec("completion", "zsh"); diff --git a/src/test/java/com/streamx/cli/commands/context/ContextCommandIT.java b/src/test/java/com/streamx/cli/commands/context/ContextCommandIT.java index 33f33dba..98007ea2 100644 --- a/src/test/java/com/streamx/cli/commands/context/ContextCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/context/ContextCommandIT.java @@ -261,6 +261,42 @@ void helpHeaderShowsCurrentContext() throws Exception { .contains("Current context: default"); } + @Test + void helpHeaderShowsOrgAndProjectContext() throws Exception { + ProcessResult bareHelp = exec("--help"); + bareHelp.assertSuccess(); + assertThat(bareHelp.stdout()) + .contains("Current context: default") + .contains("Current organization: -") + .contains("Current project: -"); + + exec("context", "org", "use", "acme").assertSuccess(); + exec("context", "project", "use", "acme-shop").assertSuccess(); + + ProcessResult contextHelp = exec("--help"); + contextHelp.assertSuccess(); + String out = contextHelp.stdout(); + assertThat(out) + .contains("Current organization: acme") + .contains("Current project: acme-shop"); + assertThat(out.indexOf("Current context:")) + .isLessThan(out.indexOf("Current organization:")); + assertThat(out.indexOf("Current organization:")) + .isLessThan(out.indexOf("Current project:")); + assertThat(out.indexOf("Current project:")).isLessThan(out.indexOf("Commands:")); + + setEnv("STREAMX_ORG", "globex"); + try { + ProcessResult envHelp = exec("--help"); + envHelp.assertSuccess(); + assertThat(envHelp.stdout()) + .as("header reflects the STREAMX_ORG override") + .contains("Current organization: globex"); + } finally { + clearEnv("STREAMX_ORG"); + } + } + @Test void completeContextNamesListsAllContexts() throws Exception { exec("context", "create", "prod").assertSuccess(); diff --git a/src/test/java/com/streamx/cli/commands/org/OrgCommandIT.java b/src/test/java/com/streamx/cli/commands/org/OrgCommandIT.java index 8f5a84d2..22497ce0 100644 --- a/src/test/java/com/streamx/cli/commands/org/OrgCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/org/OrgCommandIT.java @@ -349,4 +349,37 @@ void membersAddRequiresEmailArgument() throws Exception { assertThat(platform.getRequests()).isEmpty(); } + @Test + void orgUseSwitchClearsCurrentProject() throws Exception { + exec("context", "org", "use", "acme").assertSuccess(); + exec("context", "project", "use", "my-proj").assertSuccess(); + + ProcessResult switched = exec("context", "org", "use", "globex"); + + switched.assertSuccess(); + assertThat(switched.stderr()).contains(msg.orgUseClearedProject("my-proj")); + assertThat(streamxHome.resolve("contexts/default/current-project")).doesNotExist(); + assertThat(exec("context", "project", "current").exitCode()).isEqualTo(1); + } + + @Test + void unsetOrgClearsOrgAndProject() throws Exception { + exec("context", "org", "use", "acme").assertSuccess(); + exec("context", "project", "use", "my-proj").assertSuccess(); + + ProcessResult result = exec("context", "org", "unset"); + + result.assertSuccess(); + assertThat(result.stdout()) + .contains(msg.orgUnset()) + .contains(msg.projectUnset()); + assertThat(streamxHome.resolve("contexts/default/current-org")).doesNotExist(); + assertThat(streamxHome.resolve("contexts/default/current-project")).doesNotExist(); + assertThat(exec("context", "org", "current").exitCode()).isEqualTo(1); + + ProcessResult again = exec("context", "org", "unset"); + again.assertSuccess(); + assertThat(again.stdout()).contains(msg.orgUnset()).doesNotContain(msg.projectUnset()); + } + } diff --git a/src/test/java/com/streamx/cli/commands/project/ProjectClustersCommandIT.java b/src/test/java/com/streamx/cli/commands/project/ProjectClustersCommandIT.java new file mode 100644 index 00000000..9fa73e37 --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/project/ProjectClustersCommandIT.java @@ -0,0 +1,204 @@ +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.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Properties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class ProjectClustersCommandIT extends CliBaseIT { + + private static final String ORG = "so-testorg"; + private static final String PROJECT = "so-org-web-a1b2c"; + private static final String CLUSTERS_PATH = + "/api/v1/organizations/" + ORG + "/projects/" + PROJECT + "/clusters"; + + 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")); + } + + @Test + void shouldListProjectClustersWithEnabledColumn() throws Exception { + ProcessResult result = + exec("project", "clusters", "list", "--org", ORG, "--project", PROJECT); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly("GET " + CLUSTERS_PATH); + assertThat(result.stdout()).contains("ID", "TYPE", "NAME", "ENABLED"); + assertThat(result.stdout()).contains("processing-eu-central", "true"); + assertThat(result.stdout()).contains("edge-us-east", "false"); + } + + @Test + void shouldListProjectClustersFromContext() throws Exception { + exec("context", "org", "use", ORG).assertSuccess(); + exec("context", "project", "use", PROJECT).assertSuccess(); + + ProcessResult result = exec("project", "clusters", "list", "-q"); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly("GET " + CLUSTERS_PATH); + assertThat(result.stdout()).isEqualTo("processing-eu-central\nedge-us-east\n"); + } + + @Test + void shouldSetProjectClustersAfterValidatingAgainstAvailable() throws Exception { + ProcessResult result = exec("project", "clusters", "set", + "processing-eu-central", "edge-us-east", "--org", ORG, "--project", PROJECT); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly( + "GET " + CLUSTERS_PATH, + "PATCH " + CLUSTERS_PATH); + assertThat(platform.getRequestBodies().get(1)) + .isEqualTo("[\"processing-eu-central\",\"edge-us-east\"]"); + assertThat(result.stdout()).contains( + msg.projectClustersSet(PROJECT, "processing-eu-central, edge-us-east")); + } + + @Test + void shouldRejectSettingUnknownCluster() throws Exception { + ProcessResult result = exec("project", "clusters", "set", + "nope-cluster", "--org", ORG, "--project", PROJECT); + + result.assertExitCode(1); + assertThat(result.stderr()).contains( + msg.projectClusterUnknown("nope-cluster", "edge-us-east, processing-eu-central")); + assertThat(platform.getRequests()).containsExactly("GET " + CLUSTERS_PATH); + } + + @Test + void shouldRequireAtLeastOneClusterForSet() throws Exception { + ProcessResult result = + exec("project", "clusters", "set", "--org", ORG, "--project", PROJECT); + + result.assertExitCode(2); + assertThat(result.stderr()).contains(""); + assertThat(platform.getRequests()).isEmpty(); + } + + @Test + void shouldEnableDisabledCluster() throws Exception { + ProcessResult result = exec("project", "clusters", "enable", + "edge-us-east", "--org", ORG, "--project", PROJECT); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly( + "GET " + CLUSTERS_PATH, + "PATCH " + CLUSTERS_PATH); + assertThat(platform.getRequestBodies().get(1)) + .isEqualTo("[\"processing-eu-central\",\"edge-us-east\"]"); + assertThat(result.stdout()).contains(msg.projectClusterEnabled("edge-us-east", PROJECT)); + } + + @Test + void shouldNotPatchWhenClusterAlreadyEnabled() throws Exception { + ProcessResult result = exec("project", "clusters", "enable", + "processing-eu-central", "--org", ORG, "--project", PROJECT); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly("GET " + CLUSTERS_PATH); + assertThat(result.stdout()) + .contains(msg.projectClusterAlreadyEnabled("processing-eu-central", PROJECT)); + } + + @Test + void shouldDisableEnabledCluster() throws Exception { + ProcessResult result = exec("project", "clusters", "disable", + "processing-eu-central", "--org", ORG, "--project", PROJECT); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly( + "GET " + CLUSTERS_PATH, + "PATCH " + CLUSTERS_PATH); + assertThat(platform.getRequestBodies().get(1)).isEqualTo("[]"); + assertThat(result.stdout()) + .contains(msg.projectClusterDisabled("processing-eu-central", PROJECT)); + } + + @Test + void shouldNotPatchWhenClusterAlreadyDisabled() throws Exception { + ProcessResult result = exec("project", "clusters", "disable", + "edge-us-east", "--org", ORG, "--project", PROJECT); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly("GET " + CLUSTERS_PATH); + assertThat(result.stdout()) + .contains(msg.projectClusterAlreadyDisabled("edge-us-east", PROJECT)); + } + + @Test + void shouldRejectEnablingUnknownCluster() throws Exception { + ProcessResult result = exec("project", "clusters", "enable", + "nope-cluster", "--org", ORG, "--project", PROJECT); + + result.assertExitCode(1); + assertThat(result.stderr()).contains( + msg.projectClusterUnknown("nope-cluster", "edge-us-east, processing-eu-central")); + assertThat(platform.getRequests()).containsExactly("GET " + CLUSTERS_PATH); + } + + @Test + void completeClusterIdsListsOrgClusters() throws Exception { + ProcessResult result = exec("__complete-cluster-ids", ORG); + + result.assertSuccess(); + assertThat(result.stdout().strip().lines()) + .containsExactly("edge-us-east", "processing-eu-central"); + } + + @Test + void completeClusterIdsFallsBackToCurrentOrg() throws Exception { + exec("context", "org", "use", ORG).assertSuccess(); + + ProcessResult result = exec("__complete-cluster-ids"); + + result.assertSuccess(); + assertThat(result.stdout().strip().lines()) + .containsExactly("edge-us-east", "processing-eu-central"); + } +} diff --git a/src/test/java/com/streamx/cli/commands/project/ProjectCommandIT.java b/src/test/java/com/streamx/cli/commands/project/ProjectCommandIT.java new file mode 100644 index 00000000..8e52b5a2 --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/project/ProjectCommandIT.java @@ -0,0 +1,469 @@ +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.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Properties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class ProjectCommandIT extends CliBaseIT { + + private static final String ORG = "so-testorg"; + + 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")); + } + + @Test + void shouldListProjects() throws Exception { + ProcessResult result = exec("project", "list", "--org", ORG); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("GET /api/v1/organizations/" + ORG + "/projects"); + assertThat(result.stdout()).contains("ID", "NAME", "STATE", "DESCRIPTION"); + assertThat(result.stdout()).contains("so-org-web-a1b2c", "web", "Ready"); + assertThat(result.stdout()).contains("so-org-api-d3e4f", "api", "Pending"); + assertThat(result.stdout()).doesNotContain("CLUSTERS", "REPOSITORY", "SSH KEY"); + } + + @Test + void shouldListProjectsWideWithClustersAndRepository() throws Exception { + ProcessResult result = exec("project", "list", "--org", ORG, "--wide"); + + result.assertSuccess(); + String api = "/api/v1/organizations/" + ORG + "/projects/so-org-api-d3e4f"; + String web = "/api/v1/organizations/" + ORG + "/projects/so-org-web-a1b2c"; + assertThat(platform.getRequests()).containsExactly( + "GET /api/v1/organizations/" + ORG + "/projects", + "GET " + api + "/clusters", "GET " + api + "/repository", + "GET " + web + "/clusters", "GET " + web + "/repository"); + assertThat(result.stdout()) + .contains("CLUSTERS", "REPOSITORY", "SSH KEY") + .contains("processing-eu-central") + .contains("git@github.com:acme/web.git") + .contains("not specified"); + } + + @Test + void wideListShowsDashesWhenNoClustersOrRepository() throws Exception { + platform.returnNoClusters(); + platform.returnNoRepository(); + + ProcessResult result = exec("project", "list", "--org", ORG, "--wide"); + + result.assertSuccess(); + assertThat(result.stdout()).contains("CLUSTERS", "REPOSITORY", "SSH KEY"); + } + + @Test + void completeProjectIdsListsIdsOfTheGivenOrg() throws Exception { + ProcessResult result = exec("__complete-project-ids", ORG); + + result.assertSuccess(); + assertThat(result.stdout().strip().lines()) + .containsExactly("so-org-api-d3e4f", "so-org-web-a1b2c"); + } + + @Test + void completeProjectIdsIsSilentWithoutOrgArgument() throws Exception { + ProcessResult result = exec("__complete-project-ids"); + + result.assertSuccess(); + assertThat(result.stdout().strip()).isEmpty(); + } + + @Test + void shouldListOnlyProjectIdsWhenQuiet() throws Exception { + ProcessResult result = exec("project", "list", "--org", ORG, "-q"); + + result.assertSuccess(); + assertThat(result.stdout()).isEqualTo("so-org-api-d3e4f\nso-org-web-a1b2c\n"); + } + + @Test + void shouldPrintNothingWhenQuietAndNoProjects() throws Exception { + platform.returnNoProjects(); + + ProcessResult result = exec("project", "list", "--org", ORG, "-q"); + + result.assertSuccess(); + assertThat(result.stdout()).isEmpty(); + } + + @Test + void shouldGetProjectWithClustersAndRepository() throws Exception { + ProcessResult result = exec("project", "get", "so-org-web-a1b2c", "--org", ORG); + + result.assertSuccess(); + String base = "/api/v1/organizations/" + ORG + "/projects/so-org-web-a1b2c"; + assertThat(platform.getRequests()) + .containsExactly("GET " + base, "GET " + base + "/clusters", "GET " + base + "/repository"); + assertThat(result.stdout()) + .contains("id = so-org-web-a1b2c") + .contains("clusters = processing-eu-central") + .contains("repository = git@github.com:acme/web.git") + .contains("branch = main") + .contains("ssh key = not specified"); + } + + @Test + void shouldGetProjectShowingSshKeySpecifiedWhenPresent() throws Exception { + platform.sshKeyExists(true); + + ProcessResult result = exec("project", "get", "so-org-web-a1b2c", "--org", ORG); + + result.assertSuccess(); + assertThat(result.stdout()).contains("ssh key = specified"); + } + + @Test + void shouldGetProjectShowingNotConnectedWhenNoRepository() throws Exception { + platform.returnNoRepository(); + + ProcessResult result = exec("project", "get", "so-org-web-a1b2c", "--org", ORG); + + result.assertSuccess(); + assertThat(result.stdout()) + .contains("repository = not connected") + .contains("branch = -") + .contains("ssh key = not specified"); + } + + /** The id is server-derived, so create must report the id from the response, not the name. */ + @Test + void shouldCreateProjectAndReportServerDerivedId() throws Exception { + ProcessResult result = + exec("project", "create", "web", "--org", ORG, "--description", "Frontend"); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("POST /api/v1/organizations/" + ORG + "/projects"); + assertThat(platform.getRequestBodies().get(0)) + .contains("\"name\":\"web\"", "\"description\":\"Frontend\""); + assertThat(result.stdout()).contains(msg.projectCreated("web", "so-web")); + } + + @Test + void shouldCreateProjectWithRepositoryAndClusters() throws Exception { + Path keyFile = streamxHome.resolve("test-deploy-key"); + Files.writeString(keyFile, "PRIVATE KEY BYTES"); + String expectedKey = java.util.Base64.getEncoder() + .encodeToString("PRIVATE KEY BYTES".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + ProcessResult result = exec("project", "create", "web", "--org", ORG, + "--description", "Frontend", + "--repository-uri", "git@github.com:acme/web.git", + "--repository-branch", "main", + "--ssh-private-key", keyFile.toString(), + "--cluster", "eu-central", "--cluster", "us-east"); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("POST /api/v1/organizations/" + ORG + "/projects"); + assertThat(platform.getRequestBodies().get(0)) + .contains("\"name\":\"web\"") + .contains("\"description\":\"Frontend\"") + .contains("\"uri\":\"git@github.com:acme/web.git\"") + .contains("\"branch\":\"main\"") + .contains("\"sshPrivateKeyBase64\":\"" + expectedKey + "\"") + .contains("\"clusters\":[\"eu-central\",\"us-east\"]"); + } + + /** The server requires branch whenever a repository is connected; the CLI enforces it early. */ + @Test + void shouldRequireBranchWhenRepositoryUriIsGiven() throws Exception { + ProcessResult result = exec("project", "create", "web", "--org", ORG, + "--repository-uri", "git@github.com:acme/web.git"); + + result.assertExitCode(2); + assertThat(result.stderr()).contains("--repository-branch"); + assertThat(platform.getRequests()).isEmpty(); + } + + @Test + void shouldFailWhenSshKeyFileIsUnreadable() throws Exception { + ProcessResult result = exec("project", "create", "web", "--org", ORG, + "--repository-uri", "git@github.com:acme/web.git", + "--repository-branch", "main", + "--ssh-private-key", streamxHome.resolve("nope-key").toString()); + + result.assertExitCode(1); + assertThat(result.stderr()).contains("Could not read SSH private key file"); + assertThat(platform.getRequests()).isEmpty(); + } + + @Test + void shouldCreateProjectAsJson() throws Exception { + ProcessResult result = exec("project", "create", "web", "--org", ORG, "--output", "json"); + + result.assertSuccess(); + assertThat(result.stdout()).contains("\"id\" : \"so-web\"", "\"name\" : \"web\""); + } + + /** update is a partial patch: the unspecified field is read back from the current project. */ + @Test + void shouldUpdateOnlyDescriptionKeepingCurrentName() throws Exception { + ProcessResult result = + exec("project", "update", "so-org-web-a1b2c", "--org", ORG, "--description", "New desc"); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly( + "GET /api/v1/organizations/" + ORG + "/projects/so-org-web-a1b2c", + "PATCH /api/v1/organizations/" + ORG + "/projects/so-org-web-a1b2c"); + assertThat(platform.getRequestBodies().get(1)) + .contains("\"name\":\"web\"", "\"description\":\"New desc\""); + assertThat(result.stdout()).contains(msg.projectUpdated("so-org-web-a1b2c")); + } + + @Test + void shouldFailUpdateWhenNothingGiven() throws Exception { + ProcessResult result = exec("project", "update", "so-org-web-a1b2c", "--org", ORG); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.projectUpdateNothingToDo()); + assertThat(platform.getRequests()).isEmpty(); + } + + @Test + void shouldDeleteProjectAfterTypedConfirmation() throws Exception { + ProcessResult result = + execWithStdin("so-org-web-a1b2c\n", "project", "delete", "so-org-web-a1b2c", "--org", ORG); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("DELETE /api/v1/organizations/" + ORG + "/projects/so-org-web-a1b2c"); + assertThat(result.stdout()).contains(msg.projectDeleted("so-org-web-a1b2c")); + } + + @Test + void shouldDeleteProjectWithForceWithoutPrompting() throws Exception { + ProcessResult result = exec("project", "delete", "-f", "so-org-web-a1b2c", "--org", ORG); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("DELETE /api/v1/organizations/" + ORG + "/projects/so-org-web-a1b2c"); + } + + @Test + void shouldCancelProjectDeletionWhenConfirmationDoesNotMatch() throws Exception { + // Typing the ORG id instead of the project id must not confirm. + ProcessResult result = + execWithStdin(ORG + "\n", "project", "delete", "so-org-web-a1b2c", "--org", ORG); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.deleteConfirmMismatch("so-org-web-a1b2c")); + assertThat(platform.getRequests()).isEmpty(); + } + + @Test + void shouldShowProjectStatusWithComponents() throws Exception { + ProcessResult result = exec("project", "status", "so-org-web-a1b2c", "--org", ORG); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("GET /api/v1/organizations/" + ORG + "/projects/so-org-web-a1b2c/status"); + assertThat(result.stdout()).contains("state = Pending"); + assertThat(result.stdout()).contains("Ready", "Deployed", "web is running"); + } + + @Test + void shouldListPendingChanges() throws Exception { + ProcessResult result = exec("project", "pending-changes", "so-org-web-a1b2c", "--org", ORG); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly( + "GET /api/v1/organizations/" + ORG + "/projects/so-org-web-a1b2c/changes/pending"); + assertThat(result.stdout()).contains("web will be created", "image: nginx", "replicas: 2"); + } + + @Test + void shouldReportNoPendingChanges() throws Exception { + platform.failWith(200, "[]"); + + ProcessResult result = exec("project", "pending-changes", "so-org-web-a1b2c", "--org", ORG); + + result.assertSuccess(); + assertThat(result.stdout()).contains(msg.projectPendingChangesEmpty()); + } + + /** + * A crafted project id must stay one path segment. Unencoded, "so-x/status" would route to the + * status endpoint instead of delete; the raw path is asserted because getPath() decodes it. + */ + @Test + void shouldEncodeProjectIdIntoASinglePathSegment() throws Exception { + exec("project", "delete", "-f", "so-x/status", "--org", ORG); + + assertThat(platform.getRawRequests()).containsExactly( + "DELETE /api/v1/organizations/" + ORG + "/projects/so-x%2Fstatus"); + } + + @Test + void projectUseRequiresCurrentOrg() throws Exception { + ProcessResult result = exec("context", "project", "use", "so-org-web-a1b2c"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.noCurrentOrg()); + } + + @Test + void projectUseCurrentLifecycle() throws Exception { + exec("context", "org", "use", ORG).assertSuccess(); + + ProcessResult use = exec("context", "project", "use", "so-org-web-a1b2c"); + use.assertSuccess(); + assertThat(use.stdout()).contains(msg.projectUseSet("so-org-web-a1b2c")); + + ProcessResult current = exec("context", "project", "current"); + current.assertSuccess(); + assertThat(current.stdout().strip()).isEqualTo("so-org-web-a1b2c"); + } + + @Test + void projectGetFallsBackToFullContext() throws Exception { + exec("context", "org", "use", ORG).assertSuccess(); + exec("context", "project", "use", "so-org-web-a1b2c").assertSuccess(); + + ProcessResult result = exec("project", "get"); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .first().isEqualTo("GET /api/v1/organizations/" + ORG + "/projects/so-org-web-a1b2c"); + } + + @Test + void projectGetUsesPositionalProjectWithContextOrg() throws Exception { + exec("context", "org", "use", ORG).assertSuccess(); + + ProcessResult result = exec("project", "get", "so-org-web-a1b2c"); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .first().isEqualTo("GET /api/v1/organizations/" + ORG + "/projects/so-org-web-a1b2c"); + } + + @Test + void projectListRejectsPositionalArgument() throws Exception { + ProcessResult result = exec("project", "list", ORG); + + result.assertExitCode(2); + assertThat(result.stderr()).contains("Unmatched argument"); + assertThat(platform.getRequests()).isEmpty(); + } + + @Test + void projectCreateRequiresNameArgument() throws Exception { + ProcessResult result = exec("project", "create", "--org", ORG); + + result.assertExitCode(2); + assertThat(result.stderr()).contains(""); + assertThat(platform.getRequests()).isEmpty(); + } + + @Test + void projectListFallsBackToCurrentOrg() throws Exception { + exec("context", "org", "use", ORG).assertSuccess(); + + ProcessResult result = exec("project", "list"); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("GET /api/v1/organizations/" + ORG + "/projects"); + } + + @Test + void projectGetFailsWithoutProjectContext() throws Exception { + exec("context", "org", "use", ORG).assertSuccess(); + + ProcessResult result = exec("project", "get"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.noProjectContext()); + assertThat(platform.getRequests()).isEmpty(); + } + + @Test + void envVarOverridesCurrentProjectFile() throws Exception { + exec("context", "org", "use", ORG).assertSuccess(); + exec("context", "project", "use", "so-org-web-a1b2c").assertSuccess(); + setEnv("STREAMX_PROJECT", "so-org-api-d3e4f"); + try { + ProcessResult current = exec("context", "project", "current"); + current.assertSuccess(); + assertThat(current.stdout().strip()).isEqualTo("so-org-api-d3e4f"); + } finally { + clearEnv("STREAMX_PROJECT"); + } + } + + @Test + void completeProjectIdsFallsBackToCurrentOrg() throws Exception { + exec("context", "org", "use", ORG).assertSuccess(); + + ProcessResult result = exec("__complete-project-ids"); + + result.assertSuccess(); + assertThat(result.stdout().strip().lines()) + .containsExactly("so-org-api-d3e4f", "so-org-web-a1b2c"); + } + + @Test + void unsetProjectKeepsOrg() throws Exception { + exec("context", "org", "use", ORG).assertSuccess(); + exec("context", "project", "use", "so-org-web-a1b2c").assertSuccess(); + + ProcessResult result = exec("context", "project", "unset"); + + result.assertSuccess(); + assertThat(result.stdout()).contains(msg.projectUnset()); + assertThat(streamxHome.resolve("contexts/default/current-project")).doesNotExist(); + assertThat(exec("context", "org", "current").stdout().strip()).isEqualTo(ORG); + assertThat(exec("context", "project", "current").exitCode()).isEqualTo(1); + } + +} diff --git a/src/test/java/com/streamx/cli/commands/project/StubProjectServer.java b/src/test/java/com/streamx/cli/commands/project/StubProjectServer.java new file mode 100644 index 00000000..f949caef --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/project/StubProjectServer.java @@ -0,0 +1,262 @@ +package com.streamx.cli.commands.project; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** Stand-in for the Projects Resource, so project commands can be tested without a deployment. */ +public class StubProjectServer implements AutoCloseable { + + private final HttpServer server; + private final List requests = new ArrayList<>(); + private final List rawRequests = new ArrayList<>(); + private final List requestBodies = new ArrayList<>(); + + private volatile int forcedStatus; + private volatile String forcedBody = ""; + private volatile boolean empty; + private volatile boolean repositoryConnected = true; + private volatile boolean sshKeyExists; + private volatile boolean noClusters; + + public StubProjectServer() throws IOException { + this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/v1/organizations", this::route); + server.start(); + } + + public String getUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + public List getRequests() { + return requests; + } + + public List getRawRequests() { + return rawRequests; + } + + public List getRequestBodies() { + return requestBodies; + } + + public void failWith(int status, String body) { + this.forcedStatus = status; + this.forcedBody = body; + } + + public void returnNoProjects() { + this.empty = true; + } + + public void returnNoClusters() { + this.noClusters = true; + } + + public void returnNoRepository() { + this.repositoryConnected = false; + } + + public void sshKeyExists(boolean exists) { + this.sshKeyExists = exists; + } + + private void route(HttpExchange exchange) throws IOException { + String method = exchange.getRequestMethod(); + String path = exchange.getRequestURI().getPath(); + String body = new String(readBody(exchange), StandardCharsets.UTF_8); + requests.add(method + " " + path); + rawRequests.add(method + " " + exchange.getRequestURI().getRawPath()); + requestBodies.add(body); + + if (forcedStatus != 0) { + respond(exchange, forcedStatus, forcedBody); + return; + } + + if (path.contains("/repository")) { + handleRepository(exchange, method, path); + } else if (path.endsWith("/status")) { + respond(exchange, 200, """ + {"state":"Pending","statuses":[ + {"state":"Ready","reason":"Deployed","message":"web is running"}, + {"state":"Pending","reason":"Scaling","message":"api starting"} + ]} + """); + } else if (path.endsWith("/changes/pending")) { + respond(exchange, 200, """ + [{"message":"web will be created","details":["image: nginx","replicas: 2"]}] + """); + } else if (path.endsWith("/clusters")) { + handleClusters(exchange, method); + } else if (path.endsWith("/projects")) { + handleCollection(exchange, method, body); + } else { + handleItem(exchange, method, body); + } + } + + private void handleCollection(HttpExchange exchange, String method, String body) + throws IOException { + if ("POST".equals(method)) { + String name = extract(body, "name"); + // The id is server-derived; the CLI must read it from the response, so it differs from name. + respond(exchange, 201, + project("so-" + name.replace("-", ""), name, extract(body, "description"), "Pending")); + } else if (empty) { + respond(exchange, 200, "[]"); + } else { + respond(exchange, 200, """ + [ + %s, + %s + ] + """.formatted( + project("so-org-web-a1b2c", "web", "Frontend", "Ready"), + project("so-org-api-d3e4f", "api", "Backend", "Pending"))); + } + } + + private void handleRepository(HttpExchange exchange, String method, String path) + throws IOException { + if (path.endsWith("/repository/ssh-key/generate-key-pair")) { + respond(exchange, 200, + "{\"privateKey\":\"GENERATED-PRIVATE\",\"publicKey\":\"ssh-ed25519 GENERATED-PUBLIC\"}"); + } else if (path.endsWith("/repository/ssh-key/exists")) { + respond(exchange, 200, String.valueOf(sshKeyExists)); + } else if (path.endsWith("/repository/ssh-key/public-key")) { + if (sshKeyExists) { + respond(exchange, 200, "{\"publicKey\":\"ssh-ed25519 STORED-PUBLIC\"}"); + } else { + respond(exchange, 404, "{\"errorCode\":\"NOT_FOUND\",\"message\":\"no key\"}"); + } + } else if (path.endsWith("/repository/ssh-key")) { + switch (method) { + case "POST", "PATCH" -> { + sshKeyExists = true; + respond(exchange, 200, "{}"); + } + case "DELETE" -> { + if (sshKeyExists) { + sshKeyExists = false; + respond(exchange, 204, ""); + } else { + respond(exchange, 404, "{\"errorCode\":\"NOT_FOUND\",\"message\":\"no key\"}"); + } + } + default -> respond(exchange, 405, ""); + } + } else { + switch (method) { + case "GET" -> { + if (repositoryConnected) { + respond(exchange, 200, """ + {"name":"web","uri":"git@github.com:acme/web.git","branch":"main", + "commitId":"abc1234","lastSynced":1784800000, + "projectRepositoryStatus":{"ready":true,"errorMessages":[]}, + "sshKeyProvided":%s} + """.formatted(sshKeyExists)); + } else { + respond(exchange, 404, "{\"errorCode\":\"NOT_FOUND\",\"message\":\"no repository\"}"); + } + } + case "POST" -> { + repositoryConnected = true; + respond(exchange, 201, ""); + } + case "PATCH" -> { + if (repositoryConnected) { + respond(exchange, 200, "{}"); + } else { + respond(exchange, 404, "{\"errorCode\":\"NOT_FOUND\",\"message\":\"no repository\"}"); + } + } + case "DELETE" -> { + if (repositoryConnected) { + repositoryConnected = false; + respond(exchange, 204, ""); + } else { + respond(exchange, 404, "{\"errorCode\":\"NOT_FOUND\",\"message\":\"no repository\"}"); + } + } + default -> respond(exchange, 405, ""); + } + } + } + + private void handleClusters(HttpExchange exchange, String method) throws IOException { + if ("PATCH".equals(method)) { + respond(exchange, 200, "{}"); + return; + } + if (noClusters) { + respond(exchange, 200, "{\"processing\":[],\"edge\":[]}"); + return; + } + respond(exchange, 200, """ + { + "processing": [ + {"id":"processing-eu-central","enabled":true,"name":"EU Central", + "location":{"latitude":50.1,"longitude":8.6}} + ], + "edge": [ + {"id":"edge-us-east","enabled":false,"name":"US East", + "location":{"latitude":40.7,"longitude":-74.0}} + ] + } + """); + } + + private void handleItem(HttpExchange exchange, String method, String body) throws IOException { + String id = exchange.getRequestURI().getPath() + .replaceAll(".*/projects/", "").replaceAll("/.*", ""); + switch (method) { + case "GET" -> respond(exchange, 200, project(id, "web", "Frontend", "Ready")); + case "PATCH" -> respond(exchange, 200, + project(id, extract(body, "name"), extract(body, "description"), "Ready")); + case "DELETE" -> respond(exchange, 204, ""); + default -> respond(exchange, 405, ""); + } + } + + private static String project(String id, String name, String description, String state) { + return "{\"id\":\"%s\",\"name\":\"%s\",\"description\":\"%s\",\"state\":\"%s\"}" + .formatted(id, name, description, state); + } + + private static String extract(String body, String field) { + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("\"" + field + "\"\\s*:\\s*\"([^\"]*)\"") + .matcher(body); + return matcher.find() ? matcher.group(1) : ""; + } + + private static byte[] readBody(HttpExchange exchange) throws IOException { + try (InputStream in = exchange.getRequestBody()) { + return in.readAllBytes(); + } + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + if (bytes.length == 0) { + exchange.sendResponseHeaders(status, -1); + } else { + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + } + exchange.close(); + } + + @Override + public void close() { + server.stop(0); + } +}