Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,6 +18,7 @@
subcommands = {
ListCommand.class,
CreateCommand.class,
ConfigureCommand.class,
UseCommand.class,
CurrentCommand.class,
OrgCommand.class,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Void> 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<String> 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<String> 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);
}
}
}
114 changes: 114 additions & 0 deletions src/test/java/com/streamx/cli/commands/context/ContextCommandIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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");
}
}

}
Loading