diff --git a/devtools/cli/src/main/java/io/quarkus/cli/CliPlugins.java b/devtools/cli/src/main/java/io/quarkus/cli/CliPlugins.java new file mode 100644 index 0000000000000..9830ec05f8a92 --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/CliPlugins.java @@ -0,0 +1,42 @@ +package io.quarkus.cli; + +import java.util.List; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; + +import io.quarkus.cli.common.OutputOptionMixin; +import io.quarkus.cli.plugin.CliPluginsAdd; +import io.quarkus.cli.plugin.CliPluginsList; +import io.quarkus.cli.plugin.CliPluginsRemove; +import io.quarkus.cli.plugin.CliPluginsSync; +import picocli.CommandLine; +import picocli.CommandLine.ParseResult; +import picocli.CommandLine.Unmatched; + +@CommandLine.Command(name = "plugin", aliases = { "plug" }, header = "Configure plugins of the Quarkus CLI.", subcommands = { + CliPluginsList.class, + CliPluginsAdd.class, + CliPluginsRemove.class, + CliPluginsSync.class +}) +public class CliPlugins implements Callable { + + @CommandLine.Mixin(name = "output") + protected OutputOptionMixin output; + + @CommandLine.Spec + protected CommandLine.Model.CommandSpec spec; + + @Unmatched // avoids throwing errors for unmatched arguments + List unmatchedArgs; + + @Override + public Integer call() throws Exception { + output.info("Listing plguins (default action, see --help)."); + ParseResult result = spec.commandLine().getParseResult(); + List args = result.originalArgs().stream().filter(x -> !"plugin".equals(x) && !"plug".equals(x)) + .collect(Collectors.toList()); + CommandLine listCommand = spec.subcommands().get("list"); + return listCommand.execute(args.toArray(new String[0])); + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/QuarkusCli.java b/devtools/cli/src/main/java/io/quarkus/cli/QuarkusCli.java index 5bdc1d4f70bff..a35d0228d8c44 100644 --- a/devtools/cli/src/main/java/io/quarkus/cli/QuarkusCli.java +++ b/devtools/cli/src/main/java/io/quarkus/cli/QuarkusCli.java @@ -2,8 +2,13 @@ import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_COMMAND_LIST; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.concurrent.Callable; import jakarta.inject.Inject; @@ -11,14 +16,24 @@ import io.quarkus.cli.common.HelpOption; import io.quarkus.cli.common.OutputOptionMixin; import io.quarkus.cli.common.PropertiesOptions; +import io.quarkus.cli.plugin.Plugin; +import io.quarkus.cli.plugin.PluginCommandFactory; +import io.quarkus.cli.plugin.PluginManager; +import io.quarkus.cli.plugin.PluginManagerSettings; +import io.quarkus.cli.registry.RegistryClientMixin; +import io.quarkus.cli.utils.Registries; +import io.quarkus.devtools.utils.Prompt; import io.quarkus.runtime.QuarkusApplication; import picocli.CommandLine; +import picocli.CommandLine.ExitCode; import picocli.CommandLine.Help; import picocli.CommandLine.IHelpSectionRenderer; import picocli.CommandLine.IParameterExceptionHandler; import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Model.UsageMessageSpec; +import picocli.CommandLine.MutuallyExclusiveArgsException; import picocli.CommandLine.ParameterException; +import picocli.CommandLine.ParseResult; import picocli.CommandLine.ScopeType; import picocli.CommandLine.UnmatchedArgumentException; @@ -26,6 +41,8 @@ Create.class, Build.class, Dev.class, Test.class, ProjectExtensions.class, Image.class, Deploy.class, Registry.class, Info.class, Update.class, + Version.class, + CliPlugins.class, Completion.class }, scope = ScopeType.INHERIT, sortOptions = false, showDefaultValues = true, versionProvider = Version.class, subcommandsRepeatable = false, mixinStandardHelpOptions = false, commandListHeading = "%nCommands:%n", synopsisHeading = "%nUsage: ", optionListHeading = "Options:%n", headerHeading = "%n", parameterListHeading = "%n") public class QuarkusCli implements QuarkusApplication, Callable { static { @@ -35,6 +52,9 @@ public class QuarkusCli implements QuarkusApplication, Callable { @Inject CommandLine.IFactory factory; + @CommandLine.Mixin + protected RegistryClientMixin registryClient; + @CommandLine.Mixin protected HelpOption helpOption; @@ -56,9 +76,62 @@ public int run(String... args) throws Exception { CommandLine cmd = factory == null ? new CommandLine(this) : new CommandLine(this, factory); cmd.getHelpSectionMap().put(SECTION_KEY_COMMAND_LIST, new SubCommandListRenderer()); cmd.setParameterExceptionHandler(new ShortErrorMessageHandler()); + + //When running tests the cli should not prompt for user input. + boolean interactiveMode = Arrays.stream(args).noneMatch(arg -> arg.equals("--cli-test")); + PluginCommandFactory pluginCommandFactory = new PluginCommandFactory(output); + PluginManager pluginManager = pluginManager(output, interactiveMode); + pluginManager.syncIfNeeded(); + Map plugins = new HashMap<>(pluginManager.getInstalledPlugins()); + pluginCommandFactory.populateCommands(cmd, plugins); + try { + Optional missing = checkMissingCommand(cmd, args); + missing.ifPresent(m -> { + Map installable = pluginManager.getInstallablePlugins(); + if (installable.containsKey(m)) { + if (interactiveMode && Prompt.yesOrNo(true, + "Command %s is not installed, but a matching plugin is available. Would you like to install it now ?", + args)) { + pluginManager.addPlugin(m).ifPresent(added -> plugins.put(added.getName(), added)); + pluginCommandFactory.populateCommands(cmd, plugins); + } + } + }); + } catch (MutuallyExclusiveArgsException e) { + return ExitCode.USAGE; + } return cmd.execute(args); } + /** + * Recursivelly processes the arguments passed to the command and checks wether a subcommand is missing. + * + * @param root the root command + * @param args the arguments passed to the root command + * @retunr the missing subcommand wrapped in {@link Optional} or empty if no subcommand is missing. + */ + public Optional checkMissingCommand(CommandLine root, String[] args) { + if (args.length == 0) { + return Optional.empty(); + } + + try { + ParseResult result = root.parseArgs(args); + if (args.length == 1) { + return Optional.empty(); + } + CommandLine next = root.getSubcommands().get(args[0]); + if (next == null) { + return Optional.of(args[0]); + } + String[] remaining = new String[args.length - 1]; + System.arraycopy(args, 1, remaining, 0, remaining.length); + return checkMissingCommand(next, remaining).map(nextMissing -> root.getCommandName() + "-" + nextMissing); + } catch (UnmatchedArgumentException e) { + return Optional.of(args[0]); + } + } + @Override public Integer call() throws Exception { output.info("%n@|bold Quarkus CLI|@ version %s", Version.clientVersion()); @@ -147,4 +220,20 @@ private String description(UsageMessageSpec usageMessage) { } } + private static Optional getProjectRoot(OutputOptionMixin output) { + Path projectRoot = output != null ? output.getTestDirectory() : null; + if (projectRoot == null) { + projectRoot = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + } + return Optional.ofNullable(projectRoot); + } + + private PluginManager pluginManager(OutputOptionMixin output, boolean interactiveMode) { + PluginManagerSettings settings = PluginManagerSettings.defaultSettings() + .withCatalogs(Registries.getRegistries(registryClient, "quarkusio")) + .withInteractivetMode(interactiveMode); // Why not just getting it from output.isClieTest ? Cause args have not been parsed yet. + + return new PluginManager(settings, output, Optional.ofNullable(Paths.get(System.getProperty("user.home"))), + getProjectRoot(output), Optional.empty(), p -> true); + } } diff --git a/devtools/cli/src/main/java/io/quarkus/cli/build/ExecuteUtil.java b/devtools/cli/src/main/java/io/quarkus/cli/build/ExecuteUtil.java index 0efc73b0b7dbd..03db5df0fe546 100644 --- a/devtools/cli/src/main/java/io/quarkus/cli/build/ExecuteUtil.java +++ b/devtools/cli/src/main/java/io/quarkus/cli/build/ExecuteUtil.java @@ -8,51 +8,30 @@ import java.io.IOException; import java.io.InputStreamReader; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; -import java.util.regex.Pattern; -import java.util.stream.Stream; import io.quarkus.cli.common.OutputOptionMixin; -import io.quarkus.utilities.OS; +import io.quarkus.devtools.exec.ExecSupport; +import io.quarkus.devtools.exec.Executable; public class ExecuteUtil { - public static File findExecutableFile(String base) { - String path = null; - String executable = base; + private static ExecSupport withOutput(OutputOptionMixin output) { + return new ExecSupport(output.out(), output.err(), output.isVerbose(), output.isCliTest()); + } - if (OS.determineOS() == OS.WINDOWS) { - executable = base + ".cmd"; - path = findExecutable(executable); - if (path == null) { - executable = base + ".bat"; - path = findExecutable(executable); - } - } else { - executable = base; - path = findExecutable(executable); - } - if (path == null) - return null; - return new File(path, executable); + public static File findExecutableFile(String base) { + return Executable.findExecutableFile(base); } private static String findExecutable(String exec) { - return Stream.of(System.getenv("PATH").split(Pattern.quote(File.pathSeparator))).map(Paths::get) - .map(path -> path.resolve(exec).toFile()).filter(File::exists).findFirst().map(File::getParent) - .orElse(null); + return Executable.findExecutable(exec); } public static File findExecutable(String name, String errorMessage, OutputOptionMixin output) { - File command = ExecuteUtil.findExecutableFile(name); - if (command == null) { - output.error(errorMessage); - throw new RuntimeException("Unable to find " + name + " command"); - } - return command; + return Executable.findExecutable(name, errorMessage, output); } public static int executeProcess(OutputOptionMixin output, String[] args, File parentDir) @@ -102,24 +81,6 @@ public static int executeProcess(OutputOptionMixin output, String[] args, File p } public static File findWrapper(Path projectRoot, String[] windows, String other) { - if (OS.determineOS() == OS.WINDOWS) { - for (String name : windows) { - File wrapper = new File(projectRoot + File.separator + name); - if (wrapper.isFile()) - return wrapper; - } - } else { - File wrapper = new File(projectRoot + File.separator + other); - if (wrapper.isFile()) - return wrapper; - } - - // look for a wrapper in a parent directory - Path normalizedPath = projectRoot.normalize(); - if (!normalizedPath.equals(projectRoot.getRoot())) { - return findWrapper(normalizedPath.getParent(), windows, other); - } else { - return null; - } + return Executable.findWrapper(projectRoot, windows, other); } } diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsAdd.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsAdd.java new file mode 100644 index 0000000000000..95ed90c33ee61 --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsAdd.java @@ -0,0 +1,81 @@ +package io.quarkus.cli.plugin; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.concurrent.Callable; + +import io.quarkus.cli.common.RunModeOption; +import picocli.CommandLine; + +@CommandLine.Command(name = "add", header = "Add plugin(s) to the Quarkus CLI.") +public class CliPluginsAdd extends CliPluginsBase implements Callable { + + @CommandLine.Mixin + RunModeOption runMode; + + @CommandLine.Option(names = { "-d", + "--description" }, paramLabel = "Plugin description", order = 5, description = "The plugin description") + Optional description; + + @CommandLine.Parameters(arity = "1", paramLabel = "PLUGIN_NAME", description = " The plugin name or location (e.g. url, path or maven coordinates in GACTV form)") + String nameOrLocation; + + @Override + public Integer call() { + try { + output.debug("Add plugin with initial parameters: %s", this); + output.throwIfUnmatchedArguments(spec.commandLine()); + + if (runMode.isDryRun()) { + dryRunAdd(spec.commandLine().getHelp()); + return CommandLine.ExitCode.OK; + } + + return addPlugin(); + } catch (Exception e) { + return output.handleCommandException(e, + "Unable to add plugin(s): " + nameOrLocation + " of type: " + type.map(PluginType::name).orElse("") + + "." + + e.getMessage()); + } + } + + Integer addPlugin() throws IOException { + PluginManager pluginManager = pluginManager(); + Optional addedPlugin = pluginManager.addPlugin(nameOrLocation, description); + + return addedPlugin.map(plugin -> { + PluginListTable table = new PluginListTable(List.of(new PluginListItem(true, plugin)), false); + output.info("Added plugin:"); + output.info(table.getContent()); + return CommandLine.ExitCode.OK; + }).orElseGet(() -> { + output.error("No plugin available at: " + this.nameOrLocation); + printHints(true); + return CommandLine.ExitCode.USAGE; + }); + } + + private void printHints(boolean pluginListHint) { + if (runMode.isBatchMode()) + return; + + if (pluginListHint) { + output.info("To see the list of installable plugins, use the 'plugin list' subcommand."); + } + } + + void dryRunAdd(CommandLine.Help help) { + output.printText(new String[] { + "\nAdd plugin to the CLI\n", + "\t" + projectRoot().toString() + }); + Map dryRunOutput = new TreeMap<>(); + dryRunOutput.put("Name or Location", nameOrLocation); + type.ifPresent(t -> dryRunOutput.put("Type", t.name())); + output.info(help.createTextTable(dryRunOutput).toString()); + }; +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsBase.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsBase.java new file mode 100644 index 0000000000000..edc432bf77554 --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsBase.java @@ -0,0 +1,51 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; +import java.util.Set; + +import io.quarkus.cli.build.BaseBuildCommand; +import io.quarkus.cli.common.TargetQuarkusPlatformGroup; +import io.quarkus.cli.utils.Registries; +import io.quarkus.devtools.project.QuarkusProject; +import picocli.CommandLine; + +public class CliPluginsBase extends BaseBuildCommand { + + @CommandLine.Option(names = { "-t", + "--type" }, paramLabel = "PLUGIN_TYPE", order = 3, description = "Only list plugins from the specified type.") + Optional type = Optional.empty(); + + @CommandLine.ArgGroup(order = 2, heading = "%nQuarkus version (absolute):%n") + TargetQuarkusPlatformGroup targetQuarkusVersion = new TargetQuarkusPlatformGroup(); + + @CommandLine.ArgGroup(order = 3, heading = "%nCatalog:%n") + PluginCatalogOptions catalogOptions = new PluginCatalogOptions(); + + public Optional quarkusProject() { + try { + Path projectRoot = projectRoot(); + if (projectRoot == null || !projectRoot.toFile().exists()) { + return Optional.empty(); + } + return Optional.of( + registryClient.createQuarkusProject(projectRoot, targetQuarkusVersion, getRunner().getBuildTool(), output)); + } catch (Exception e) { + return Optional.empty(); + } + } + + public PluginManager pluginManager() { + Set registries = Registries.getRegistries(registryClient, "quarkusio"); + PluginManagerSettings settings = PluginManagerSettings.defaultSettings() + .withCatalogs(registries) + .withInteractivetMode(!output.isCliTest()); + + return new PluginManager(settings, output, + catalogOptions.userDirectory.or(() -> Optional.ofNullable(Paths.get(System.getProperty("user.home")))), + catalogOptions.user ? Optional.empty() : Optional.ofNullable(projectRoot()), + catalogOptions.user ? Optional.empty() : quarkusProject(), + p -> type.map(t -> t == p.getType()).orElse(true)); + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsList.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsList.java new file mode 100644 index 0000000000000..9ca87849422ae --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsList.java @@ -0,0 +1,129 @@ +package io.quarkus.cli.plugin; + +import static io.quarkus.devtools.utils.Patterns.isExpression; +import static io.quarkus.devtools.utils.Patterns.toRegex; + +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.Callable; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import io.quarkus.cli.common.RunModeOption; +import io.quarkus.devtools.project.BuildTool; +import io.quarkus.runtime.util.StringUtil; +import picocli.CommandLine; + +@CommandLine.Command(name = "list", aliases = "ls", header = "List CLI plugins. ") +public class CliPluginsList extends CliPluginsBase implements Callable { + + @CommandLine.Mixin + RunModeOption runMode; + + @CommandLine.Option(names = { "-i", + "--installable" }, defaultValue = "false", order = 4, description = "List plugins that can be installed") + boolean installable = false; + + @CommandLine.Option(names = { "-s", + "--search" }, defaultValue = "*", order = 5, paramLabel = "PATTERN", description = "Search for matching plugins (simple glob using '*' and '?').") + String searchPattern; + + @CommandLine.Option(names = { "-c", + "--show-command" }, defaultValue = "false", order = 6, description = "Show the command that corresponds to the plugin") + boolean showCommand = false; + + Map installedPlugins = new HashMap<>(); + + @Override + public Integer call() { + try { + output.debug("List extensions with initial parameters: %s", this); + output.throwIfUnmatchedArguments(spec.commandLine()); + + if (runMode.isDryRun()) { + return dryRunList(spec.commandLine().getHelp(), null); + } + Integer exitCode = listPluigns(); + printHints(!installable && installedPlugins.isEmpty(), installable); + return exitCode; + } catch (Exception e) { + return output.handleCommandException(e, + "Unable to list plugins: " + e.getMessage()); + } + } + + Integer dryRunList(CommandLine.Help help, BuildTool buildTool) { + Map dryRunOutput = new TreeMap<>(); + output.printText(new String[] { "\nList plugins\n" }); + dryRunOutput.put("Search pattern", searchPattern); + dryRunOutput.put("List installable", String.valueOf(installable)); + dryRunOutput.put("Type", String.valueOf(type)); + dryRunOutput.put("Only user", String.valueOf(catalogOptions.user)); + + output.info(help.createTextTable(dryRunOutput).toString()); + return CommandLine.ExitCode.OK; + } + + Integer listPluigns() { + PluginManager pluginManager = pluginManager(); + pluginManager.reconcile(); + installedPlugins.putAll(pluginManager.getInstalledPlugins()); + + Map items = new HashMap<>(); + if (installable) { + Map availablePlugins = pluginManager.getInstallablePlugins(); + items.putAll(availablePlugins + .entrySet().stream() + .filter(e -> !installedPlugins.containsKey(e.getKey())) + .map(e -> new PluginListItem(installedPlugins.containsKey(e.getKey()), e.getValue())) + .collect(Collectors.toMap(p -> p.getName(), p -> p))); + } + + items.putAll(installedPlugins.entrySet().stream() + .map(e -> new PluginListItem(true, e.getValue())) + .collect(Collectors.toMap(p -> p.getName(), p -> p))); + + if (items.isEmpty()) { + output.info("No plugins " + (installable ? "installable" : "installed") + "!"); + } else { + PluginListTable table = new PluginListTable( + items.values().stream().filter(this::filter).collect(Collectors.toList()), showCommand); + output.info(table.getContent()); + } + return CommandLine.ExitCode.OK; + } + + private void printHints(boolean installableHint, boolean remoteHint) { + if (runMode.isBatchMode()) + return; + + if (installableHint) { + output.info("To include the installable plugins in the list, append --installable to the command."); + } + + if (remoteHint) { + output.info( + "Use the 'plugin add' sub command and pass the location of any plugin listed above, or any remote location in the form of URL / GACTV pointing to a remote plugin."); + } + } + + private boolean filter(PluginListItem item) { + if (StringUtil.isNullOrEmpty(searchPattern)) { + return true; + } + if (!isExpression(searchPattern)) { + return item.getName().contains(searchPattern); + } + Pattern p = toRegex(searchPattern); + return p.matcher(item.getName()).matches(); + } + + @Override + public String toString() { + return "CliPluginsList [" + + ", output=" + output + + ", runMode=" + runMode + + "]"; + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsRemove.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsRemove.java new file mode 100644 index 0000000000000..7bd9e8c016fa6 --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsRemove.java @@ -0,0 +1,64 @@ +package io.quarkus.cli.plugin; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.concurrent.Callable; + +import io.quarkus.cli.common.RunModeOption; +import picocli.CommandLine; + +@CommandLine.Command(name = "remove", header = "Remove plugin(s) to the Quarkus CLI.") +public class CliPluginsRemove extends CliPluginsBase implements Callable { + + @CommandLine.Mixin + RunModeOption runMode; + + @CommandLine.Parameters(arity = "1", paramLabel = "PLUGIN_NAME", description = "Plugin name to add to the CLI") + String name; + + @Override + public Integer call() { + try { + output.debug("Remove plugin with initial parameters: %s", this); + output.throwIfUnmatchedArguments(spec.commandLine()); + + if (runMode.isDryRun()) { + dryRunRemove(spec.commandLine().getHelp()); + return CommandLine.ExitCode.OK; + } + + return removePlugin(); + } catch (Exception e) { + return output.handleCommandException(e, + "Unable to remove extension(s): " + e.getMessage()); + } + } + + Integer removePlugin() throws IOException { + PluginManager pluginManager = pluginManager(); + Optional removedPlugin = pluginManager.removePlugin(name); + + return removedPlugin.map(plugin -> { + PluginListTable table = new PluginListTable(List.of(new PluginListItem(false, plugin)), false); + output.info("Removed plugin:"); + output.info(table.getContent()); + return CommandLine.ExitCode.OK; + }).orElseGet(() -> { + output.error("Plugin: " + name + " not found in catalog!"); + return CommandLine.ExitCode.USAGE; + }); + } + + void dryRunRemove(CommandLine.Help help) { + output.printText(new String[] { + "\nRemove plugin from the CLI\n", + "\t" + projectRoot().toString() + }); + Map dryRunOutput = new TreeMap<>(); + dryRunOutput.put("Plugin to remove", name); + output.info(help.createTextTable(dryRunOutput).toString()); + }; +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsSync.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsSync.java new file mode 100644 index 0000000000000..f745da6158ff4 --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/CliPluginsSync.java @@ -0,0 +1,59 @@ +package io.quarkus.cli.plugin; + +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; + +import io.quarkus.cli.common.RunModeOption; +import picocli.CommandLine; + +@CommandLine.Command(name = "sync", header = "Sync (discover / purge) CLI Plugins.") +public class CliPluginsSync extends CliPluginsBase implements Callable { + + @CommandLine.Mixin + RunModeOption runMode; + + PluginCatalogService pluginCatalogService = new PluginCatalogService(); + + @Override + public Integer call() { + output.throwIfUnmatchedArguments(spec.commandLine()); + + if (runMode.isDryRun()) { + dryRunAdd(spec.commandLine().getHelp()); + return CommandLine.ExitCode.OK; + } + + PluginManager pluginManager = pluginManager(); + Map before = pluginManager.getInstalledPlugins(); + if (pluginManager.sync()) { + Map after = pluginManager.getInstalledPlugins(); + + Map installed = after.entrySet().stream().filter(e -> !before.containsKey(e.getKey())) + .collect(Collectors.toMap(e -> e.getKey(), e -> new PluginListItem(true, e.getValue()))); + Map uninstalled = before.entrySet().stream().filter(e -> !after.containsKey(e.getKey())) + .collect(Collectors.toMap(e -> e.getKey(), e -> new PluginListItem(false, e.getValue()))); + Map all = new HashMap<>(); + all.putAll(installed); + all.putAll(uninstalled); + + PluginListTable table = new PluginListTable(all.values(), false, true); + output.info("Sync completed. The following plugins were added/removed:"); + output.info(table.getContent()); + } else { + output.info("Nothing to sync (no plugins were added or removed)."); + } + return CommandLine.ExitCode.OK; + } + + void dryRunAdd(CommandLine.Help help) { + output.printText(new String[] { + "\tSync plugin to the CLI\n", + "\t" + projectRoot().toString() + }); + Map dryRunOutput = new TreeMap<>(); + output.info(help.createTextTable(dryRunOutput).toString()); + }; +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/JBangCommand.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/JBangCommand.java new file mode 100644 index 0000000000000..81da6f30c36a7 --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/JBangCommand.java @@ -0,0 +1,52 @@ +package io.quarkus.cli.plugin; + +import java.util.ArrayList; +import java.util.List; + +import io.quarkus.cli.common.OutputOptionMixin; +import picocli.CommandLine.Command; +import picocli.CommandLine.ExitCode; + +@Command +public class JBangCommand implements PluginCommand { + + private String location; //alias, url, maven coords + private JBangSupport jbang; + private OutputOptionMixin output; + private final List arguments = new ArrayList<>(); + + public JBangCommand() { + super(); + } + + public JBangCommand(String location, OutputOptionMixin output) { + this.location = location; + this.jbang = new JBangSupport(output.isCliTest(), output); + this.output = output; + this.arguments.add(location); + } + + @Override + public Integer call() throws Exception { + if (jbang.isAvailable()) { + return PluginCommand.super.call(); + } else { + output.error("Unable to find JBang! Command execution aborted!"); + return ExitCode.SOFTWARE; + } + } + + @Override + public List getCommand() { + return jbang.getCommand(); + } + + @Override + public List getArguments() { + return arguments; + } + + public OutputOptionMixin getOutput() { + return output; + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/JarCommand.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/JarCommand.java new file mode 100644 index 0000000000000..3217747c89ecf --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/JarCommand.java @@ -0,0 +1,53 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import io.quarkus.cli.common.OutputOptionMixin; +import picocli.CommandLine.Command; + +@Command +public class JarCommand implements PluginCommand { + + private JBangSupport jbang; + private String name; + private Path location; //May be path, url or CAGTV + private List arguments = new ArrayList<>(); + + private OutputOptionMixin output; + private Path workingDirectory; + + public JarCommand() { + } + + public JarCommand(String name, Path location, OutputOptionMixin output, Path workingDirectory) { + this.jbang = new JBangSupport(output.isCliTest(), output, workingDirectory); + this.name = name; + this.location = location; + this.arguments = new ArrayList<>(); + this.output = output; + this.workingDirectory = workingDirectory; + arguments.add(location.toAbsolutePath().toString()); + } + + @Override + public List getCommand() { + return jbang.getCommand(); + } + + @Override + public List getArguments() { + return arguments; + } + + @Override + public OutputOptionMixin getOutput() { + return output; + } + + @Override + public Path getWorkingDirectory() { + return workingDirectory; + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginCatalogOptions.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginCatalogOptions.java new file mode 100644 index 0000000000000..56aef19e51c29 --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginCatalogOptions.java @@ -0,0 +1,18 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.util.Optional; + +import picocli.CommandLine; + +public class PluginCatalogOptions { + + @CommandLine.Option(names = { + "--user" }, defaultValue = "", paramLabel = "USER", order = 4, description = "Use the user catalog.") + boolean user; + + @CommandLine.Option(names = { + "--user-dir" }, paramLabel = "USER_DIR", order = 5, description = "Use the user catalog directory.") + Optional userDirectory = Optional.empty(); + +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginCommand.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginCommand.java new file mode 100644 index 0000000000000..4b4972497642b --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginCommand.java @@ -0,0 +1,38 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; + +import io.quarkus.cli.build.ExecuteUtil; +import io.quarkus.cli.common.OutputOptionMixin; + +public interface PluginCommand extends Callable { + + List getCommand(); + + List getArguments(); + + OutputOptionMixin getOutput(); + + default Path getWorkingDirectory() { + return Paths.get(System.getProperty("user.dir")); + } + + default Integer call() throws Exception { + try { + List commandWithArgs = new ArrayList<>(); + commandWithArgs.addAll(getCommand()); + commandWithArgs.addAll(getArguments()); + ExecuteUtil.executeProcess(getOutput(), commandWithArgs.toArray(new String[commandWithArgs.size()]), + getWorkingDirectory().toFile()); + return 1; + } catch (Exception e) { + e.printStackTrace(); + return getOutput().handleCommandException(e, "Unable to run plugin command: [" + String.join(" ", getCommand()) + + "] with arguments: [" + String.join(" ", getArguments()) + "]"); + } + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginCommandFactory.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginCommandFactory.java new file mode 100644 index 0000000000000..ec6657437dbab --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginCommandFactory.java @@ -0,0 +1,116 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import io.quarkus.cli.common.OutputOptionMixin; +import io.quarkus.maven.dependency.GACTV; +import io.quarkus.runtime.util.StringUtil; +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Model.ISetter; +import picocli.CommandLine.Model.OptionSpec; +import picocli.CommandLine.Model.PositionalParamSpec; + +public class PluginCommandFactory { + + private final OutputOptionMixin output; + + public PluginCommandFactory(OutputOptionMixin output) { + this.output = output; + } + + private Optional createPluginCommand(Plugin plugin) { + switch (plugin.getType()) { + case jar: + case maven: + return plugin.getLocation().flatMap(PluginUtil::checkGACTV).map(g -> new JBangCommand(toGAVC(g), output)); + case jbang: + return plugin.getLocation().map(l -> new JBangCommand(l, output)); + case executable: + return plugin.getLocation().map(l -> new ShellCommand(plugin.getName(), Paths.get(l), output)); + default: + throw new IllegalStateException("Unknown plugin type!"); + } + } + + /** + * Create a command for the specified plugin + */ + public Optional createCommand(Plugin plugin) { + return createPluginCommand(plugin).map(command -> { + CommandSpec spec = CommandSpec.wrapWithoutInspection(command); + String description = plugin.getDescription().orElse(""); + if (!StringUtil.isNullOrEmpty(description)) { + spec.usageMessage().description(description); + } + spec.parser().unmatchedArgumentsAllowed(true); + spec.addOption(OptionSpec.builder("options").type(Map.class).description("options").build()); //This is needed or options are ignored. + spec.add(PositionalParamSpec.builder().type(String[].class).arity("0..*").description("Positional arguments") + .setter(new ISetter() { + @Override + public T set(T value) throws Exception { + if (value == null) { + return value; + } + if (value instanceof String[]) { + String[] array = (String[]) value; + command.getArguments().addAll(Arrays.asList(array)); + } + return value; + } + }).build()); + return spec; + }); + } + + /** + * Populate the plugin commands listed in the {@link PluginCatalog} to the {@link CommandLine}. + * + * @param cmd the CommandLine. + * @param plugins the available plugins. + * @param factory the factory use to create the commands. + */ + public void populateCommands(CommandLine cmd, Map plugins) { + plugins.entrySet().stream() + .map(Map.Entry::getValue).forEach(plugin -> { + CommandLine current = cmd; + String name = plugin.getName(); + while (current != null && current.getCommandName() != null + && name.startsWith(current.getCommandName() + "-")) { + String remaining = name.substring(current.getCommandName().length() + 1); + name = remaining; + List subcommandKeys = current.getSubcommands().keySet().stream() + .filter(k -> remaining.startsWith(k)) + .collect(Collectors.toList()); + Optional matchedKey = subcommandKeys.stream().sorted(Comparator.comparingInt(String::length)) + .findFirst(); + if (!matchedKey.isPresent()) { + break; + } + current = current.getSubcommands().get(matchedKey.get()); + } + //JBang aliases from remote catalogs are suffixed with '@' + //We keep the catalog in the name, so that we can call the command, but + //let's not use it in the subcommand name + name = name.contains("@") ? name.split("@")[0] : name; + final String commandName = name; + final CommandLine commandParent = current; + createCommand(plugin).ifPresent(command -> { + if (!commandParent.getSubcommands().containsKey(commandName)) { + commandParent.addSubcommand(commandName, command); + } + }); + }); + } + + private static String toGAVC(GACTV gactv) { + return gactv.getGroupId() + ":" + gactv.getArtifactId() + ":" + gactv.getVersion() + + (StringUtil.isNullOrEmpty(gactv.getClassifier()) ? "" : ":" + gactv.getClassifier()); + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginListItem.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginListItem.java new file mode 100644 index 0000000000000..03849ad59b373 --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginListItem.java @@ -0,0 +1,64 @@ +package io.quarkus.cli.plugin; + +public class PluginListItem { + + private final boolean installed; + private final Plugin plugin; + + public PluginListItem(boolean installed, Plugin plugin) { + this.installed = installed; + this.plugin = plugin; + } + + public boolean isInstalled() { + return installed; + } + + public String getSymbol() { + return installed ? "*" : " "; + } + + public String getName() { + return plugin.getName(); + } + + public String getType() { + return plugin.getType().name(); + } + + public String getScope() { + return plugin.isInUserCatalog() ? "user" : "project"; + } + + public String getLocation() { + return plugin.getLocation().orElse(""); + } + + public String getDescription() { + return plugin.getDescription().orElse(""); + } + + public String getCommand() { + switch (plugin.getType()) { + case jar: + case maven: + return "jbang " + plugin.getLocation().orElse(""); + case jbang: + return "jbang " + plugin.getLocation().orElse(plugin.getName()); + case executable: + return plugin.getLocation().orElse(""); + default: + return ""; + } + } + + public String[] getFields() { + return getFields(false); + } + + public String[] getFields(boolean withCommand) { + return withCommand + ? new String[] { getSymbol(), getName(), getType(), getScope(), getLocation(), getDescription(), getCommand() } + : new String[] { getSymbol(), getName(), getType(), getScope(), getLocation(), getDescription() }; + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginListTable.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginListTable.java new file mode 100644 index 0000000000000..c35b40f963ed9 --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/PluginListTable.java @@ -0,0 +1,155 @@ +package io.quarkus.cli.plugin; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +public class PluginListTable { + + private static final String INSTALLED = " "; + private static final String NAME = "Name"; + private static final String TYPE = "Type"; + private static final String SCOPE = "Scope"; + private static final String LOCATION = "Location"; + private static final String DESCRIPTION = "Description"; + private static final String COMMAND = "Command"; + + private static final String NEWLINE = "\n"; + + private List items; + private boolean withCommand; + private boolean withDiff; + + public PluginListTable(Collection items) { + this(items, false, false); + } + + public PluginListTable(Collection items, boolean withCommand) { + this(items, withCommand, false); + } + + public PluginListTable(Collection items, boolean withCommand, boolean withDiff) { + this.items = new ArrayList<>(items); + this.withCommand = withCommand; + this.withDiff = withDiff; + } + + public PluginListTable() { + } + + public String getContent() { + return getContent(items, withCommand, withDiff); + } + + // Utils + private static String[] getLabels() { + return getLabels(false); + } + + private static String[] getLabels(boolean withCommand) { + if (withCommand) { + return new String[] { INSTALLED, NAME, TYPE, SCOPE, LOCATION, DESCRIPTION, COMMAND }; + } else { + return new String[] { INSTALLED, NAME, TYPE, SCOPE, LOCATION, DESCRIPTION }; + } + } + + private static String getHeader(String format, Collection items, boolean withCommand) { + return String.format(format, getLabels(withCommand)); + } + + private static String getBody(String format, Collection items, boolean withCommand, boolean withDiff) { + StringBuilder sb = new StringBuilder(); + for (PluginListItem item : items) { + sb.append(String.format(format, fieldsWithDiff(item.getFields(withCommand), withDiff))); + sb.append(NEWLINE); + } + return sb.toString(); + } + + public static String getContent(Collection items, boolean wtihCommand, boolean withDiff) { + String format = getFormat(items, wtihCommand); + return getContent(format, items, wtihCommand, withDiff); + } + + public static String getContent(String format, Collection items, boolean wtihCommand, boolean withDiff) { + StringBuilder sb = new StringBuilder(); + sb.append(getHeader(format, items, wtihCommand)); + sb.append(NEWLINE); + sb.append(getBody(format, items, wtihCommand, withDiff)); + return sb.toString(); + } + + private static String getFormat(Collection items, boolean withCommand) { + StringBuilder sb = new StringBuilder(); + sb.append(" %-1s "); + + int maxNameLength = Stream.concat(Stream.of(NAME), + items.stream().map(PluginListItem::getName)) + .filter(Objects::nonNull) + .map(String::length) + .max(Comparator.naturalOrder()) + .orElse(0); + sb.append(" %-" + maxNameLength + "s "); + sb.append("\t"); + + int maxTypeLength = Stream.concat(Stream.of(TYPE), + items.stream().map(PluginListItem::getType)) + .filter(Objects::nonNull) + .map(String::length) + .max(Comparator.naturalOrder()) + .orElse(0); + sb.append(" %-" + maxTypeLength + "s "); + sb.append("\t"); + + int maxScopeLength = Stream.concat(Stream.of(SCOPE), + items.stream().map(PluginListItem::getScope)) + .filter(Objects::nonNull) + .map(String::length) + .max(Comparator.naturalOrder()) + .orElse(0); + sb.append(" %-" + maxScopeLength + "s "); + sb.append("\t"); + + int maxLocationLength = Stream.concat(Stream.of(LOCATION), + items.stream().map(PluginListItem::getLocation)) + .filter(Objects::nonNull) + .map(String::length) + .max(Comparator.naturalOrder()) + .orElse(0); + sb.append(" %-" + maxLocationLength + "s "); + sb.append("\t"); + + int maxDescriptionLength = Stream.concat(Stream.of(DESCRIPTION), + items.stream().map(PluginListItem::getDescription)) + .filter(Objects::nonNull) + .map(String::length) + .max(Comparator.naturalOrder()) + .orElse(0); + sb.append(" %-" + maxDescriptionLength + "s "); + sb.append("\t"); + + if (withCommand) { + int maxCommandLength = Stream.concat(Stream.of(COMMAND), + items.stream().map(PluginListItem::getCommand)) + .filter(Objects::nonNull) + .map(String::length) + .max(Comparator.naturalOrder()) + .orElse(0); + sb.append(" %-" + maxCommandLength + "s "); + } + return sb.toString(); + } + + private static String[] fieldsWithDiff(String[] fields, boolean showDiff) { + if (!showDiff) { + return fields; + } + //Map '*'' -> '+'' and ' ' -> '-' + fields[0] = fields[0].replace("*", "+").replace(" ", "-"); + return fields; + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/plugin/ShellCommand.java b/devtools/cli/src/main/java/io/quarkus/cli/plugin/ShellCommand.java new file mode 100644 index 0000000000000..c7ce963ade3d4 --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/plugin/ShellCommand.java @@ -0,0 +1,48 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; + +import io.quarkus.cli.common.OutputOptionMixin; +import picocli.CommandLine.Command; + +@Command +public class ShellCommand implements PluginCommand, Callable { + + private String name; + private Path command; + private OutputOptionMixin output; + + private final List arguments = new ArrayList<>(); + + public ShellCommand() { + } + + public ShellCommand(String name, Path command, OutputOptionMixin output) { + this.name = name; + this.command = command; + this.output = output; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getCommand() { + return List.of(command.toString()); + } + + public List getArguments() { + return arguments; + } + + public OutputOptionMixin getOutput() { + return output; + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/utils/Registries.java b/devtools/cli/src/main/java/io/quarkus/cli/utils/Registries.java new file mode 100644 index 0000000000000..cfacfacf2cd3a --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/utils/Registries.java @@ -0,0 +1,31 @@ +package io.quarkus.cli.utils; + +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Set; + +import io.quarkus.cli.registry.RegistryClientMixin; +import io.quarkus.registry.RegistryResolutionException; +import io.quarkus.registry.config.RegistryConfig; + +public final class Registries { + + private Registries() { + //Utility class + } + + public static Set getRegistries(RegistryClientMixin client, String... additionalRegistires) { + Set registries = new LinkedHashSet<>(); + try { + for (RegistryConfig c : client.resolveConfig().getRegistries()) { + registries.add(c.getId()); + } + for (String r : additionalRegistires) { + registries.add(r); + } + return registries; + } catch (RegistryResolutionException e) { + return new HashSet<>(); + } + } +} diff --git a/devtools/cli/src/main/resources/application.properties b/devtools/cli/src/main/resources/application.properties index 1ec0fef292941..85aa943af439c 100644 --- a/devtools/cli/src/main/resources/application.properties +++ b/devtools/cli/src/main/resources/application.properties @@ -2,8 +2,11 @@ quarkus.log.level=WARN quarkus.banner.enabled=false quarkus.package.type=uber-jar quarkus.native.resources.includes=quarkus.properties -quarkus.native.additional-build-args=--initialize-at-run-time=org.apache.maven.wagon.shared.http.AbstractHttpClientWagon +quarkus.native.additional-build-args=--initialize-at-run-time=org.apache.maven.wagon.shared.http.AbstractHttpClientWagon,\ +-H:ReflectionConfigurationFiles=reflection-config.json # Do not attempt to detect "unused removed beans" false positives during programmatic lookup # at runtime to conserve some memory quarkus.arc.detect-unused-false-positives=false quarkus.config.sources.system-only=true +# Needed to be able to download from the jbang catalog +quarkus.native.enable-https-url-handler=true diff --git a/devtools/cli/src/main/resources/reflection-config.json b/devtools/cli/src/main/resources/reflection-config.json new file mode 100644 index 0000000000000..e360f3f30d6d4 --- /dev/null +++ b/devtools/cli/src/main/resources/reflection-config.json @@ -0,0 +1,38 @@ +[ + { + "name" : "io.quarkus.cli.plugin.Plugin", + "allDeclaredConstructors" : true, + "allPublicConstructors" : true, + "allDeclaredMethods" : true, + "allPublicMethods" : true, + "allDeclaredFields" : true, + "allPublicFields" : true + }, + { + "name" : "io.quarkus.cli.plugin.PluginCatalog", + "allDeclaredConstructors" : true, + "allPublicConstructors" : true, + "allDeclaredMethods" : true, + "allPublicMethods" : true, + "allDeclaredFields" : true, + "allPublicFields" : true + }, + { + "name" : "io.quarkus.cli.plugin.JBangAlias", + "allDeclaredConstructors" : true, + "allPublicConstructors" : true, + "allDeclaredMethods" : true, + "allPublicMethods" : true, + "allDeclaredFields" : true, + "allPublicFields" : true + }, + { + "name" : "io.quarkus.cli.plugin.JBangCatalog", + "allDeclaredConstructors" : true, + "allPublicConstructors" : true, + "allDeclaredMethods" : true, + "allPublicMethods" : true, + "allDeclaredFields" : true, + "allPublicFields" : true + } +] diff --git a/devtools/cli/src/test/java/io/quarkus/cli/CliHelpTest.java b/devtools/cli/src/test/java/io/quarkus/cli/CliHelpTest.java index 433f30c744f17..8e5990447fe6f 100644 --- a/devtools/cli/src/test/java/io/quarkus/cli/CliHelpTest.java +++ b/devtools/cli/src/test/java/io/quarkus/cli/CliHelpTest.java @@ -335,4 +335,50 @@ public void testDeployKnativeHelp() throws Exception { assertThat(result.stdout).contains("--namespace"); } + @Order(105) + public void testPluginHelp() throws Exception { + CliDriver.Result result = CliDriver.execute(workspaceRoot, "plug", "--help"); + result.echoSystemOut(); + assertThat(result.stdout).contains("Usage"); + + CliDriver.Result result2 = CliDriver.execute(workspaceRoot, "plugin", "--help"); + assertThat(result.stdout).isEqualTo(result2.stdout); + CliDriver.println("-- same as above\n\n"); + } + + @Test + @Order(106) + public void testPlugnListHelp() throws Exception { + CliDriver.Result result = CliDriver.execute(workspaceRoot, "plug", "list", "--help"); + result.echoSystemOut(); + assertThat(result.stdout).contains("Usage"); + + CliDriver.Result result2 = CliDriver.execute(workspaceRoot, "plugin", "list", "--help"); + assertThat(result.stdout).isEqualTo(result2.stdout); + CliDriver.println("-- same as above\n\n"); + } + + @Test + @Order(107) + public void testPlugnAddHelp() throws Exception { + CliDriver.Result result = CliDriver.execute(workspaceRoot, "plug", "add", "--help"); + result.echoSystemOut(); + assertThat(result.stdout).contains("Usage"); + + CliDriver.Result result2 = CliDriver.execute(workspaceRoot, "plugin", "add", "--help"); + assertThat(result.stdout).isEqualTo(result2.stdout); + CliDriver.println("-- same as above\n\n"); + } + + @Test + @Order(108) + public void testPlugnRemoveHelp() throws Exception { + CliDriver.Result result = CliDriver.execute(workspaceRoot, "plug", "remove", "--help"); + result.echoSystemOut(); + assertThat(result.stdout).contains("Usage"); + + CliDriver.Result result2 = CliDriver.execute(workspaceRoot, "plugin", "remove", "--help"); + assertThat(result.stdout).isEqualTo(result2.stdout); + CliDriver.println("-- same as above\n\n"); + } } diff --git a/devtools/cli/src/test/java/io/quarkus/cli/plugin/PluginCatalogServiceTest.java b/devtools/cli/src/test/java/io/quarkus/cli/plugin/PluginCatalogServiceTest.java new file mode 100644 index 0000000000000..49045053c6ad9 --- /dev/null +++ b/devtools/cli/src/test/java/io/quarkus/cli/plugin/PluginCatalogServiceTest.java @@ -0,0 +1,142 @@ +package io.quarkus.cli.plugin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.cli.CliDriver; + +public class PluginCatalogServiceTest { + + private PluginCatalogService service = new PluginCatalogService(); + Path userRoot; + Path projectRoot; + + @BeforeEach + public void initial() throws Exception { + userRoot = Paths.get(System.getProperty("user.dir")).toAbsolutePath() + .resolve("target/test-project/PluginCatalogServiceTest/user-root"); + CliDriver.deleteDir(userRoot); + Files.createDirectories(userRoot); + + projectRoot = Paths.get(System.getProperty("user.dir")).toAbsolutePath() + .resolve("target/test-project/PluginCatalogServiceTest/project-root"); + CliDriver.deleteDir(projectRoot); + Files.createDirectories(projectRoot); + Files.createDirectories(projectRoot.resolve(".git")); + } + + @Test + public void shouldMapToCatalogFile() { + Optional path = service.getRelativeCatalogPath(Optional.of(userRoot)); + assertTrue(path.isPresent()); + assertTrue(path.get().getFileName().endsWith("quarkus-cli-catalog.json")); + assertTrue(path.get().toAbsolutePath().toString().contains("user-root")); + + path = service.getRelativeCatalogPath(Optional.of(projectRoot)); + assertTrue(path.isPresent()); + assertTrue(path.get().getFileName().endsWith("quarkus-cli-catalog.json")); + assertTrue(path.get().toAbsolutePath().toString().contains("project-root")); + } + + @Test + public void shouldFallbackToUserRootCatalog() { + Optional path = service.getCatalogPath(Optional.of(projectRoot), Optional.of(userRoot)); + assertTrue(path.isPresent()); + assertTrue(path.get().getFileName().endsWith("quarkus-cli-catalog.json")); + assertTrue(path.get().toAbsolutePath().toString().contains("user-root")); + } + + @Test + public void shouldReadEmptyCatalog() { + Optional catalogPath = service.getRelativeCatalogPath(Optional.of(userRoot)); + assertTrue(catalogPath.isPresent()); + + PluginCatalog catalog = service.readCatalog(catalogPath.get()); + assertTrue(catalog.getPlugins().isEmpty()); + + catalogPath = service.getRelativeCatalogPath(Optional.of(projectRoot)); + assertTrue(catalogPath.isPresent()); + catalog = service.readCatalog(catalogPath.get()); + assertTrue(catalog.getPlugins().isEmpty()); + } + + @Test + public void shouldCreateCatalogInProjectRoot() { + shouldCreateCatalogIn(service.findProjectCatalogPath(Optional.of(projectRoot)).orElseThrow(), "foo"); + } + + @Test + public void shouldCreateCatalogInUserRoot() { + shouldCreateCatalogIn(service.getRelativeCatalogPath(Optional.of(userRoot)).orElseThrow(), "foo"); + } + + @Test + public void shouldCombineCatalogs() { + Path userCatalog = service.getRelativeCatalogPath(Optional.of(userRoot)).orElseThrow(); + Path projectCatalog = service.findProjectCatalogPath(Optional.of(projectRoot)).orElseThrow(); + shouldCreateCatalogIn(userCatalog, "foo", "bar"); + shouldCreateCatalogIn(projectCatalog, "foo", "baz"); + PluginCatalog catalog = service.readCombinedCatalog(Optional.of(projectRoot), Optional.of(userRoot)); + assertTrue(catalog.getPlugins().size() == 3); + assertTrue(catalog.getPlugins().containsKey("foo")); + assertTrue(catalog.getPlugins().containsKey("bar")); + assertTrue(catalog.getPlugins().containsKey("baz")); + + //The project catalog should alway override the user catalog, so `foo` that is used in both should be read from project + assertEquals(catalog.getPlugins().get("foo").getCatalogLocation().map(Path::toAbsolutePath).map(Path::toString).get(), + projectCatalog.toAbsolutePath().toString()); + } + + @Test + public void shouldSyncWhenProjectFileIsNewerThanCatalog() throws IOException { + PluginCatalog catalog = service.readCombinedCatalog(Optional.of(projectRoot), Optional.of(userRoot)); + Files.createFile(projectRoot.resolve("pom.xml")); + assertTrue(PluginUtil.shouldSync(projectRoot, catalog)); + } + + @Test + public void shouldNotSyncWhenProjectFileIsOlderThanCatalog() throws IOException { + Files.createFile(projectRoot.resolve("pom.xml")); + PluginCatalog catalog = new PluginCatalog("v1", LocalDateTime.now().plusMinutes(1), Collections.emptyMap(), + Optional.empty()); + assertFalse(PluginUtil.shouldSync(projectRoot, catalog)); + } + + public void shouldCreateCatalogIn(Path catalogPath, String... commands) { + File catalogFile = catalogPath.toFile(); + + assertFalse(catalogFile.exists()); + + Map plugins = new HashMap<>(); + + //Let's populate a few commands + for (String command : commands) { + plugins.put(command, + new Plugin(command, PluginType.jbang, Optional.of(command), Optional.empty())); + PluginCatalog catalog = new PluginCatalog(plugins).withCatalogLocation(catalogFile); + service.writeCatalog(catalog); + } + + //Let's read the catalog and verify it's content + assertTrue(catalogFile.exists()); + PluginCatalog catalog = service.readCatalog(catalogPath); + for (String command : commands) { + assertTrue(catalog.getPlugins().containsKey(command)); + } + } +} diff --git a/docs/src/main/asciidoc/cli-tooling.adoc b/docs/src/main/asciidoc/cli-tooling.adoc index 6a333fb3ffb7c..45b56128bb818 100644 --- a/docs/src/main/asciidoc/cli-tooling.adoc +++ b/docs/src/main/asciidoc/cli-tooling.adoc @@ -37,7 +37,7 @@ Choose the alternative that is the most practical for you: [role="primary asciidoc-tabs-sync-jbang"] .JBang **** -The Quarkus CLI is available as a jar installable using https://jbang.dev[JBang]. +The Quarkus CLI is available as a jar installable using https://jbang.dev[JBang]. JBang will use your existing Java or install one for you if needed. @@ -415,7 +415,7 @@ The Quarkus CLI can be used to list Quarkus extensions. quarkus ext ls ---- -The format of the result can be controlled with one of four options: +The format of the result can be controlled with one of four options: - `--name` Display the name (artifactId) only - `--concise` Display the name (artifactId) and description @@ -604,3 +604,162 @@ The `image push` command is similar to `image build`, and surfaces some basic op ---- quarkus image push --registry= --registry-username= --registry-password-stdin ---- + + +== Extending the CLI +The pull request introduces a plugin mechanism for the Quarkus CLI. This allows to dynamically add commands / subcommand to the CLI. + +=== What is a Plugin +A plugin can be any executable, jar or java command that can be found locally or obtained remotely. +So plugins are classified in the following types: + +* Plugins executed via shell + * *executable* (any executable prefixed with `quarkus-` found locally) +* Plugins executed via jbang + * *jar* (any runnable jar found locally) + * *jbang alias* (any jbang alias prefixed with `quarkus-` installed locally or through the quarkusio catalog) + * *maven* (any maven coordinate in GACTV form pointing to a runnable jar) + +=== How to obtain plugins +Plugins can be found via multiple sources that are described below. + +==== Extension(s) +A quarkus extension may define list of `cli-plugins` as part of its metadata. The list may contains GACTV string pointing to executable jars. + +*Limitations*: At the moment the cli is able to obtain the list of available extensions, without being very accurate on the exact version of the extension (it uses the version found in the extension catalog). + +==== Local path scanning +Scan the path item for executable files prefixed with `quarkus`. +==== Using JBang aliases +Scan the local or project jbang catalog for aliases prefixed with `quarkus-`. +==== Using the JBang quarkusio catalog +Scan the quarkusio catalog for aliases prefixed wtih `quarkus-`. +*Note:* uses the jbang binary. If missing it will be automatically installed unser `.jbang`. +==== Explicitly using the plugin commands +See `quarkus plugin add` below + +=== Managing plugins +Plugins are managed using the following commands: + +==== Listing plugins + +The following command lists the installed add plugins. + +[source, shell] +---- +quarkus plug list +No plugins installed! +To include the installable plugins in the list, append --installable to the command. +---- + +To list available / installable plugins: + +[source, shell] +---- +❯ quarkus plug list --installable + Name Type Scope Location Description + kill jbang user quarkus-kill@quarkusio + * fmt jbang user quarkus-fmt@quarkusio + greeter executable user /home/iocanel/bin/quarkus-greeter + + Use the 'plugin add' sub command and pass the location of any plugin listed above, or any remote location in the form of URL / GACTV pointing to a remote plugin. + +---- + +The output of the `list` command may be filtered by `type` using `-t` or by name using `-s` flag and a search pattern. +*Example:* To list all installable plugins that start with the letter `k`: + + +[source, shell] +---- +quarkus plug list --installable -s "k*" + Name Type Scope Location Description + kill jbang user quarkus-kill@quarkusio + +Use the 'plugin add' sub command and pass the location of any plugin listed above, or any remote location in the form of URL / GACTV pointing to a remote plugin. + +---- + +==== Adding plugins + +To add any of the installable plugins, use `quarkus plug add `: + + +[source, shell] +---- +quarkus plug add kill +Added plugin: + Name Type Scope Location Description + * kill jbang user quarkus-kill@quarkusio + +---- + +The command above installed a plugin by `name` using the name as listed by `quarkus plug list --installable`. + +The command can be now executed using `quarkus kill`. + +*Note*: Users are not limited to the plugins discovered by `quarkus plug list --installable`. Users may install plugins as long as the provide the URL or the Maven coordinates pointing to an executable jar or java file. + +*Example*: Installing an executable jar as a plugin via maven coordinates +For this example will use: `io.quarkiverse.authzed:quarkus-authzed-cli:runner:jar:0.2.0` which is a a real executable jar that provide a cli utility for the `quarkus-authzed` extension. + + +[source, shell] +---- +quarkus plug add io.quarkiverse.authzed:quarkus-authzed-cli:runner:jar:0.2.0 -d "Authzed CLI" +Added plugin: + Name Type Location Description + * authzed maven io.quarkiverse.authzed:quarkus-authzed-cli:runner:jar:0.2.0 Authzed CLI + +---- +*Note*: It's also possible to set a description that will appear to the help messages. + + +[source, shell] +---- +quarkus --help + +Usage: quarkus [-ehv] [--verbose] [-D=]... [COMMAND] +... +Commands: +... + plugin, plug Configure plugins of the Quarkus CLI. + list, ls List CLI plugins. + add Add plugin(s) to the Quarkus CLI. + remove Remove plugin(s) to the Quarkus CLI. + sync Sync (discover / purge) CLI Plugins. + completion bash/zsh completion: source <(quarkus completion) + authzed Authzed CLI +... +---- + +==== Where are the plugins added? +Plugins are added in the plugin catalog that lives at: `/.quakrus/cli/plugins/quarkus-cli-catalog.json`. + +There is a second plugin catalog that is relative to the current project (if available): `/.quarkus/cli/plugins/quarkus-cli-catalog.json`. + +The effective catalog is the combination of both the `user` and `project` catalogs with the latter being able to override entries of the former (e.g. use a different version or location for a plugin). + +If the project catalog is available it will allways be prefered, unless explicitly specified with the user of `--user` flag. + +The column `scope` the plugin table indicates where the plugin is/will be added. + +==== Removing plugins +Plugins are removed using `quarkus plug remove `. + +[source, shell] +---- +quarkus plug remove kill +Removed plugin: + Name Type Scope Location Description + kill jbang user quarkus-kill@quarkusio +---- + +==== Syncing plugins +To remove stale plugins or discover new plugins provided by extensions the `quarkus plugin sync` is available. +With this command binaries and jbang aliases that are added to the catalog but are not longer available will be purged. +*Note*: Remote plugins that are explicitly added by the user using URL / Maven coordinates are excluded. + +The command is also executed implicitly through any of the CLI commands: +* Weekly +* If the project files have been updated since the last catalog update (limited to the module). diff --git a/independent-projects/tools/devtools-common/pom.xml b/independent-projects/tools/devtools-common/pom.xml index 32bff7df0d1f9..8f52c0ee0821e 100644 --- a/independent-projects/tools/devtools-common/pom.xml +++ b/independent-projects/tools/devtools-common/pom.xml @@ -62,6 +62,10 @@ com.fasterxml.jackson.dataformat jackson-dataformat-yaml + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + org.apache.maven maven-plugin-api diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/Binaries.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/Binaries.java new file mode 100644 index 0000000000000..27fe2cb1a6e88 --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/Binaries.java @@ -0,0 +1,48 @@ +package io.quarkus.cli.plugin; + +import java.io.File; +import java.util.Arrays; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public final class Binaries { + + public static Predicate WITH_QUARKUS_PREFIX = f -> f.getName().startsWith("quarkus-"); + + private Binaries() { + //Utility class + } + + public static Stream streamCommands() { + return Arrays.stream(System.getenv().getOrDefault("PATH", "").split(File.pathSeparator)) + .map(String::trim) + .filter(p -> p != null && !p.isEmpty()) + .map(p -> new File(p)) + .filter(File::exists) + .filter(File::isDirectory) + .flatMap(d -> Arrays.stream(d.listFiles())) + .filter(File::isFile) + .filter(File::canExecute); + } + + public static Set findCommands(Predicate filter) { + return streamCommands() + .filter(filter) + .collect(Collectors.toSet()); + } + + public static Set findCommands() { + return findCommands(f -> true); + } + + public static Set findQuarkusPrefixedCommands() { + return findCommands(WITH_QUARKUS_PREFIX); + } + + public static Optional pathOfComamnd(String name) { + return streamCommands().filter(f -> f.getName().equals(name)).findFirst(); + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/Catalog.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/Catalog.java new file mode 100644 index 0000000000000..4378c8aa2730f --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/Catalog.java @@ -0,0 +1,22 @@ +package io.quarkus.cli.plugin; + +import java.io.File; +import java.nio.file.Path; +import java.util.Optional; + +public interface Catalog> { + + Optional getCatalogLocation(); + + default T withCatalogLocation(File catalogLocation) { + return withCatalogLocation(catalogLocation.toPath()); + } + + default T withCatalogLocation(Path catalogLocation) { + return withCatalogLocation(Optional.of(catalogLocation)); + } + + T withCatalogLocation(Optional catalogLocation); + + T refreshLastUpdate(); +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/CatalogService.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/CatalogService.java new file mode 100644 index 0000000000000..96939bc83dcf4 --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/CatalogService.java @@ -0,0 +1,180 @@ +package io.quarkus.cli.plugin; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; + +public class CatalogService> { + + private static final Predicate EXISTS_AND_WRITABLE = p -> p != null && p.toFile().exists() && p.toFile().canRead() + && p.toFile().canWrite(); + + protected static final Predicate GIT_ROOT = p -> p != null && p.resolve(".git").toFile().exists(); + + protected final ObjectMapper objectMapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .enable(SerializationFeature.INDENT_OUTPUT) + .registerModule(new Jdk8Module()); + + protected final Class catalogType; + protected final Predicate projectRoot; + protected final Function relativePath; + + public CatalogService(Class catalogType, Predicate projectRoot, Function relativePath) { + this.catalogType = catalogType; + this.projectRoot = projectRoot; + this.relativePath = relativePath; + } + + /** + * Reads the plguin catalog from the user home. + * + * @param userdir An optional path pointing to the user directory. + * @return a catalog wrapped in optional or empty if the catalog is not present. + */ + public Optional readUserCatalog(Optional userDir) { + Path userCatalogPath = getUserCatalogPath(userDir); + return Optional.of(userCatalogPath).map(this::readCatalog); + } + + public Optional readProjectCatalog(Optional dir) { + Optional projectCatalogPath = findProjectCatalogPath(dir); + return projectCatalogPath.map(this::readCatalog); + } + + /** + * Get the project catalog path relative to the specified path. + * The method will traverse from the specified path up to upmost directory that the user can write and + * is under version control seeking for a `.quarkus/cli/plugins/catalog.json`. + * + * @param dir the specified path + * @return the catalog path wrapped as {@link Optional} or empty if the catalog does not exist. + */ + public Optional findProjectCatalogPath(Path dir) { + Optional catalogPath = Optional.of(dir).map(relativePath).filter(EXISTS_AND_WRITABLE); + if (catalogPath.isPresent()) { + return catalogPath; + } + if (projectRoot.test(dir)) { + return Optional.of(dir).map(relativePath); + } + return Optional.ofNullable(dir).map(Path::getParent) + .filter(EXISTS_AND_WRITABLE) + .flatMap(this::findProjectCatalogPath); + } + + public Optional findProjectCatalogPath(Optional dir) { + return dir.flatMap(this::findProjectCatalogPath); + } + + /** + * Read the catalog from project or fallback to global catalog. + * + * @param projectDir An optional path pointing to the project directory. + * @param userdir An optional path pointing to the user directory + * @return the catalog + */ + public Optional readCatalog(Optional projectDir, Optional userDir) { + return readProjectCatalog(projectDir).or(() -> readUserCatalog(userDir)); + } + + /** + * Read the catalog from the specified path. + * + * @param path the path to read the catalog from. + * @return the catalog + */ + public T readCatalog(Path path) { + try { + return (path.toFile().length() == 0 ? catalogType.getConstructor().newInstance() + : objectMapper.readValue(path.toFile(), catalogType)).withCatalogLocation(path); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Write the catalog to the specified {@link Path}. + * The method will create the directory structure if missing. + * + * @param catalog the catalog + * @param path the path + */ + public void writeCatalog(T catalog) { + try { + File catalogFile = catalog.getCatalogLocation().map(Path::toFile) + .orElseThrow(() -> new IllegalStateException("Don't know where to save catalog!")); + if (!catalogFile.exists() && !catalogFile.getParentFile().mkdirs() && !catalogFile.createNewFile()) { + throw new IOException("Failed to create catalog at: " + catalogFile.getAbsolutePath()); + } + objectMapper.writeValue(catalogFile, catalog.refreshLastUpdate()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Get the global catalog path that is under `.quarkus/cli/plugins/catalog.json` under the specified user home directory. + * The specified directory is optional and the method will fallback to the `user.home` system property. + * Using a different value if mostly needed for testing. + * + * @param userDir An optional user directory to use as a base path for the catalog lookup + * + * @return the catalog path wrapped as {@link Optional} or empty if the catalog does not exist. + */ + public Path getUserCatalogPath(Optional userDir) { + return relativePath.apply(userDir.orElse(Paths.get(System.getProperty("user.home")))); + } + + /** + * Get the global catalog path that is under `~/.quarkus/cli/plugins/catalog.json` + * + * @return the catalog path wrapped as {@link Optional} or empty if the catalog does not exist. + */ + public Path getUserCatalogPath() { + return getUserCatalogPath(Optional.empty()); + } + + /** + * Get the catalog relative to the specified path. + * + * @param dir the specified path + * + * @return the catalog path wrapped as {@link Optional} or empty if the catalog does not exist. + */ + public Optional getRelativeCatalogPath(Path dir) { + return getRelativeCatalogPath(Optional.of(dir)); + } + + /** + * Get the catalog relative to the current dir. + * + * @param ouput an {@link OutputOptionMixin} that can be used for tests to substitute current dir with a test directory. + * @return the catalog path wrapped as {@link Optional} or empty if the catalog does not exist. + */ + public Optional getRelativeCatalogPath(Optional dir) { + return dir.or(() -> Optional.ofNullable(Paths.get(System.getProperty("user.dir")))).map(relativePath); + } + + /** + * Get the project or user catalog path. + * The method with lookup the relative catalog path to the current dir and will fallback to the user catalog path. + * + * @param projectDir An optional path pointing to the project directory. + * @param userdir An optional path pointing to the user directory + * @return the catalog path wrapped as {@link Optional} or empty if the catalog does not exist. + */ + public Optional getCatalogPath(Optional projectDir, Optional userDir) { + return getRelativeCatalogPath(projectDir).filter(EXISTS_AND_WRITABLE) + .or(() -> Optional.of(getUserCatalogPath(userDir))); + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/JBangAlias.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/JBangAlias.java new file mode 100644 index 0000000000000..b9009830b7fab --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/JBangAlias.java @@ -0,0 +1,50 @@ +package io.quarkus.cli.plugin; + +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties +public class JBangAlias { + + @JsonProperty("script-ref") + private String scriptRef; + private Optional description; + @JsonIgnore + private Optional remote; + + public JBangAlias() { + } + + public JBangAlias(String scriptRef, Optional description, Optional remote) { + this.scriptRef = scriptRef; + this.description = description; + this.remote = remote; + } + + public String getScriptRef() { + return scriptRef; + } + + public void setScriptRef(String scriptRef) { + this.scriptRef = scriptRef; + } + + public Optional getDescription() { + return description; + } + + public void setDescription(Optional description) { + this.description = description; + } + + public Optional getRemote() { + return remote; + } + + public JBangAlias withRemote(Optional remote) { + return new JBangAlias(scriptRef, description, remote); + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/JBangCatalog.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/JBangCatalog.java new file mode 100644 index 0000000000000..ee64683b82cfb --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/JBangCatalog.java @@ -0,0 +1,64 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class JBangCatalog implements Catalog { + + private final Map catalogs; + private final Map aliases; + + @JsonProperty("catalog-ref") + private final Optional catalogRef; + + @JsonIgnore + private final Optional catalogLocation; + + public static JBangCatalog empty() { + return new JBangCatalog(); + } + + public JBangCatalog() { + this(Collections.emptyMap(), Collections.emptyMap(), Optional.empty(), Optional.empty()); + } + + public JBangCatalog(Map catalogs, Map aliases, Optional catalogRef, + Optional catalogLocation) { + this.catalogs = catalogs; + this.aliases = aliases; + this.catalogRef = catalogRef; + this.catalogLocation = catalogLocation; + } + + public Map getCatalogs() { + return catalogs; + } + + public Map getAliases() { + return aliases; + } + + public Optional getCatalogRef() { + return catalogRef; + } + + @Override + public Optional getCatalogLocation() { + return catalogLocation; + } + + @Override + public JBangCatalog refreshLastUpdate() { + return this; + } + + @Override + public JBangCatalog withCatalogLocation(Optional catalogLocation) { + return new JBangCatalog(catalogs, aliases, catalogRef, catalogLocation); + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/JBangCatalogService.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/JBangCatalogService.java new file mode 100644 index 0000000000000..adf9abac11477 --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/JBangCatalogService.java @@ -0,0 +1,151 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import io.quarkus.devtools.messagewriter.MessageWriter; + +public class JBangCatalogService extends CatalogService { + + private static final Function RELATIVE_PLUGIN_CATALOG = p -> p.resolve(".jbang").resolve("jbang-catalog.json"); + private static final String PATH_REGEX = "^\\s*\\((?.*)\\)\\s*$"; + private static final Pattern PATH = Pattern.compile(PATH_REGEX); + + private final String pluginPrefix; + private final String[] remoteCatalogs; + private final JBangSupport jbang; + + public JBangCatalogService(MessageWriter output) { + this(output, "quarkus", "quarkusio"); + } + + public JBangCatalogService(MessageWriter output, String pluginPrefix, String... remoteCatalogs) { + this(false, output, pluginPrefix, remoteCatalogs); + } + + public JBangCatalogService(boolean interactiveMode, MessageWriter output, String pluginPrefix, String... remoteCatalogs) { + super(JBangCatalog.class, GIT_ROOT, RELATIVE_PLUGIN_CATALOG); + this.pluginPrefix = pluginPrefix; + this.remoteCatalogs = remoteCatalogs; + this.jbang = new JBangSupport(interactiveMode, output); + } + + @Override + public JBangCatalog readCatalog(Path path) { + if (!jbang.isAvailable() && !jbang.isInstallable()) { + // When jbang is not available / installable just return an empty catalog. + // We don't even return the parsed one as plugins won't be able to run without jbang anyway. + return new JBangCatalog(); + } + + JBangCatalog localCatalog = super.readCatalog(path); + Map aliases = localCatalog.getAliases().entrySet().stream() + .filter(e -> e.getKey().startsWith(pluginPrefix + "-")) + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); + return new JBangCatalog(localCatalog.getCatalogs(), aliases, localCatalog.getCatalogRef(), + localCatalog.getCatalogLocation()); + } + + /** + * Read the {@link JBangCatalog} from project or fallback to global catalog. + * + * @param ouput an {@link OutputOptionMixin} that can be used for tests to + * substitute current dir with a test directory. + * @param projectDir An optional path pointing to the project directory. + * @param userdir An optional path pointing to the user directory + * @return the catalog + */ + public JBangCatalog readCombinedCatalog(Optional projectDir, Optional userDir) { + if (!jbang.isAvailable() && !jbang.isInstallable()) { + // When jbang is not available / installable just return an empty catalog. + // We don't even return the parsed one as plugins won't be able to run without jbang anyway. + return new JBangCatalog(); + } + + Map catalogs = new HashMap<>(); + Map aliases = new HashMap<>(); + + Optional projectCatalog = readProjectCatalog(projectDir); + Optional userCatalog = readUserCatalog(userDir); + + userCatalog.ifPresent(u -> { + aliases.putAll(u.getAliases()); + + }); + + projectCatalog.ifPresent(p -> { + aliases.putAll(p.getAliases()); + Optional catalogFile = projectDir + .map(d -> RELATIVE_PLUGIN_CATALOG.apply(d).toAbsolutePath().toString()); + catalogFile.ifPresent(f -> { + List lines = jbang.execute("alias", "list", "-f", f, "--verbose"); + aliases.putAll(readAliases(lines)); + }); + }); + + for (String remoteCatalog : remoteCatalogs) { + List lines = jbang.execute("alias", "list", "--verbose", remoteCatalog); + aliases.putAll(readAliases(lines).entrySet() + .stream() + .filter(e -> !aliases.containsKey(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); + } + return new JBangCatalog(catalogs, aliases, Optional.empty(), Optional.empty()); + } + + private Map readAliases(List lines) { + Map aliases = new HashMap<>(); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (line.startsWith(pluginPrefix + "")) { + String name = aliasName(line); + Optional remote = aliasRemote(line); + Optional next = i + 1 < lines.size() ? Optional.of(lines.get(i + 1)) : Optional.empty(); + Optional path = next.filter(n -> n.matches(PATH_REGEX)).flatMap(JBangCatalogService::aliasPath); + Optional description = path.filter(JBangCatalogService::hasDescription) + .map(JBangCatalogService::aliasDescription); + JBangAlias alias = new JBangAlias(name, description, remote); + aliases.put(name, alias); + } + } + return aliases; + } + + private static final String aliasName(String s) { + return s.split("=")[0].trim(); + } + + private static final Optional aliasRemote(String s) { + if (s == null || s.isEmpty()) { + return Optional.empty(); + } + String nameWithRemote = s.split("=")[0].trim(); + if (!nameWithRemote.contains("@")) { + return Optional.empty(); + } + return Optional.of(nameWithRemote.split("@")[1].trim()); + } + + private static final boolean hasDescription(String s) { + return s.contains("="); + } + + private static final String aliasDescription(String s) { + return s.split("=")[1].trim(); + } + + private static final Optional aliasPath(String s) { + Matcher m = PATH.matcher(s); + if (m.matches()) { + return Optional.of(m.group("path")); + } + return Optional.empty(); + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/JBangSupport.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/JBangSupport.java new file mode 100644 index 0000000000000..f46f4ee26a0bb --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/JBangSupport.java @@ -0,0 +1,195 @@ +package io.quarkus.cli.plugin; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Predicate; + +import io.quarkus.devtools.exec.Executable; +import io.quarkus.devtools.messagewriter.MessageWriter; +import io.quarkus.devtools.utils.Prompt; +import io.quarkus.fs.util.ZipUtils; + +public class JBangSupport { + + private static final boolean IS_OS_WINDOWS = System.getProperty("os.name").toLowerCase(Locale.ENGLISH).contains("windows"); + private static final String JBANG_EXECUTABLE = IS_OS_WINDOWS ? "jbang.cmd" : "jbang"; + + private static final Predicate EXISTS_AND_WRITABLE = p -> p != null && p.toFile().exists() && p.toFile().canWrite(); + + private static final String[] windowsWrapper = { "jbang.cmd", "jbang.ps1" }; + private static final String otherWrapper = "jbang"; + + private final boolean interactiveMode; + private final MessageWriter output; + private Path workingDirectory; + + private boolean promptForInstallation = true; + + public JBangSupport(boolean interactiveMode, MessageWriter output) { + this(interactiveMode, output, Paths.get(System.getProperty("user.dir"))); + } + + public JBangSupport(boolean interactiveMode, MessageWriter output, Path workingDirectory) { + this.interactiveMode = interactiveMode; + this.output = output; + this.workingDirectory = workingDirectory; + } + + public Optional findWrapper() { + return Optional.ofNullable(Executable.findWrapper(workingDirectory, windowsWrapper, otherWrapper)); + } + + public Optional findExecutableInPath() { + try { + return Optional.ofNullable(Executable.findExecutableFile(otherWrapper)); + } catch (Exception e) { + output.warn("jbang not found in PATH"); + return Optional.empty(); + } + } + + public Optional findExecutableInLocalJbang() { + try { + return Optional.ofNullable(getInstallationDir()).map(d -> d.resolve("bin").resolve(JBANG_EXECUTABLE)) + .map(Path::toFile).filter(File::exists); + } catch (Exception e) { + output.warn("jbang not found in .jbang"); + return Optional.empty(); + } + } + + public Optional getOptionalExecutable() { + return findWrapper() + .or(() -> findExecutableInPath()) + .or(() -> findExecutableInLocalJbang()) + .or(() -> { + try { + // We don't want to prompt users for input when running tests. + if (interactiveMode && promptForInstallation && Prompt.yesOrNo(true, + "JBang is needed to list / run jbang plugins, would you like to install it now ?")) { + installJBang(); + return findExecutableInLocalJbang(); + } else + return Optional.empty(); + } finally { + promptForInstallation = false; + } + }).map(e -> { + if (!e.canExecute()) { + e.setExecutable(true); + } + return e; + }); + } + + public File getExecutable() { + return getOptionalExecutable().orElseThrow(() -> new IllegalStateException("Unable to find and install jbang!")); + } + + public Path getWorkingDirectory() { + return workingDirectory; + } + + public List getCommand() { + return List.of(getExecutable().getAbsolutePath()); + } + + public List execute(String... args) { + try { + List command = new ArrayList<>(); + command.add(getExecutable().getAbsolutePath()); + for (String arg : args) { + command.add(arg); + } + List lines = new ArrayList<>(); + try { + Process process = new ProcessBuilder() + .directory(workingDirectory.toFile()) + .command(command) + .start(); + + try (InputStreamReader isr = new InputStreamReader(process.getInputStream()); + BufferedReader reader = new BufferedReader(isr)) { + for (String line = reader.readLine(); line != null; line = reader.readLine()) { + //Remove ansi escape codes + lines.add(line.replaceAll("\u001B\\[[;\\d]*m", "")); + } + + } catch (IOException e) { + throw new RuntimeException(e); + } + process.waitFor(); + + } catch (IOException e) { + throw new RuntimeException(e); + } + return lines; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + public Optional version() { + return execute("version").stream().findFirst(); + + } + + public boolean isAvailable() { + return getOptionalExecutable().isPresent() && version().isPresent(); + } + + public boolean isInstallable() { + return interactiveMode; //installation requires interaction + } + + private Path getInstallationDir() { + Path currentDir = workingDirectory; + Optional dir = Optional.ofNullable(currentDir).filter(EXISTS_AND_WRITABLE); + while (dir.map(Path::getParent).filter(EXISTS_AND_WRITABLE).isPresent()) { + dir = dir.map(Path::getParent); + } + return dir.map(d -> d.resolve(".jbang")) + .orElseThrow(() -> new IllegalStateException("Failed to determinte .jbang directory!")); + } + + private void installJBang() { + try { + String uri = "https://www.jbang.dev/releases/latest/download/jbang.zip"; + Path downloadDir = Files.createTempDirectory("jbang-download-"); + + if (!downloadDir.toFile().exists() && !downloadDir.toFile().mkdirs()) { + throw new IOException("Failed to create jbang download directory: " + downloadDir.toAbsolutePath().toString()); + } + + Path downloadFile = downloadDir.resolve("jbang.zip"); + Path installDir = getInstallationDir(); + if (!installDir.toFile().exists() && !installDir.toFile().mkdirs()) { + throw new IOException("Failed to create jbang install directory: " + installDir.toAbsolutePath().toString()); + } + HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(new URI(uri)) + .GET() + .build(); + HttpResponse response = client.send(request, BodyHandlers.ofFile(downloadFile)); + ZipUtils.unzip(downloadFile, downloadDir); + ZipUtils.copyFromZip(downloadDir.resolve("jbang"), installDir); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/Plugin.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/Plugin.java new file mode 100644 index 0000000000000..38439bd56889f --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/Plugin.java @@ -0,0 +1,94 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonInclude(Include.NON_NULL) +public class Plugin { + + private final String name; + private final PluginType type; + private final Optional location; + private final Optional description; + + @JsonIgnore + private final boolean inUserCatalog; + /** + * This is mostly used for testing. + */ + @JsonIgnore + private final Optional catalogLocation; + + public Plugin(String name, PluginType type) { + this(name, type, Optional.empty(), Optional.empty(), Optional.empty(), true); + } + + @JsonCreator + public Plugin(@JsonProperty("name") String name, + @JsonProperty("type") PluginType type, + @JsonProperty("location") Optional location, + @JsonProperty("description") Optional description) { + this(name, type, location, description, Optional.empty(), true); + } + + public Plugin(String name, + PluginType type, + Optional location, + Optional description, + Optional catalogLocation, + boolean inUserCatalog) { + this.name = Objects.requireNonNull(name); + this.type = Objects.requireNonNull(type); + this.description = description != null ? description : Optional.empty(); + this.location = location != null ? location : Optional.empty(); + this.catalogLocation = catalogLocation != null ? catalogLocation : Optional.empty(); + this.inUserCatalog = inUserCatalog; + } + + public String getName() { + return name; + } + + public PluginType getType() { + return type; + } + + public Optional getDescription() { + return description; + } + + public Optional getLocation() { + return location; + } + + public boolean isInUserCatalog() { + return inUserCatalog; + } + + public Optional getCatalogLocation() { + return catalogLocation; + } + + public Plugin withDescription(Optional description) { + return new Plugin(name, type, location, description, catalogLocation, inUserCatalog); + } + + public Plugin withCatalogLocation(Optional catalogLocation) { + return new Plugin(name, type, location, description, catalogLocation, inUserCatalog); + } + + public Plugin inUserCatalog() { + return new Plugin(name, type, location, description, catalogLocation, true); + } + + public Plugin inProjectCatalog() { + return new Plugin(name, type, location, description, catalogLocation, false); + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginCatalog.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginCatalog.java new file mode 100644 index 0000000000000..539907baffac3 --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginCatalog.java @@ -0,0 +1,113 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +@JsonInclude(Include.NON_NULL) +public class PluginCatalog implements Catalog { + + public static final String VERSION = "v1"; + protected static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"); + + private final String version; + private final String lastUpdate; + private final Map plugins; + + @JsonIgnore + private final Optional catalogLocation; + + public static PluginCatalog empty() { + return new PluginCatalog(); + } + + public static PluginCatalog combine(Optional userCatalog, Optional projectCatalog) { + Map plugins = new HashMap<>(); + plugins.putAll(userCatalog.map(PluginCatalog::getPlugins).orElse(Collections.emptyMap())); + plugins.putAll(projectCatalog.map(PluginCatalog::getPlugins).orElse(Collections.emptyMap())); + return new PluginCatalog(plugins); + } + + public PluginCatalog() { + this(Collections.emptyMap()); + } + + public PluginCatalog(Map plugins) { + this(VERSION, now(), plugins, Optional.empty()); + } + + public PluginCatalog(String version, LocalDateTime lastUpdate, Map plugins, + Optional catalogLocation) { + this(version, DATETIME_FORMATTER.format(lastUpdate), plugins, catalogLocation); + } + + public PluginCatalog(String version, String lastUpdate, Map plugins, Optional catalogLocation) { + this.version = version; + this.lastUpdate = lastUpdate; + this.catalogLocation = catalogLocation; + // Apply the the catalog location if available, else retain original values (needed for combined catalog). + this.plugins = Collections.unmodifiableMap(plugins.entrySet().stream() + .collect(Collectors.toMap(e -> e.getKey(), + e -> catalogLocation.isPresent() ? e.getValue().withCatalogLocation(catalogLocation) : e.getValue()))); + } + + public String getVersion() { + return version; + } + + public String getLastUpdate() { + return lastUpdate; + } + + @JsonIgnore + public LocalDateTime getLastUpdateDate() { + return LocalDateTime.from(DATETIME_FORMATTER.parse(lastUpdate)); + } + + public Map getPlugins() { + return plugins; + } + + public Optional getCatalogLocation() { + return catalogLocation; + } + + public PluginCatalog withCatalogLocation(Optional catalogLocation) { + return new PluginCatalog(version, lastUpdate, plugins, catalogLocation); + } + + public PluginCatalog refreshLastUpdate() { + return new PluginCatalog(version, now(), plugins, catalogLocation); + } + + public PluginCatalog addPlugin(Plugin plugin) { + Map newPlugins = new HashMap<>(plugins); + newPlugins.put(plugin.getName(), plugin); + return new PluginCatalog(version, now(), newPlugins, catalogLocation); + } + + public PluginCatalog removePlugin(Plugin plugin) { + Map newPlugins = new HashMap<>(plugins); + newPlugins.remove(plugin.getName()); + return new PluginCatalog(version, now(), newPlugins, catalogLocation); + } + + public PluginCatalog removePlugin(String pluginName) { + Map newPlugins = new HashMap<>(plugins); + newPlugins.remove(pluginName); + return new PluginCatalog(version, now(), newPlugins, catalogLocation); + } + + private static String now() { + return LocalDateTime.now().format(DATETIME_FORMATTER); + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginCatalogService.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginCatalogService.java new file mode 100644 index 0000000000000..cae25b1c60a18 --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginCatalogService.java @@ -0,0 +1,65 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; + +public class PluginCatalogService extends CatalogService { + + private static final Function RELATIVE_CATALOG_JSON = p -> p.resolve(".quarkus").resolve("cli") + .resolve("plugins").resolve("quarkus-cli-catalog.json"); + + public PluginCatalogService() { + this(GIT_ROOT, RELATIVE_CATALOG_JSON); + } + + public PluginCatalogService(Function relativePath) { + this(GIT_ROOT, relativePath); + } + + public PluginCatalogService(Predicate projectRoot, Function relativePath) { + super(PluginCatalog.class, projectRoot, relativePath); + } + + @Override + public Optional readUserCatalog(Optional userDir) { + return super.readUserCatalog(userDir).map(u -> u.withCatalogLocation(userDir.map(RELATIVE_CATALOG_JSON))); + } + + @Override + public Optional readProjectCatalog(Optional dir) { + return super.readProjectCatalog(dir).map(p -> p.withCatalogLocation(dir.map(RELATIVE_CATALOG_JSON))); + } + + /** + * Read the {@link PluginCatalog} from project or fallback to global catalog. + * + * @param ouput an {@link OutputOptionMixin} that can be used for tests to substitute current dir with a test directory. + * @param projectDir An optional path pointing to the project directory. + * @param userdir An optional path pointing to the user directory + * @return the catalog + */ + public PluginCatalog readCombinedCatalog(Optional proejctDir, Optional userDir) { + Map plugins = new HashMap<>(); + + Optional projectCatalog = readProjectCatalog(proejctDir); + Optional userCatalog = readUserCatalog(userDir); + + userCatalog.ifPresent(u -> { + plugins.putAll(u.getPlugins()); + }); + + projectCatalog.ifPresent(p -> { + plugins.putAll(p.getPlugins()); + }); + + LocalDateTime userCatalogTime = userCatalog.map(PluginCatalog::getLastUpdateDate).orElse(LocalDateTime.now()); + LocalDateTime projectCatalogTime = projectCatalog.map(PluginCatalog::getLastUpdateDate).orElse(LocalDateTime.now()); + LocalDateTime oldest = userCatalogTime.isBefore(projectCatalogTime) ? userCatalogTime : projectCatalogTime; + return new PluginCatalog(PluginCatalog.VERSION, oldest, plugins, Optional.empty()); + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginManager.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginManager.java new file mode 100644 index 0000000000000..251b3f92c4c25 --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginManager.java @@ -0,0 +1,225 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import io.quarkus.devtools.messagewriter.MessageWriter; +import io.quarkus.devtools.project.QuarkusProject; + +public class PluginManager { + + private final MessageWriter output; + private final PluginMangerState state; + private final PluginManagerSettings settings; + private final PluginManagerUtil util; + + public PluginManager(PluginManagerSettings settings, MessageWriter output, Optional userHome, + Optional projectRoot, Optional quarkusProject, Predicate pluginFilter) { + this.settings = settings; + this.output = output; + this.util = PluginManagerUtil.getUtil(settings); + this.state = new PluginMangerState(settings, output, userHome, projectRoot, quarkusProject, pluginFilter); + } + + /** + * Adds the {@link Plugin} with the specified name or location to the installed plugins. + * Plugins that have been detected as installable may be added by name. + * Remote plugins, that are not detected can be added by the location (e.g. url or maven coordinates). + * + * @param nameOrLocation The name or location of the plugin. + * @return the pugin that was added wrapped in {@link Optional}, or empty if no plugin was added. + */ + public Optional addPlugin(String nameOrLocation) { + return addPlugin(nameOrLocation, Optional.empty()); + } + + /** + * Adds the {@link Plugin} with the specified name or location to the installed plugins. + * Plugins that have been detected as installable may be added by name. + * Remote plugins, that are not detected can be added by the location (e.g. url or maven coordinates). + * + * @param nameOrLocation The name or location of the plugin. + * @param description An optional description to add to the plugin. + * @return The pugin that was added wrapped in {@link Optional}, or empty if no plugin was added. + */ + public Optional addPlugin(String nameOrLocation, Optional description) { + PluginCatalogService pluginCatalogService = state.getPluginCatalogService(); + String name = util.getName(nameOrLocation); + if (PluginUtil.isRemoteLocation(nameOrLocation)) { + Plugin plugin = new Plugin(name, PluginUtil.getType(nameOrLocation), Optional.of(nameOrLocation), description); + PluginCatalog updatedCatalog = state.getPluginCatalog().addPlugin(plugin); + pluginCatalogService.writeCatalog(updatedCatalog); + return Optional.of(plugin); + } + + Map installablePlugins = state.installablePlugins(); + Optional plugin = Optional.ofNullable(installablePlugins.get(name)); + return plugin.map(p -> { + PluginCatalog updatedCatalog = state.getPluginCatalog().addPlugin(p); + pluginCatalogService.writeCatalog(updatedCatalog); + return p; + }); + } + + /** + * Adds the {@link Plugin} with the specified name or location to the installed plugins. + * Plugins that have been detected as installable may be added by name. + * Remote plugins, that are not detected can be added by the location (e.g. url or maven coordinates). + * + * @param plugin The plugin. + * @return The pugin that was added wrapped in {@link Optional}, or empty if no plugin was added. + */ + public Optional addPlugin(Plugin plugin) { + PluginCatalogService pluginCatalogService = state.getPluginCatalogService(); + PluginCatalog updatedCatalog = state.getPluginCatalog().addPlugin(plugin); + pluginCatalogService.writeCatalog(updatedCatalog); + return Optional.of(plugin); + } + + /** + * Removes a {@link Plugin} by name. + * The catalog from which the plugin will be removed is selected + * based on where the plugin is found. If plugin is found in both catalogs + * the project catalog is prefered. + * + * @param name The name of the plugin to remove. + * @return The removed plugin wrapped in Optional, empty if no plugin was removed. + */ + public Optional removePlugin(String name) { + PluginCatalogService pluginCatalogService = state.getPluginCatalogService(); + Plugin plugin = state.getInstalledPluigns().get(name); + if (plugin == null) { + return Optional.empty(); + } else if (state.getProjectCatalog().map(PluginCatalog::getPlugins).map(p -> p.containsKey(name)).orElse(false)) { + pluginCatalogService.writeCatalog(state.getProjectCatalog() + .orElseThrow(() -> new IllegalStateException("Project catalog should be available!")) + .removePlugin(name)); + return Optional.of(plugin); + } + + pluginCatalogService.writeCatalog(state.getUserCatalog() + .orElseThrow(() -> new IllegalStateException("User catalog should be available!")) + .removePlugin(name)); + return Optional.of(plugin); + } + + /** + * Removes a {@link Plugin} by name. + * The catalog from which the plugin will be removed is selected + * based on where the plugin is found. If plugin is found in both catalogs + * the project catalog is prefered. + * + * @param plugin The plugin to remove + * @return The removed plugin wrapped in Optional, empty if no plugin was removed. + */ + public Optional removePlugin(Plugin plugin) { + return removePlugin(plugin.getName()); + } + + /** + * Check that the installed plugins are still available in the environment. + * + * @return true if any catalog was changed. + */ + public boolean reconcile() { + //We are using `|` instead of `||` cause we always want both branches to be executed + if (state.getUserCatalog().map(c -> reconcile(c)).orElse(false) + | state.getProjectCatalog().map(c -> reconcile(c)).orElse(false)) { + // Refresh the list of installed plugins + state.invalidate(); + return true; + } else { + return false; + } + } + + /** + * Check that the installed plugins are still available in the environment. + * + * @param catalog The {@PluginCatalog} to use + * @return true if catalog was modified + */ + private boolean reconcile(PluginCatalog catalog) { + Path location = catalog.getCatalogLocation() + .orElseThrow(() -> new IllegalArgumentException("Unknwon plugin catalog location.")); + List installedTypes = catalog.getPlugins().entrySet().stream().map(Map.Entry::getValue).map(Plugin::getType) + .collect(Collectors.toList()); + Map installablePlugins = state.installablePlugins(installedTypes); + + Map unreachable = catalog.getPlugins().entrySet().stream() + .filter(i -> !installablePlugins.containsKey(i.getKey())) + .filter(i -> PluginUtil.shouldRemove(i.getValue())) + .collect(Collectors.toMap(m -> m.getKey(), m -> m.getValue())); + + if (unreachable.isEmpty()) { + return false; + } + + Path backupLocation = location.getParent().resolve("quarkus-cli-catalog.json.bkp"); + + output.warn( + "The following plugins found in the catalog: [%s] but no longer available: %s.\n" + + "The unavailable plugin will be purged! A backup of the catalog will be saved at: [%s].", + location, + unreachable.entrySet().stream().map(Map.Entry::getKey).collect(Collectors.joining(", ", "[", "]")), + backupLocation); + + PluginCatalogService pluginCatalogService = state.getPluginCatalogService(); + pluginCatalogService.writeCatalog(catalog.withCatalogLocation(Optional.of(backupLocation))); + for (String u : unreachable.keySet()) { + catalog = catalog.removePlugin(u); + } + pluginCatalogService.writeCatalog(catalog); + return true; + } + + /** + * Remove unavailable plugins, add extension plugins if available. + * + * @return true if changes any catalog was modified. + */ + public boolean sync() { + boolean catalogModified = reconcile(); + Map installedPlugins = getInstallablePlugins(); + Map extensionPlugins = state.getExtensionPlugins(); + Map pluginsToInstall = extensionPlugins.entrySet().stream() + .filter(e -> !installedPlugins.containsKey(e.getKey())) + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); + catalogModified = catalogModified || !pluginsToInstall.isEmpty(); + pluginsToInstall.forEach((name, plugin) -> { + addPlugin(plugin); + }); + state.invalidate(); + return catalogModified; + } + + /** + * Optionally sync if needed. + * Sync happens weekly or when project files are updated. + */ + public boolean syncIfNeeded() { + if (!settings.isInteractiveMode()) { + //syncing may require user interaction, so just return false + return false; + } + + PluginCatalog catalog = state.getCombinedCatalog(); + if (PluginUtil.shouldSync(state.getProjectRoot(), catalog)) { + output.info("Plugin catalog last updated on: " + catalog.getLastUpdate() + ". Syncing!"); + return true; + } + return false; + } + + public Map getInstalledPlugins() { + return state.getInstalledPluigns(); + } + + public Map getInstallablePlugins() { + return state.getInstallablePlugins(); + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginManagerSettings.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginManagerSettings.java new file mode 100644 index 0000000000000..3619abbb5682b --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginManagerSettings.java @@ -0,0 +1,88 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.util.Set; +import java.util.function.Function; + +/** + * Settings class for the {@link PluginManager}. + * The {@link PluginManager} can be used beyond the Quarkus CLI. + * Users are able to build extensible CLI apps using the {@link PluginManager} + * with customized settings. + */ +public class PluginManagerSettings { + + public static String DEFAULT_PLUGIN_PREFIX = "quarkus"; + public static String[] DEFAULT_REMOTE_JBANG_CATALOGS = new String[] { "quarkusio" }; + public static Function DEFAULT_RELATIVE_PATH_FUNC = p -> p.resolve(".quarkus").resolve("cli").resolve("plugins") + .resolve("quarkus-cli-catalog.json"); + + private final boolean interactiveMode; + private final String pluginPrefix; + private final String[] remoteJBangCatalogs; + private final Function toRelativePath; + + public PluginManagerSettings(boolean interactiveMode, String pluginPrefix, String[] remoteJBangCatalogs, + Function toRelativePath) { + this.interactiveMode = interactiveMode; + this.pluginPrefix = pluginPrefix; + this.remoteJBangCatalogs = remoteJBangCatalogs; + this.toRelativePath = toRelativePath; + } + + public static PluginManagerSettings defaultSettings() { + return new PluginManagerSettings(false, DEFAULT_PLUGIN_PREFIX, DEFAULT_REMOTE_JBANG_CATALOGS, + DEFAULT_RELATIVE_PATH_FUNC); + } + + public PluginManagerSettings withPluignPrefix(String pluginPrefix) { + return new PluginManagerSettings(interactiveMode, pluginPrefix, remoteJBangCatalogs, toRelativePath); + } + + public PluginManagerSettings withCatalogs(Set remoteJBangCatalogs) { + return new PluginManagerSettings(interactiveMode, pluginPrefix, + remoteJBangCatalogs.toArray(new String[remoteJBangCatalogs.size()]), toRelativePath); + } + + public PluginManagerSettings withCatalogs(String... remoteJBangCatalogs) { + return new PluginManagerSettings(interactiveMode, pluginPrefix, remoteJBangCatalogs, toRelativePath); + } + + public PluginManagerSettings withInteractivetMode(boolean interactiveMode) { + return new PluginManagerSettings(interactiveMode, pluginPrefix, remoteJBangCatalogs, toRelativePath); + } + + /** + * The prefix of the {@link Plugin}. + * This value is used to strip the prefix for the location + * when creating the name. + * + * @return the prefix. + */ + public String getPluginPrefix() { + return pluginPrefix; + } + + /** + * The names of the JBang catalogs to get plugins from. + * + * @return the name of the catalog. + */ + public String[] getRemoteJBangCatalogs() { + return remoteJBangCatalogs; + } + + public boolean isInteractiveMode() { + return interactiveMode; + } + + /** + * A {@link Function} from getting the relative path to the catalog. + * For example: `~ -> ~/.quarkus/cli/plugins/quarkus-cli-catalog.json`. + * + * @return the path. + */ + public Function getToRelativePath() { + return toRelativePath; + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginManagerUtil.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginManagerUtil.java new file mode 100644 index 0000000000000..4e6412e40ab4c --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginManagerUtil.java @@ -0,0 +1,89 @@ +package io.quarkus.cli.plugin; + +import java.net.URL; +import java.nio.file.Path; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.quarkus.maven.dependency.GACTV; + +public class PluginManagerUtil { + + private static final Pattern CLI_SUFFIX = Pattern.compile("(\\-cli)(@\\w+)?$"); + + private final PluginManagerSettings settings; + + public static PluginManagerUtil getUtil(PluginManagerSettings settings) { + return new PluginManagerUtil(settings); + } + + public static PluginManagerUtil getUtil() { + return getUtil(PluginManagerSettings.defaultSettings()); + } + + public PluginManagerUtil(PluginManagerSettings settings) { + this.settings = settings; + } + + /** + * Create a {@link Plugin} from the specified location. + * + * @param the location + * @return the {@link Plugin} that corresponds to the location. + */ + public Plugin from(String location) { + Optional url = PluginUtil.checkUrl(location); + Optional path = PluginUtil.checkPath(location); + Optional gactv = PluginUtil.checkGACTV(location); + String name = getName(gactv, url, path); + PluginType type = PluginUtil.getType(gactv, url, path); + return new Plugin(name, type, Optional.of(location), Optional.empty()); + } + + /** + * Get the name that corresponds the the specified location. + * The name is the filename (without the jar extension) of any of the specified gactv, url or path. + * + * @param location the location + * @return the name. + */ + public String getName(String location) { + Optional url = PluginUtil.checkUrl(location); + Optional path = PluginUtil.checkPath(location); + Optional gactv = PluginUtil.checkGACTV(location); + return getName(gactv, url, path); + } + + /** + * Get the name that corresponds the the specified locations. + * The name is the filename (without the jar extension) of any of the specified gactv, url or path. + * + * @param url the url + * @param path the path + * @param gactv the gactv + * @return the name. + */ + public String getName(Optional gactv, Optional url, Optional path) { + String prefix = settings.getPluginPrefix(); + return gactv.map(GACTV::getArtifactId) + .or(() -> url.map(URL::getPath).map(s -> s.substring(s.lastIndexOf("/") + 1)) + .map(s -> s.replaceAll("\\.jar$", ""))) + .or(() -> path.map(Path::getFileName).map(Path::toString).map(s -> s.replaceAll("\\.jar$", ""))) + .map(n -> stripCliSuffix(n)) + .map(n -> n.replaceAll("^" + prefix + "\\-cli\\-", prefix + "")) // stip cli prefix (after the quarkus bit) + .map(n -> n.replaceAll("^" + prefix + "\\-", "")) // stip quarkus prefix (after the quarkus bit) + .map(n -> n.replaceAll("@.*$", "")) // stip the @sufix + .orElseThrow(() -> new IllegalStateException("Could not determinate name for location.")); + } + + private String stripCliSuffix(String s) { + Matcher m = CLI_SUFFIX.matcher(s); + if (m.find()) { + String replacement = m.group(2); + replacement = replacement != null ? replacement : ""; + return m.replaceAll(replacement); + } + return s; + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginMangerState.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginMangerState.java new file mode 100644 index 0000000000000..d1098df93bc43 --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginMangerState.java @@ -0,0 +1,261 @@ +package io.quarkus.cli.plugin; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import io.quarkus.devtools.messagewriter.MessageWriter; +import io.quarkus.devtools.project.QuarkusProject; +import io.quarkus.maven.dependency.ArtifactCoords; +import io.quarkus.maven.dependency.ArtifactKey; +import io.quarkus.platform.catalog.processor.ExtensionProcessor; + +class PluginMangerState { + + PluginMangerState(PluginManagerSettings settings, MessageWriter output, Optional userHome, + Optional projectRoot, + Optional quarkusProject, + Predicate pluginFilter) { + this.settings = settings; + this.userHome = userHome; + this.quarkusProject = quarkusProject; + this.pluginFilter = pluginFilter; + + //Inferred + this.projectRoot = projectRoot.or(() -> quarkusProject.map(QuarkusProject::getProjectDirPath)) + .filter(p -> !p.equals(userHome.orElse(null))); + this.jbangCatalogService = new JBangCatalogService(settings.isInteractiveMode(), output, settings.getPluginPrefix(), + settings.getRemoteJBangCatalogs()); + this.pluginCatalogService = new PluginCatalogService(settings.getToRelativePath()); + this.util = PluginManagerUtil.getUtil(settings); + } + + private final PluginManagerSettings settings; + private final PluginManagerUtil util; + private final Optional userHome; + private final Optional projectRoot; + private final Optional quarkusProject; + + private final PluginCatalogService pluginCatalogService; + private final JBangCatalogService jbangCatalogService; + + private final Predicate pluginFilter; + + // + private Map _userPlugins; + private Map _projectPlugins; + private Map _installedPlugins; + + private Map _installablePlugins; + private Map _extensionPlugins; + + private Optional _userCatalog; + private Optional _projectCatalog; + private PluginCatalog _combinedCatalog; + private PluginCatalog _pluginCatalog; + + public PluginCatalogService getPluginCatalogService() { + return pluginCatalogService; + } + + public JBangCatalogService getJbangCatalogService() { + return jbangCatalogService; + } + + public Map installedPlugins() { + Map allInstalledPlugins = new HashMap<>(); + allInstalledPlugins.putAll(userPlugins()); + allInstalledPlugins.putAll(projectPlugins()); + return allInstalledPlugins; + } + + public Map getInstalledPluigns() { + if (_installedPlugins == null) { + _installedPlugins = installedPlugins(); + } + return Collections.unmodifiableMap(_installedPlugins); + } + + public Map projectPlugins() { + return pluginCatalogService.readProjectCatalog(projectRoot).map(catalog -> catalog.getPlugins().values().stream() + .filter(pluginFilter) + .map(Plugin::inProjectCatalog) + .collect(Collectors.toMap(p -> p.getName(), p -> p))).orElse(Collections.emptyMap()); + } + + public Map getProjectPluigns() { + if (_projectPlugins == null) { + _projectPlugins = projectPlugins(); + } + return Collections.unmodifiableMap(_projectPlugins); + } + + public Map userPlugins() { + return pluginCatalogService.readUserCatalog(userHome).map(catalog -> catalog.getPlugins().values().stream() + .filter(pluginFilter) + .map(Plugin::inUserCatalog) + .collect(Collectors.toMap(p -> p.getName(), p -> p))).orElse(Collections.emptyMap()); + } + + public Map getUserPluigns() { + if (_userPlugins == null) { + _userPlugins = userPlugins(); + } + return Collections.unmodifiableMap(_userPlugins); + } + + public Map installablePlugins(List types) { + Map installablePlugins = new HashMap<>(); + for (PluginType type : types) { + switch (type) { + case jbang: + installablePlugins.putAll(jbangPlugins()); + break; + case executable: + installablePlugins.putAll(executablePlugins()); + break; + } + } + installablePlugins.putAll(executablePlugins().entrySet().stream().filter(e -> types.contains(e.getValue().getType())) + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue()))); + return installablePlugins; + } + + public Map installablePlugins(PluginType... types) { + return installablePlugins(List.of(types)); + } + + public Map installablePlugins() { + return installablePlugins(PluginType.values()); + } + + public Map getInstallablePlugins() { + if (_installablePlugins == null) { + this._installablePlugins = installablePlugins(); + } + return Collections.unmodifiableMap(_installablePlugins); + } + + public Map jbangPlugins() { + boolean isUserScoped = !quarkusProject.isPresent(); + Map jbangPlugins = new HashMap<>(); + JBangCatalog jbangCatalog = jbangCatalogService.readCombinedCatalog(projectRoot, userHome); + jbangCatalog.getAliases().forEach((location, alias) -> { + String name = util.getName(location); + Optional description = alias.getDescription(); + Plugin plugin = new Plugin(name, PluginType.jbang, Optional.of(location), description, Optional.empty(), + isUserScoped); + if (pluginFilter.test(plugin)) { + jbangPlugins.put(name, plugin); + } + }); + return jbangPlugins; + } + + public Map executablePlugins() { + boolean isUserScoped = !quarkusProject.isPresent(); + Map executablePlugins = new HashMap<>(); + Binaries.findQuarkusPrefixedCommands().forEach(f -> { + String name = util.getName(f.getName()); + Optional description = Optional.empty(); + Optional location = Optional.of(f.getAbsolutePath()); + Plugin plugin = new Plugin(name, PluginType.executable, location, description, Optional.empty(), isUserScoped); + if (pluginFilter.test(plugin)) { + executablePlugins.put(name, plugin); + } + }); + return executablePlugins; + } + + public Map extensionPlugins() { + //Get extension plugins + Map extensionPlugins = new HashMap<>(); + quarkusProject.ifPresent(project -> { + try { + Set installed = project.getExtensionManager().getInstalled().stream() + .map(ArtifactCoords::getKey).collect(Collectors.toSet()); + + extensionPlugins.putAll(project.getExtensionsCatalog().getExtensions().stream() + .filter(e -> installed.contains(e.getArtifact().getKey())) + .map(ExtensionProcessor::getCliPlugins).flatMap(Collection::stream).map(util::from) + .collect(Collectors.toMap(p -> p.getName(), p -> p.inProjectCatalog()))); + } catch (Exception e) { + throw new RuntimeException("Error reading the extension catalog", e); + } + }); + return extensionPlugins; + } + + public Map getExtensionPlugins() { + if (_extensionPlugins == null) { + this._extensionPlugins = extensionPlugins(); + } + + return Collections.unmodifiableMap(_extensionPlugins); + } + + public Optional projectCatalog() { + return projectRoot.flatMap(p -> pluginCatalogService.readProjectCatalog(Optional.of(p))); + } + + public Optional getProjectCatalog() { + if (_projectCatalog == null) { + _projectCatalog = pluginCatalogService.readProjectCatalog(projectRoot); + } + return _projectCatalog; + } + + public Optional userCatalog() { + return userHome.flatMap(h -> pluginCatalogService.readUserCatalog(Optional.of(h))); + } + + public Optional getUserCatalog() { + if (_userCatalog == null) { + _userCatalog = userCatalog(); + } + return _userCatalog; + } + + public PluginCatalog combinedCatalog() { + return PluginCatalog.combine(getUserCatalog(), getProjectCatalog()); + } + + public PluginCatalog getCombinedCatalog() { + if (_combinedCatalog == null) { + _combinedCatalog = combinedCatalog(); + } + return _combinedCatalog; + } + + public PluginCatalog pluginCatalog() { + return getProjectCatalog().or(() -> getUserCatalog()) + .orElseThrow(() -> new IllegalStateException("Unable to get project and user plugin catalogs!")); + } + + public PluginCatalog getPluginCatalog() { + if (_pluginCatalog == null) { + _pluginCatalog = pluginCatalog(); + } + return _pluginCatalog; + } + + public Optional getProjectRoot() { + return this.projectRoot; + } + + public void invalidate() { + _userPlugins = null; + _projectPlugins = null; + _installedPlugins = null; + _installablePlugins = null; + _extensionPlugins = null; + } + +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginType.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginType.java new file mode 100644 index 0000000000000..6d6e9e317722f --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginType.java @@ -0,0 +1,8 @@ +package io.quarkus.cli.plugin; + +public enum PluginType { + jar, + maven, + executable, + jbang; +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginUtil.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginUtil.java new file mode 100644 index 0000000000000..0138c2d725878 --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/cli/plugin/PluginUtil.java @@ -0,0 +1,179 @@ +package io.quarkus.cli.plugin; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +import io.quarkus.devtools.project.BuildTool; +import io.quarkus.devtools.project.QuarkusProjectHelper; +import io.quarkus.maven.dependency.GACTV; + +public final class PluginUtil { + + private static final Pattern CLI_SUFFIX = Pattern.compile("(\\-cli)(@\\w+)?$"); + + private PluginUtil() { + //Utility + } + + public static boolean shouldSync(Path projectRoot, PluginCatalog catalog) { + return shouldSync(Optional.ofNullable(projectRoot), catalog); + } + + public static boolean shouldSync(Optional projectRoot, PluginCatalog catalog) { + LocalDateTime catalogTime = catalog.getLastUpdateDate(); + LocalDateTime lastBuildFileModifiedTime = getLastBuildFileModifiedTime(projectRoot); + return catalogTime.isBefore(lastBuildFileModifiedTime) || LocalDateTime.now().minusDays(7).isAfter(catalogTime); + } + + /** + * Get the {@link PluginType} that corresponds the the specified location. + * + * @param the location + * @return the {@link PluginType} that corresponds to the location. + */ + public static PluginType getType(String location) { + Optional url = checkUrl(location); + Optional path = checkPath(location); + Optional gactv = checkGACTV(location); + return getType(gactv, url, path); + } + + /** + * Get the {@link PluginType} that corresponds the the specified locations. + * + * @param url the url + * @param path the path + * @param gactv the gactv + * @return the {@link PluginType} that corresponds to the location. + */ + public static PluginType getType(Optional gactv, Optional url, Optional path) { + + return gactv.map(i -> PluginType.maven) + .or(() -> url.map(u -> u.getPath()).or(() -> path.map(Path::toAbsolutePath).map(Path::toString)) + .filter(f -> f.endsWith(".jar")).map(i -> PluginType.jar)) + .or(() -> path.filter(p -> p.toFile().exists()).map(i -> PluginType.executable)) + .orElse(PluginType.jbang); + } + + /** + * Check if the plugin can be found. + * The method is used to determined the plugin can be located. + * + * @return true if path is not null and points to an existing file. + */ + public static boolean shouldRemove(Plugin p) { + if (p == null) { + return true; + } + if (!p.getLocation().isPresent()) { + return true; + } + if (p.getType() == PluginType.executable) { + return !checkPath(p.getLocation()).map(Path::toFile).map(File::exists).orElse(false); + } + if (checkUrl(p.getLocation()).isPresent()) { //We don't want to remove remotely located plugins + return false; + } + if (checkGACTV(p.getLocation()).isPresent()) { //We don't want to remove remotely located plugins + return false; + } + return true; + } + + /** + * Chekcs if specified {@link String} is a valid {@URL}. + * + * @param location The string to check + * @return The {@link URL} wrapped in {@link Optional} if valid, empty otherwise. + */ + public static Optional checkUrl(String location) { + try { + return Optional.of(new URL(location)); + } catch (MalformedURLException | NullPointerException e) { + return Optional.empty(); + } + } + + public static Optional checkUrl(Optional location) { + return location.flatMap(PluginUtil::checkUrl); + } + + /** + * Chekcs if specified {@link String} is a valid {@URL}. + * + * @param location The string to check + * @return The {@link URL} wrapped in {@link Optional} if valid, empty otherwise. + */ + public static Optional checkGACTV(String location) { + try { + return Optional.of(GACTV.fromString(location)); + } catch (IllegalArgumentException | NullPointerException e) { + return Optional.empty(); + } + } + + public static Optional checkGACTV(Optional location) { + return location.flatMap(PluginUtil::checkGACTV); + } + + /** + * Chekcs if specified {@link String} is a valid path. + * + * @param location The string to check + * @return The {@link Path} wrapped in {@link Optional} if valid, empty otherwise. + */ + public static Optional checkPath(String location) { + try { + return Optional.of(Path.of(location)); + } catch (InvalidPathException | NullPointerException e) { + return Optional.empty(); + } + } + + public static Optional checkPath(Optional location) { + return location.flatMap(PluginUtil::checkPath); + } + + /** + * Checks if location is remote. + * + * @param location the specifiied location. + * @return true if location is url or gactv + */ + public static boolean isRemoteLocation(String location) { + return checkUrl(location).isPresent() || checkGACTV(location).isPresent(); + } + + private static List getBuildFiles(Optional projectRoot) { + List buildFiles = new ArrayList<>(); + if (projectRoot == null) { + return buildFiles; + } + projectRoot.ifPresent(root -> { + BuildTool buildTool = QuarkusProjectHelper.detectExistingBuildTool(root); + if (buildTool != null) { + for (String buildFile : buildTool.getBuildFiles()) { + buildFiles.add(root.resolve(buildFile)); + } + } + }); + return buildFiles; + } + + private static LocalDateTime getLastBuildFileModifiedTime(Optional projectRoot) { + long lastModifiedMillis = getBuildFiles(projectRoot).stream().map(Path::toFile).filter(File::exists) + .map(File::lastModified) + .max(Long::compare).orElse(0L); + return Instant.ofEpochMilli(lastModifiedMillis).atZone(ZoneId.systemDefault()).toLocalDateTime(); + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/commands/handlers/QuarkusCommandHandlers.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/commands/handlers/QuarkusCommandHandlers.java index d081d107a019e..6b2a3c04935ff 100644 --- a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/commands/handlers/QuarkusCommandHandlers.java +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/commands/handlers/QuarkusCommandHandlers.java @@ -1,6 +1,8 @@ package io.quarkus.devtools.commands.handlers; import static io.quarkus.devtools.messagewriter.MessageIcons.ERROR_ICON; +import static io.quarkus.devtools.utils.Patterns.isExpression; +import static io.quarkus.devtools.utils.Patterns.toRegex; import static io.quarkus.platform.catalog.processor.ExtensionProcessor.getExtendedKeywords; import static io.quarkus.platform.catalog.processor.ExtensionProcessor.getShortName; import static io.quarkus.platform.catalog.processor.ExtensionProcessor.isUnlisted; @@ -12,7 +14,6 @@ import java.util.Map; import java.util.Set; import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; @@ -177,64 +178,6 @@ private static boolean matchLabels(Pattern pattern, Collection labels) { return matches; } - private static Pattern toRegex(final String str) { - try { - String wildcardToRegex = wildcardToRegex(str); - if (wildcardToRegex != null && !wildcardToRegex.isEmpty()) { - return Pattern.compile(wildcardToRegex); - } - } catch (PatternSyntaxException e) { - //ignore it - } - return null; - } - - private static String wildcardToRegex(String wildcard) { - // don't try with file match char in pattern - if (!isExpression(wildcard)) { - return null; - } - StringBuffer s = new StringBuffer(wildcard.length()); - s.append("^.*"); - for (int i = 0, is = wildcard.length(); i < is; i++) { - char c = wildcard.charAt(i); - switch (c) { - case '*': - s.append(".*"); - break; - case '?': - s.append("."); - break; - case '^': // escape character in cmd.exe - s.append("\\"); - break; - // escape special regexp-characters - case '(': - case ')': - case '[': - case ']': - case '$': - case '.': - case '{': - case '}': - case '|': - case '\\': - s.append("\\"); - s.append(c); - break; - default: - s.append(c); - break; - } - } - s.append(".*$"); - return (s.toString()); - } - - private static boolean isExpression(String s) { - return s == null || s.isEmpty() ? false : s.contains("*") || s.contains("?"); - } - private static boolean matchesShortName(Extension extension, String q) { return q.equalsIgnoreCase(getShortName(extension)); } diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/exec/ExecSupport.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/exec/ExecSupport.java new file mode 100644 index 0000000000000..39489cbaf8eb8 --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/exec/ExecSupport.java @@ -0,0 +1,83 @@ +package io.quarkus.devtools.exec; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.io.PrintWriter; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +public class ExecSupport { + + private final PrintWriter out; + private final PrintWriter err; + + private final boolean verbose; + private final boolean cliTest; + + public ExecSupport() { + this(System.out, System.err, false, false); + } + + public ExecSupport(PrintStream out, PrintStream err, boolean verbose, boolean cliTest) { + this(new PrintWriter(out), new PrintWriter(err), verbose, cliTest); + } + + public ExecSupport(PrintWriter out, PrintWriter err, boolean verbose, boolean cliTest) { + this.out = out; + this.err = err; + this.verbose = verbose; + this.cliTest = cliTest; + } + + public int executeProcess(String[] args, File parentDir) throws IOException, InterruptedException { + int exit = 0; + if (isVerbose()) { + out.println(String.join(" ", args)); + out.println(); + } + + if (isCliTest()) { + // We have to capture IO differently in tests.. + Process process = new ProcessBuilder() + .command(args) + .redirectInput(ProcessBuilder.Redirect.INHERIT) + .directory(parentDir) + .start(); + + // Drain the output/errors streams + ExecutorService service = Executors.newFixedThreadPool(2); + service.submit(() -> { + new BufferedReader(new InputStreamReader(process.getInputStream())).lines() + .forEach(out::println); + }); + service.submit(() -> { + new BufferedReader(new InputStreamReader(process.getErrorStream())).lines() + .forEach(err::println); + }); + process.waitFor(5, TimeUnit.MINUTES); + service.shutdown(); + + exit = process.exitValue(); + } else { + Process process = new ProcessBuilder() + .command(args) + .inheritIO() + .directory(parentDir) + .start(); + exit = process.waitFor(); + } + return exit; + } + + public boolean isVerbose() { + return verbose; + } + + public boolean isCliTest() { + return cliTest; + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/exec/Executable.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/exec/Executable.java new file mode 100644 index 0000000000000..367bb70c1eea5 --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/exec/Executable.java @@ -0,0 +1,73 @@ +package io.quarkus.devtools.exec; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import io.quarkus.devtools.messagewriter.MessageWriter; +import io.quarkus.utilities.OS; + +public class Executable { + + public static File findExecutableFile(String base) { + String path = null; + String executable = base; + + if (OS.determineOS() == OS.WINDOWS) { + executable = base + ".cmd"; + path = findExecutable(executable); + if (path == null) { + executable = base + ".bat"; + path = findExecutable(executable); + } + } else { + executable = base; + path = findExecutable(executable); + } + if (path == null) + return null; + return new File(path, executable); + } + + public static String findExecutable(String exec) { + return Stream.of(System.getenv("PATH").split(Pattern.quote(File.pathSeparator))).map(Paths::get) + .map(path -> path.resolve(exec).toFile()).filter(File::exists).findFirst().map(File::getParent) + .orElse(null); + } + + public static File findExecutable(String name, String errorMessage, MessageWriter output) { + File command = findExecutableFile(name); + if (command == null) { + output.error(errorMessage); + throw new RuntimeException("Unable to find " + name + " command"); + } + return command; + } + + public static File findWrapper(Path projectRoot, String[] windows, String other) { + if (projectRoot == null) { + return null; + } + if (OS.determineOS() == OS.WINDOWS) { + for (String name : windows) { + File wrapper = new File(projectRoot + File.separator + name); + if (wrapper.isFile()) + return wrapper; + } + } else { + File wrapper = new File(projectRoot + File.separator + other); + if (wrapper.isFile()) + return wrapper; + } + + // look for a wrapper in a parent directory + Path normalizedPath = projectRoot.normalize(); + if (!normalizedPath.equals(projectRoot.getRoot())) { + return findWrapper(normalizedPath.getParent(), windows, other); + } else { + return null; + } + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/utils/Patterns.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/utils/Patterns.java new file mode 100644 index 0000000000000..9b40cf604392b --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/utils/Patterns.java @@ -0,0 +1,65 @@ +package io.quarkus.devtools.utils; + +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +public class Patterns { + + public static boolean isExpression(String s) { + return s == null || s.isEmpty() ? false : s.contains("*") || s.contains("?"); + } + + public static Pattern toRegex(final String str) { + try { + String wildcardToRegex = wildcardToRegex(str); + if (wildcardToRegex != null && !wildcardToRegex.isEmpty()) { + return Pattern.compile(wildcardToRegex); + } + } catch (PatternSyntaxException e) { + //ignore it + } + return null; + } + + private static String wildcardToRegex(String wildcard) { + // don't try with file match char in pattern + if (!isExpression(wildcard)) { + return null; + } + StringBuffer s = new StringBuffer(wildcard.length()); + s.append("^.*"); + for (int i = 0, is = wildcard.length(); i < is; i++) { + char c = wildcard.charAt(i); + switch (c) { + case '*': + s.append(".*"); + break; + case '?': + s.append("."); + break; + case '^': // escape character in cmd.exe + s.append("\\"); + break; + // escape special regexp-characters + case '(': + case ')': + case '[': + case ']': + case '$': + case '.': + case '{': + case '}': + case '|': + case '\\': + s.append("\\"); + s.append(c); + break; + default: + s.append(c); + break; + } + } + s.append(".*$"); + return (s.toString()); + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/utils/Prompt.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/utils/Prompt.java new file mode 100644 index 0000000000000..57932ea7d70ef --- /dev/null +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/devtools/utils/Prompt.java @@ -0,0 +1,45 @@ +package io.quarkus.devtools.utils; + +import java.io.Console; +import java.util.Optional; + +public final class Prompt { + + private Prompt() { + //Utility + } + + /** + * Utility to prompt users for a yes/no answer. + * The utility will prompt user in a loop until it gets a yes/no or blank response. + * + * @param defaultValue The value to return if user provides a blank response. + * @param prompt The text to display + * @param args Formatting args for the prompt + * @return true if user replied with `y` or `yes`, false if user provided `n` or `no`, defaultValue if user provided empty + * response. + */ + public static boolean yesOrNo(boolean defaultValue, String prompt, String... args) { + String choices = defaultValue ? " (Y/n)" : " (y/N)"; + String optionalQuestionMark = prompt.matches(".*\\?\\s*$") ? " " : " ? "; + while (true) { + try { + Optional console = Optional.ofNullable(System.console()); + String response = console + .map(c -> c.readLine(prompt + choices + optionalQuestionMark, args).trim().toLowerCase()) + .orElse(defaultValue ? "y" : "n"); + if (response.isBlank()) { + return defaultValue; + } + if (response.equals("y") || response.equals("yes")) { + return true; + } + if (response.equals("n") || response.equals("no")) { + return false; + } + } catch (Exception ignore) { + return defaultValue; + } + } + } +} diff --git a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/platform/catalog/processor/ExtensionProcessor.java b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/platform/catalog/processor/ExtensionProcessor.java index 7d7a1e5e954b5..1106eb948b6fa 100644 --- a/independent-projects/tools/devtools-common/src/main/java/io/quarkus/platform/catalog/processor/ExtensionProcessor.java +++ b/independent-projects/tools/devtools-common/src/main/java/io/quarkus/platform/catalog/processor/ExtensionProcessor.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -106,6 +107,10 @@ public static List getKeywords(Extension extension) { return getMetadataValue(extension, MD_KEYWORDS).asStringList(); } + public static Set getCliPlugins(Extension extension) { + return new HashSet<>(getMetadataValue(extension, MD_CLI_PLUGINS).asStringList()); + } + /** * List of strings to use for optimised word matching. *
@@ -222,6 +227,10 @@ public Set getExtendedKeywords() { return getExtendedKeywords(extension); } + public Set getCliPlugins() { + return getCliPlugins(extension); + } + public Map> getSyntheticMetadata() { return getSyntheticMetadata(extension); } diff --git a/independent-projects/tools/devtools-common/src/test/java/io/quarkus/cli/plugin/PluginManagerUtilTest.java b/independent-projects/tools/devtools-common/src/test/java/io/quarkus/cli/plugin/PluginManagerUtilTest.java new file mode 100644 index 0000000000000..6e62324e81e5f --- /dev/null +++ b/independent-projects/tools/devtools-common/src/test/java/io/quarkus/cli/plugin/PluginManagerUtilTest.java @@ -0,0 +1,68 @@ +package io.quarkus.cli.plugin; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class PluginManagerUtilTest { + + @Test + public void shouldGetTypeFromLocation() { + assertEquals(PluginType.jar, PluginUtil.getType("http://shomehost/some/path/my.jar")); + assertEquals(PluginType.maven, PluginUtil.getType("my.group:my-artifact:my.version")); + assertEquals(PluginType.jbang, PluginUtil.getType("something_not_in_path")); + } + + @Test + public void shouldGetNameFromLocation() { + PluginManagerUtil util = PluginManagerUtil.getUtil(); + assertEquals("my", util.getName("http://shomehost/some/path/my.jar")); + assertEquals("command", util.getName("http://shomehost/some/path/quarkus-command.jar")); + assertEquals("command", util.getName("http://shomehost/some/path/quarkus-command-cli.jar")); + + assertEquals("my-artifact", util.getName("my.group:my-artifact:my.version")); + assertEquals("my-artifact", util.getName("my.group:my-artifact-cli:my.version")); + + assertEquals("something_not_in_path", util.getName("something_not_in_path")); + assertEquals("something", util.getName("something@quarkusio")); + assertEquals("something", util.getName("something-cli@quarkusio")); + //No replacement here + assertEquals("something-cli2", util.getName("something-cli2@quarkusio")); + + PluginManagerSettings customSetttings = PluginManagerSettings.defaultSettings() + .withPluignPrefix("awesomeoss") + .withCatalogs("awesomeossio"); + util = PluginManagerUtil.getUtil(customSetttings); + + assertEquals("my", util.getName("http://shomehost/some/path/my.jar")); + + assertEquals("quarkus-command", util.getName("http://shomehost/some/path/quarkus-command.jar")); + assertEquals("quarkus-command", util.getName("http://shomehost/some/path/quarkus-command-cli.jar")); + + assertEquals("command", util.getName("http://shomehost/some/path/awesomeoss-command.jar")); + assertEquals("command", util.getName("http://shomehost/some/path/awesomeoss-command-cli.jar")); + + assertEquals("something-cli2", util.getName("something-cli2@awesomeossio")); + } + + @Test + public void shouldGetPluginFromLocation() { + PluginManagerUtil util = PluginManagerUtil.getUtil(); + + Plugin p = util.from("http://shomehost/some/path/my.jar"); + assertEquals("my", p.getName()); + assertEquals(PluginType.jar, p.getType()); + + p = util.from("/some/path/my.jar"); + assertEquals("my", p.getName()); + assertEquals(PluginType.jar, p.getType()); + + p = util.from("my.group:my-artifact-cli:my.version"); + assertEquals("my-artifact", p.getName()); + assertEquals(PluginType.maven, p.getType()); + + p = util.from("quarkus-alias"); + assertEquals("alias", p.getName()); + assertEquals(PluginType.jbang, p.getType()); + } +} diff --git a/independent-projects/tools/devtools-common/src/test/java/io/quarkus/cli/plugin/PluginTest.java b/independent-projects/tools/devtools-common/src/test/java/io/quarkus/cli/plugin/PluginTest.java new file mode 100644 index 0000000000000..a050cfe5aa630 --- /dev/null +++ b/independent-projects/tools/devtools-common/src/test/java/io/quarkus/cli/plugin/PluginTest.java @@ -0,0 +1,29 @@ +package io.quarkus.cli.plugin; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; + +public class PluginTest { + + private final ObjectMapper objectMapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModule(new Jdk8Module()); + + @Test + public void shouldNotHaveNullProperties() throws Exception { + assertPluginFieldsNotNull(new Plugin("my-plugin", PluginType.executable)); + assertPluginFieldsNotNull(new Plugin("my-plugin", PluginType.executable, null, null, null, false)); + assertPluginFieldsNotNull(objectMapper.readValue("{\"name\": \"my-plugin\", \"type\": \"executable\"}", Plugin.class)); + } + + private void assertPluginFieldsNotNull(Plugin p) { + assertNotNull(p.getDescription()); + assertNotNull(p.getLocation()); + assertNotNull(p.getCatalogLocation()); + } +} diff --git a/independent-projects/tools/devtools-common/src/test/java/io/quarkus/cli/plugin/PluginUtilTest.java b/independent-projects/tools/devtools-common/src/test/java/io/quarkus/cli/plugin/PluginUtilTest.java new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/independent-projects/tools/registry-client/src/main/java/io/quarkus/registry/catalog/Extension.java b/independent-projects/tools/registry-client/src/main/java/io/quarkus/registry/catalog/Extension.java index bf2089ef285db..2349b9c924c35 100644 --- a/independent-projects/tools/registry-client/src/main/java/io/quarkus/registry/catalog/Extension.java +++ b/independent-projects/tools/registry-client/src/main/java/io/quarkus/registry/catalog/Extension.java @@ -24,6 +24,7 @@ public interface Extension { String MD_CATEGORIES = "categories"; String MD_STATUS = "status"; String MD_BUILT_WITH_QUARKUS_CORE = "built-with-quarkus-core"; + String MD_CLI_PLUGINS = "cli-plugins"; String getName();