diff --git a/pom.xml b/pom.xml index d9c406b5..9ab481f1 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ 21 UTF-8 UTF-8 - 3.30.6 + 3.37.4 3.2.5 3.13.0 3.6.0 @@ -482,4 +482,4 @@ - \ No newline at end of file + diff --git a/src/main/java/com/streamx/cli/Main.java b/src/main/java/com/streamx/cli/Main.java index ee4745f8..96b00f22 100644 --- a/src/main/java/com/streamx/cli/Main.java +++ b/src/main/java/com/streamx/cli/Main.java @@ -27,7 +27,9 @@ public int run(String... args) throws Exception { Object lastCommand = parsed.get(parsed.size() - 1).getCommand(); if (lastCommand instanceof AbstractCommand abstractCommand) { try { - abstractCommand.populateStreamxHome(); + abstractCommand.populateStreamxHome(parsed); + // -H/--context are applied now; refresh the root help header to reflect them. + SynopsisHelper.applyRootUsageLayout(parsed.get(0)); } catch (Exception e) { return abstractCommand.handleExecutionError(e); } @@ -36,6 +38,7 @@ public int run(String... args) throws Exception { }); SynopsisHelper.applyCustomSynopses(commandLine); + SynopsisHelper.applyRootUsageLayout(commandLine); return commandLine.execute(args); } diff --git a/src/main/java/com/streamx/cli/commands/StreamxCommand.java b/src/main/java/com/streamx/cli/commands/StreamxCommand.java index dca7b300..808126c1 100644 --- a/src/main/java/com/streamx/cli/commands/StreamxCommand.java +++ b/src/main/java/com/streamx/cli/commands/StreamxCommand.java @@ -1,11 +1,13 @@ package com.streamx.cli.commands; +import com.streamx.cli.commands.completion.CompleteContextNamesCommand; import com.streamx.cli.commands.completion.CompleteNonDefaultTemplateIdsCommand; import com.streamx.cli.commands.completion.CompleteRegisteredTemplateIdsCommand; import com.streamx.cli.commands.completion.CompleteSettingsKeysCommand; import com.streamx.cli.commands.completion.CompleteSettingsSetKeysCommand; import com.streamx.cli.commands.completion.CompleteTemplateIdsCommand; import com.streamx.cli.commands.completion.CompletionCommand; +import com.streamx.cli.commands.context.ContextCommand; import com.streamx.cli.commands.local.LocalCommand; import com.streamx.cli.commands.publish.PublishCommand; import com.streamx.cli.commands.settings.SettingsCommand; @@ -16,6 +18,7 @@ name = "streamx", header = "StreamX CLI. More info at https://streamx.com", subcommands = { + ContextCommand.class, LocalCommand.class, SettingsCommand.class, PublishCommand.class, @@ -24,7 +27,8 @@ CompleteRegisteredTemplateIdsCommand.class, CompleteNonDefaultTemplateIdsCommand.class, CompleteSettingsKeysCommand.class, - CompleteSettingsSetKeysCommand.class + CompleteSettingsSetKeysCommand.class, + CompleteContextNamesCommand.class } ) public class StreamxCommand extends AbstractCommandGroup { diff --git a/src/main/java/com/streamx/cli/commands/completion/CompleteContextNamesCommand.java b/src/main/java/com/streamx/cli/commands/completion/CompleteContextNamesCommand.java new file mode 100644 index 00000000..de602418 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/completion/CompleteContextNamesCommand.java @@ -0,0 +1,30 @@ +package com.streamx.cli.commands.completion; + +import com.streamx.cli.config.StreamxHome; +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import java.util.List; +import picocli.CommandLine; + +@CommandLine.Command( + name = "__complete-context-names", + hidden = true, + header = "Internal: list every context name, one per line" +) +public class CompleteContextNamesCommand extends AbstractCommand> { + + @Override + public boolean needsContext() { + return false; + } + + @Override + public CommandResult> runCommand() { + return new CommandResult<>(StreamxHome.listContextNames()); + } + + @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 c61b4996..3e1624df 100644 --- a/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java +++ b/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java @@ -5,6 +5,7 @@ import com.streamx.cli.commands.settings.eventtemplates.NonDefaultTemplateIdCompletionCandidates; import com.streamx.cli.commands.settings.eventtemplates.RegisteredTemplateIdCompletionCandidates; import com.streamx.cli.commands.settings.eventtemplates.TemplateIdCompletionCandidates; +import com.streamx.cli.config.ContextNameCompletionCandidates; import java.io.File; import java.nio.file.Path; import java.util.ArrayList; @@ -17,6 +18,7 @@ public final class ZshCompletionGenerator { + private ZshCompletionGenerator() { } @@ -169,7 +171,7 @@ private static String getOptionArgSpec(OptionSpec opt) { return ""; } String label = opt.paramLabel(); - String action = getCompletionAction(opt.type(), opt, null); + String action = getCompletionAction(opt.type(), opt, opt.completionCandidates()); if (label == null || label.isEmpty()) { label = "value"; } @@ -210,6 +212,16 @@ private static String getCompletionAction( if (completionCandidates instanceof SettingsKeyCompletionCandidates) { return "($(streamx __complete-settings-keys 2>/dev/null))"; } + if (completionCandidates instanceof ContextNameCompletionCandidates) { + return "($(streamx __complete-context-names 2>/dev/null))"; + } + // Any remaining candidates are a fixed list (e.g. roles); the dynamic ones are handled above. + if (completionCandidates != null) { + String values = renderCandidates(completionCandidates); + if (!values.isEmpty()) { + return values; + } + } if (type != null && type.isEnum()) { Object[] constants = type.getEnumConstants(); StringBuilder values = new StringBuilder("("); @@ -228,6 +240,19 @@ private static String getCompletionAction( return ""; } + private static String renderCandidates(Iterable completionCandidates) { + StringBuilder values = new StringBuilder("("); + boolean empty = true; + for (String candidate : completionCandidates) { + if (!empty) { + values.append(" "); + } + values.append(escape(candidate)); + empty = false; + } + return empty ? "" : values.append(")").toString(); + } + private static String preferredOptionName(OptionSpec opt) { String shortName = null; String longName = 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 new file mode 100644 index 00000000..ed919ee9 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/ContextCommand.java @@ -0,0 +1,24 @@ +package com.streamx.cli.commands.context; + +import com.streamx.cli.commands.context.create.CreateCommand; +import com.streamx.cli.commands.context.current.CurrentCommand; +import com.streamx.cli.commands.context.delete.DeleteCommand; +import com.streamx.cli.commands.context.list.ListCommand; +import com.streamx.cli.commands.context.use.UseCommand; +import com.streamx.cli.framework.AbstractCommandGroup; +import picocli.CommandLine; + +@CommandLine.Command( + name = "context", + header = "Manage StreamX contexts (bundled settings, event templates and login " + + "per environment)", + subcommands = { + ListCommand.class, + CreateCommand.class, + UseCommand.class, + CurrentCommand.class, + DeleteCommand.class + } +) +public class ContextCommand extends AbstractCommandGroup { +} diff --git a/src/main/java/com/streamx/cli/commands/context/create/CreateCommand.java b/src/main/java/com/streamx/cli/commands/context/create/CreateCommand.java new file mode 100644 index 00000000..68a92fb1 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/create/CreateCommand.java @@ -0,0 +1,94 @@ +package com.streamx.cli.commands.context.create; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.config.ContextNameCompletionCandidates; +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 java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.stream.Stream; +import picocli.CommandLine; + +@CommandLine.Command( + name = "create", + header = "Create a context and switch to it", + description = "The new context starts with empty settings and becomes the current context." +) +public class CreateCommand extends AbstractSilentCommand { + + @CommandLine.Parameters(index = "0", description = "Context name (lowercase, digits, dashes)") + public String name; + + @CommandLine.Option( + names = "--from", + description = "Copy settings and event templates (not the login) from this context", + completionCandidates = ContextNameCompletionCandidates.class + ) + public String from; + + @Override + public boolean needsContext() { + return false; + } + + @Override + public CommandResult runCommand() { + StreamxHome.requireValidContextName(name); + if (StreamxHome.contextExists(name)) { + throw new CliException(msg.contextAlreadyExists(name)); + } + if (from != null) { + StreamxHome.requireValidContextName(from); + if (!StreamxHome.contextExists(from)) { + throw new CliException(msg.contextNotFound(from)); + } + } + + try { + Files.createDirectories(StreamxHome.getConfigDirOf(name)); + Files.createDirectories(StreamxHome.getEventTemplatesDirOf(name)); + if (from != null) { + Path source = StreamxHome.getConfigDirOf(from).resolve("application.properties"); + if (Files.isRegularFile(source)) { + Files.copy(source, StreamxHome.getConfigDirOf(name).resolve("application.properties")); + } + copyTree( + StreamxHome.getEventTemplatesDirOf(from), + StreamxHome.getEventTemplatesDirOf(name)); + } + } catch (IOException e) { + throw new CliException(msg.contextCreateFailed(name, e.getMessage()), e); + } + + System.out.println(msg.contextCreated(name)); + try { + StreamxHome.writeCurrentContextPointer(name); + System.out.println(msg.contextSwitched(name)); + } catch (IOException e) { + throw new CliException(msg.contextSwitchFailed(e.getMessage()), e); + } + System.err.println(msg.contextCreateConfigureHint()); + return new CommandResult<>(null); + } + + private static void copyTree(Path source, Path target) throws IOException { + if (!Files.isDirectory(source)) { + return; + } + try (Stream paths = Files.walk(source)) { + for (Path path : paths.toList()) { + Path destination = target.resolve(source.relativize(path).toString()); + if (Files.isDirectory(path)) { + Files.createDirectories(destination); + } else { + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/context/current/CurrentCommand.java b/src/main/java/com/streamx/cli/commands/context/current/CurrentCommand.java new file mode 100644 index 00000000..ace2507b --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/current/CurrentCommand.java @@ -0,0 +1,24 @@ +package com.streamx.cli.commands.context.current; + +import com.streamx.cli.config.StreamxHome; +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CommandResult; +import picocli.CommandLine; + +@CommandLine.Command( + name = "current", + header = "Print the active context name" +) +public class CurrentCommand extends AbstractSilentCommand { + + @Override + public boolean needsContext() { + return false; + } + + @Override + public CommandResult runCommand() { + System.out.println(StreamxHome.getActiveContext()); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/context/delete/DeleteCommand.java b/src/main/java/com/streamx/cli/commands/context/delete/DeleteCommand.java new file mode 100644 index 00000000..382d0441 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/delete/DeleteCommand.java @@ -0,0 +1,71 @@ +package com.streamx.cli.commands.context.delete; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.config.ContextNameCompletionCandidates; +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 java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.stream.Stream; +import picocli.CommandLine; + +@CommandLine.Command( + name = "delete", + header = "Delete a context", + description = "Removes the context's settings, event templates and stored login from this " + + "machine. The login is not revoked; run 'streamx auth logout' in the context first." +) +public class DeleteCommand extends AbstractSilentCommand { + + @CommandLine.Parameters( + index = "0", + description = "Context name", + completionCandidates = ContextNameCompletionCandidates.class + ) + public String name; + + @Override + public boolean needsContext() { + return false; + } + + @Override + public CommandResult runCommand() { + StreamxHome.requireValidContextName(name); + if (!StreamxHome.contextExists(name)) { + throw new CliException(msg.contextDoesNotExist(name)); + } + if (name.equals(StreamxHome.getActiveContext())) { + throw new CliException(msg.contextCannotDeleteActive(name)); + } + if (name.equals(StreamxHome.readCurrentContextPointer())) { + throw new CliException(msg.contextCannotDeleteCurrent(name)); + } + + Path contextDir = StreamxHome.getContextDirOf(name); + boolean hadLogin = + Files.isRegularFile(StreamxHome.getConfigDirOf(name).resolve("credentials.json")); + try (Stream paths = Files.walk(contextDir)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + throw new CliException(msg.contextDeleteFailed(name, e.getMessage()), e); + } + }); + } catch (IOException e) { + throw new CliException(msg.contextDeleteFailed(name, e.getMessage()), e); + } + + System.out.println(msg.contextDeleted(name)); + if (hadLogin) { + System.err.println(msg.contextDeletedLoginNote()); + } + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/context/list/ContextInfo.java b/src/main/java/com/streamx/cli/commands/context/list/ContextInfo.java new file mode 100644 index 00000000..6fe7059d --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/list/ContextInfo.java @@ -0,0 +1,7 @@ +package com.streamx.cli.commands.context.list; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record ContextInfo(String name, boolean active, String platformUrl, boolean loggedIn) { +} diff --git a/src/main/java/com/streamx/cli/commands/context/list/ListCommand.java b/src/main/java/com/streamx/cli/commands/context/list/ListCommand.java new file mode 100644 index 00000000..a65cd9cb --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/list/ListCommand.java @@ -0,0 +1,75 @@ +package com.streamx.cli.commands.context.list; + +import com.streamx.cli.config.StreamxHome; +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.framework.TextTable; +import com.streamx.cli.platform.PlatformConfig; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; +import java.util.stream.Collectors; +import picocli.CommandLine; + +@CommandLine.Command( + name = "list", + header = "List contexts" +) +public class ListCommand extends AbstractCommand> { + + @CommandLine.Option( + names = {"-q", "--quiet"}, + description = "Only display context names, one per line (for piping to xargs)" + ) + public boolean quiet; + + @Override + public boolean needsContext() { + return false; + } + + @Override + public String getTextOutput(CommandResult> result) { + List contexts = result.getData(); + if (quiet) { + return contexts.stream().map(ContextInfo::name).collect(Collectors.joining("\n")); + } + return TextTable.render( + List.of("NAME", "ACTIVE", "PLATFORM URL", "LOGGED IN"), + contexts.stream() + .map(context -> List.of( + context.name(), + context.active() ? "*" : "", + context.platformUrl() == null ? "-" : context.platformUrl(), + context.loggedIn() ? "yes" : "-")) + .toList()); + } + + @Override + public CommandResult> runCommand() { + String active = StreamxHome.getActiveContext(); + List contexts = StreamxHome.listContextNames().stream() + .map(name -> describe(name, name.equals(active))) + .toList(); + return new CommandResult<>(contexts); + } + + private static ContextInfo describe(String name, boolean active) { + Path configDir = StreamxHome.getConfigDirOf(name); + String platformUrl = null; + Path settings = configDir.resolve("application.properties"); + if (Files.isRegularFile(settings)) { + Properties properties = new Properties(); + try (InputStream in = Files.newInputStream(settings)) { + properties.load(in); + platformUrl = properties.getProperty(PlatformConfig.STREAMX_PLATFORM_URL); + } catch (IOException expected) { + } + } + boolean loggedIn = Files.isRegularFile(configDir.resolve("credentials.json")); + return new ContextInfo(name, active, platformUrl, loggedIn); + } +} diff --git a/src/main/java/com/streamx/cli/commands/context/use/UseCommand.java b/src/main/java/com/streamx/cli/commands/context/use/UseCommand.java new file mode 100644 index 00000000..b0389d66 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/use/UseCommand.java @@ -0,0 +1,45 @@ +package com.streamx.cli.commands.context.use; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.config.ContextNameCompletionCandidates; +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 = "use", + header = "Switch the current context" +) +public class UseCommand extends AbstractSilentCommand { + + @CommandLine.Parameters( + index = "0", + description = "Context name", + completionCandidates = ContextNameCompletionCandidates.class + ) + public String name; + + @Override + public boolean needsContext() { + return false; + } + + @Override + public CommandResult runCommand() { + StreamxHome.requireValidContextName(name); + if (!StreamxHome.contextExists(name)) { + throw new CliException(msg.contextNotFound(name)); + } + try { + StreamxHome.writeCurrentContextPointer(name); + } catch (IOException e) { + throw new CliException(msg.contextSwitchFailed(e.getMessage()), e); + } + System.out.println(msg.contextSwitched(name)); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/publish/event/DefaultEventTemplates.java b/src/main/java/com/streamx/cli/commands/publish/event/DefaultEventTemplates.java index 09806933..cd84b137 100644 --- a/src/main/java/com/streamx/cli/commands/publish/event/DefaultEventTemplates.java +++ b/src/main/java/com/streamx/cli/commands/publish/event/DefaultEventTemplates.java @@ -13,7 +13,7 @@ public final class DefaultEventTemplates { - public static final String DIRECTORY = "event-templates/default"; + public static final String DIRECTORY = "default-event-templates"; public static final String EXTENSION = ".json"; static final String RESOURCE_DIRECTORY = "default-event-templates"; diff --git a/src/main/java/com/streamx/cli/commands/publish/event/EventCommand.java b/src/main/java/com/streamx/cli/commands/publish/event/EventCommand.java index bafa4a42..59906e81 100644 --- a/src/main/java/com/streamx/cli/commands/publish/event/EventCommand.java +++ b/src/main/java/com/streamx/cli/commands/publish/event/EventCommand.java @@ -52,8 +52,8 @@ public class EventCommand extends AbstractCommand { index = "0", description = { "Template ID (the template to use for this event).", - "Resolved from /event-templates/custom and " - + "/event-templates/default (~/.streamx by default).", + "Resolved from the context's event-templates folder and the shared " + + "/default-event-templates (~/.streamx by default).", "Run `streamx settings event-templates list` to see all available templates." }, completionCandidates = TemplateIdCompletionCandidates.class diff --git a/src/main/java/com/streamx/cli/commands/publish/event/EventTemplateCatalog.java b/src/main/java/com/streamx/cli/commands/publish/event/EventTemplateCatalog.java index aae83108..6fc8a307 100644 --- a/src/main/java/com/streamx/cli/commands/publish/event/EventTemplateCatalog.java +++ b/src/main/java/com/streamx/cli/commands/publish/event/EventTemplateCatalog.java @@ -23,9 +23,9 @@ public final class EventTemplateCatalog { public static final String SOURCE_SETTINGS = "settings"; - public static final String SOURCE_CUSTOM = "event-templates/custom"; + public static final String SOURCE_CUSTOM = "custom"; - public static final String SOURCE_DEFAULT = "event-templates/default"; + public static final String SOURCE_DEFAULT = "default"; private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -80,15 +80,15 @@ public static Map readSettingsEntries() { if (value == null || value.isBlank()) { continue; } - result.put(id, resolveRelativeToHome(value)); + result.put(id, resolveRelativeToContextDir(value)); } return result; } - public static Path resolveRelativeToHome(String pathAsString) { + public static Path resolveRelativeToContextDir(String pathAsString) { Path path = Paths.get(pathAsString); if (!path.isAbsolute()) { - path = StreamxHome.getStreamxHome().resolve(path); + path = StreamxHome.getContextDir().resolve(path); } return path.toAbsolutePath(); } diff --git a/src/main/java/com/streamx/cli/commands/publish/event/UserEventTemplates.java b/src/main/java/com/streamx/cli/commands/publish/event/UserEventTemplates.java index f44734dd..67d0fdbb 100644 --- a/src/main/java/com/streamx/cli/commands/publish/event/UserEventTemplates.java +++ b/src/main/java/com/streamx/cli/commands/publish/event/UserEventTemplates.java @@ -3,16 +3,16 @@ import com.streamx.cli.config.StreamxHome; import java.nio.file.Path; +/** Custom event templates of the active context. */ public final class UserEventTemplates { - public static final String DIRECTORY = "event-templates/custom"; public static final String EXTENSION = ".json"; private UserEventTemplates() { } public static Path getDirectory() { - return StreamxHome.getStreamxHome().resolve(DIRECTORY); + return StreamxHome.getEventTemplatesDir(); } public static Path resolve(String templateName) { diff --git a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/copy/CopyCommand.java b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/copy/CopyCommand.java index d427e207..d87f1ae1 100644 --- a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/copy/CopyCommand.java +++ b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/copy/CopyCommand.java @@ -19,10 +19,10 @@ @CommandLine.Command( name = "copy", header = "Copy an existing event template under a new ID", - description = "Copies the resolved content of into " - + "/event-templates/custom/.json. Works with templates from any " + description = "Copies the resolved content of into the context's " + + "event-templates/.json. Works with templates from any " + "source (default / custom / registered in settings). " - + "The copy always lands in the custom folder.", + + "The copy always lands in the context's event-templates folder.", footer = { "", "Examples:", diff --git a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/create/CreateCommand.java b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/create/CreateCommand.java index 53ddbeb8..2d487bbc 100644 --- a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/create/CreateCommand.java +++ b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/create/CreateCommand.java @@ -21,7 +21,7 @@ name = "create", header = "Create a new event template (interactive wizard)", description = "Prompts for a template ID and a CloudEvent type, then writes a starter " - + "template to /event-templates/custom/.json. Run `edit` afterwards to " + + "template to the context's event-templates/.json. Run `edit` afterwards to " + "customize the content.", footer = { "", diff --git a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/delete/DeleteCommand.java b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/delete/DeleteCommand.java index 26020cf6..76bf267b 100644 --- a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/delete/DeleteCommand.java +++ b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/delete/DeleteCommand.java @@ -18,7 +18,7 @@ name = "delete", header = "Delete a user-created event template", description = { - "Deletes a template from /event-templates/custom/.", + "Deletes a template from the context's event-templates/ folder.", "Default templates cannot be deleted this way - use " + "`reset-default-templates` to restore them.", "Registered templates cannot be deleted this way - use `unregister` instead." @@ -27,7 +27,7 @@ "", "Examples:", " streamx settings event-templates delete my.custom", - " streamx settings event-templates delete my.custom --yes", + " streamx settings event-templates delete my.custom --force", " streamx settings event-templates delete # picks interactively" } ) @@ -42,10 +42,10 @@ public class DeleteCommand extends AbstractCommand { public String templateId; @CommandLine.Option( - names = {"-y", "--yes"}, + names = {"-f", "--force"}, description = "Skip the confirmation prompt" ) - public boolean yes; + public boolean force; @Override public CommandResult runCommand() { @@ -60,7 +60,7 @@ public CommandResult runCommand() { break; } - if (!yes) { + if (!force) { String answer = InteractivePicker.pick( msg.eventTemplateDeleteConfirm(found.id(), found.path()), null); if (answer == null || !isYes(answer)) { diff --git a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/edit/EditCommand.java b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/edit/EditCommand.java index bbbf5c54..d44cfae7 100644 --- a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/edit/EditCommand.java +++ b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/edit/EditCommand.java @@ -20,7 +20,7 @@ name = "edit", header = "Open an event template in $EDITOR", description = { - "Default templates are copied to /event-templates/custom/ before editing.", + "Default templates are copied into the context's event-templates/ folder before editing.", "On save, the file is re-validated as JSON; invalid JSON re-opens the editor." }, footer = { diff --git a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/list/ListCommand.java b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/list/ListCommand.java index d183971d..59f18322 100644 --- a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/list/ListCommand.java +++ b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/list/ListCommand.java @@ -7,6 +7,8 @@ import com.streamx.cli.config.StreamxHome; import com.streamx.cli.framework.AbstractCommand; import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.framework.TextTable; +import java.util.Arrays; import java.util.List; import picocli.CommandLine; @@ -40,33 +42,10 @@ public String getTextOutput(CommandResult result) { return msg.eventTemplatesNoTemplatesFound(); } - int idWidth = Math.max(11, templates.stream() - .mapToInt(t -> t.id() == null ? 0 : t.id().length()) - .max().orElse(0)); - int typeWidth = Math.max(4, templates.stream() - .mapToInt(t -> t.type() == null ? 0 : t.type().length()) - .max().orElse(0)); - int sourceWidth = Math.max(10, templates.stream() - .mapToInt(t -> t.source() == null ? 0 : t.source().length()) - .max().orElse(0)); - - StringBuilder sb = new StringBuilder(); - sb.append(msg.eventTemplatesListHeader()).append("\n"); - sb.append(String.format( - "%-" + idWidth + "s %-" + typeWidth + "s %-" + sourceWidth + "s %s%n", - "TEMPLATE ID", "TYPE", "DEFINED AT", "PATH")); - for (TemplateLocation t : templates) { - sb.append(String.format( - "%-" + idWidth + "s %-" + typeWidth + "s %-" + sourceWidth + "s %s%n", - nullToDash(t.id()), - nullToDash(t.type()), - nullToDash(t.source()), - nullToDash(t.path()))); - } - return sb.toString().stripTrailing(); - } - - private static String nullToDash(String s) { - return s == null ? "-" : s; + return TextTable.render( + List.of("TEMPLATE ID", "TYPE", "DEFINED AT", "PATH"), + templates.stream() + .map(t -> Arrays.asList(t.id(), t.type(), t.source(), t.path())) + .toList()); } } diff --git a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/register/RegisterCommand.java b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/register/RegisterCommand.java index 0cd61100..1041f870 100644 --- a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/register/RegisterCommand.java +++ b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/register/RegisterCommand.java @@ -20,10 +20,10 @@ @CommandLine.Command( name = "register", header = "Register an event template file under a template ID (writes to settings)", - description = "Adds an `eventtemplate.=` entry to " - + "/config/application.properties. The path can be absolute or relative " - + "to streamxHome. Registered templates take precedence over templates in " - + "event-templates/custom and event-templates/default.", + description = "Adds an `eventtemplate.=` entry to the active context's " + + "application.properties. The path can be absolute or relative to the context " + + "directory. Registered templates take precedence over the context's custom " + + "templates and the shared default templates.", footer = { "", "Examples:", @@ -41,7 +41,8 @@ public class RegisterCommand extends AbstractSilentCommand { @CommandLine.Parameters( index = "1", - description = "Path to the template JSON file (relative to streamxHome or absolute)" + description = "Path to the template JSON file (relative to the context directory " + + "or absolute)" ) public String path; diff --git a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/rename/RenameCommand.java b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/rename/RenameCommand.java index d16975f0..5a4f8a31 100644 --- a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/rename/RenameCommand.java +++ b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/rename/RenameCommand.java @@ -27,7 +27,7 @@ name = "rename", header = "Rename an event template", description = { - "For user-created templates: renames the file in /event-templates/custom/.", + "For user-created templates: renames the file in the context's event-templates/ folder.", "For registered templates: rewrites the settings entry under the new ID " + "(the underlying file is not moved).", "Default templates cannot be renamed - use `copy` to create a clone under a new ID." diff --git a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/resetdefaulttemplates/ResetDefaultTemplatesCommand.java b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/resetdefaulttemplates/ResetDefaultTemplatesCommand.java index bf40b123..c31b6243 100644 --- a/src/main/java/com/streamx/cli/commands/settings/eventtemplates/resetdefaulttemplates/ResetDefaultTemplatesCommand.java +++ b/src/main/java/com/streamx/cli/commands/settings/eventtemplates/resetdefaulttemplates/ResetDefaultTemplatesCommand.java @@ -17,32 +17,32 @@ @CommandLine.Command( name = "reset-default-templates", - header = "Delete and repopulate the /event-templates/default folder", - description = "Wipes the bundled default templates from /event-templates/default " - + "and restores them from the files embedded in the CLI jar. User-created templates in " - + "/event-templates/custom and registered templates in settings are not " + header = "Delete and repopulate the /default-event-templates folder", + description = "Wipes the bundled default templates from /default-event-templates " + + "(shared by all contexts) and restores them from the files embedded in the CLI jar. " + + "The contexts' own event templates and registered templates in settings are not " + "touched.", footer = { "", "Examples:", " streamx settings event-templates reset-default-templates", - " streamx settings event-templates reset-default-templates --yes # skip confirmation" + " streamx settings event-templates reset-default-templates --force # skip confirmation" } ) public class ResetDefaultTemplatesCommand extends AbstractCommand { @CommandLine.Option( - names = {"-y", "--yes"}, + names = {"-f", "--force"}, description = "Skip the confirmation prompt (required in non-interactive environments)" ) - public boolean yes; + public boolean force; @Override public CommandResult runCommand() { Path dir = StreamxHome.getStreamxHome().resolve(DefaultEventTemplates.DIRECTORY); - if (!yes) { + if (!force) { String prompt = msg.eventTemplatesResetConfirm(dir.toAbsolutePath().toString()); String answer = InteractivePicker.pick(prompt, null); diff --git a/src/main/java/com/streamx/cli/commands/settings/list/ListCommand.java b/src/main/java/com/streamx/cli/commands/settings/list/ListCommand.java index a5a09bf7..9eda29c1 100644 --- a/src/main/java/com/streamx/cli/commands/settings/list/ListCommand.java +++ b/src/main/java/com/streamx/cli/commands/settings/list/ListCommand.java @@ -6,8 +6,10 @@ import com.streamx.cli.framework.AbstractCommand; import com.streamx.cli.framework.CliException; import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.framework.TextTable; import java.io.InputStream; import java.net.URL; +import java.util.List; import java.util.Map; import java.util.Properties; import java.util.TreeMap; @@ -25,27 +27,14 @@ public String getTextOutput(CommandResult> result) { return msg.listSettingsNoPropertiesFound(); } - StringBuilder stringOutput = new StringBuilder(); - Map sortedProperties = new TreeMap<>(result.getData()); - - int maxKeyLength = sortedProperties.keySet().stream() - .mapToInt(String::length) - .max() - .orElse(0); - - stringOutput.append(msg.listSettingsHeader()).append("\n"); - - for (Map.Entry entry : sortedProperties.entrySet()) { - String paddedKey = String.format("%-" + maxKeyLength + "s", entry.getKey()); - stringOutput.append(paddedKey).append(" ="); - if (!entry.getValue().isEmpty()) { - stringOutput.append(" ").append(entry.getValue()); - } - stringOutput.append("\n"); - } - - return stringOutput.toString().strip(); + return TextTable.render( + List.of("KEY", "VALUE"), + sortedProperties.entrySet().stream() + .map(entry -> List.of( + entry.getKey(), + entry.getValue().isEmpty() ? "-" : entry.getValue())) + .toList()); } @Override diff --git a/src/main/java/com/streamx/cli/config/ContextNameCompletionCandidates.java b/src/main/java/com/streamx/cli/config/ContextNameCompletionCandidates.java new file mode 100644 index 00000000..b4cad62b --- /dev/null +++ b/src/main/java/com/streamx/cli/config/ContextNameCompletionCandidates.java @@ -0,0 +1,10 @@ +package com.streamx.cli.config; + +import java.util.Iterator; + +public class ContextNameCompletionCandidates implements Iterable { + @Override + public Iterator iterator() { + return StreamxHome.listContextNames().iterator(); + } +} diff --git a/src/main/java/com/streamx/cli/config/StreamxHome.java b/src/main/java/com/streamx/cli/config/StreamxHome.java index 09ac5319..8fe64929 100644 --- a/src/main/java/com/streamx/cli/config/StreamxHome.java +++ b/src/main/java/com/streamx/cli/config/StreamxHome.java @@ -11,13 +11,24 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import java.util.Properties; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import java.util.stream.Stream; public class StreamxHome { private static final String DEFAULT_HOME_DIR = ".streamx"; + public static final String DEFAULT_CONTEXT = "default"; + private static final String CONTEXTS_DIR = "contexts"; + private static final String CONFIG_DIR = "config"; + private static final String EVENT_TEMPLATES_DIR = "event-templates"; + private static final String CURRENT_CONTEXT_FILE = "current-context"; + private static final Pattern CONTEXT_NAME = Pattern.compile("[a-z0-9][a-z0-9-]{0,31}"); private static String streamxHomeCliArg; + private static String contextCliArg; private static final Set appliedKeys = ConcurrentHashMap.newKeySet(); public static void setStreamxHomeCliArg(String path) { @@ -28,6 +39,203 @@ public static void clearStreamxHomeCliArg() { streamxHomeCliArg = null; } + public static void setContextCliArg(String name) { + contextCliArg = name; + } + + public static void clearContextCliArg() { + contextCliArg = null; + } + + public static boolean isValidContextName(String name) { + return name != null && CONTEXT_NAME.matcher(name).matches(); + } + + public static String requireValidContextName(String name) { + if (!isValidContextName(name)) { + throw new CliException(msg.contextNameInvalid(String.valueOf(name))); + } + return name; + } + + /** Precedence: --context > STREAMX_CONTEXT > current-context file > default. */ + public static String getActiveContext() { + if (contextCliArg != null && !contextCliArg.isBlank()) { + return requireValidContextName(contextCliArg.trim()); + } + String env = System.getProperty("STREAMX_CONTEXT"); + if (env == null || env.isBlank()) { + env = System.getenv("STREAMX_CONTEXT"); + } + if (env != null && !env.isBlank()) { + return requireValidContextName(env.trim()); + } + String stored = readCurrentContextPointer(); + if (stored != null && !stored.isEmpty()) { + if (!isValidContextName(stored)) { + throw new CliException( + msg.contextInvalidPointer(stored, getCurrentContextFile().toString())); + } + return stored; + } + return DEFAULT_CONTEXT; + } + + /** Trimmed content of the current-context file, or null if absent or unreadable. */ + public static String readCurrentContextPointer() { + Path pointer = getCurrentContextFile(); + if (!Files.isRegularFile(pointer)) { + return null; + } + try { + return Files.readString(pointer).trim(); + } catch (IOException expected) { + return null; + } + } + + public static void writeCurrentContextPointer(String name) throws IOException { + Path pointer = getCurrentContextFile(); + Files.createDirectories(pointer.getParent()); + Files.writeString(pointer, name + System.lineSeparator()); + } + + /** Which precedence layer picked the streamx home, for diagnostics ({@code streamx info}). */ + public static String getStreamxHomeSource() { + if (streamxHomeCliArg != null && !streamxHomeCliArg.isBlank()) { + return "from the --streamx-home flag"; + } + String env = getStreamxHomeEnv(); + if (env != null && !env.isBlank()) { + return "from the STREAMX_HOME environment variable"; + } + String prop = System.getProperty("STREAMX_HOME"); + if (prop != null && !prop.isBlank()) { + return "from the STREAMX_HOME system property"; + } + return "default"; + } + + /** Which precedence layer picked the active context, for diagnostics ({@code streamx info}). */ + public static String getActiveContextSource() { + if (contextCliArg != null && !contextCliArg.isBlank()) { + return "from the --context flag"; + } + String env = System.getProperty("STREAMX_CONTEXT"); + if (env == null || env.isBlank()) { + env = System.getenv("STREAMX_CONTEXT"); + } + if (env != null && !env.isBlank()) { + return "from the STREAMX_CONTEXT environment variable"; + } + String stored = readCurrentContextPointer(); + if (stored != null && !stored.isEmpty()) { + return "from the current-context file"; + } + return "default, nothing selected"; + } + + public static Path getCurrentContextFile() { + return getStreamxHome().resolve(CURRENT_CONTEXT_FILE); + } + + public static Path getCurrentOrgFile() { + return getContextDir().resolve("current-org"); + } + + public static Path getCurrentProjectFile() { + return getContextDir().resolve("current-project"); + } + + public static String readCurrentOrg() { + return readPointerFile(getCurrentOrgFile()); + } + + public static String readCurrentProject() { + return readPointerFile(getCurrentProjectFile()); + } + + public static void writeCurrentOrg(String orgId) throws IOException { + writePointerFile(getCurrentOrgFile(), orgId); + } + + public static void writeCurrentProject(String projectId) throws IOException { + writePointerFile(getCurrentProjectFile(), projectId); + } + + public static void clearCurrentOrg() throws IOException { + Files.deleteIfExists(getCurrentOrgFile()); + } + + public static void clearCurrentProject() throws IOException { + Files.deleteIfExists(getCurrentProjectFile()); + } + + private static String readPointerFile(Path pointer) { + if (!Files.isRegularFile(pointer)) { + return null; + } + try { + String stored = Files.readString(pointer).trim(); + return stored.isEmpty() ? null : stored; + } catch (IOException expected) { + return null; + } + } + + private static void writePointerFile(Path pointer, String value) throws IOException { + Files.createDirectories(pointer.getParent()); + Files.writeString(pointer, value + System.lineSeparator()); + } + + public static Path getContextsDir() { + return getStreamxHome().resolve(CONTEXTS_DIR); + } + + public static Path getContextDirOf(String context) { + return getContextsDir().resolve(context); + } + + public static Path getContextDir() { + return getContextDirOf(getActiveContext()); + } + + public static Path getConfigDirOf(String context) { + return getContextDirOf(context).resolve(CONFIG_DIR); + } + + public static Path getConfigDir() { + return getConfigDirOf(getActiveContext()); + } + + public static Path getEventTemplatesDirOf(String context) { + return getContextDirOf(context).resolve(EVENT_TEMPLATES_DIR); + } + + public static Path getEventTemplatesDir() { + return getEventTemplatesDirOf(getActiveContext()); + } + + public static boolean contextExists(String context) { + return Files.isDirectory(getContextDirOf(context)); + } + + public static List listContextNames() { + List names = new ArrayList<>(); + Path contextsDir = getContextsDir(); + if (Files.isDirectory(contextsDir)) { + try (Stream entries = Files.list(contextsDir)) { + entries.filter(Files::isDirectory) + .map(path -> path.getFileName().toString()) + .filter(StreamxHome::isValidContextName) + .sorted() + .forEach(names::add); + } catch (IOException expected) { + } + } + return names; + } + public static Path getStreamxHome() { if (streamxHomeCliArg != null && !streamxHomeCliArg.isBlank()) { return Path.of(streamxHomeCliArg); @@ -44,7 +252,7 @@ public static Path getStreamxHome() { } public static Path getConfigPath() { - return getStreamxHome().resolve("config/application.properties"); + return getConfigDir().resolve("application.properties"); } public static URL getConfigUrl() { @@ -64,15 +272,40 @@ public static void createConfigIfNotExists() { } } - public static void populate() { + public static void populate(boolean needsContext) { DefaultEventTemplates.populate(); + + String active = getActiveContext(); + if (!contextExists(active)) { + if (DEFAULT_CONTEXT.equals(active)) { + bootstrapDefaultContext(); + } else if (needsContext) { + throw new CliException(msg.contextNotFound(active)); + } else { + clearAppliedSystemProperties(); + return; + } + } createConfigIfNotExists(); applySettingsToSystemProperties(); } + private static void bootstrapDefaultContext() { + try { + Files.createDirectories(getConfigDirOf(DEFAULT_CONTEXT)); + Files.createDirectories(getEventTemplatesDirOf(DEFAULT_CONTEXT)); + String stored = readCurrentContextPointer(); + if (stored == null || stored.isEmpty()) { + writeCurrentContextPointer(DEFAULT_CONTEXT); + } + } catch (IOException e) { + throw new CliException(msg.contextCreateFailed(DEFAULT_CONTEXT, e.getMessage()), e); + } + } + /** - * Loads every key/value from {@code streamxHome/config/application.properties} and forwards + * Loads every key/value from the active context's {@code application.properties} and forwards * them to JVM system properties so any code that reads via {@link * org.eclipse.microprofile.config.ConfigProvider} (e.g. {@code StreamxBaseConfig} in * the streamx-service-mesh runner) picks them up. System properties already set externally @@ -80,10 +313,7 @@ public static void populate() { * a previous call are cleared first so a fresh re-apply reflects the current file. */ public static void applySettingsToSystemProperties() { - for (String key : appliedKeys) { - System.clearProperty(key); - } - appliedKeys.clear(); + clearAppliedSystemProperties(); Path configPath = getConfigPath(); if (!Files.isRegularFile(configPath)) { @@ -104,6 +334,13 @@ public static void applySettingsToSystemProperties() { } } + private static void clearAppliedSystemProperties() { + for (String key : appliedKeys) { + System.clearProperty(key); + } + appliedKeys.clear(); + } + static String getStreamxHomeEnv() { return System.getenv("STREAMX_HOME"); } diff --git a/src/main/java/com/streamx/cli/framework/AbstractCommand.java b/src/main/java/com/streamx/cli/framework/AbstractCommand.java index 03b7d707..f1e99129 100644 --- a/src/main/java/com/streamx/cli/framework/AbstractCommand.java +++ b/src/main/java/com/streamx/cli/framework/AbstractCommand.java @@ -134,13 +134,33 @@ private void writeErrorToTempDir(Exception e) { } } - public void populateStreamxHome() { - if (helpOptions.streamxHome != null) { - StreamxHome.setStreamxHomeCliArg(helpOptions.streamxHome); + /** + * Whether this command operates on the active context's state (settings, credentials, + * event templates). Context-management commands return false so they keep working when the + * selected context does not exist and the user needs to repair the selection. + */ + public boolean needsContext() { + return true; + } + + public void populateStreamxHome(List parsedChain) { + // Reset first: these per-invocation statics would otherwise leak between in-JVM executions. + StreamxHome.clearStreamxHomeCliArg(); + StreamxHome.clearContextCliArg(); + // -H/--context may sit at any level of the invocation (streamx --context x sub cmd); + // collect across the chain, last occurrence wins. + for (CommandLine commandLine : parsedChain) { + if (commandLine.getCommand() instanceof AbstractCommand command) { + if (command.helpOptions.streamxHome != null) { + StreamxHome.setStreamxHomeCliArg(command.helpOptions.streamxHome); + } + if (command.helpOptions.context != null) { + StreamxHome.setContextCliArg(command.helpOptions.context); + } + } } - StreamxHome.applySettingsToSystemProperties(); - StreamxHome.populate(); + StreamxHome.populate(needsContext()); } public int execute() { diff --git a/src/main/java/com/streamx/cli/framework/AbstractCommandGroup.java b/src/main/java/com/streamx/cli/framework/AbstractCommandGroup.java index 27f536ce..92bc75ae 100644 --- a/src/main/java/com/streamx/cli/framework/AbstractCommandGroup.java +++ b/src/main/java/com/streamx/cli/framework/AbstractCommandGroup.java @@ -9,6 +9,11 @@ public CommandResult runCommand() { return new CommandResult<>(null); } + @Override + public boolean needsContext() { + return false; + } + @Override public String getTextOutput(CommandResult result) { return ""; diff --git a/src/main/java/com/streamx/cli/framework/CommandResult.java b/src/main/java/com/streamx/cli/framework/CommandResult.java index 0dc7e2b6..c3b85a84 100644 --- a/src/main/java/com/streamx/cli/framework/CommandResult.java +++ b/src/main/java/com/streamx/cli/framework/CommandResult.java @@ -4,8 +4,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import java.util.Optional; import java.util.function.Function; @@ -40,7 +42,7 @@ public String toText( return textFormatter.apply(this); } case OutputFormat.json -> { - ObjectMapper mapper = new ObjectMapper(); + ObjectMapper mapper = withJavaTime(new ObjectMapper()); JsonNode jsonNode = mapper.valueToTree(data); return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode); } @@ -48,7 +50,7 @@ public String toText( YAMLFactory yamlFactory = YAMLFactory.builder() .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) .build(); - ObjectMapper mapper = new ObjectMapper(yamlFactory); + ObjectMapper mapper = withJavaTime(new ObjectMapper(yamlFactory)); JsonNode jsonNode = mapper.valueToTree(data); String formattedJsonNode = mapper .writerWithDefaultPrettyPrinter() @@ -71,6 +73,11 @@ public String toText( } } + private static ObjectMapper withJavaTime(ObjectMapper mapper) { + return mapper.registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } + public Optional getError() { return Optional.ofNullable(error); } diff --git a/src/main/java/com/streamx/cli/framework/CommonOptions.java b/src/main/java/com/streamx/cli/framework/CommonOptions.java index edeaef7e..bfb9276c 100644 --- a/src/main/java/com/streamx/cli/framework/CommonOptions.java +++ b/src/main/java/com/streamx/cli/framework/CommonOptions.java @@ -1,5 +1,6 @@ package com.streamx.cli.framework; +import com.streamx.cli.config.ContextNameCompletionCandidates; import picocli.CommandLine; public class CommonOptions { @@ -12,6 +13,9 @@ public class CommonOptions { public static final String STREAMX_HOME_SHORT = "-H"; public static final String STREAMX_HOME_LONG = "--streamx-home"; + public static final String CONTEXT_SHORT = "-C"; + public static final String CONTEXT_LONG = "--context"; + public static final String HELP_SHORT = "-h"; public static final String HELP_LONG = "--help"; @@ -31,6 +35,13 @@ public class CommonOptions { ) public String streamxHome; + @CommandLine.Option( + names = {CONTEXT_SHORT, CONTEXT_LONG}, + description = "Context to use for this invocation [default: default, env: STREAMX_CONTEXT]", + completionCandidates = ContextNameCompletionCandidates.class + ) + public String context; + @CommandLine.Option( names = {VERSION_SHORT, VERSION_LONG}, versionHelp = true, diff --git a/src/main/java/com/streamx/cli/framework/DeleteConfirmation.java b/src/main/java/com/streamx/cli/framework/DeleteConfirmation.java new file mode 100644 index 00000000..93638107 --- /dev/null +++ b/src/main/java/com/streamx/cli/framework/DeleteConfirmation.java @@ -0,0 +1,22 @@ +package com.streamx.cli.framework; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +public final class DeleteConfirmation { + + private DeleteConfirmation() { + } + + public static void require(boolean force, String id) { + if (force) { + return; + } + String answer = InteractivePicker.pick(msg.deleteConfirmPrompt(id), null); + if (answer == null || answer.isBlank()) { + throw new CliException(msg.deleteConfirmRequired()); + } + if (!answer.strip().equals(id)) { + throw new CliException(msg.deleteConfirmMismatch(id)); + } + } +} diff --git a/src/main/java/com/streamx/cli/framework/SynopsisHelper.java b/src/main/java/com/streamx/cli/framework/SynopsisHelper.java index af83d387..e72a090f 100644 --- a/src/main/java/com/streamx/cli/framework/SynopsisHelper.java +++ b/src/main/java/com/streamx/cli/framework/SynopsisHelper.java @@ -2,12 +2,16 @@ import static com.streamx.cli.i18n.MessageProvider.msg; +import com.streamx.cli.config.StreamxHome; +import com.streamx.cli.platform.PlatformContext; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import picocli.CommandLine; import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Model.PositionalParamSpec; +import picocli.CommandLine.Model.UsageMessageSpec; public final class SynopsisHelper { @@ -18,6 +22,41 @@ public static void applyCustomSynopses(CommandLine commandLine) { applyRecursively(commandLine); } + public static void applyRootUsageLayout(CommandLine commandLine) { + UsageMessageSpec usage = commandLine.getCommandSpec().usageMessage(); + + List keys = new ArrayList<>(usage.sectionKeys()); + keys.remove(UsageMessageSpec.SECTION_KEY_SYNOPSIS_HEADING); + keys.remove(UsageMessageSpec.SECTION_KEY_SYNOPSIS); + usage.sectionKeys(keys); + + usage.description( + msg.currentContextHeader("@|bold " + currentContext() + "|@"), + msg.currentOrgHeader(boldOrDash(quiet(PlatformContext::effectiveOrg))), + msg.currentProjectHeader(boldOrDash(quiet(PlatformContext::effectiveProject))), + ""); + } + + private static String currentContext() { + try { + return StreamxHome.getActiveContext(); + } catch (RuntimeException corruptOrUnreadable) { + return StreamxHome.DEFAULT_CONTEXT; + } + } + + private static String boldOrDash(String value) { + return value == null ? "-" : "@|bold " + value + "|@"; + } + + private static String quiet(Supplier supplier) { + try { + return supplier.get(); + } catch (RuntimeException corruptOrUnreadable) { + return null; + } + } + private static void applyRecursively(CommandLine commandLine) { if (commandLine.getCommand() instanceof AbstractCommand) { diff --git a/src/main/java/com/streamx/cli/framework/TextTable.java b/src/main/java/com/streamx/cli/framework/TextTable.java new file mode 100644 index 00000000..3a10f52d --- /dev/null +++ b/src/main/java/com/streamx/cli/framework/TextTable.java @@ -0,0 +1,48 @@ +package com.streamx.cli.framework; + +import java.util.List; + +public final class TextTable { + + private static final String COLUMN_SEPARATOR = " "; + private static final String ABSENT = "-"; + + private TextTable() { + } + + public static String render(List headers, List> rows) { + int[] widths = new int[headers.size()]; + for (int column = 0; column < headers.size(); column++) { + widths[column] = headers.get(column).length(); + for (List row : rows) { + widths[column] = Math.max(widths[column], cell(row, column).length()); + } + } + + StringBuilder output = new StringBuilder(); + appendRow(output, headers, widths); + for (List row : rows) { + output.append("\n"); + appendRow(output, row, widths); + } + return output.toString(); + } + + private static void appendRow(StringBuilder output, List row, int[] widths) { + for (int column = 0; column < widths.length; column++) { + String value = cell(row, column); + boolean last = column == widths.length - 1; + output.append(last ? value : value + " ".repeat(widths[column] - value.length())); + if (!last) { + output.append(COLUMN_SEPARATOR); + } + } + } + + private static String cell(List row, int column) { + if (column >= row.size() || row.get(column) == null) { + return ABSENT; + } + return row.get(column); + } +} diff --git a/src/main/java/com/streamx/cli/framework/Urls.java b/src/main/java/com/streamx/cli/framework/Urls.java new file mode 100644 index 00000000..b0baf741 --- /dev/null +++ b/src/main/java/com/streamx/cli/framework/Urls.java @@ -0,0 +1,36 @@ +package com.streamx.cli.framework; + +import java.net.URI; +import java.util.Locale; +import java.util.regex.Pattern; + +public final class Urls { + + private static final Pattern LOOPBACK_IPV4 = + Pattern.compile("127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"); + + private Urls() { + } + + public static boolean isCleartextRemote(String url) { + String trimmed = url == null ? "" : url.trim(); + if (!trimmed.regionMatches(true, 0, "http://", 0, 7)) { + return false; + } + URI uri; + try { + uri = URI.create(trimmed); + } catch (IllegalArgumentException unparseable) { + return true; + } + String host = uri.getHost(); + if (host == null) { + return true; + } + String normalized = host.toLowerCase(Locale.ROOT); + return !("localhost".equals(normalized) + || "::1".equals(normalized) + || "[::1]".equals(normalized) + || LOOPBACK_IPV4.matcher(normalized).matches()); + } +} diff --git a/src/main/java/com/streamx/cli/i18n/MessageProvider.java b/src/main/java/com/streamx/cli/i18n/MessageProvider.java index cc75e786..f8368319 100644 --- a/src/main/java/com/streamx/cli/i18n/MessageProvider.java +++ b/src/main/java/com/streamx/cli/i18n/MessageProvider.java @@ -14,6 +14,26 @@ public interface MessageProvider { @Message(id = 100, value = "Unsupported output format") String unsupportedOutputFormat(); + @Message(id = 429, value = "Token '%s' created. Copy it now - it will not be shown again.") + String authTokenCreated(String name); + + @Message(id = 430, value = "Token revoked") + String authTokenRevoked(); + + @Message(id = 431, value = "No personal access tokens") + String authTokenListEmpty(); + + @Message(id = 432, value = "Could not read the context the token belongs to") + String authTokenIdentityUnavailable(); + + @Message(id = 433, value = "Not authorized. The personal access token in " + + "STREAMX_PLATFORM_TOKEN is invalid or has been revoked") + String platformTokenUnauthorized(); + + @Message(id = 434, value = "A personal access token cannot manage personal access tokens. " + + "Unset %s and run 'streamx auth login' first.") + String authTokenNeedsLoginSession(String variableName); + @Message(id = 101, value = "Try '%s%s' for more information on the available options%n") String tryForMoreInformationOnAvailableOptions( String qualifiedCommandName, @@ -144,12 +164,6 @@ String tryForMoreInformationOnAvailableOptions( @Message(id = 138, value = "Mesh file not found at: %s") String meshFileNotFound(String path); - @Message(id = 139, value = """ - StreamX settings properties: - ================================= - """) - String listSettingsHeader(); - @Message(id = 140, value = "No StreamX settings properties found") String listSettingsNoPropertiesFound(); @@ -414,12 +428,6 @@ String streamBatchPublishingCompleted( @Message(id = 213, value = "No event templates found.") String eventTemplatesNoTemplatesFound(); - @Message(id = 214, value = """ - Event templates: - ================ - """) - String eventTemplatesListHeader(); - @Message(id = 215, value = "Failed to list event templates from %s: %s") String failedToListEventTemplates(String path, String reason); @@ -604,4 +612,427 @@ String streamBatchPublishingCompleted( @Message(id = 263, value = "(TAB for options)") String interactivePickerHint(); + + @Message( + id = 277, + value = "StreamX auth server URL is not configured.%n" + + "Set it with: streamx settings set %s " + ) + String authServerUrlNotConfigured(String key); + + @Message(id = 278, value = "%s does not support the device authorization flow") + String authDeviceFlowUnsupported(String issuerUrl); + + @Message( + id = 279, + value = "To finish signing in, open:%n %s%nand enter the code:%n %s%n%nWaiting..." + ) + String authLoginInstructions(String verificationUri, String userCode); + + @Message(id = 280, value = "Or open this link directly:%n %s") + String authLoginDirectLink(String verificationUriComplete); + + @Message(id = 281, value = "Logged in successfully") + String authLoginSuccess(); + + @Message(id = 282, value = "Login was denied") + String authLoginDenied(); + + @Message(id = 283, value = "Login timed out before it was confirmed. Run 'streamx auth login'" + + " again") + String authLoginExpired(); + + @Message(id = 284, value = "Login failed: %s") + String authLoginFailed(String error); + + @Message(id = 285, value = "Login was interrupted") + String authLoginInterrupted(); + + @Message(id = 286, value = "Request to %s failed: %s") + String authRequestFailed(String url, String reason); + + @Message(id = 287, value = "Request to %s failed with status %d") + String authRequestFailedWithStatus(String url, int statusCode); + + @Message(id = 288, value = "Response from %s was not valid JSON") + String authResponseNotJson(String url); + + @Message(id = 289, value = "Unable to save credentials to %s: %s") + String authCredentialsNotSaved(String path, String reason); + + @Message(id = 290, value = "Unable to read credentials from %s: %s") + String authCredentialsUnreadable(String path, String reason); + + @Message(id = 291, value = "Unable to delete credentials at %s: %s") + String authCredentialsNotDeleted(String path, String reason); + + @Message(id = 292, value = "Logged out successfully") + String authLogoutSuccess(); + + @Message(id = 293, value = "Not logged in") + String authLogoutNotLoggedIn(); + + @Message(id = 294, value = "Unable to disable TLS verification: %s") + String authInsecureTlsFailed(String reason); + + @Message(id = 353, value = "Refusing to send credentials over cleartext HTTP to '%s'.%n" + + "Use an https:// auth server URL (http:// is allowed only for localhost)") + String authCleartextHttpBlocked(String url); + + @Message(id = 295, value = "Your session has expired. Run 'streamx auth login' again") + String authSessionExpired(); + + @Message(id = 296, value = "Not logged in. Run 'streamx auth login' first") + String platformNotLoggedIn(); + + @Message( + id = 297, + value = "StreamX platform URL is not configured.%nSet it with: streamx settings set %s " + ) + String platformUrlNotConfigured(String key); + + @Message(id = 298, value = "Not authorized. Run 'streamx auth login' again") + String platformUnauthorized(); + + @Message(id = 301, value = "Request to %s failed: %s") + String platformRequestFailed(String url, String reason); + + @Message(id = 302, value = "Request to %s failed with status %d") + String platformRequestFailedWithStatus(String url, int statusCode); + + @Message(id = 303, value = "Request rejected (%d): %s") + String platformRequestRejected(int statusCode, String detail); + + @Message(id = 354, value = "Refusing to send credentials over cleartext HTTP to '%s'.%n" + + "Use an https:// platform URL (http:// is allowed only for localhost)") + String platformCleartextHttpBlocked(String url); + + @Message(id = 309, value = "Stored access token is not a readable JWT") + String authTokenMalformed(); + + @Message(id = 319, value = "Identity provider returned a token response without an access token") + String authTokenResponseIncomplete(); + + @Message(id = 331, value = "Opening your browser to sign in. If it does not open, visit:") + String authLoginOpeningBrowser(); + + @Message(id = 332, value = "No browser available; falling back to device code sign-in.") + String authBrowserFallbackToDevice(); + + @Message( + id = 333, + value = "The identity provider does not advertise an authorization endpoint. " + + "Retry with --no-browser to use the device flow." + ) + String authCodeFlowUnsupported(); + + @Message(id = 334, value = "Could not start the local login listener: %s") + String authLoopbackFailed(String reason); + + @Message(id = 335, value = "Signed in. You can close this tab and return to the terminal.") + String authLoopbackSuccess(); + + @Message(id = 336, value = "Sign-in failed. Return to the terminal and try again.") + String authLoopbackDenied(); + + @Message(id = 337, value = "Unable to generate a PKCE challenge: %s") + String authPkceFailed(String reason); + + @Message(id = 338, value = "Configured issuer '%s' does not match discovery document issuer '%s'") + String authIssuerMismatch(String configured, String documentIssuer); + + @Message(id = 339, value = "Token request rejected (%d): %s") + String authTokenRequestRejected(int statusCode, String detail); + + @Message(id = 352, value = "The identity provider does not advertise a revocation endpoint") + String authRevocationUnsupported(); + + + @Message(id = 306, value = "No organizations found") + String orgListEmpty(); + + @Message(id = 307, value = "Organization '%s' created") + String orgCreated(String name); + + @Message(id = 308, value = "Organization '%s' deleted") + String orgDeleted(String orgId); + + @Message(id = 310, value = "No members found") + String orgMembersListEmpty(); + + @Message(id = 311, value = "Member '%s' added with role '%s'") + String orgMemberAdded(String name, String role); + + @Message(id = 312, value = "Member '%s' removed") + String orgMemberRemoved(String userId); + + @Message(id = 313, value = "Role of '%s' changed to '%s'") + String orgMemberRoleChanged(String userId, String role); + + @Message(id = 314, value = "No invitations found") + String orgInvitationsListEmpty(); + + @Message(id = 315, value = "Invitation sent to '%s' with role '%s'") + String orgInvitationCreated(String email, String role); + + @Message(id = 316, value = "Invitation accepted") + String orgInvitationAccepted(); + + @Message(id = 317, value = "Invitation for '%s' cancelled") + String orgInvitationCancelled(String email); + + @Message(id = 318, value = "No clusters found") + String orgClustersListEmpty(); + + @Message(id = 320, value = "Paste the invitation token") + String orgInvitationTokenPrompt(); + + @Message(id = 321, value = "Invitation token is required") + String orgInvitationTokenRequired(); + + @Message(id = 322, value = "'%s' is not a member of organization '%s'") + String orgMemberNotFound(String userId, String orgId); + + @Message( + id = 323, + value = "'%s' is a pending invitation (%s), not an active member.%n" + + "Cancel it with: streamx org invitations cancel %s %s" + ) + String orgMemberNotActiveForRemoval(String userId, String status, String orgId, String email); + + @Message( + id = 324, + value = "'%s' is a pending invitation (%s), not an active member.%n" + + "Changing its role would grant membership without the invitation being accepted.%n" + + "Wait for the invitation to be accepted, or add the account directly with:%n" + + " streamx org members add %s %s --role " + ) + String orgMemberNotActiveForRoleChange(String userId, String status, String orgId, String email); + + @Message(id = 325, value = "No projects found") + String projectListEmpty(); + + @Message(id = 326, value = "Project '%s' created (id: %s)") + String projectCreated(String name, String id); + + @Message(id = 327, value = "Project '%s' updated") + String projectUpdated(String projectId); + + @Message(id = 328, value = "Project '%s' deleted") + String projectDeleted(String projectId); + + @Message(id = 329, value = "At least one of --name or --description must be given") + String projectUpdateNothingToDo(); + + @Message(id = 330, value = "No pending changes") + String projectPendingChangesEmpty(); + + @Message(id = 355, + value = "Invalid context name '%s'. Use 1-32 lowercase letters, digits or dashes") + String contextNameInvalid(String name); + + @Message(id = 356, + value = "Context '%1$s' does not exist. Create it with: streamx context create %1$s") + String contextNotFound(String name); + + @Message(id = 357, value = "Context '%s' already exists") + String contextAlreadyExists(String name); + + @Message(id = 358, value = "Context '%s' created") + String contextCreated(String name); + + @Message(id = 359, value = "Switched to context '%s'") + String contextSwitched(String name); + + @Message(id = 360, value = "Context '%s' deleted") + String contextDeleted(String name); + + @Message(id = 361, value = "The context's stored login was removed locally but NOT revoked.%n" + + "Next time run 'streamx auth logout' in the context before deleting it.") + String contextDeletedLoginNote(); + + @Message(id = 362, + value = "Context '%s' is set as the current context. Switch to another context first") + String contextCannotDeleteCurrent(String name); + + @Message(id = 363, value = "Context '%s' is active. Switch to another context first") + String contextCannotDeleteActive(String name); + + @Message(id = 364, value = "Current context: %s") + String currentContextHeader(String name); + + @Message(id = 365, value = "Could not create context '%s': %s") + String contextCreateFailed(String name, String reason); + + @Message(id = 366, value = "Could not switch context: %s") + String contextSwitchFailed(String reason); + + @Message(id = 367, value = "Could not delete context '%s': %s") + String contextDeleteFailed(String name, String reason); + + @Message(id = 368, value = "Invalid context name '%1$s' in %2$s. " + + "Fix or delete that file, or pass --context to override") + String contextInvalidPointer(String name, String pointerFile); + + @Message(id = 369, value = "Context '%s' does not exist") + String contextDoesNotExist(String name); + + @Message(id = 370, value = "Auth server URL") + String contextConfigurePromptAuthUrl(); + + @Message(id = 371, value = "Platform API URL") + String contextConfigurePromptPlatformUrl(); + + @Message(id = 372, + value = "Ingestion URL (per-project on the cloud platform; leave empty to skip)") + String contextConfigurePromptIngestionUrl(); + + @Message(id = 373, + value = "Verify TLS certificates for %s (answer no for self-signed dev certs)?") + String contextConfigurePromptVerifyTls(String target); + + @Message(id = 374, value = "A value for '%s' is required") + String contextConfigureValueRequired(String key); + + @Message(id = 375, value = "Invalid URL '%s'. Use http:// or https://") + String contextConfigureInvalidUrl(String value); + + @Message(id = 376, value = "Invalid answer '%s'") + String contextConfigureInvalidAnswer(String value); + + @Message(id = 377, value = "Context '%s' configured") + String contextConfigureSaved(String name); + + @Message(id = 378, value = "Log in now?") + String contextConfigurePromptLogin(); + + @Message(id = 379, value = "Login method") + String contextConfigurePromptLoginMethod(); + + @Message(id = 380, value = "Run 'streamx context configure' to set its endpoints") + String contextCreateConfigureHint(); + + @Message(id = 381, value = "This permanently deletes '%s'. Type the ID to confirm") + String deleteConfirmPrompt(String id); + + @Message(id = 382, value = "Deletion cancelled: the entered value did not match '%s'") + String deleteConfirmMismatch(String id); + + @Message(id = 383, + value = "Deletion needs confirmation. Re-run with --force in non-interactive environments") + String deleteConfirmRequired(); + + @Message(id = 384, value = "No organization given. Pass , set STREAMX_ORG, " + + "or run: streamx context org use ") + String noOrgContext(); + + @Message(id = 386, value = "No project given. Pass , set STREAMX_PROJECT, " + + "or run: streamx context project use ") + String noProjectContext(); + + @Message(id = 387, value = "Current organization set to '%s'") + String orgUseSet(String orgId); + + @Message(id = 388, value = "No current organization set. Run: streamx context org use ") + String noCurrentOrg(); + + @Message(id = 389, value = "Current project set to '%s'") + String projectUseSet(String projectId); + + @Message(id = 390, value = "No current project set. Run: streamx context project use ") + String noCurrentProject(); + + @Message(id = 391, + value = "Cleared current project '%s' (it belonged to the previous organization)") + String orgUseClearedProject(String projectId); + + @Message(id = 392, value = "Current organization (Enter to skip)") + String contextConfigurePromptOrg(); + + @Message(id = 393, value = "Current project (Enter to skip)") + String contextConfigurePromptProject(); + + @Message(id = 394, value = "Skipping organization/project selection: %s") + String contextConfigureContextSkipped(String reason); + + @Message(id = 395, value = "Current organization cleared") + String orgUnset(); + + @Message(id = 396, value = "Current project cleared") + String projectUnset(); + + @Message(id = 397, value = "Current organization: %s") + String currentOrgHeader(String orgId); + + @Message(id = 398, value = "Current project: %s") + String currentProjectHeader(String projectId); + + @Message(id = 399, value = "Project '%s' now runs on: %s") + String projectClustersSet(String projectId, String clusterIds); + + @Message(id = 400, value = "Cluster '%s' enabled for project '%s'") + String projectClusterEnabled(String clusterId, String projectId); + + @Message(id = 401, value = "Cluster '%s' disabled for project '%s'") + String projectClusterDisabled(String clusterId, String projectId); + + @Message(id = 402, value = "Cluster '%s' is already enabled for project '%s'") + String projectClusterAlreadyEnabled(String clusterId, String projectId); + + @Message(id = 403, value = "Cluster '%s' is already disabled for project '%s'") + String projectClusterAlreadyDisabled(String clusterId, String projectId); + + @Message(id = 404, value = "Unknown cluster '%s'. Available clusters: %s") + String projectClusterUnknown(String clusterId, String available); + + @Message(id = 405, value = "Could not read SSH private key file '%s': %s") + String projectSshKeyFileUnreadable(String path, String reason); + + @Message(id = 406, value = "Repository connected to project '%s'") + String projectRepoConnected(String projectId); + + @Message(id = 407, value = "Repository settings updated for project '%s'") + String projectRepoUpdated(String projectId); + + @Message(id = 408, value = "Repository disconnected from project '%s'") + String projectRepoRemoved(String projectId); + + @Message(id = 409, + value = "Project '%1$s' has no repository connected. " + + "Connect one with: streamx project repo set --uri --branch ") + String projectRepoNotConnected(String projectId); + + @Message(id = 410, value = "SSH key set for project '%s'") + String projectSshKeySet(String projectId); + + @Message(id = 411, value = "SSH key removed for project '%s'") + String projectSshKeyRemoved(String projectId); + + @Message(id = 412, value = "Project '%s' has no SSH key configured") + String projectSshKeyMissing(String projectId); + + @Message(id = 413, value = "SSH key pair written: '%s' (private) and '%s' (public). " + + "Add the public key to the Git hosting's deploy keys") + String projectSshKeyPairWritten(String privatePath, String publicPath); + + @Message(id = 414, value = "Refusing to overwrite existing file '%s'") + String projectSshKeyFileExists(String path); + + @Message(id = 415, value = "Could not write '%s': %s") + String projectSshKeyFileWriteFailed(String path, String reason); + + @Message(id = 416, value = "specified") + String sshKeySpecified(); + + @Message(id = 417, value = "not specified") + String sshKeyNotSpecified(); + + @Message(id = 418, value = "not connected") + String repositoryNotConnected(); + + @Message(id = 419, value = "Not found, or you do not have access to it") + String platformNotFound(); + + @Message(id = 420, value = "You do not have permission to perform this action") + String platformAccessDenied(); } diff --git a/src/main/java/com/streamx/cli/platform/PlatformConfig.java b/src/main/java/com/streamx/cli/platform/PlatformConfig.java new file mode 100644 index 00000000..9b53df30 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/PlatformConfig.java @@ -0,0 +1,39 @@ +package com.streamx.cli.platform; + +import com.streamx.cli.config.StreamxHome; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.PropertiesConfigSource; +import io.smallrye.config.SmallRyeConfigBuilder; +import io.smallrye.config.WithDefault; +import io.smallrye.config.WithName; +import java.io.IOException; +import java.util.Optional; +import org.apache.commons.lang3.BooleanUtils; + +@ConfigMapping +public interface PlatformConfig { + String STREAMX_PLATFORM_URL = "streamx.platform.url"; + String STREAMX_PLATFORM_INSECURE = "streamx.platform.insecure"; + + @WithName(STREAMX_PLATFORM_URL) + Optional url(); + + @WithName(STREAMX_PLATFORM_INSECURE) + @WithDefault(BooleanUtils.FALSE) + boolean insecure(); + + static PlatformConfig load() { + SmallRyeConfigBuilder builder = new SmallRyeConfigBuilder() + .withMapping(PlatformConfig.class) + .addDefaultSources(); + + try { + builder.withSources(new PropertiesConfigSource(StreamxHome.getConfigUrl(), 260)); + } catch (IOException expected) { + } + + return builder + .build() + .getConfigMapping(PlatformConfig.class); + } +} diff --git a/src/main/java/com/streamx/cli/platform/PlatformContext.java b/src/main/java/com/streamx/cli/platform/PlatformContext.java new file mode 100644 index 00000000..033cf87c --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/PlatformContext.java @@ -0,0 +1,106 @@ +package com.streamx.cli.platform; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.config.StreamxHome; +import com.streamx.cli.framework.CliException; + +/** + * Resolves the organization/project a command operates on. + * + *
+ *   explicit argument > STREAMX_ORG / STREAMX_PROJECT > context's current-org/current-project
+ * 
+ * + * The env vars are invocation-scoped overrides (CI, scripts) and deliberately not bound to a + * context; the files are the per-context persisted context written by {@code org use} and + * {@code project use}. + */ +public final class PlatformContext { + + public static final String STREAMX_ORG = "STREAMX_ORG"; + public static final String STREAMX_PROJECT = "STREAMX_PROJECT"; + + private PlatformContext() { + } + + public record OrgProject(String org, String project) { + } + + public static String effectiveOrg() { + String env = override(STREAMX_ORG); + return env != null ? env : StreamxHome.readCurrentOrg(); + } + + public static String effectiveProject() { + String env = override(STREAMX_PROJECT); + return env != null ? env : StreamxHome.readCurrentProject(); + } + + public static String effectiveOrgSource() { + if (override(STREAMX_ORG) != null) { + return "from the STREAMX_ORG environment variable"; + } + return StreamxHome.readCurrentOrg() != null ? "from the current-org file" : null; + } + + public static String effectiveProjectSource() { + if (override(STREAMX_PROJECT) != null) { + return "from the STREAMX_PROJECT environment variable"; + } + return StreamxHome.readCurrentProject() != null ? "from the current-project file" : null; + } + + public static String requireOrg(String orgArg) { + if (orgArg != null && !orgArg.isBlank()) { + return orgArg; + } + String effective = effectiveOrg(); + if (effective == null) { + throw new CliException(msg.noOrgContext()); + } + return effective; + } + + public static OrgProject orgAndProject(String orgArg, String projectArg) { + String project = projectArg != null ? projectArg : effectiveProject(); + if (project == null) { + throw new CliException(msg.noProjectContext()); + } + return new OrgProject(requireOrg(orgArg), project); + } + + public static String setCurrentOrg(String orgId) { + try { + String previousOrg = StreamxHome.readCurrentOrg(); + String currentProject = StreamxHome.readCurrentProject(); + StreamxHome.writeCurrentOrg(orgId); + if (currentProject != null && previousOrg != null && !previousOrg.equals(orgId)) { + StreamxHome.clearCurrentProject(); + return currentProject; + } + return null; + } catch (java.io.IOException e) { + throw new CliException(e.getMessage(), e); + } + } + + public static void setCurrentProject(String projectId) { + if (StreamxHome.readCurrentOrg() == null) { + throw new CliException(msg.noCurrentOrg()); + } + try { + StreamxHome.writeCurrentProject(projectId); + } catch (java.io.IOException e) { + throw new CliException(e.getMessage(), e); + } + } + + private static String override(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + value = System.getenv(name); + } + return value == null || value.isBlank() ? null : value; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d3e014ce..970bc01d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,13 @@ quarkus.package.jar.type=uber-jar +# Default endpoints offered by `streamx context configure` (baked in at build time). +# Ingestion has no default: its URL is per-project (ingestion..). +# TEMPORARY. Restore before release: +# streamx.defaults.auth.server-url=https://auth.streamx.com +# streamx.defaults.platform.url=https://cloud.streamx.com +streamx.defaults.auth.server-url=https://auth.demo-1.82-29-94-68.sslip.io:1443 +streamx.defaults.platform.url=https://console.demo-1.82-29-94-68.sslip.io:1443 + quarkus.banner.enabled=false quarkus.log.level=ERROR quarkus.log.console.enable=true @@ -19,4 +27,4 @@ quarkus.native.additional-build-args=\ # Fixes "Cannot load required properties from maven-build.properties" error # when run streamx-runner with native-image executable. -quarkus.native.resources.includes=maven-build.properties,default-event-templates/**,container/** \ No newline at end of file +quarkus.native.resources.includes=maven-build.properties,default-event-templates/**,container/** 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 16e393e0..97d0b538 100644 --- a/src/test/java/com/streamx/cli/commands/completion/CompletionCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/completion/CompletionCommandIT.java @@ -36,6 +36,14 @@ void shouldEmitDynamicTemplateIdCompletionForPublishEvent() throws Exception { .contains("$(streamx __complete-template-ids 2>/dev/null)"); } + @Test + void shouldEmitDynamicContextNameCompletion() throws Exception { + ProcessResult result = exec("completion", "zsh"); + result.assertSuccess(); + assertThat(result.stdout()) + .contains("$(streamx __complete-context-names 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 new file mode 100644 index 00000000..7b5175b8 --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/context/ContextCommandIT.java @@ -0,0 +1,295 @@ +package com.streamx.cli.commands.context; + +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.sampleTemplate; +import static com.streamx.cli.i18n.MessageProvider.msg; +import static org.assertj.core.api.Assertions.assertThat; + +import com.streamx.cli.test.CliBaseIT; +import io.quarkus.test.junit.QuarkusTest; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class ContextCommandIT extends CliBaseIT { + + + @BeforeEach + void cleanContexts() throws IOException { + deleteRecursively(streamxHome.resolve("contexts")); + Files.deleteIfExists(streamxHome.resolve("current-context")); + } + + @Test + void firstUseBootstrapsDefaultContext() throws Exception { + ProcessResult result = exec("context", "current"); + + result.assertSuccess(); + assertThat(result.stdout().strip()).isEqualTo("default"); + assertThat(streamxHome.resolve("contexts/default/config")).isDirectory(); + assertThat(streamxHome.resolve("contexts/default/event-templates")).isDirectory(); + assertThat(streamxHome.resolve("current-context")).content().contains("default"); + } + + @Test + void createSwitchesToNewContextAndSuggestsConfigure() throws Exception { + ProcessResult created = exec("context", "create", "prod"); + + created.assertSuccess(); + assertThat(created.stdout()) + .contains("Context 'prod' created") + .contains("Switched to context 'prod'"); + assertThat(created.stderr()).contains("streamx context configure"); + assertThat(exec("context", "current").stdout().strip()).isEqualTo("prod"); + } + + @Test + void createUseCurrentLifecycle() throws Exception { + exec("context", "create", "prod").assertSuccess(); + exec("context", "use", "prod").assertSuccess(); + + ProcessResult current = exec("context", "current"); + current.assertSuccess(); + assertThat(current.stdout().strip()).isEqualTo("prod"); + + ProcessResult list = exec("context", "list"); + list.assertSuccess(); + assertThat(list.stdout()).contains("default").contains("prod").contains("*"); + + ProcessResult quiet = exec("context", "list", "-q"); + quiet.assertSuccess(); + assertThat(quiet.stdout().strip().lines()).containsExactly("default", "prod"); + } + + @Test + void useMissingContextFailsHard() throws Exception { + ProcessResult result = exec("context", "use", "nope"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains("does not exist"); + assertThat(exec("context", "current").stdout().strip()).isEqualTo("default"); + } + + @Test + void corruptedPointerFileErrorsWithPathAndFlagRepairs() throws Exception { + Files.writeString(streamxHome.resolve("current-context"), "Bad_Name\n"); + + ProcessResult result = exec("context", "current"); + result.assertExitCode(1); + assertThat(result.stderr()).contains("Bad_Name").contains("current-context"); + + ProcessResult repaired = exec("context", "current", "-C", "default"); + repaired.assertSuccess(); + assertThat(repaired.stdout().strip()).isEqualTo("default"); + } + + @Test + void createRejectsInvalidNamesAndDuplicates() throws Exception { + assertThat(exec("context", "create", "Bad_Name").stderr()).contains("Invalid context name"); + assertThat(exec("context", "create", "default").stderr()).contains("already exists"); + + exec("context", "create", "dup").assertSuccess(); + assertThat(exec("context", "create", "dup").stderr()).contains("already exists"); + } + + @Test + void missingContextFailsAndCreatesNothing() throws Exception { + ProcessResult result = exec("settings", "list", "--context", "ghost"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains("does not exist"); + assertThat(streamxHome.resolve("contexts/ghost")).doesNotExist(); + + ProcessResult use = exec("context", "use", "ghost2", "--context", "ghost2"); + use.assertExitCode(1); + assertThat(use.stderr()).contains("does not exist"); + assertThat(streamxHome.resolve("contexts/ghost2")).doesNotExist(); + } + + @Test + void createFromCopiesSettingsAndTemplatesButNeverCredentials() throws Exception { + exec("context", "current").assertSuccess(); + Files.writeString(streamxHome.resolve("contexts/default/config/application.properties"), + "streamx.platform.url=https://dev.example.com\n"); + Files.writeString(streamxHome.resolve("contexts/default/config/credentials.json"), "{}"); + Files.writeString(streamxHome.resolve("contexts/default/event-templates/mine.json"), + sampleTemplate("com.example.mine.v1")); + + exec("context", "create", "clone", "--from", "default").assertSuccess(); + + Path cloneDir = streamxHome.resolve("contexts/clone"); + assertThat(cloneDir.resolve("config/application.properties")) + .content().contains("dev.example.com"); + assertThat(cloneDir.resolve("event-templates/mine.json")).isRegularFile(); + assertThat(cloneDir.resolve("config/credentials.json")).doesNotExist(); + } + + @Test + void settingsFollowTheActiveContext() throws Exception { + exec("context", "create", "prod").assertSuccess(); + exec("context", "use", "prod").assertSuccess(); + + exec("settings", "set", "streamx.platform.url", "https://prod.example.com").assertSuccess(); + + assertThat(streamxHome.resolve("contexts/prod/config/application.properties")) + .content().contains("prod.example.com"); + Path defaultSettings = streamxHome.resolve("contexts/default/config/application.properties"); + if (Files.exists(defaultSettings)) { + assertThat(defaultSettings).content().doesNotContain("prod.example.com"); + } + } + + @Test + void customTemplatesAndRegistrationsAreContextScoped() throws Exception { + exec("context", "create", "prod").assertSuccess(); + exec("context", "use", "prod").assertSuccess(); + Files.writeString(streamxHome.resolve("contexts/prod/event-templates/mine.json"), + sampleTemplate("com.example.mine.v1")); + Path registeredFile = streamxHome.resolve("reg-src.json"); + Files.writeString(registeredFile, sampleTemplate("com.example.registered.v1")); + exec("settings", "event-templates", "register", "reg.tpl", registeredFile.toString()) + .assertSuccess(); + + ProcessResult prodList = exec("settings", "event-templates", "list"); + prodList.assertSuccess(); + assertThat(prodList.stdout()) + .contains("mine") + .contains("reg.tpl") + .contains("page.published"); + + exec("context", "use", "default").assertSuccess(); + ProcessResult defaultList = exec("settings", "event-templates", "list"); + defaultList.assertSuccess(); + assertThat(defaultList.stdout()) + .doesNotContain("mine") + .doesNotContain("reg.tpl") + .contains("page.published"); + } + + @Test + void contextFlagOverridesPointerWithoutChangingIt() throws Exception { + exec("context", "create", "prod").assertSuccess(); + exec("context", "use", "default").assertSuccess(); + + ProcessResult overridden = exec("context", "current", "--context", "prod"); + overridden.assertSuccess(); + assertThat(overridden.stdout().strip()).isEqualTo("prod"); + + assertThat(exec("context", "current").stdout().strip()).isEqualTo("default"); + } + + @Test + void globalFlagsWorkAtAnyPosition() throws Exception { + exec("context", "create", "prod").assertSuccess(); + // Point the pointer away from prod so each flag position must actually override it. + exec("context", "use", "default").assertSuccess(); + + ProcessResult root = exec("--context", "prod", "context", "current"); + root.assertSuccess(); + assertThat(root.stdout().strip()).isEqualTo("prod"); + + ProcessResult mid = exec("context", "--context", "prod", "current"); + mid.assertSuccess(); + assertThat(mid.stdout().strip()).isEqualTo("prod"); + + ProcessResult shortFlag = exec("-C", "prod", "context", "current"); + shortFlag.assertSuccess(); + assertThat(shortFlag.stdout().strip()).isEqualTo("prod"); + + Path altHome = streamxHome.resolve("alt-home"); + ProcessResult alt = exec("-H", altHome.toString(), "context", "current"); + alt.assertSuccess(); + assertThat(alt.stdout().strip()).isEqualTo("default"); + assertThat(altHome.resolve("contexts/default/config")).isDirectory(); + } + + @Test + void helpHeaderShowsCurrentContext() throws Exception { + ProcessResult defaultHelp = exec("--help"); + defaultHelp.assertSuccess(); + String out = defaultHelp.stdout(); + assertThat(out).contains("Current context: default"); + assertThat(out).doesNotContain("Usage:"); + assertThat(out.indexOf("Current context:")) + .as("context line renders above the command list") + .isLessThan(out.indexOf("Commands:")); + + exec("context", "create", "prod").assertSuccess(); + exec("context", "use", "prod").assertSuccess(); + + ProcessResult prodHelp = exec("--help"); + prodHelp.assertSuccess(); + assertThat(prodHelp.stdout()).contains("Current context: prod"); + + ProcessResult flagHelp = exec("-C", "default", "--help"); + flagHelp.assertSuccess(); + assertThat(flagHelp.stdout()) + .as("help header honors --context over the pointer") + .contains("Current context: default"); + } + + @Test + void completeContextNamesListsAllContexts() throws Exception { + exec("context", "create", "prod").assertSuccess(); + exec("context", "create", "staging").assertSuccess(); + + ProcessResult result = exec("__complete-context-names"); + + result.assertSuccess(); + assertThat(result.stdout().strip().lines()) + .containsExactly("default", "prod", "staging"); + } + + @Test + void deleteRefusesActiveAndCurrentContext() throws Exception { + exec("context", "create", "prod").assertSuccess(); + exec("context", "use", "prod").assertSuccess(); + + assertThat(exec("context", "delete", "missing").stderr()).contains("does not exist"); + assertThat(exec("context", "delete", "prod").stderr()).contains("is active"); + assertThat(exec("context", "delete", "prod", "--context", "default").stderr()) + .contains("is set as the current context"); + assertThat(streamxHome.resolve("contexts/prod")).isDirectory(); + } + + @Test + void deleteDefaultAllowedWhenNotCurrentAndBootstrapRecreatesIt() throws Exception { + exec("context", "create", "prod").assertSuccess(); + exec("context", "use", "prod").assertSuccess(); + Files.writeString( + streamxHome.resolve("contexts/default/config/credentials.json"), "{}"); + + ProcessResult deleted = exec("context", "delete", "default"); + deleted.assertSuccess(); + assertThat(deleted.stderr()).contains("NOT revoked"); + assertThat(streamxHome.resolve("contexts/default")).doesNotExist(); + + assertThat(exec("context", "use", "default").stderr()).contains("does not exist"); + + ProcessResult bootstrapped = exec("context", "current", "--context", "default"); + bootstrapped.assertSuccess(); + assertThat(bootstrapped.stdout().strip()).isEqualTo("default"); + assertThat(streamxHome.resolve("contexts/default/config")).isDirectory(); + assertThat(exec("context", "current").stdout().strip()).isEqualTo("prod"); + } + + private static void deleteRecursively(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (Stream paths = Files.walk(root)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + } + +} diff --git a/src/test/java/com/streamx/cli/commands/publish/event/DefaultEventTemplatesIT.java b/src/test/java/com/streamx/cli/commands/publish/event/DefaultEventTemplatesIT.java index 757bc716..f0c7c17f 100644 --- a/src/test/java/com/streamx/cli/commands/publish/event/DefaultEventTemplatesIT.java +++ b/src/test/java/com/streamx/cli/commands/publish/event/DefaultEventTemplatesIT.java @@ -173,9 +173,9 @@ void shouldPreferSettingsOverPopulatedTemplate(@TempDir Path tempDir) throws Exc Path payloadFile = tempDir.resolve("payload.html"); Files.writeString(payloadFile, "hello"); - Files.createDirectories(customHome); + Files.createDirectories(customHome.resolve("contexts/default")); Files.writeString( - customHome.resolve("override-page-published.json"), + customHome.resolve("contexts/default/override-page-published.json"), templateWithOrigin("settings") ); diff --git a/src/test/java/com/streamx/cli/commands/publish/event/EventTemplateCatalogTest.java b/src/test/java/com/streamx/cli/commands/publish/event/EventTemplateCatalogTest.java index 6b495c89..8e38397b 100644 --- a/src/test/java/com/streamx/cli/commands/publish/event/EventTemplateCatalogTest.java +++ b/src/test/java/com/streamx/cli/commands/publish/event/EventTemplateCatalogTest.java @@ -44,6 +44,7 @@ class EventTemplateCatalogTest { @BeforeEach void redirectStreamxHome() { StreamxHome.setStreamxHomeCliArg(home.toString()); + StreamxHome.clearContextCliArg(); } @AfterEach @@ -100,7 +101,7 @@ void settingsRegistrationOverridesUserAndDefaults() throws Exception { seedDefault("page.published", SAMPLE_DEFAULT); seedUser("page.published", SAMPLE_USER); - Path registered = home.resolve("registered.json"); + Path registered = home.resolve("contexts/default/registered.json"); Files.writeString(registered, SAMPLE_REGISTERED); seedSettings("page.published", "registered.json"); @@ -124,7 +125,8 @@ void listAllSortsByIdAndDeduplicates() throws Exception { @Test void listSettingsRegistrationsSkipsBlankAndNonPrefixedKeys() throws Exception { - Path some = home.resolve("some.json"); + Path some = home.resolve("contexts/default/some.json"); + Files.createDirectories(some.getParent()); Files.writeString(some, SAMPLE_REGISTERED); Properties props = new Properties(); props.setProperty("eventtemplate.real", "some.json"); @@ -138,16 +140,17 @@ void listSettingsRegistrationsSkipsBlankAndNonPrefixedKeys() throws Exception { } @Test - void resolveRelativeToHomeAbsolutizesAgainstStreamxHome() { - Path resolved = EventTemplateCatalog.resolveRelativeToHome("nested/file.json"); + void resolveRelativeToContextDirAbsolutizesAgainstContextDir() { + Path resolved = EventTemplateCatalog.resolveRelativeToContextDir("nested/file.json"); assertThat(resolved).isAbsolute(); - assertThat(resolved).isEqualTo(home.resolve("nested/file.json").toAbsolutePath()); + assertThat(resolved) + .isEqualTo(home.resolve("contexts/default/nested/file.json").toAbsolutePath()); } @Test - void resolveRelativeToHomeKeepsAbsolutePathsUntouched() { + void resolveRelativeToContextDirKeepsAbsolutePathsUntouched() { Path absolute = home.resolve("abs.json").toAbsolutePath(); - Path resolved = EventTemplateCatalog.resolveRelativeToHome(absolute.toString()); + Path resolved = EventTemplateCatalog.resolveRelativeToContextDir(absolute.toString()); assertThat(resolved).isEqualTo(absolute); } @@ -166,7 +169,7 @@ private void seedDefault(String id, String body) throws Exception { } private void seedUser(String id, String body) throws Exception { - Path dir = home.resolve(UserEventTemplates.DIRECTORY); + Path dir = home.resolve("contexts/default/event-templates"); Files.createDirectories(dir); Files.writeString(dir.resolve(id + UserEventTemplates.EXTENSION), body); } @@ -178,7 +181,7 @@ private void seedSettings(String id, String pathValue) throws Exception { } private void writeConfig(Properties props) throws Exception { - Path config = home.resolve("config/application.properties"); + Path config = home.resolve("contexts/default/config/application.properties"); Files.createDirectories(config.getParent()); try (OutputStream out = Files.newOutputStream(config)) { props.store(out, null); diff --git a/src/test/java/com/streamx/cli/commands/publish/event/EventTemplateLoaderIT.java b/src/test/java/com/streamx/cli/commands/publish/event/EventTemplateLoaderIT.java index 9730cc9c..32d3ee8e 100644 --- a/src/test/java/com/streamx/cli/commands/publish/event/EventTemplateLoaderIT.java +++ b/src/test/java/com/streamx/cli/commands/publish/event/EventTemplateLoaderIT.java @@ -34,6 +34,13 @@ static void stopMesh() { private static final String DEFAULT_TEMPLATE_TYPE = "page.published"; private static final ObjectMapper MAPPER = new ObjectMapper(); + /** Relative registration paths resolve against the active (default) context's dir. */ + private static Path contextDir() throws Exception { + Path dir = streamxHome.resolve("contexts/default"); + Files.createDirectories(dir); + return dir; + } + private static String createTemplateContent() { return """ { @@ -82,7 +89,7 @@ void shouldResolveTemplateFromRelativeFilename(@TempDir Path tempDir) throws Exc Path payloadFile = tempDir.resolve("payload.html"); Files.writeString(payloadFile, "hello"); - Path templateFile = streamxHome.resolve("relative-test-template.json"); + Path templateFile = contextDir().resolve("relative-test-template.json"); Files.writeString(templateFile, createTemplateContent()); try { @@ -111,7 +118,7 @@ void shouldResolveTemplateFromRelativeSubdirectoryPath(@TempDir Path tempDir) th Path payloadFile = tempDir.resolve("payload.html"); Files.writeString(payloadFile, "hello"); - Path templateSubDir = streamxHome.resolve("templates"); + Path templateSubDir = contextDir().resolve("templates"); Files.createDirectories(templateSubDir); Path templateFile = templateSubDir.resolve("sub-template.json"); Files.writeString(templateFile, createTemplateContent()); @@ -188,7 +195,7 @@ void shouldPreferSettingsTemplateWithRelativePathOverDefault(@TempDir Path tempD Path payloadFile = tempDir.resolve("payload.html"); Files.writeString(payloadFile, "hello"); - Path templateFile = streamxHome.resolve("override-page-published.json"); + Path templateFile = contextDir().resolve("override-page-published.json"); Files.writeString(templateFile, createTemplateContent()); try { @@ -257,7 +264,8 @@ void shouldFailWhenRelativePathDoesNotExist(@TempDir Path tempDir) throws Except ); result.assertExitCode(1); - Path expectedPath = streamxHome.resolve("non-existent-template.json").toAbsolutePath(); + Path expectedPath = + streamxHome.resolve("contexts/default/non-existent-template.json").toAbsolutePath(); assertThat(result.stderr()).contains(msg.eventTemplateFileMissing(expectedPath.toString())); } @@ -285,7 +293,7 @@ void shouldFailWhenRelativePathResolvesToDirectory(@TempDir Path tempDir) throws Path payloadFile = tempDir.resolve("payload.html"); Files.writeString(payloadFile, "hello"); - Path templateDir = streamxHome.resolve("template-dir"); + Path templateDir = contextDir().resolve("template-dir"); Files.createDirectories(templateDir); try { diff --git a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/EventTemplatesTestSupport.java b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/EventTemplatesTestSupport.java index 3c1f0ae0..77f88c91 100644 --- a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/EventTemplatesTestSupport.java +++ b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/EventTemplatesTestSupport.java @@ -3,6 +3,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import com.streamx.cli.commands.publish.event.DefaultEventTemplates; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; public final class EventTemplatesTestSupport { @@ -12,6 +16,24 @@ public final class EventTemplatesTestSupport { private EventTemplatesTestSupport() { } + public static Path userTemplatesDir(Path home) { + return home.resolve("contexts/default/event-templates"); + } + + public static Path defaultTemplatesDir(Path home) { + return home.resolve(DefaultEventTemplates.DIRECTORY); + } + + public static Path configFile(Path home) { + return home.resolve("contexts/default/config/application.properties"); + } + + public static Path contextFile(Path home, String name) throws IOException { + Path file = home.resolve("contexts/default").resolve(name); + Files.createDirectories(file.getParent()); + return file; + } + public static String sampleTemplate(String type) { return """ { diff --git a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/copy/CopyCommandIT.java b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/copy/CopyCommandIT.java index 5d2b4872..d3dd65e0 100644 --- a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/copy/CopyCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/copy/CopyCommandIT.java @@ -1,11 +1,12 @@ package com.streamx.cli.commands.settings.eventtemplates.copy; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.defaultTemplatesDir; import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.sampleTemplate; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.userTemplatesDir; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.streamx.cli.commands.publish.event.UserEventTemplates; import com.streamx.cli.test.CliBaseIT; import io.quarkus.test.junit.QuarkusTest; import java.nio.file.Files; @@ -56,18 +57,18 @@ void shouldCopyDefaultTemplateToUserFolder(@TempDir Path tempDir) throws Excepti result.assertSuccess(); - Path copy = home.resolve(UserEventTemplates.DIRECTORY).resolve("my.page.json"); + Path copy = userTemplatesDir(home).resolve("my.page.json"); assertThat(copy).isRegularFile(); String content = Files.readString(copy); assertThat(content).contains("com.streamx.blueprints.page.published"); - assertThat(home.resolve("event-templates/default/page.published.json")).isRegularFile(); + assertThat(defaultTemplatesDir(home).resolve("page.published.json")).isRegularFile(); } @Test void shouldCopyUserTemplateUnderNewId(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Path source = userDir.resolve("source.json"); Files.writeString(source, sampleTemplate("com.example.source.v1")); @@ -88,7 +89,7 @@ void shouldCopyUserTemplateUnderNewId(@TempDir Path tempDir) throws Exception { @Test void shouldRefuseToOverwriteExistingId(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Files.writeString(userDir.resolve("source.json"), sampleTemplate("com.example.source.v1")); Files.writeString(userDir.resolve("dest.json"), sampleTemplate("com.example.dest.v1")); @@ -132,7 +133,7 @@ void shouldCopyViaInteractivePrompts(@TempDir Path tempDir) throws Exception { ); result.assertSuccess(); - assertThat(home.resolve(UserEventTemplates.DIRECTORY).resolve("my.copy.json")) + assertThat(userTemplatesDir(home).resolve("my.copy.json")) .isRegularFile(); } } diff --git a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/create/CreateCommandIT.java b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/create/CreateCommandIT.java index 510bdca8..038861d1 100644 --- a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/create/CreateCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/create/CreateCommandIT.java @@ -1,10 +1,11 @@ package com.streamx.cli.commands.settings.eventtemplates.create; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.defaultTemplatesDir; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.userTemplatesDir; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.streamx.cli.commands.publish.event.UserEventTemplates; import com.streamx.cli.test.CliBaseIT; import io.quarkus.test.junit.QuarkusTest; import java.nio.file.Files; @@ -29,7 +30,7 @@ void shouldCreateTemplateFromWizard(@TempDir Path tempDir) throws Exception { result.assertSuccess(); - Path created = home.resolve(UserEventTemplates.DIRECTORY) + Path created = userTemplatesDir(home) .resolve("my.new.template.json"); assertThat(created).isRegularFile(); @@ -71,14 +72,14 @@ void shouldFailWhenTypeBlank(@TempDir Path tempDir) throws Exception { assertThat(result.exitCode()).isNotZero(); assertThat(result.stderr()).contains("CloudEvent type is required"); - Path notCreated = home.resolve(UserEventTemplates.DIRECTORY).resolve("my.blank.json"); + Path notCreated = userTemplatesDir(home).resolve("my.blank.json"); assertThat(notCreated).doesNotExist(); } @Test void shouldRepromptOnIdConflictAndContinueWithFreshId(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Path existing = userDir.resolve("already.there.json"); Files.writeString(existing, "{}"); @@ -121,17 +122,17 @@ void shouldRepromptOnConflictWithDefaultTemplate(@TempDir Path tempDir) throws E assertThat(result.stderr()).contains("already exists"); assertThat(result.stderr()) - .contains(home.resolve("event-templates/default/page.published.json") + .contains(defaultTemplatesDir(home).resolve("page.published.json") .toAbsolutePath().toString()); - Path created = home.resolve(UserEventTemplates.DIRECTORY).resolve("my.custom.json"); + Path created = userTemplatesDir(home).resolve("my.custom.json"); assertThat(created).isRegularFile(); } @Test void shouldFailWhenInputExhaustedDuringConflictLoop(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Files.writeString(userDir.resolve("already.there.json"), "{}"); diff --git a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/delete/DeleteCommandIT.java b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/delete/DeleteCommandIT.java index 4aa0827f..bc09506b 100644 --- a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/delete/DeleteCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/delete/DeleteCommandIT.java @@ -1,11 +1,13 @@ package com.streamx.cli.commands.settings.eventtemplates.delete; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.contextFile; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.defaultTemplatesDir; import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.sampleTemplate; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.userTemplatesDir; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.streamx.cli.commands.publish.event.UserEventTemplates; import com.streamx.cli.test.CliBaseIT; import io.quarkus.test.junit.QuarkusTest; import java.nio.file.Files; @@ -21,7 +23,7 @@ class DeleteCommandIT extends CliBaseIT { @Test void shouldWorkWithJsonOutput(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Path target = userDir.resolve("my.thing.json"); Files.writeString(target, sampleTemplate("com.example.thing.v1")); @@ -30,7 +32,7 @@ void shouldWorkWithJsonOutput(@TempDir Path tempDir) throws Exception { "settings", "event-templates", "delete", "--streamx-home", home.toString(), "my.thing", - "--yes", + "--force", "-o", "json" ); @@ -44,7 +46,7 @@ void shouldWorkWithJsonOutput(@TempDir Path tempDir) throws Exception { @Test void shouldDeleteUserTemplateWithYesFlag(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Path target = userDir.resolve("my.thing.json"); Files.writeString(target, sampleTemplate("com.example.thing.v1")); @@ -53,7 +55,7 @@ void shouldDeleteUserTemplateWithYesFlag(@TempDir Path tempDir) throws Exception "settings", "event-templates", "delete", "--streamx-home", home.toString(), "my.thing", - "--yes" + "--force" ); result.assertSuccess(); @@ -63,7 +65,7 @@ void shouldDeleteUserTemplateWithYesFlag(@TempDir Path tempDir) throws Exception @Test void shouldDeleteUserTemplateAfterConfirmation(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Path target = userDir.resolve("my.thing.json"); Files.writeString(target, sampleTemplate("com.example.thing.v1")); @@ -82,7 +84,7 @@ void shouldDeleteUserTemplateAfterConfirmation(@TempDir Path tempDir) throws Exc @Test void shouldCancelOnNo(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Path target = userDir.resolve("my.thing.json"); Files.writeString(target, sampleTemplate("com.example.thing.v1")); @@ -110,13 +112,13 @@ void shouldRefuseDeleteOfDefaultTemplate(@TempDir Path tempDir) throws Exception "settings", "event-templates", "delete", "--streamx-home", home.toString(), "page.published", - "--yes" + "--force" ); assertThat(result.exitCode()).isNotZero(); assertThat(result.stderr()).contains("Cannot delete a default template"); assertThat(result.stderr()).contains("reset-default-templates"); - Path defaultFile = home.resolve("event-templates/default/page.published.json"); + Path defaultFile = defaultTemplatesDir(home).resolve("page.published.json"); assertThat(defaultFile).isRegularFile(); } @@ -124,7 +126,7 @@ void shouldRefuseDeleteOfDefaultTemplate(@TempDir Path tempDir) throws Exception void shouldRefuseDeleteOfRegisteredTemplate(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); Files.createDirectories(home); - Path file = home.resolve("registered.json"); + Path file = contextFile(home, "registered.json"); Files.writeString(file, sampleTemplate("com.example.reg.v1")); exec("settings", "event-templates", "register", "--streamx-home", home.toString(), @@ -134,7 +136,7 @@ void shouldRefuseDeleteOfRegisteredTemplate(@TempDir Path tempDir) throws Except "settings", "event-templates", "delete", "--streamx-home", home.toString(), "my.alias", - "--yes" + "--force" ); assertThat(result.exitCode()).isNotZero(); @@ -151,7 +153,7 @@ void shouldFailForUnknownTemplate(@TempDir Path tempDir) throws Exception { "settings", "event-templates", "delete", "--streamx-home", home.toString(), "definitely.does.not.exist", - "--yes" + "--force" ); assertThat(result.exitCode()).isNotZero(); diff --git a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/edit/EditCommandIT.java b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/edit/EditCommandIT.java index 62162633..97dd42e7 100644 --- a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/edit/EditCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/edit/EditCommandIT.java @@ -1,10 +1,11 @@ package com.streamx.cli.commands.settings.eventtemplates.edit; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.defaultTemplatesDir; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.userTemplatesDir; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.streamx.cli.commands.publish.event.UserEventTemplates; import com.streamx.cli.test.CliBaseIT; import io.quarkus.test.junit.QuarkusTest; import java.nio.file.Files; @@ -34,7 +35,7 @@ void clearEditor() { @Test void shouldWorkWithJsonOutput(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Path target = userDir.resolve("my.thing.json"); Files.writeString(target, "{\"type\":\"x\"}"); @@ -65,19 +66,19 @@ void shouldCopyDefaultIntoUserFolderOnFirstEdit(@TempDir Path tempDir) throws Ex result.assertSuccess(); - Path userCopy = home.resolve(UserEventTemplates.DIRECTORY).resolve("asset.published.json"); + Path userCopy = userTemplatesDir(home).resolve("asset.published.json"); assertThat(userCopy).isRegularFile(); String content = Files.readString(userCopy); assertThat(content).contains("com.streamx.blueprints.asset.published.v1"); - Path defaultFile = home.resolve("event-templates/default/asset.published.json"); + Path defaultFile = defaultTemplatesDir(home).resolve("asset.published.json"); assertThat(defaultFile).isRegularFile(); } @Test void shouldEditUserTemplateInPlace(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Path target = userDir.resolve("my.thing.json"); Files.writeString(target, "{\"type\":\"x\"}"); @@ -104,7 +105,7 @@ void shouldEditViaPrompt(@TempDir Path tempDir) throws Exception { ); result.assertSuccess(); - Path userCopy = home.resolve(UserEventTemplates.DIRECTORY).resolve("page.published.json"); + Path userCopy = userTemplatesDir(home).resolve("page.published.json"); assertThat(userCopy).isRegularFile(); } diff --git a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/list/ListCommandIT.java b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/list/ListCommandIT.java index 42ef2d64..2ca9e45a 100644 --- a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/list/ListCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/list/ListCommandIT.java @@ -4,12 +4,11 @@ import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.YAML; import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.findById; import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.sampleTemplate; -import static com.streamx.cli.i18n.MessageProvider.msg; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.userTemplatesDir; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.JsonNode; import com.streamx.cli.commands.publish.event.EventTemplateCatalog; -import com.streamx.cli.commands.publish.event.UserEventTemplates; import com.streamx.cli.test.CliBaseIT; import io.quarkus.test.junit.QuarkusTest; import java.nio.file.Files; @@ -53,7 +52,7 @@ void shouldListBuiltinTemplatesAsJson(@TempDir Path tempDir) throws Exception { @Test void shouldShowUserTemplateFromEventTemplatesFolder(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Files.writeString(userDir.resolve("my.custom.json"), sampleTemplate("com.example.custom.v1")); @@ -76,7 +75,7 @@ void shouldPrioritizeUserOverDefaults(@TempDir Path tempDir) throws Exception { exec("settings", "event-templates", "list", "--streamx-home", home.toString()).assertSuccess(); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Files.writeString( userDir.resolve("page.published.json"), @@ -109,13 +108,13 @@ void shouldPrioritizeSettingsOverUserAndDefaults(@TempDir Path tempDir) throws E exec("settings", "event-templates", "list", "--streamx-home", home.toString()).assertSuccess(); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Files.writeString( userDir.resolve("page.published.json"), sampleTemplate("com.example.user.page.v1")); - Path settingsFile = home.resolve("page-from-settings.json"); + Path settingsFile = home.resolve("contexts/default/page-from-settings.json"); Files.writeString(settingsFile, sampleTemplate("com.example.settings.page.v1")); exec("settings", "event-templates", "register", @@ -142,7 +141,6 @@ void shouldRenderTextOutput(@TempDir Path tempDir) throws Exception { ProcessResult result = exec("settings", "event-templates", "list", "--streamx-home", home.toString()); result.assertSuccess(); - assertThat(result.stdout()).contains(msg.eventTemplatesListHeader().strip()); assertThat(result.stdout()).contains("TEMPLATE ID"); assertThat(result.stdout()).contains("page.published"); } diff --git a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/register/RegisterCommandIT.java b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/register/RegisterCommandIT.java index 6eb0e6ad..af9f6729 100644 --- a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/register/RegisterCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/register/RegisterCommandIT.java @@ -1,5 +1,7 @@ package com.streamx.cli.commands.settings.eventtemplates.register; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.configFile; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.contextFile; import static org.assertj.core.api.Assertions.assertThat; import com.streamx.cli.commands.publish.event.EventTemplateLoader; @@ -19,7 +21,7 @@ class RegisterCommandIT extends CliBaseIT { void shouldWriteSettingsEntry(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); Files.createDirectories(home); - Path templateFile = home.resolve("custom.json"); + Path templateFile = contextFile(home, "custom.json"); Files.writeString(templateFile, "{}"); ProcessResult result = exec( @@ -31,7 +33,7 @@ void shouldWriteSettingsEntry(@TempDir Path tempDir) throws Exception { result.assertSuccess(); - Path config = home.resolve("config/application.properties"); + Path config = configFile(home); assertThat(config).isRegularFile(); Properties props = new Properties(); try (InputStream is = Files.newInputStream(config)) { diff --git a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/rename/RenameCommandIT.java b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/rename/RenameCommandIT.java index bfe1f8ad..a212bb84 100644 --- a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/rename/RenameCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/rename/RenameCommandIT.java @@ -1,12 +1,14 @@ package com.streamx.cli.commands.settings.eventtemplates.rename; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.configFile; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.contextFile; import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.sampleTemplate; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.userTemplatesDir; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.streamx.cli.commands.publish.event.EventTemplateLoader; -import com.streamx.cli.commands.publish.event.UserEventTemplates; import com.streamx.cli.test.CliBaseIT; import io.quarkus.test.junit.QuarkusTest; import java.io.InputStream; @@ -24,7 +26,7 @@ class RenameCommandIT extends CliBaseIT { @Test void shouldWorkWithJsonOutput(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Files.writeString(userDir.resolve("old.json"), sampleTemplate("com.example.v1")); @@ -46,7 +48,7 @@ void shouldWorkWithJsonOutput(@TempDir Path tempDir) throws Exception { @Test void shouldRenameUserTemplateFile(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Path original = userDir.resolve("old.json"); String content = sampleTemplate("com.example.v1"); @@ -69,7 +71,7 @@ void shouldRenameUserTemplateFile(@TempDir Path tempDir) throws Exception { void shouldRenameSettingsRegisteredTemplate(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); Files.createDirectories(home); - Path file = home.resolve("registered.json"); + Path file = contextFile(home, "registered.json"); Files.writeString(file, sampleTemplate("com.example.reg.v1")); exec("settings", "event-templates", "register", "--streamx-home", home.toString(), @@ -112,7 +114,7 @@ void shouldRefuseToRenameDefault(@TempDir Path tempDir) throws Exception { @Test void shouldRefuseToRenameWhenNewIdAlreadyExists(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Files.writeString(userDir.resolve("a.json"), sampleTemplate("com.example.a.v1")); Files.writeString(userDir.resolve("b.json"), sampleTemplate("com.example.b.v1")); @@ -128,7 +130,7 @@ void shouldRefuseToRenameWhenNewIdAlreadyExists(@TempDir Path tempDir) throws Ex } private static Properties readConfig(Path home) throws Exception { - Path config = home.resolve("config/application.properties"); + Path config = configFile(home); Properties props = new Properties(); try (InputStream is = Files.newInputStream(config)) { props.load(is); diff --git a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/resetdefaulttemplates/ResetDefaultTemplatesCommandIT.java b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/resetdefaulttemplates/ResetDefaultTemplatesCommandIT.java index f3d926a5..6827f51f 100644 --- a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/resetdefaulttemplates/ResetDefaultTemplatesCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/resetdefaulttemplates/ResetDefaultTemplatesCommandIT.java @@ -2,6 +2,7 @@ import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.JSON; import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.YAML; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.userTemplatesDir; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.JsonNode; @@ -104,7 +105,7 @@ void shouldResetWithoutPromptWhenYesFlagSet(@TempDir Path tempDir) throws Except ProcessResult result = exec( "settings", "event-templates", "reset-default-templates", "--streamx-home", home.toString(), - "--yes" + "--force" ); result.assertSuccess(); @@ -121,7 +122,7 @@ void shouldRecreateMissingDefaultsDir(@TempDir Path tempDir) throws Exception { ProcessResult result = exec( "settings", "event-templates", "reset-default-templates", "--streamx-home", home.toString(), - "--yes" + "--force" ); result.assertSuccess(); @@ -138,7 +139,7 @@ void shouldOutputJson(@TempDir Path tempDir) throws Exception { ProcessResult result = exec( "settings", "event-templates", "reset-default-templates", "--streamx-home", home.toString(), - "--yes", + "--force", "--output", "json" ); @@ -167,7 +168,7 @@ void shouldOutputYaml(@TempDir Path tempDir) throws Exception { ProcessResult result = exec( "settings", "event-templates", "reset-default-templates", "--streamx-home", home.toString(), - "--yes", + "--force", "--output", "yaml" ); @@ -180,7 +181,7 @@ void shouldOutputYaml(@TempDir Path tempDir) throws Exception { @Test void shouldNotTouchUserEventTemplatesFolder(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve("event-templates/custom"); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Path userFile = userDir.resolve("my.custom.json"); Files.writeString(userFile, "{\"type\":\"user\"}"); @@ -188,7 +189,7 @@ void shouldNotTouchUserEventTemplatesFolder(@TempDir Path tempDir) throws Except ProcessResult result = exec( "settings", "event-templates", "reset-default-templates", "--streamx-home", home.toString(), - "--yes" + "--force" ); result.assertSuccess(); diff --git a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/unregister/UnregisterCommandIT.java b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/unregister/UnregisterCommandIT.java index cd6082cd..03835334 100644 --- a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/unregister/UnregisterCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/unregister/UnregisterCommandIT.java @@ -1,5 +1,8 @@ package com.streamx.cli.commands.settings.eventtemplates.unregister; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.configFile; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.contextFile; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.defaultTemplatesDir; import static org.assertj.core.api.Assertions.assertThat; import com.streamx.cli.commands.publish.event.EventTemplateLoader; @@ -19,7 +22,7 @@ class UnregisterCommandIT extends CliBaseIT { void shouldRemoveSettingsEntryByName(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); Files.createDirectories(home); - Files.writeString(home.resolve("custom.json"), "{}"); + Files.writeString(contextFile(home, "custom.json"), "{}"); exec("settings", "event-templates", "register", "--streamx-home", home.toString(), @@ -43,8 +46,8 @@ void shouldRemoveSettingsEntryByName(@TempDir Path tempDir) throws Exception { void shouldRemoveSettingsEntryViaPrompt(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); Files.createDirectories(home); - Files.writeString(home.resolve("a.json"), "{}"); - Files.writeString(home.resolve("b.json"), "{}"); + Files.writeString(contextFile(home, "a.json"), "{}"); + Files.writeString(contextFile(home, "b.json"), "{}"); exec("settings", "event-templates", "register", "--streamx-home", home.toString(), @@ -72,7 +75,7 @@ void shouldRemoveSettingsEntryViaPrompt(@TempDir Path tempDir) throws Exception void shouldRefuseUnknownName(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); Files.createDirectories(home); - Files.writeString(home.resolve("a.json"), "{}"); + Files.writeString(contextFile(home, "a.json"), "{}"); exec("settings", "event-templates", "register", "--streamx-home", home.toString(), "real.one", "a.json").assertSuccess(); @@ -104,13 +107,13 @@ void shouldRefuseWhenNoRegistrationsExist(@TempDir Path tempDir) throws Exceptio void shouldNotTouchDefaultsFolder(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); Files.createDirectories(home); - Files.writeString(home.resolve("a.json"), "{}"); + Files.writeString(contextFile(home, "a.json"), "{}"); exec("settings", "event-templates", "register", "--streamx-home", home.toString(), "my.alias", "a.json").assertSuccess(); - Path defaultPagePublished = home.resolve("event-templates/default/page.published.json"); + Path defaultPagePublished = defaultTemplatesDir(home).resolve("page.published.json"); assertThat(defaultPagePublished).isRegularFile(); exec("settings", "event-templates", "unregister", @@ -121,7 +124,7 @@ void shouldNotTouchDefaultsFolder(@TempDir Path tempDir) throws Exception { } private static Properties readConfig(Path home) throws Exception { - Path config = home.resolve("config/application.properties"); + Path config = configFile(home); Properties props = new Properties(); try (InputStream is = Files.newInputStream(config)) { props.load(is); diff --git a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/validate/ValidateCommandIT.java b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/validate/ValidateCommandIT.java index 96156f4d..44d6e19b 100644 --- a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/validate/ValidateCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/validate/ValidateCommandIT.java @@ -2,10 +2,10 @@ import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.JSON; import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.sampleTemplate; +import static com.streamx.cli.commands.settings.eventtemplates.EventTemplatesTestSupport.userTemplatesDir; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.JsonNode; -import com.streamx.cli.commands.publish.event.UserEventTemplates; import com.streamx.cli.test.CliBaseIT; import io.quarkus.test.junit.QuarkusTest; import java.nio.file.Files; @@ -31,7 +31,7 @@ void shouldValidateBundledDefault(@TempDir Path tempDir) throws Exception { @Test void shouldFailOnInvalidJson(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Files.writeString(userDir.resolve("broken.json"), "{ this is not json"); @@ -48,7 +48,7 @@ void shouldFailOnInvalidJson(@TempDir Path tempDir) throws Exception { @Test void shouldFailOnMissingRequiredField(@TempDir Path tempDir) throws Exception { Path home = tempDir.resolve("streamx-home"); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Files.writeString(userDir.resolve("nospec.json"), "{\"id\":\"x\",\"source\":\"s\"}"); @@ -89,7 +89,7 @@ void shouldReportMixedResultsWithAllFlag(@TempDir Path tempDir) throws Exception exec("settings", "event-templates", "list", "--streamx-home", home.toString()).assertSuccess(); - Path userDir = home.resolve(UserEventTemplates.DIRECTORY); + Path userDir = userTemplatesDir(home); Files.createDirectories(userDir); Files.writeString(userDir.resolve("good.json"), sampleTemplate("com.example.good.v1")); Files.writeString(userDir.resolve("bad.json"), "{}"); diff --git a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/which/WhichCommandIT.java b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/which/WhichCommandIT.java index 04847e1c..510b3ae8 100644 --- a/src/test/java/com/streamx/cli/commands/settings/eventtemplates/which/WhichCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/settings/eventtemplates/which/WhichCommandIT.java @@ -23,7 +23,7 @@ void shouldPrintAbsolutePathOfDefault(@TempDir Path tempDir) throws Exception { ); result.assertSuccess(); String stdout = result.stdout().strip(); - assertThat(stdout).endsWith("event-templates/default/page.published.json"); + assertThat(stdout).endsWith("default-event-templates/page.published.json"); assertThat(Path.of(stdout)).isAbsolute(); } @@ -51,7 +51,7 @@ void shouldOutputJsonWithFullLocation(@TempDir Path tempDir) throws Exception { result.assertSuccess(); JsonNode root = JSON.readTree(result.stdout()); assertThat(root.get("id").asText()).isEqualTo("page.published"); - assertThat(root.get("source").asText()).isEqualTo("event-templates/default"); + assertThat(root.get("source").asText()).isEqualTo("default"); assertThat(root.get("path").asText()).endsWith("page.published.json"); } } diff --git a/src/test/java/com/streamx/cli/commands/settings/list/ListCommandIT.java b/src/test/java/com/streamx/cli/commands/settings/list/ListCommandIT.java index 44757524..6b6d5ba9 100644 --- a/src/test/java/com/streamx/cli/commands/settings/list/ListCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/settings/list/ListCommandIT.java @@ -48,12 +48,13 @@ void shouldFormatOutputAsText() throws Exception { ProcessResult result = exec("settings", "list"); - String expectedOutput = msg.listSettingsHeader() + "\n" + """ - another.key = another.value - empty.value = - spaced.value = value with spaces - special.chars = value=with:special@chars! - test.key = test.value + String expectedOutput = """ + KEY VALUE + another.key another.value + empty.value - + spaced.value value with spaces + special.chars value=with:special@chars! + test.key test.value """.strip(); assertThat(result.stdout().strip()).isEqualTo(expectedOutput); diff --git a/src/test/java/com/streamx/cli/config/StreamxHomeTest.java b/src/test/java/com/streamx/cli/config/StreamxHomeTest.java index 5c3bbfef..92d3f961 100644 --- a/src/test/java/com/streamx/cli/config/StreamxHomeTest.java +++ b/src/test/java/com/streamx/cli/config/StreamxHomeTest.java @@ -23,6 +23,7 @@ class StreamxHomeTest { void cleanup() { System.clearProperty("STREAMX_HOME"); StreamxHome.clearStreamxHomeCliArg(); + StreamxHome.clearContextCliArg(); } @Test @@ -36,7 +37,7 @@ void shouldUseStreamxHomeEnvVariable() throws Exception { URL url = StreamxHome.getConfigUrl(); Path result = Path.of(url.toURI()); - assertEquals(tempDir.resolve("config/application.properties"), result); + assertEquals(tempDir.resolve("contexts/default/config/application.properties"), result); assertTrue(Files.exists(result)); } } @@ -49,7 +50,7 @@ void shouldUseStreamxHomeSystemProperty() throws Exception { URL url = StreamxHome.getConfigUrl(); Path result = Path.of(url.toURI()); - assertEquals(tempDir.resolve("config/application.properties"), result); + assertEquals(tempDir.resolve("contexts/default/config/application.properties"), result); assertTrue(Files.exists(result), "application.properties should be created"); } @@ -73,7 +74,7 @@ void shouldCreateConfigDirectoryWhenItDoesNotExist() throws Exception { StreamxHome.createConfigIfNotExists(); - Path configDir = homeDir.resolve("config"); + Path configDir = homeDir.resolve("contexts/default/config"); assertTrue(Files.isDirectory(configDir), "Config directory should be created"); assertTrue(Files.exists(configDir.resolve("application.properties"))); } @@ -86,7 +87,7 @@ void shouldApplySettingsToSystemProperties() throws Exception { System.clearProperty(key); System.clearProperty(otherKey); try { - Path configDir = tempDir.resolve("config"); + Path configDir = tempDir.resolve("contexts/default/config"); Files.createDirectories(configDir); Files.writeString(configDir.resolve("application.properties"), key + "=true\n" + otherKey + "=8081\n"); @@ -107,7 +108,7 @@ void shouldNotOverrideExplicitlySetSystemProperty() throws Exception { String key = "streamx.runner.gateway.http-port"; System.setProperty(key, "9999"); try { - Path configDir = tempDir.resolve("config"); + Path configDir = tempDir.resolve("contexts/default/config"); Files.createDirectories(configDir); Files.writeString(configDir.resolve("application.properties"), key + "=8081\n"); @@ -125,7 +126,7 @@ void shouldClearStaleAppliedKeysOnReapply() throws Exception { String key = "streamx.runner.gateway.http-port"; System.clearProperty(key); try { - Path configDir = tempDir.resolve("config"); + Path configDir = tempDir.resolve("contexts/default/config"); Files.createDirectories(configDir); Path configFile = configDir.resolve("application.properties"); diff --git a/src/test/java/com/streamx/cli/framework/UrlsTest.java b/src/test/java/com/streamx/cli/framework/UrlsTest.java new file mode 100644 index 00000000..7b64d9fa --- /dev/null +++ b/src/test/java/com/streamx/cli/framework/UrlsTest.java @@ -0,0 +1,43 @@ +package com.streamx.cli.framework; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class UrlsTest { + + @ParameterizedTest + @ValueSource(strings = { + "http://example.com", + "http://example.com:8080/api", + "http://10.0.0.5", + "HTTP://EXAMPLE.COM", + "http://127.0.0.1.evil.example", + "http://127.evil.example", + "http://my_host.example", + "http://localhost@evil.example/", + "http://", + "http:// bad url" + }) + void cleartextToRemoteOrUnprovableHostsIsBlocked(String url) { + assertThat(Urls.isCleartextRemote(url)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { + "https://example.com", + "https://keycloak.127.0.0.1.nip.io", + "http://localhost:8085", + "http://LOCALHOST:8080", + "http://127.0.0.1:8080", + "http://127.1.2.3", + "http://[::1]:8080", + "not a url", + "ftp://example.com" + }) + void httpsAndProvableLoopbackAndOtherSchemesAreAllowed(String url) { + assertThat(Urls.isCleartextRemote(url)).isFalse(); + } +} diff --git a/src/test/java/com/streamx/cli/test/CliBaseIT.java b/src/test/java/com/streamx/cli/test/CliBaseIT.java index ba49fc2f..b5dcf23d 100644 --- a/src/test/java/com/streamx/cli/test/CliBaseIT.java +++ b/src/test/java/com/streamx/cli/test/CliBaseIT.java @@ -31,7 +31,7 @@ public abstract class CliBaseIT { private static final long DEFAULT_TIMEOUT_SECONDS = 30; protected static final String CONFIG_FILE_PATH = - "config/application.properties"; + "contexts/default/config/application.properties"; @TempDir public static Path streamxHome; @@ -171,7 +171,9 @@ public K create(Class cls) throws Exception { if (command instanceof AbstractCommand abstractCommand) { try { - abstractCommand.populateStreamxHome(); + abstractCommand.populateStreamxHome(parsed); + // -H/--context are applied now; refresh the root help header to reflect them. + com.streamx.cli.framework.SynopsisHelper.applyRootUsageLayout(parsed.get(0)); } catch (Exception e) { return abstractCommand.handleExecutionError(e); } @@ -192,6 +194,7 @@ public K create(Class cls) throws Exception { return new CommandLine.RunLast().execute(parseResult); }); + com.streamx.cli.framework.SynopsisHelper.applyRootUsageLayout(cmd); return cmd; } diff --git a/src/test/java/com/streamx/cli/test/MeshAssertions.java b/src/test/java/com/streamx/cli/test/MeshAssertions.java index 4641abd8..eebde2c6 100644 --- a/src/test/java/com/streamx/cli/test/MeshAssertions.java +++ b/src/test/java/com/streamx/cli/test/MeshAssertions.java @@ -25,7 +25,7 @@ public static synchronized void assertEventsPublished(long count) { prevEventCount.set(topicMessageCount); } catch (PulsarAdminException e) { throw new AssertionError( - "Failed to retrieve stats for topic: " + PULSAR_TOPIC + " — " + e.getMessage(), e); + "Failed to retrieve stats for topic: " + PULSAR_TOPIC + " - " + e.getMessage(), e); } catch (Exception e) { throw new RuntimeException( "Failed to create Pulsar admin client: " + e.getMessage(), e); @@ -38,7 +38,7 @@ public static synchronized void resetPublishedEventsBaseline() { TopicStats stats = admin.topics().getStats(PULSAR_TOPIC); prevEventCount.set(stats.getMsgInCounter()); } catch (Exception e) { - // ignore — topic may not exist yet + // ignore - topic may not exist yet } } }