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 7cf29f7..d495b6e 100644 --- a/src/main/java/com/streamx/cli/commands/context/ContextCommand.java +++ b/src/main/java/com/streamx/cli/commands/context/ContextCommand.java @@ -1,5 +1,6 @@ package com.streamx.cli.commands.context; +import com.streamx.cli.commands.context.configure.ConfigureCommand; import com.streamx.cli.commands.context.create.CreateCommand; import com.streamx.cli.commands.context.current.CurrentCommand; import com.streamx.cli.commands.context.delete.DeleteCommand; @@ -17,6 +18,7 @@ subcommands = { ListCommand.class, CreateCommand.class, + ConfigureCommand.class, UseCommand.class, CurrentCommand.class, OrgCommand.class, diff --git a/src/main/java/com/streamx/cli/commands/context/configure/ConfigureCommand.java b/src/main/java/com/streamx/cli/commands/context/configure/ConfigureCommand.java new file mode 100644 index 0000000..fad4b40 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/configure/ConfigureCommand.java @@ -0,0 +1,218 @@ +package com.streamx.cli.commands.context.configure; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.auth.AuthConfig; +import com.streamx.cli.commands.auth.login.LoginCommand; +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 com.streamx.cli.framework.InteractivePicker; +import com.streamx.cli.framework.InteractivePicker.Session; +import com.streamx.cli.ingestion.IngestionClientConfig; +import com.streamx.cli.platform.OrganizationsApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformConfig; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.ProjectsApi; +import com.streamx.cli.platform.generated.model.Organization; +import com.streamx.cli.platform.generated.model.Project; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Objects; +import java.util.Properties; +import org.eclipse.microprofile.config.ConfigProvider; +import picocli.CommandLine; + +@CommandLine.Command( + name = "configure", + header = "Interactively configure the active context", + description = "Asks for the endpoints the CLI talks to and offers to log in. " + + "Press Enter to keep the value shown in brackets. Values are written to the " + + "context's application.properties (same as `settings set`)." +) +public class ConfigureCommand extends AbstractSilentCommand { + + static final String DEFAULT_AUTH_URL_KEY = "streamx.defaults.auth.server-url"; + static final String DEFAULT_PLATFORM_URL_KEY = "streamx.defaults.platform.url"; + + private static final String SKIP = "-"; + private static final String METHOD_BROWSER = "browser"; + private static final String METHOD_DEVICE = "device-code"; + + @Override + public CommandResult runCommand() { + Properties settings = loadSettings(); + + boolean login; + boolean device = false; + try (Session session = InteractivePicker.open()) { + configureUrl(session, settings, msg.contextConfigurePromptAuthUrl(), + AuthConfig.STREAMX_AUTH_SERVER_URL, DEFAULT_AUTH_URL_KEY, false); + configureInsecure(session, settings, AuthConfig.STREAMX_AUTH_INSECURE, "auth"); + + configureUrl(session, settings, msg.contextConfigurePromptPlatformUrl(), + PlatformConfig.STREAMX_PLATFORM_URL, DEFAULT_PLATFORM_URL_KEY, false); + configureInsecure(session, settings, PlatformConfig.STREAMX_PLATFORM_INSECURE, "platform"); + + boolean ingestionSet = configureUrl(session, settings, + msg.contextConfigurePromptIngestionUrl(), + IngestionClientConfig.STREAMX_INGESTION_URL, null, true); + if (ingestionSet) { + configureInsecure(session, settings, + IngestionClientConfig.STREAMX_INGESTION_INSECURE, "ingestion"); + } + + storeSettings(settings); + StreamxHome.applySettingsToSystemProperties(); + System.out.println(msg.contextConfigureSaved(StreamxHome.getActiveContext())); + + login = promptYesNo(session, msg.contextConfigurePromptLogin(), true); + if (login) { + String method = session.pick( + msg.contextConfigurePromptLoginMethod() + " [" + METHOD_BROWSER + "]", + List.of(METHOD_BROWSER, METHOD_DEVICE)); + if (method != null && !method.isBlank() + && !METHOD_BROWSER.equalsIgnoreCase(method.strip()) + && !METHOD_DEVICE.equalsIgnoreCase(method.strip())) { + throw new CliException(msg.contextConfigureInvalidAnswer(method)); + } + device = method != null && METHOD_DEVICE.equalsIgnoreCase(method.strip()); + + LoginCommand loginCommand = new LoginCommand(); + loginCommand.noBrowser = device; + loginCommand.runCommand(); + + askOrgAndProject(session); + } + } + return new CommandResult<>(null); + } + + private void askOrgAndProject(Session session) { + try (PlatformClients client = PlatformClients.fromConfig()) { + List orgIds = new OrganizationsApi(client).list().stream() + .map(Organization::getId) + .filter(Objects::nonNull) + .toList(); + + String org = session.pick(msg.contextConfigurePromptOrg(), orgIds); + if (org == null || org.isBlank()) { + return; + } + org = org.strip(); + String clearedProject = PlatformContext.setCurrentOrg(org); + if (clearedProject != null) { + System.err.println(msg.orgUseClearedProject(clearedProject)); + } + System.out.println(msg.orgUseSet(org)); + + List projectIds = new ProjectsApi(client).list(org).stream() + .map(Project::getId) + .filter(Objects::nonNull) + .toList(); + + String project = session.pick(msg.contextConfigurePromptProject(), projectIds); + if (project == null || project.isBlank()) { + return; + } + PlatformContext.setCurrentProject(project.strip()); + System.out.println(msg.projectUseSet(project.strip())); + } catch (RuntimeException fetchFailed) { + System.err.println( + msg.contextConfigureContextSkipped(String.valueOf(fetchFailed.getMessage()))); + } + } + + private boolean configureUrl(Session session, Properties settings, String prompt, + String settingsKey, String buildTimeDefaultKey, boolean optional) { + String defaultValue = currentOrBuiltIn(settings, settingsKey, buildTimeDefaultKey); + String suffix = defaultValue == null ? "" : " [" + defaultValue + "]"; + String answer = session.pick(prompt + suffix, null); + + if (answer != null && SKIP.equals(answer.strip())) { + if (optional) { + return settings.getProperty(settingsKey) != null; + } + throw new CliException(msg.contextConfigureValueRequired(settingsKey)); + } + String value = answer == null || answer.isBlank() ? defaultValue : answer.strip(); + if (value == null) { + if (optional) { + return false; + } + throw new CliException(msg.contextConfigureValueRequired(settingsKey)); + } + value = value.replaceAll("/+$", ""); + if (!value.matches("https?://.+")) { + throw new CliException(msg.contextConfigureInvalidUrl(value)); + } + settings.setProperty(settingsKey, value); + return true; + } + + private void configureInsecure(Session session, Properties settings, String settingsKey, + String target) { + boolean currentInsecure = Boolean.parseBoolean(settings.getProperty(settingsKey)); + boolean verify = promptYesNo( + session, msg.contextConfigurePromptVerifyTls(target), !currentInsecure); + settings.setProperty(settingsKey, String.valueOf(!verify)); + } + + private boolean promptYesNo(Session session, String prompt, boolean defaultValue) { + String suffix = defaultValue ? " (Y/n)" : " (y/N)"; + String answer = session.pick(prompt + suffix, null); + if (answer == null || answer.isBlank()) { + return defaultValue; + } + String normalized = answer.strip().toLowerCase(); + if (normalized.equals("y") || normalized.equals("yes") || normalized.equals("true")) { + return true; + } + if (normalized.equals("n") || normalized.equals("no") || normalized.equals("false")) { + return false; + } + throw new CliException(msg.contextConfigureInvalidAnswer(answer)); + } + + private static String currentOrBuiltIn(Properties settings, String settingsKey, + String buildTimeDefaultKey) { + String current = settings.getProperty(settingsKey); + if (current != null && !current.isBlank()) { + return current; + } + if (buildTimeDefaultKey == null) { + return null; + } + return ConfigProvider.getConfig() + .getOptionalValue(buildTimeDefaultKey, String.class) + .orElse(null); + } + + private static Properties loadSettings() { + Properties properties = new Properties(); + try (InputStream inputStream = StreamxHome.getConfigUrl().openStream()) { + properties.load(inputStream); + } catch (IOException e) { + throw new CliException(msg.unableToSetSettingsProperty(), e); + } + return properties; + } + + private static void storeSettings(Properties properties) { + URL url = StreamxHome.getConfigUrl(); + Path path = Paths.get(url.getPath()); + try (OutputStream outputStream = Files.newOutputStream(path)) { + properties.store(outputStream, null); + } catch (IOException e) { + throw new CliException(msg.unableToSetSettingsProperty(), e); + } + } +} 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 98007ea..83d8d39 100644 --- a/src/test/java/com/streamx/cli/commands/context/ContextCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/context/ContextCommandIT.java @@ -182,6 +182,45 @@ void customTemplatesAndRegistrationsAreContextScoped() throws Exception { .contains("page.published"); } + @Test + void configureAcceptsBuildTimeDefaultsOnEnter() throws Exception { + org.eclipse.microprofile.config.Config config = + org.eclipse.microprofile.config.ConfigProvider.getConfig(); + String authDefault = config.getValue("streamx.defaults.auth.server-url", String.class); + String platformDefault = config.getValue("streamx.defaults.platform.url", String.class); + + ProcessResult result = execWithStdin("\n\n\n\n\nn\n", "context", "configure"); + + result.assertSuccess(); + assertThat(result.stdout()).contains("Context 'default' configured"); + Path settings = streamxHome.resolve("contexts/default/config/application.properties"); + assertThat(settings).content() + .contains("streamx.auth.server-url=" + authDefault.replace(":", "\\:")) + .contains("streamx.auth.insecure=false") + .contains("streamx.platform.url=" + platformDefault.replace(":", "\\:")) + .contains("streamx.platform.insecure=false") + // Ingestion has no default (per-project URL); Enter leaves it unset. + .doesNotContain("streamx.ingestion.url"); + } + + @Test + void configureTakesCustomValuesIncludingIngestion() throws Exception { + ProcessResult result = execWithStdin( + "https://kc.example.com/\nn\nhttps://api.example.com\ny\n" + + "https://in.proj.example.com\nn\nn\n", + "context", "configure"); + + result.assertSuccess(); + Path settings = streamxHome.resolve("contexts/default/config/application.properties"); + assertThat(settings).content() + .contains("streamx.auth.server-url=https\\://kc.example.com") + .contains("streamx.auth.insecure=true") + .contains("streamx.platform.url=https\\://api.example.com") + .contains("streamx.platform.insecure=false") + .contains("streamx.ingestion.url=https\\://in.proj.example.com") + .contains("streamx.ingestion.insecure=true"); + } + @Test void contextFlagOverridesPointerWithoutChangingIt() throws Exception { exec("context", "create", "prod").assertSuccess(); @@ -357,4 +396,79 @@ private static void deleteRecursively(Path root) throws IOException { } } + @Test + void configureAsksOrgAndProjectAfterLogin() throws Exception { + oidcServer = new StubOidcServer("streamx", 0); + try (StubPlatformServer platform = new StubPlatformServer()) { + String stdin = String.join("\n", + oidcServer.getServerUrl(), + "n", + platform.getUrl(), + "n", + "", + "y", + "device-code", + "acme", + "so-acme-shop-a1b2c") + "\n"; + + ProcessResult result = execWithStdin(stdin, "context", "configure"); + + result.assertSuccess(); + assertThat(result.stdout()) + .contains(msg.orgUseSet("acme")) + .contains(msg.projectUseSet("so-acme-shop-a1b2c")); + assertThat(streamxHome.resolve("contexts/default/current-org")).content() + .isEqualToIgnoringNewLines("acme"); + assertThat(streamxHome.resolve("contexts/default/current-project")).content() + .isEqualToIgnoringNewLines("so-acme-shop-a1b2c"); + assertThat(platform.getRequests()) + .contains("GET /api/v1/organizations", "GET /api/v1/organizations/acme/projects"); + } + } + + @Test + void configureSkipsContextWhenPlatformUnreachable() throws Exception { + oidcServer = new StubOidcServer("streamx", 0); + String stdin = String.join("\n", + oidcServer.getServerUrl(), + "n", + "https://127.0.0.1:9", // unreachable platform + "n", + "", + "y", + "device-code") + "\n"; + + ProcessResult result = execWithStdin(stdin, "context", "configure"); + + result.assertSuccess(); + assertThat(result.stderr()).contains("Skipping organization/project selection"); + assertThat(streamxHome.resolve("contexts/default/current-org")).doesNotExist(); + } + + @Test + void configureRefreshesUrlsBeforeLoginOnReconfigure() throws Exception { + exec("settings", "set", "streamx.platform.url", "https://127.0.0.1:9").assertSuccess(); + + oidcServer = new StubOidcServer("streamx", 0); + try (StubPlatformServer platform = new StubPlatformServer()) { + String stdin = String.join("\n", + oidcServer.getServerUrl(), + "n", + platform.getUrl(), + "n", + "", + "y", + "device-code", + "acme", + "") + "\n"; // skip project + + ProcessResult result = execWithStdin(stdin, "context", "configure"); + + result.assertSuccess(); + assertThat(result.stderr()).doesNotContain("Skipping organization/project selection"); + assertThat(streamxHome.resolve("contexts/default/current-org")).content() + .isEqualToIgnoringNewLines("acme"); + } + } + }