This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26440-lazy-plugins in repository https://gitbox.apache.org/repos/asf/camel.git
commit 2dc9d87a51b35e3e45b1580f09d62514743ef33b Author: Claus Ibsen <[email protected]> AuthorDate: Sat Oct 31 19:41:08 2026 +0100 CAMEL-26440: camel-jbang - A command of a plugin creates all the commands of the CLI and of the other plugins A command of a plugin creates no command of the CLI, and only that plugin. The help of a command creates only that command; the banner of the plugins that are not installed is printed by camel and camel --help only. The shell panel of camel tui makes its own command line with all commands. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 3 +- .../dsl/jbang/core/commands/CamelJBangMain.java | 61 +++++++++++----- .../camel/dsl/jbang/core/common/PluginHelper.java | 26 +++++++ .../commands/CamelJBangMainOnlyCommandTest.java | 85 ++++++++++++++++++---- .../dsl/jbang/core/common/PluginHelperTest.java | 25 +++++++ .../dsl/jbang/core/commands/tui/ShellPanel.java | 9 ++- .../dsl/jbang/launcher/CamelLauncherMain.java | 37 +++++++--- .../dsl/jbang/launcher/CamelLauncherTest.java | 41 +++++++++++ 8 files changed, 239 insertions(+), 48 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 0ff788fe9f49..2c76744d50f6 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -872,7 +872,8 @@ A script that checks the exit code of a command must take these changes into acc Errors, warnings and tips are printed on stderr instead of stdout, so stdout only has the output of the command, such as JSON. If a script reads the error text of a command from stdout, read stderr instead. The list of plugins that are -not installed, after `camel --help`, is printed on stderr, and only in an interactive terminal. +not installed, after `camel` and `camel --help` (no longer after the help of a command, such as `camel run --help`), +is printed on stderr, and only in an interactive terminal. === camel-pqc - parameterSpec accepts MAYO and SNOVA, and the FIPS advisory no longer flags two aliases diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java index 655ba828fe4e..be75a82fac10 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java @@ -120,9 +120,14 @@ public class CamelJBangMain implements Callable<Integer> { // classes) costs more than most commands take to run String only = onlyCommand(args); commandLine = createCommandLine(only); + boolean pluginCommand = false; if (only != null && !commandLine.getSubcommands().containsKey(only)) { - // not a command of the CLI (such as the command of a plugin, or a typo, for which picocli suggests others) - commandLine = createCommandLine(null); + // the command of a plugin needs none of the commands of the CLI, anything else (such as a typo, for which + // picocli suggests other commands) needs all of them + pluginCommand = isPluginCommand(only); + if (!pluginCommand) { + commandLine = createCommandLine(null); + } } postAddCommands(commandLine, args); @@ -138,6 +143,11 @@ public class CamelJBangMain implements Callable<Integer> { return; } } + if (pluginCommand && !commandLine.getSubcommands().containsKey(only)) { + // no plugin added the command: all the commands of the CLI, for the commands that picocli suggests + commandLine = createCommandLine(null); + postAddCommands(commandLine, args); + } withErrorHandling(commandLine); commandLine.getCommandSpec().versionProvider(() -> { @@ -164,9 +174,10 @@ public class CamelJBangMain implements Callable<Integer> { } /** - * The command of the CLI that a command line runs, when the CLI needs to create only that command: not for the - * help, a command line that starts with an option (such as {@code --version}), or the commands that use the other - * commands ({@link #FULL_COMMANDS}) or the plugins ({@link PluginHelper#isPluginConsumingCommand(String)}). + * The command that a command line runs, when the CLI needs to create only that command (of the CLI, or of a + * plugin): not for {@code camel} and a command line that starts with an option (such as {@code --help}), which list + * all commands, or the commands that use the other commands ({@link #FULL_COMMANDS}) or the plugins + * ({@link PluginHelper#isPluginConsumingCommand(String)}). * * @param args the command line * @return the name of the command, or {@code null} to create all commands @@ -180,12 +191,6 @@ public class CamelJBangMain implements Callable<Integer> { || PluginHelper.isPluginConsumingCommand(target)) { return null; } - for (String arg : args) { - if ("--help".equals(arg) || "-h".equals(arg)) { - // the help lists the plugins that are not installed, from all commands - return null; - } - } return target; } @@ -202,6 +207,29 @@ public class CamelJBangMain implements Callable<Integer> { return only != null && commandLine.getSubcommands().size() == 1 && commandLine.getSubcommands().containsKey(only); } + /** + * The command of the plugin that the command line runs, when the command line has none of the commands of the CLI + * (see {@link #isPluginCommand(String)}), so it needs only that plugin. + * + * @param commandLine the command line + * @param args the arguments it runs + * @return the name of the command, or {@code null} when the command line needs all plugins + */ + public static String onlyPluginCommand(CommandLine commandLine, String... args) { + String only = onlyCommand(args); + return only != null && commandLine.getSubcommands().isEmpty() ? only : null; + } + + /** + * Whether the command is the command of a plugin, which needs none of the commands of the CLI. + * + * @param command the name of the command + * @return {@code true} for the command of a plugin that the CLI can add + */ + protected boolean isPluginCommand(String command) { + return discoverPlugins && PluginHelper.isPluginCommand(command); + } + private static boolean shouldCreate(String only, String name) { return only == null || only.equals(name); } @@ -518,16 +546,9 @@ public class CamelJBangMain implements Callable<Integer> { return 0; } + // camel and camel --help, which list all commands private static boolean isHelpRequest(String[] args) { - if (args == null || args.length == 0) { - return true; - } - for (String arg : args) { - if ("--help".equals(arg) || "-h".equals(arg)) { - return true; - } - } - return false; + return args == null || args.length == 0 || "--help".equals(args[0]) || "-h".equals(args[0]); } void printAvailablePlugins() { diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java index cf05692f8090..a7abe8630526 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java @@ -25,6 +25,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Enumeration; import java.util.HashMap; @@ -125,6 +126,31 @@ public final class PluginHelper { return PLUGIN_CONSUMING_BUILTINS.contains(command); } + /** + * Whether the command is the command of a plugin: of a plugin of Camel (which is installed on first use), of a + * plugin in the plugin configuration, or of a plugin embedded in the classpath. + * + * @param command the name of the command + * @return {@code true} when a plugin adds the command + */ + public static boolean isPluginCommand(String command) { + if (Arrays.stream(PluginType.values()).anyMatch(p -> p.getCommand().equals(command))) { + return true; + } + JsonObject config = getPluginConfig(); + if (config != null) { + JsonObject plugins = config.getMap("plugins"); + for (String pluginKey : plugins.keySet()) { + JsonObject properties = plugins.getMap(pluginKey); + String name = properties.getOrDefault("name", pluginKey).toString(); + if (command.equals(properties.getOrDefault("command", name).toString())) { + return true; + } + } + } + return PluginHelper.class.getClassLoader().getResource(PLUGIN_SERVICE_DIR + "camel-jbang-plugin-" + command) != null; + } + /** * Loads the plugin Json configuration from the user home and goes through all configured plugins adding the plugin * commands to the current command line. Tries to resolve each plugin from the classpath with the factory finder diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMainOnlyCommandTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMainOnlyCommandTest.java index 39939ab2b169..4d1170f511b9 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMainOnlyCommandTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMainOnlyCommandTest.java @@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands; import java.util.List; import java.util.Set; +import java.util.concurrent.Callable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -51,7 +52,27 @@ class CamelJBangMainOnlyCommandTest extends CamelCommandBaseTestSupport { public void theHelpCreatesAllCommands() { assertAllCommands(execute("--help")); assertAllCommands(execute()); - assertAllCommands(execute("config", "--help")); + } + + @Test + public void theHelpOfACommandCreatesOnlyThatCommand() { + CommandLine commandLine = execute("config", "--help"); + + assertEquals(Set.of("config"), commandLine.getSubcommands().keySet()); + assertTrue(CamelJBangMain.hasOnlyCommand(commandLine, "config", "--help")); + } + + @Test + public void aCommandOfAPluginCreatesNoCommandOfTheCli() { + CommandLine commandLine = execute(new PluginMain(true), "fake", "--help"); + + assertEquals(Set.of("fake"), commandLine.getSubcommands().keySet()); + } + + @Test + public void aCommandOfAPluginThatAddsNoCommandCreatesAllCommands() { + // for the commands that picocli suggests + assertAllCommands(execute(new PluginMain(false), "fake")); } @Test @@ -70,6 +91,8 @@ class CamelJBangMainOnlyCommandTest extends CamelCommandBaseTestSupport { } assertNull(CamelJBangMain.onlyCommand("--verbose", "version")); assertEquals("version", CamelJBangMain.onlyCommand("version", "get")); + assertEquals("version", CamelJBangMain.onlyCommand("version", "--help")); + assertNull(CamelJBangMain.onlyCommand("--help")); } @Test @@ -81,21 +104,57 @@ class CamelJBangMainOnlyCommandTest extends CamelCommandBaseTestSupport { } private CommandLine execute(String... args) { - CommandLine[] executed = new CommandLine[1]; - CamelJBangMain main = new CamelJBangMain() { - @Override - public void postExecute(CommandLine commandLine, String[] args, int exitCode) { - executed[0] = commandLine; - } + return execute(new TestMain(), args); + } - @Override - public void quit(int exitCode) { - // noop - } - }.withPrinter(printer); + private CommandLine execute(TestMain main, String... args) { + main.withPrinter(printer); main.setDiscoverPlugins(false); main.execute(args); - return executed[0]; + return main.executed; + } + + private static class TestMain extends CamelJBangMain { + CommandLine executed; + + @Override + public void postExecute(CommandLine commandLine, String[] args, int exitCode) { + executed = commandLine; + } + + @Override + public void quit(int exitCode) { + // noop + } + } + + // a main with the plugin of the command fake + private static class PluginMain extends TestMain { + private final boolean addsCommand; + + PluginMain(boolean addsCommand) { + this.addsCommand = addsCommand; + } + + @Override + protected boolean isPluginCommand(String command) { + return "fake".equals(command); + } + + @Override + public void postAddCommands(CommandLine commandLine, String[] args) { + if (addsCommand && "fake".equals(onlyPluginCommand(commandLine, args))) { + commandLine.addSubcommand("fake", new CommandLine(new FakeCommand())); + } + } + } + + @CommandLine.Command(name = "fake", description = "The command of a plugin") + static class FakeCommand implements Callable<Integer> { + @Override + public Integer call() { + return 0; + } } private static void assertAllCommands(CommandLine commandLine) { diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java index e4e49fb17071..0b4682d0be36 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java @@ -61,6 +61,31 @@ public class PluginHelperTest { assertDoesNotThrow(PluginHelper::hasEmbeddedPlugins); } + @Test + public void aPluginCommandIsOfACamelPluginOrOfTheConfiguration() throws Exception { + Path userConfig = CommandLineHelper.getHomeDir().resolve(PluginHelper.PLUGIN_CONFIG); + Files.writeString(userConfig, """ + { + "plugins": { + "forage": { + "name": "forage", + "command": "forage", + "description": "Forage plugin", + "firstVersion": "4.18.0" + } + } + } + """, StandardOpenOption.CREATE); + + // installed on first use + assertTrue(PluginHelper.isPluginCommand("tui")); + assertTrue(PluginHelper.isPluginCommand("kubernetes")); + // in the plugin configuration + assertTrue(PluginHelper.isPluginCommand("forage")); + assertFalse(PluginHelper.isPluginCommand("version")); + assertFalse(PluginHelper.isPluginCommand("confg")); + } + @Test public void testFallbackToJsonConfig() throws Exception { // Create a user plugin config file in home directory diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java index 5fdf488a61a5..4c7c20c9eb0c 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java @@ -477,7 +477,8 @@ class ShellPanel { screenTerminal, StandardCharsets.UTF_8, feedbackOutput); LineDisciplineTerminal terminal = virtualTerminal; - shellThread = new Thread(() -> runShell(terminal, commandLine), "tui-shell"); + CamelJBangMain main = (CamelJBangMain) commandLine.getCommand(); + shellThread = new Thread(() -> runShell(terminal, main), "tui-shell"); shellThread.setDaemon(true); shellThread.start(); } catch (Exception e) { @@ -487,12 +488,12 @@ class ShellPanel { } } - private void runShell(LineDisciplineTerminal terminal, CommandLine commandLine) { + private void runShell(LineDisciplineTerminal terminal, CamelJBangMain main) { try { - PicocliCommandRegistry registry = new CamelShellCommandRegistry(commandLine); + // all the commands of the CLI and the plugins, as the CLI created only the command it ran (camel tui) + PicocliCommandRegistry registry = new CamelShellCommandRegistry(main.newCommandLine()); // Redirect command output (printer()) through the virtual terminal // so it renders in the shell panel instead of the TUI's real terminal - CamelJBangMain main = (CamelJBangMain) commandLine.getCommand(); Printer originalPrinter = main.getOut(); Printer terminalPrinter = new Printer() { @Override diff --git a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherMain.java b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherMain.java index 2ab0a379fc67..187a5d6b8c15 100644 --- a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherMain.java +++ b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherMain.java @@ -17,6 +17,8 @@ package org.apache.camel.dsl.jbang.launcher; import java.util.List; +import java.util.Set; +import java.util.function.Supplier; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.dsl.jbang.core.commands.generate.GeneratePlugin; @@ -34,17 +36,36 @@ import picocli.CommandLine; */ public class CamelLauncherMain extends CamelJBangMain { + // the plugins embedded in the launcher, with the commands they add + private record EmbeddedPlugin(Supplier<Plugin> factory, Set<String> commands) { + } + + private static final List<EmbeddedPlugin> PLUGINS = List.of( + new EmbeddedPlugin(GeneratePlugin::new, Set.of("generate")), + new EmbeddedPlugin(KubernetesPlugin::new, Set.of("kubernetes", "k8s")), + new EmbeddedPlugin(SelfUpdatePlugin::new, Set.of("self-update")), + new EmbeddedPlugin(TuiPlugin::new, Set.of("tui")), + new EmbeddedPlugin(ValidatePlugin::new, Set.of("validate")), + new EmbeddedPlugin(TestPlugin::new, Set.of("test"))); + @Override public void preExecute(CommandLine commandLine, String[] args) { UpdateChecker.maybeNotify(args); } + @Override + protected boolean isPluginCommand(String command) { + return PLUGINS.stream().anyMatch(p -> p.commands().contains(command)) || super.isPluginCommand(command); + } + @Override public void postAddCommands(CommandLine commandLine, String[] args) { if (hasOnlyCommand(commandLine, args)) { // a command of the CLI, which needs none of the commands of the plugins return; } + // a command of a plugin needs only that plugin + String only = onlyPluginCommand(commandLine, args); // Install embedded plugins. Each plugin must be given the classloader that loaded it (this // class's classloader, i.e. the fat-jar classloader) so that plugins relying on ServiceLoader // discovery can see the dependencies bundled in the launcher. The TUI plugin installs this @@ -52,16 +73,12 @@ public class CamelLauncherMain extends CamelJBangMain { // without it the discovery falls back to the system classloader, which cannot see the nested // BOOT-INF/lib jars of the Spring Boot fat-jar, and the TUI fails with "No BackendProvider found". ClassLoader classLoader = getClass().getClassLoader(); - List<Plugin> plugins = List.of( - new GeneratePlugin(), - new KubernetesPlugin(), - new SelfUpdatePlugin(), - new TuiPlugin(), - new ValidatePlugin(), - new TestPlugin()); - for (Plugin plugin : plugins) { - plugin.setClassLoader(classLoader); - plugin.customize(commandLine, this); + for (EmbeddedPlugin embedded : PLUGINS) { + if (only == null || embedded.commands().contains(only)) { + Plugin plugin = embedded.factory().get(); + plugin.setClassLoader(classLoader); + plugin.customize(commandLine, this); + } } } diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherTest.java index c8eebea27c80..2c979026a64d 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherTest.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherTest.java @@ -19,6 +19,9 @@ package org.apache.camel.dsl.jbang.launcher; import java.io.ByteArrayOutputStream; import java.io.PrintStream; import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import org.apache.camel.dsl.jbang.run.common.Printer; @@ -27,6 +30,7 @@ import org.junit.jupiter.api.Test; import picocli.CommandLine; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -91,6 +95,43 @@ public class CamelLauncherTest { assertTrue(output.contains("Camel CLI version:"), "Output should contain version information"); } + @Test + public void aCommandOfAnEmbeddedPluginCreatesOnlyThatPlugin() { + // creating all the commands of the CLI and the plugins costs more than half a second + assertEquals(Set.of("generate"), commandsOf("generate")); + assertEquals(Set.of("kubernetes", "k8s"), commandsOf("k8s", "--help")); + assertEquals(Set.of("version"), commandsOf("version")); + } + + @Test + public void theHelpCreatesTheCommandsOfAllEmbeddedPlugins() { + assertTrue(commandsOf("--help").containsAll(Set.of("version", "generate", "kubernetes", "self-update", "tui"))); + } + + private static Set<String> commandsOf(String... args) { + List<CommandLine> executed = new ArrayList<>(); + CamelLauncherMain main = new CamelLauncherMain() { + @Override + public void preExecute(CommandLine commandLine, String[] args) { + // no update check + } + + @Override + public void postExecute(CommandLine commandLine, String[] args, int exitCode) { + executed.add(commandLine); + } + + @Override + public void quit(int exitCode) { + // Do nothing to prevent System.exit + } + }; + main.setDiscoverPlugins(false); + main.setOut(new PrintStreamPrinter(new PrintStream(new ByteArrayOutputStream()))); + main.execute(args); + return executed.get(0).getSubcommands().keySet(); + } + @Test public void testEmbeddedTuiPluginReceivesClassLoader() throws Exception { // Regression for the launcher TUI failure "No BackendProvider found on classpath".
