From c4aca364844630ede2badd7aaa78eb1d14ada6fe Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Wed, 27 Aug 2025 17:55:15 -0600 Subject: [PATCH 1/4] test: add compose for local testing --- compose.yml | 26 ++++++++++++++++++++++++++ gradle.properties | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 compose.yml diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..41073ae --- /dev/null +++ b/compose.yml @@ -0,0 +1,26 @@ +services: + linux-shell: + image: ghcr.io/graalvm/native-image-community:21-muslib + container_name: justserve-linux-shell + working_dir: /app + volumes: + - .:/app + env_file: .env + entrypoint: /bin/sh + command: + - -c + - "mkdir -p /temp_app && cp -r . /temp_app/ && cd /temp_app && microdnf install -y findutils && sed -i 's/\r$//' ./gradlew && if [ -t 0 ]; then /bin/bash; else tail -f /dev/null; fi" + stdin_open: true + tty: true + + linux-test: + image: ghcr.io/graalvm/native-image-community:21-muslib + container_name: justserve-linux-test + working_dir: /app + volumes: + - .:/app + env_file: .env + entrypoint: /bin/sh + command: + - -c + - "mkdir -p /temp_app && cp -r . /temp_app/ && cd /temp_app && microdnf install -y findutils && sed -i 's/\r$//' ./gradlew && ./gradlew test" \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index bd03fdd..b1b5a5a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,3 @@ micronautVersion=4.8.3 -justserveCliVersion=0.0.5-SNAPSHOT \ No newline at end of file +justserveCliVersion=0.0.5-SNAPSHOT +org.gradle.console=rich \ No newline at end of file From 1e4154b7f215cd6d715bcf251af28a9296ca2398 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Wed, 27 Aug 2025 23:11:57 -0600 Subject: [PATCH 2/4] feat(wip): parse command add base command, common options, console output, getTempPassword, JustServeFactory and interactiveShell --- build.gradle.kts | 2 + .../org/justserve/cli/InteractiveShell.java | 79 ++++++++++++++++ .../org/justserve/cli/JustServeCommand.java | 90 +++++++++++++++++++ .../org/justserve/cli/JustServeFactory.java | 39 ++++++++ .../justserve/cli/command/BaseCommand.java | 72 +++++++++++++++ .../cli/command/CommonOptionsMixin.java | 34 +++++++ .../justserve/cli/command/ConsoleOutput.java | 43 +++++++++ .../command/GetTempPassword.java} | 51 ++++++----- .../{ => cli}/util/JustServePrinter.java | 2 +- .../util/JustServeVersionProvider.java} | 8 +- .../org/justserve/BaseCommandSpec.groovy | 3 +- 11 files changed, 393 insertions(+), 30 deletions(-) create mode 100644 src/main/java/org/justserve/cli/InteractiveShell.java create mode 100644 src/main/java/org/justserve/cli/JustServeCommand.java create mode 100644 src/main/java/org/justserve/cli/JustServeFactory.java create mode 100644 src/main/java/org/justserve/cli/command/BaseCommand.java create mode 100644 src/main/java/org/justserve/cli/command/CommonOptionsMixin.java create mode 100644 src/main/java/org/justserve/cli/command/ConsoleOutput.java rename src/main/java/org/justserve/{BaseCommand.java => cli/command/GetTempPassword.java} (61%) rename src/main/java/org/justserve/{ => cli}/util/JustServePrinter.java (99%) rename src/main/java/org/justserve/{util/VersionProvider.java => cli/util/JustServeVersionProvider.java} (73%) diff --git a/build.gradle.kts b/build.gradle.kts index 61aa646..b099bb1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,8 @@ dependencies { implementation("info.picocli:picocli") implementation("info.picocli:picocli-jansi-graalvm:1.2.0") implementation("org.fusesource.jansi:jansi:2.4.2") + implementation("info.picocli:picocli-shell-jline3:4.7.6") + implementation("org.jline:jline:3.30.5") implementation("io.micronaut:micronaut-http-client") implementation("io.micronaut.picocli:micronaut-picocli") implementation("io.micronaut.serde:micronaut-serde-jackson") diff --git a/src/main/java/org/justserve/cli/InteractiveShell.java b/src/main/java/org/justserve/cli/InteractiveShell.java new file mode 100644 index 0000000..5bac799 --- /dev/null +++ b/src/main/java/org/justserve/cli/InteractiveShell.java @@ -0,0 +1,79 @@ +package org.justserve.cli; + +import org.jline.reader.*; +import org.jline.reader.impl.DefaultParser; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import picocli.CommandLine; +import picocli.jansi.graalvm.AnsiConsole; +import picocli.shell.jline3.PicocliJLineCompleter; + +import java.util.function.BiFunction; +import java.util.function.Consumer; + +import static picocli.CommandLine.Help.Ansi.AUTO; + +public class InteractiveShell { + + private static final String DEFAULT_PROMPT = "@|blue JustServe>|@ "; + + private final CommandLine commandLine; + private final Consumer executor; + private final BiFunction onError; + private final String prompt; + + public InteractiveShell(CommandLine commandLine, + Consumer executor, + BiFunction onError) { + this(commandLine, executor, onError, DEFAULT_PROMPT); + } + + public InteractiveShell(CommandLine commandLine, + Consumer executor, + BiFunction onError, + String prompt) { + this.commandLine = commandLine; + this.executor = executor; + this.onError = onError; + this.prompt = prompt; + } + + public void start() { + try (AnsiConsole ignored = AnsiConsole.windowsInstall()) { + PicocliJLineCompleter picocliCommands = new PicocliJLineCompleter(commandLine.getCommandSpec()); + Terminal terminal = TerminalBuilder.terminal(); + LineReader reader = LineReaderBuilder.builder() + .terminal(terminal) + .completer(picocliCommands) + .parser(new DefaultParser()) + .variable(LineReader.LIST_MAX, 50) // max tab completion candidates + .build(); + + String ansiPrompt = AUTO.string(prompt); + String rightPrompt = null; + + // start the shell and process input until the user quits with Ctl-D + String line; + while (true) { + try { + line = reader.readLine(ansiPrompt, rightPrompt, (MaskingCallback) null, null); + if (line.matches("^\\s*#.*")) { + continue; + } + if ("exit".equals(line)) { + return; + } + ParsedLine pl = reader.getParser().parse(line, 0); + String[] arguments = pl.words().toArray(new String[0]); + executor.accept(arguments); + } catch (UserInterruptException | EndOfFileException e) { + return; + } + } + } catch (Throwable t) { + onError.apply(t, commandLine); + } finally { + AnsiConsole.systemUninstall(); + } + } +} diff --git a/src/main/java/org/justserve/cli/JustServeCommand.java b/src/main/java/org/justserve/cli/JustServeCommand.java new file mode 100644 index 0000000..e8ce128 --- /dev/null +++ b/src/main/java/org/justserve/cli/JustServeCommand.java @@ -0,0 +1,90 @@ +package org.justserve.cli; + +import io.micronaut.configuration.picocli.PicocliRunner; +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.BeanContext; +import io.micronaut.context.annotation.Value; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import jakarta.inject.Inject; +import jakarta.inject.Provider; +import org.justserve.cli.command.BaseCommand; +import org.justserve.cli.util.JustServeVersionProvider; +import org.justserve.client.UserClient; +import org.justserve.model.UserHashRequestByEmail; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParameterException; +import picocli.jansi.graalvm.AnsiConsole; + +import java.awt.datatransfer.StringSelection; +import java.util.concurrent.Callable; +import java.util.function.BiFunction; + +import static java.awt.Toolkit.getDefaultToolkit; +import static org.justserve.cli.util.JustServePrinter.printError; + +@Command(name = "justserve", versionProvider = JustServeVersionProvider.class, + description = "justserve-cli is a terminal tool to help specialists and admin using JustServe") +public class JustServeCommand extends BaseCommand implements Callable { + + private static Boolean interactiveShell = false; + + private static final BiFunction exHandler = (e, commandLine) -> { + BaseCommand command = commandLine.getCommand(); + command.err(e.getMessage()); + if (command.showStacktrace()) { + e.printStackTrace(commandLine.getErr()); + } + return 1; + }; + + + public static void main(String[] args) { + //if the command is called with no args, enter the interactive shell + if (args.length == 0) { + CommandLine commandLine = createCommandLine(); + JustServeCommand.interactiveShell = true; + new InteractiveShell(commandLine, JustServeCommand::execute, exHandler).start(); + } else { + System.exit(execute(args)); + } + + } + + static int execute(String[] args) { + boolean noOpConsole = args.length > 0 && args[0].startsWith("update-cli-config"); + try (BeanContext beanContext = ApplicationContext.builder().deduceEnvironment(false).start()) { + return createCommandLine(beanContext, noOpConsole).execute(args); + } + } + + static CommandLine createCommandLine() { + boolean noOpConsole = JustServeCommand.interactiveShell; + try (BeanContext beanContext = ApplicationContext.builder().deduceEnvironment(false).start()) { + return createCommandLine(beanContext, noOpConsole); + } + } + + private static CommandLine createCommandLine(BeanContext beanContext, boolean noOpConsole) { + JustServeCommand starter = beanContext.getBean(JustServeCommand.class); + CommandLine commandLine = new CommandLine(starter, new JustServeFactory(beanContext)); + commandLine.setExecutionExceptionHandler( + (ex, thisCmd, parseResult) -> exHandler.apply(ex, thisCmd) + ); + commandLine.setUsageHelpWidth(100); + return commandLine; + } + + /** + * Computes a result, or throws an exception if unable to do so. + * + * @return computed result + * @throws Exception if unable to compute a result + */ + @Override + public Integer call() throws Exception { + throw new ParameterException(spec.commandLine(), "No command specified"); + } +} diff --git a/src/main/java/org/justserve/cli/JustServeFactory.java b/src/main/java/org/justserve/cli/JustServeFactory.java new file mode 100644 index 0000000..be70d85 --- /dev/null +++ b/src/main/java/org/justserve/cli/JustServeFactory.java @@ -0,0 +1,39 @@ +package org.justserve.cli; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.BeanContext; +import io.micronaut.core.annotation.TypeHint; +import picocli.CommandLine; +import picocli.CommandLine.IFactory; + +import java.util.Optional; + +import static io.micronaut.core.annotation.TypeHint.AccessType.ALL_DECLARED_CONSTRUCTORS; +import static io.micronaut.core.annotation.TypeHint.AccessType.ALL_DECLARED_FIELDS; + +/** + * Picocli factory implementation that uses a Micronaut BeanContext to obtain bean instances. + */ +@TypeHint(typeNames = { + "picocli.CommandLine$AutoHelpMixin", + "picocli.CommandLine$Model$CommandSpec" +}, accessType = {ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS}) +public class JustServeFactory implements IFactory { + + private final IFactory defaultFactory = CommandLine.defaultFactory(); + private final BeanContext beanContext; + + public JustServeFactory() { + this(ApplicationContext.run()); + } + + public JustServeFactory(BeanContext beanContext) { + this.beanContext = beanContext; + } + + @Override + public K create(Class cls) throws Exception { + Optional bean = beanContext.findOrInstantiateBean(cls); + return bean.isPresent() ? bean.get() : defaultFactory.create(cls); + } +} \ No newline at end of file diff --git a/src/main/java/org/justserve/cli/command/BaseCommand.java b/src/main/java/org/justserve/cli/command/BaseCommand.java new file mode 100644 index 0000000..3ff95ab --- /dev/null +++ b/src/main/java/org/justserve/cli/command/BaseCommand.java @@ -0,0 +1,72 @@ +package org.justserve.cli.command; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.ReflectiveAccess; +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; + +import java.io.PrintWriter; +import java.util.Optional; + +import static picocli.CommandLine.Help.Ansi.AUTO; + +public class BaseCommand implements ConsoleOutput{ + + @CommandLine.Spec + @ReflectiveAccess + protected CommandSpec spec; + + @CommandLine.Mixin + @ReflectiveAccess + protected CommonOptionsMixin commonOptions = new CommonOptionsMixin(); + + + @Override + public void out(String message) { + outWriter().ifPresent(writer -> writer.println(AUTO.string(message))); + } + + public void err(String message) { + errWriter().ifPresent(writer -> writer.println(AUTO.string("@|bold,red | Error|@ " + message))); + } + + public void warning(String message) { + outWriter().ifPresent(writer -> writer.println(AUTO.string("@|bold,red | Warning|@ " + message))); + } + + @Override + public void green(String message) { + outWriter().ifPresent(writer -> writer.println(AUTO.string("@|bold,green " + message + "|@"))); + } + + @Override + public void red(String message) { + outWriter().ifPresent(writer -> writer.println(AUTO.string("@|bold,red " + message + "|@"))); + } + + @Override + public boolean showStacktrace() { + return false; + } + + @Override + public boolean verbose() { + return false; + } + + + @NonNull + public Optional outWriter() { + return getSpec().map(spec -> spec.commandLine().getOut()); + } + + @NonNull + public Optional errWriter() { + return getSpec().map(spec -> spec.commandLine().getErr()); + } + + @NonNull + public Optional getSpec() { + return Optional.ofNullable(spec); + } +} diff --git a/src/main/java/org/justserve/cli/command/CommonOptionsMixin.java b/src/main/java/org/justserve/cli/command/CommonOptionsMixin.java new file mode 100644 index 0000000..527e5c0 --- /dev/null +++ b/src/main/java/org/justserve/cli/command/CommonOptionsMixin.java @@ -0,0 +1,34 @@ +package org.justserve.cli.command; + +import io.micronaut.core.annotation.ReflectiveAccess; +import org.justserve.cli.util.JustServeVersionProvider; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +/** + * Mixin that adds help, version and other common options to a command. Example usage: + *
+ * @Command(name = "command")
+ * class App {
+ *     @Mixin
+ *     CommonOptionsMixin commonOptions // adds common options to the command
+ *
+ *     // ...
+ * }
+ * 
+ * + * @author Remko Popma + * @version 1.0 + */ +@Command(mixinStandardHelpOptions = true, versionProvider = JustServeVersionProvider.class) +@SuppressWarnings("checkstyle:VisibilityModifier") +public class CommonOptionsMixin { + + @Option(names = {"-x", "--stacktrace"}, defaultValue = "false", description = "Show full stack trace when exceptions occur.") + @ReflectiveAccess + public boolean showStacktrace; + + @Option(names = {"-v", "--verbose"}, defaultValue = "false", description = "Create verbose output.") + @ReflectiveAccess + public boolean verbose; +} \ No newline at end of file diff --git a/src/main/java/org/justserve/cli/command/ConsoleOutput.java b/src/main/java/org/justserve/cli/command/ConsoleOutput.java new file mode 100644 index 0000000..00c03b0 --- /dev/null +++ b/src/main/java/org/justserve/cli/command/ConsoleOutput.java @@ -0,0 +1,43 @@ +package org.justserve.cli.command; + +public interface ConsoleOutput { + ConsoleOutput NOOP = new ConsoleOutput() { + @Override + public void out(String message) { } + + @Override + public void err(String message) { } + + @Override + public void warning(String message) { } + + @Override + public boolean showStacktrace() { + return false; + } + + @Override + public boolean verbose() { + return false; + } + + }; + + void out(String message); + + void err(String message); + + void warning(String message); + + boolean showStacktrace(); + + boolean verbose(); + + default void green(String message) { + out(message); + } + + default void red(String message) { + out(message); + } +} diff --git a/src/main/java/org/justserve/BaseCommand.java b/src/main/java/org/justserve/cli/command/GetTempPassword.java similarity index 61% rename from src/main/java/org/justserve/BaseCommand.java rename to src/main/java/org/justserve/cli/command/GetTempPassword.java index 746f510..753d393 100644 --- a/src/main/java/org/justserve/BaseCommand.java +++ b/src/main/java/org/justserve/cli/command/GetTempPassword.java @@ -1,51 +1,51 @@ -package org.justserve; +package org.justserve.cli.command; -import io.micronaut.configuration.picocli.PicocliRunner; import io.micronaut.context.annotation.Value; +import io.micronaut.core.annotation.ReflectiveAccess; import io.micronaut.http.HttpResponse; import io.micronaut.http.client.exceptions.HttpClientResponseException; import jakarta.inject.Inject; import jakarta.inject.Provider; import org.justserve.client.UserClient; import org.justserve.model.UserHashRequestByEmail; -import org.justserve.util.VersionProvider; +import picocli.CommandLine; import picocli.CommandLine.Command; import picocli.CommandLine.Option; -import picocli.jansi.graalvm.AnsiConsole; -import static org.justserve.util.JustServePrinter.printError; -import static org.justserve.util.JustServePrinter.printNormal; +import java.awt.datatransfer.StringSelection; +import java.util.concurrent.Callable; -@Command(name = "justserve", versionProvider = VersionProvider.class, - description = "justserve-cli is a terminal tool to help specialists and admin using JustServe") -public class BaseCommand implements Runnable { +import static java.awt.Toolkit.getDefaultToolkit; +import static org.justserve.cli.util.JustServePrinter.printError; +@Command(name = "getTempPassword", description = "get a temporary password for a user") +public class GetTempPassword extends BaseCommand implements Callable { + + @ReflectiveAccess @Option(names = {"-e", "--email"}, description = "email for the user whose temporary password will be generated") String email; - - @Option(names = {"version", "--version", "-v"}, versionHelp = true, description = "print version info and exit") - boolean version = false; - + @Inject + @ReflectiveAccess Provider userClientProvider; @Value("${justserve.token}") String token; - public static void main(String[] args) { - try (AnsiConsole ignored = AnsiConsole.windowsInstall()) { - PicocliRunner.run(BaseCommand.class, args); - } - - } - - public void run() { + /** + * Computes a result, or throws an exception if unable to do so. + * + * @return computed result + * @throws Exception if unable to compute a result + */ + @Override + public Integer call() throws Exception { HttpResponse response; if ("i-need-to-be-defined".equals(token) || null == token) { printError(("NO AUTHENTICATION PROVIDED" + System.lineSeparator() + "The Authentication token is not assigned as an environment variable." + System.lineSeparator() + "Please define the environment variable \"JUSTSERVE_TOKEN\" and try again.")); - return; + return 1; } try { UserClient userClient = userClientProvider.get(); @@ -54,12 +54,15 @@ public void run() { String errorMessage = "Received an unexpected response from JustServe:" + String.format("%n%d (%s)", e.getResponse().status().getCode(), e.reason()); printError(errorMessage); - return; + return 1; } if (response != null) { - printNormal(response.body().replace("\"", "").trim()); + getDefaultToolkit().getSystemClipboard().setContents( + new StringSelection(response.body().replace("\"", "").trim()), null); + return 0; } else { printError("An unexpected error occurred. Response from JustServe was null."); + return 1; } } } diff --git a/src/main/java/org/justserve/util/JustServePrinter.java b/src/main/java/org/justserve/cli/util/JustServePrinter.java similarity index 99% rename from src/main/java/org/justserve/util/JustServePrinter.java rename to src/main/java/org/justserve/cli/util/JustServePrinter.java index 6a01c62..643431d 100644 --- a/src/main/java/org/justserve/util/JustServePrinter.java +++ b/src/main/java/org/justserve/cli/util/JustServePrinter.java @@ -1,4 +1,4 @@ -package org.justserve.util; +package org.justserve.cli.util; import org.fusesource.jansi.Ansi; diff --git a/src/main/java/org/justserve/util/VersionProvider.java b/src/main/java/org/justserve/cli/util/JustServeVersionProvider.java similarity index 73% rename from src/main/java/org/justserve/util/VersionProvider.java rename to src/main/java/org/justserve/cli/util/JustServeVersionProvider.java index 082db41..f67d90a 100644 --- a/src/main/java/org/justserve/util/VersionProvider.java +++ b/src/main/java/org/justserve/cli/util/JustServeVersionProvider.java @@ -1,12 +1,12 @@ -package org.justserve.util; +package org.justserve.cli.util; import io.micronaut.context.annotation.Value; import picocli.CommandLine; -import static org.justserve.util.JustServePrinter.styleEmphasis; -import static org.justserve.util.JustServePrinter.styleTitle; +import static org.justserve.cli.util.JustServePrinter.styleEmphasis; +import static org.justserve.cli.util.JustServePrinter.styleTitle; -public class VersionProvider implements CommandLine.IVersionProvider { +public class JustServeVersionProvider implements CommandLine.IVersionProvider { @Value("${micronaut.application.version}") String justserveCliVersion; diff --git a/src/test/groovy/org/justserve/BaseCommandSpec.groovy b/src/test/groovy/org/justserve/BaseCommandSpec.groovy index 7d54604..0411ddd 100644 --- a/src/test/groovy/org/justserve/BaseCommandSpec.groovy +++ b/src/test/groovy/org/justserve/BaseCommandSpec.groovy @@ -2,6 +2,7 @@ package org.justserve import io.micronaut.configuration.picocli.PicocliRunner import io.micronaut.context.ApplicationContext +import org.justserve.cli.JustServeCommand import spock.lang.Shared import spock.lang.Unroll @@ -89,7 +90,7 @@ class BaseCommandSpec extends JustServeSpec { OutputStream err = new ByteArrayOutputStream() System.setOut(new PrintStream(out)) System.setErr(new PrintStream(err)) - PicocliRunner.run(BaseCommand.class, ctx, args) + PicocliRunner.run(JustServeCommand.class, ctx, args) return new String[]{out.toString(), err.toString()} } From fb4020a05fdf8af33dcfe128d02d9e6cfc0437c1 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Wed, 27 Aug 2025 23:15:09 -0600 Subject: [PATCH 3/4] refactor: remove unused imports --- .../org/justserve/cli/JustServeCommand.java | 17 +---------------- .../justserve/cli/command/GetTempPassword.java | 4 +--- .../groovy/org/justserve/BaseCommandSpec.groovy | 2 +- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/justserve/cli/JustServeCommand.java b/src/main/java/org/justserve/cli/JustServeCommand.java index e8ce128..b706104 100644 --- a/src/main/java/org/justserve/cli/JustServeCommand.java +++ b/src/main/java/org/justserve/cli/JustServeCommand.java @@ -1,30 +1,16 @@ package org.justserve.cli; -import io.micronaut.configuration.picocli.PicocliRunner; import io.micronaut.context.ApplicationContext; import io.micronaut.context.BeanContext; -import io.micronaut.context.annotation.Value; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.client.exceptions.HttpClientResponseException; -import jakarta.inject.Inject; -import jakarta.inject.Provider; import org.justserve.cli.command.BaseCommand; import org.justserve.cli.util.JustServeVersionProvider; -import org.justserve.client.UserClient; -import org.justserve.model.UserHashRequestByEmail; import picocli.CommandLine; import picocli.CommandLine.Command; -import picocli.CommandLine.Option; import picocli.CommandLine.ParameterException; -import picocli.jansi.graalvm.AnsiConsole; -import java.awt.datatransfer.StringSelection; import java.util.concurrent.Callable; import java.util.function.BiFunction; -import static java.awt.Toolkit.getDefaultToolkit; -import static org.justserve.cli.util.JustServePrinter.printError; - @Command(name = "justserve", versionProvider = JustServeVersionProvider.class, description = "justserve-cli is a terminal tool to help specialists and admin using JustServe") public class JustServeCommand extends BaseCommand implements Callable { @@ -81,10 +67,9 @@ private static CommandLine createCommandLine(BeanContext beanContext, boolean no * Computes a result, or throws an exception if unable to do so. * * @return computed result - * @throws Exception if unable to compute a result */ @Override - public Integer call() throws Exception { + public Integer call() { throw new ParameterException(spec.commandLine(), "No command specified"); } } diff --git a/src/main/java/org/justserve/cli/command/GetTempPassword.java b/src/main/java/org/justserve/cli/command/GetTempPassword.java index 753d393..adf6f42 100644 --- a/src/main/java/org/justserve/cli/command/GetTempPassword.java +++ b/src/main/java/org/justserve/cli/command/GetTempPassword.java @@ -8,7 +8,6 @@ import jakarta.inject.Provider; import org.justserve.client.UserClient; import org.justserve.model.UserHashRequestByEmail; -import picocli.CommandLine; import picocli.CommandLine.Command; import picocli.CommandLine.Option; @@ -36,10 +35,9 @@ public class GetTempPassword extends BaseCommand implements Callable { * Computes a result, or throws an exception if unable to do so. * * @return computed result - * @throws Exception if unable to compute a result */ @Override - public Integer call() throws Exception { + public Integer call() { HttpResponse response; if ("i-need-to-be-defined".equals(token) || null == token) { printError(("NO AUTHENTICATION PROVIDED" + System.lineSeparator() + diff --git a/src/test/groovy/org/justserve/BaseCommandSpec.groovy b/src/test/groovy/org/justserve/BaseCommandSpec.groovy index 0411ddd..af504a7 100644 --- a/src/test/groovy/org/justserve/BaseCommandSpec.groovy +++ b/src/test/groovy/org/justserve/BaseCommandSpec.groovy @@ -24,7 +24,7 @@ class BaseCommandSpec extends JustServeSpec { | |_ _ ___| |_| (___ ___ _ ____ _____ | | | | / __| __|\\___ \\ / _ \\ '__\\ \\ / / _ \\ ___| | |_| \\__ \\ |_ ____) | __/ | \\ V / __/ -|____/ \\__,_|___/\\__|_____/ \\___|_| \\_/ \\___|"""; +|____/ \\__,_|___/\\__|_____/ \\___|_| \\_/ \\___|""" cliVersion = Pattern.compile("^${ansi}${Pattern.quote(staticText)}${ansi}\\s*${ansi}" + Pattern.quote(props.getProperty('justserveCliVersion')) + "${ansi}\\s*\$") blankRegex = Pattern.compile "^\\s*\$" From dc8e0862acf05efa2a83f4ac1e502fa31004ea92 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Thu, 4 Sep 2025 14:21:07 -0600 Subject: [PATCH 4/4] refactor: make getTempPassword a subcommand --- build.gradle.kts | 2 +- .../org/justserve/cli/InteractiveShell.java | 79 ------------------- .../org/justserve/cli/JustServeCommand.java | 67 +++------------- .../org/justserve/cli/JustServeFactory.java | 39 --------- .../justserve/cli/command/BaseCommand.java | 10 +-- .../cli/command/GetTempPassword.java | 25 ++---- .../justserve/client/UserClientSpec.groovy | 2 +- .../{ => command}/BaseCommandSpec.groovy | 36 +-------- .../command/GetTempPasswordSpec.groovy | 33 ++++++++ 9 files changed, 60 insertions(+), 233 deletions(-) delete mode 100644 src/main/java/org/justserve/cli/InteractiveShell.java delete mode 100644 src/main/java/org/justserve/cli/JustServeFactory.java rename src/test/groovy/org/justserve/{ => command}/BaseCommandSpec.groovy (67%) create mode 100644 src/test/groovy/org/justserve/command/GetTempPasswordSpec.groovy diff --git a/build.gradle.kts b/build.gradle.kts index b099bb1..2b5f4b4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,7 +38,7 @@ dependencies { application { - mainClass = "org.justserve.BaseCommand" + mainClass = "org.justserve.cli.JustServeCommand" } java { diff --git a/src/main/java/org/justserve/cli/InteractiveShell.java b/src/main/java/org/justserve/cli/InteractiveShell.java deleted file mode 100644 index 5bac799..0000000 --- a/src/main/java/org/justserve/cli/InteractiveShell.java +++ /dev/null @@ -1,79 +0,0 @@ -package org.justserve.cli; - -import org.jline.reader.*; -import org.jline.reader.impl.DefaultParser; -import org.jline.terminal.Terminal; -import org.jline.terminal.TerminalBuilder; -import picocli.CommandLine; -import picocli.jansi.graalvm.AnsiConsole; -import picocli.shell.jline3.PicocliJLineCompleter; - -import java.util.function.BiFunction; -import java.util.function.Consumer; - -import static picocli.CommandLine.Help.Ansi.AUTO; - -public class InteractiveShell { - - private static final String DEFAULT_PROMPT = "@|blue JustServe>|@ "; - - private final CommandLine commandLine; - private final Consumer executor; - private final BiFunction onError; - private final String prompt; - - public InteractiveShell(CommandLine commandLine, - Consumer executor, - BiFunction onError) { - this(commandLine, executor, onError, DEFAULT_PROMPT); - } - - public InteractiveShell(CommandLine commandLine, - Consumer executor, - BiFunction onError, - String prompt) { - this.commandLine = commandLine; - this.executor = executor; - this.onError = onError; - this.prompt = prompt; - } - - public void start() { - try (AnsiConsole ignored = AnsiConsole.windowsInstall()) { - PicocliJLineCompleter picocliCommands = new PicocliJLineCompleter(commandLine.getCommandSpec()); - Terminal terminal = TerminalBuilder.terminal(); - LineReader reader = LineReaderBuilder.builder() - .terminal(terminal) - .completer(picocliCommands) - .parser(new DefaultParser()) - .variable(LineReader.LIST_MAX, 50) // max tab completion candidates - .build(); - - String ansiPrompt = AUTO.string(prompt); - String rightPrompt = null; - - // start the shell and process input until the user quits with Ctl-D - String line; - while (true) { - try { - line = reader.readLine(ansiPrompt, rightPrompt, (MaskingCallback) null, null); - if (line.matches("^\\s*#.*")) { - continue; - } - if ("exit".equals(line)) { - return; - } - ParsedLine pl = reader.getParser().parse(line, 0); - String[] arguments = pl.words().toArray(new String[0]); - executor.accept(arguments); - } catch (UserInterruptException | EndOfFileException e) { - return; - } - } - } catch (Throwable t) { - onError.apply(t, commandLine); - } finally { - AnsiConsole.systemUninstall(); - } - } -} diff --git a/src/main/java/org/justserve/cli/JustServeCommand.java b/src/main/java/org/justserve/cli/JustServeCommand.java index b706104..67a472f 100644 --- a/src/main/java/org/justserve/cli/JustServeCommand.java +++ b/src/main/java/org/justserve/cli/JustServeCommand.java @@ -1,75 +1,28 @@ package org.justserve.cli; -import io.micronaut.context.ApplicationContext; -import io.micronaut.context.BeanContext; +import io.micronaut.configuration.picocli.PicocliRunner; import org.justserve.cli.command.BaseCommand; +import org.justserve.cli.command.GetTempPassword; import org.justserve.cli.util.JustServeVersionProvider; -import picocli.CommandLine; import picocli.CommandLine.Command; import picocli.CommandLine.ParameterException; +import picocli.jansi.graalvm.AnsiConsole; -import java.util.concurrent.Callable; -import java.util.function.BiFunction; - -@Command(name = "justserve", versionProvider = JustServeVersionProvider.class, +@Command(subcommands = GetTempPassword.class, + mixinStandardHelpOptions = true, + name = "justserve", versionProvider = JustServeVersionProvider.class, description = "justserve-cli is a terminal tool to help specialists and admin using JustServe") -public class JustServeCommand extends BaseCommand implements Callable { - - private static Boolean interactiveShell = false; - - private static final BiFunction exHandler = (e, commandLine) -> { - BaseCommand command = commandLine.getCommand(); - command.err(e.getMessage()); - if (command.showStacktrace()) { - e.printStackTrace(commandLine.getErr()); - } - return 1; - }; - +public class JustServeCommand extends BaseCommand implements Runnable { public static void main(String[] args) { - //if the command is called with no args, enter the interactive shell - if (args.length == 0) { - CommandLine commandLine = createCommandLine(); - JustServeCommand.interactiveShell = true; - new InteractiveShell(commandLine, JustServeCommand::execute, exHandler).start(); - } else { - System.exit(execute(args)); + try (AnsiConsole ignored = AnsiConsole.windowsInstall()) { + PicocliRunner.run(JustServeCommand.class, args); } } - static int execute(String[] args) { - boolean noOpConsole = args.length > 0 && args[0].startsWith("update-cli-config"); - try (BeanContext beanContext = ApplicationContext.builder().deduceEnvironment(false).start()) { - return createCommandLine(beanContext, noOpConsole).execute(args); - } - } - - static CommandLine createCommandLine() { - boolean noOpConsole = JustServeCommand.interactiveShell; - try (BeanContext beanContext = ApplicationContext.builder().deduceEnvironment(false).start()) { - return createCommandLine(beanContext, noOpConsole); - } - } - - private static CommandLine createCommandLine(BeanContext beanContext, boolean noOpConsole) { - JustServeCommand starter = beanContext.getBean(JustServeCommand.class); - CommandLine commandLine = new CommandLine(starter, new JustServeFactory(beanContext)); - commandLine.setExecutionExceptionHandler( - (ex, thisCmd, parseResult) -> exHandler.apply(ex, thisCmd) - ); - commandLine.setUsageHelpWidth(100); - return commandLine; - } - - /** - * Computes a result, or throws an exception if unable to do so. - * - * @return computed result - */ @Override - public Integer call() { + public void run() { throw new ParameterException(spec.commandLine(), "No command specified"); } } diff --git a/src/main/java/org/justserve/cli/JustServeFactory.java b/src/main/java/org/justserve/cli/JustServeFactory.java deleted file mode 100644 index be70d85..0000000 --- a/src/main/java/org/justserve/cli/JustServeFactory.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.justserve.cli; - -import io.micronaut.context.ApplicationContext; -import io.micronaut.context.BeanContext; -import io.micronaut.core.annotation.TypeHint; -import picocli.CommandLine; -import picocli.CommandLine.IFactory; - -import java.util.Optional; - -import static io.micronaut.core.annotation.TypeHint.AccessType.ALL_DECLARED_CONSTRUCTORS; -import static io.micronaut.core.annotation.TypeHint.AccessType.ALL_DECLARED_FIELDS; - -/** - * Picocli factory implementation that uses a Micronaut BeanContext to obtain bean instances. - */ -@TypeHint(typeNames = { - "picocli.CommandLine$AutoHelpMixin", - "picocli.CommandLine$Model$CommandSpec" -}, accessType = {ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS}) -public class JustServeFactory implements IFactory { - - private final IFactory defaultFactory = CommandLine.defaultFactory(); - private final BeanContext beanContext; - - public JustServeFactory() { - this(ApplicationContext.run()); - } - - public JustServeFactory(BeanContext beanContext) { - this.beanContext = beanContext; - } - - @Override - public K create(Class cls) throws Exception { - Optional bean = beanContext.findOrInstantiateBean(cls); - return bean.isPresent() ? bean.get() : defaultFactory.create(cls); - } -} \ No newline at end of file diff --git a/src/main/java/org/justserve/cli/command/BaseCommand.java b/src/main/java/org/justserve/cli/command/BaseCommand.java index 3ff95ab..29da8f6 100644 --- a/src/main/java/org/justserve/cli/command/BaseCommand.java +++ b/src/main/java/org/justserve/cli/command/BaseCommand.java @@ -2,24 +2,22 @@ import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.ReflectiveAccess; -import picocli.CommandLine; +import picocli.CommandLine.Command; import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Spec; import java.io.PrintWriter; import java.util.Optional; import static picocli.CommandLine.Help.Ansi.AUTO; +@Command public class BaseCommand implements ConsoleOutput{ - @CommandLine.Spec + @Spec @ReflectiveAccess protected CommandSpec spec; - @CommandLine.Mixin - @ReflectiveAccess - protected CommonOptionsMixin commonOptions = new CommonOptionsMixin(); - @Override public void out(String message) { diff --git a/src/main/java/org/justserve/cli/command/GetTempPassword.java b/src/main/java/org/justserve/cli/command/GetTempPassword.java index adf6f42..399d28a 100644 --- a/src/main/java/org/justserve/cli/command/GetTempPassword.java +++ b/src/main/java/org/justserve/cli/command/GetTempPassword.java @@ -11,19 +11,16 @@ import picocli.CommandLine.Command; import picocli.CommandLine.Option; -import java.awt.datatransfer.StringSelection; -import java.util.concurrent.Callable; - -import static java.awt.Toolkit.getDefaultToolkit; import static org.justserve.cli.util.JustServePrinter.printError; +import static org.justserve.cli.util.JustServePrinter.printNormal; @Command(name = "getTempPassword", description = "get a temporary password for a user") -public class GetTempPassword extends BaseCommand implements Callable { +public class GetTempPassword extends BaseCommand implements Runnable { @ReflectiveAccess @Option(names = {"-e", "--email"}, description = "email for the user whose temporary password will be generated") String email; - + @Inject @ReflectiveAccess Provider userClientProvider; @@ -31,19 +28,14 @@ public class GetTempPassword extends BaseCommand implements Callable { @Value("${justserve.token}") String token; - /** - * Computes a result, or throws an exception if unable to do so. - * - * @return computed result - */ @Override - public Integer call() { + public void run() { HttpResponse response; if ("i-need-to-be-defined".equals(token) || null == token) { printError(("NO AUTHENTICATION PROVIDED" + System.lineSeparator() + "The Authentication token is not assigned as an environment variable." + System.lineSeparator() + "Please define the environment variable \"JUSTSERVE_TOKEN\" and try again.")); - return 1; + return; } try { UserClient userClient = userClientProvider.get(); @@ -52,15 +44,12 @@ public Integer call() { String errorMessage = "Received an unexpected response from JustServe:" + String.format("%n%d (%s)", e.getResponse().status().getCode(), e.reason()); printError(errorMessage); - return 1; + return; } if (response != null) { - getDefaultToolkit().getSystemClipboard().setContents( - new StringSelection(response.body().replace("\"", "").trim()), null); - return 0; + printNormal(response.body().replace("\"", "").trim()); } else { printError("An unexpected error occurred. Response from JustServe was null."); - return 1; } } } diff --git a/src/test/groovy/org/justserve/client/UserClientSpec.groovy b/src/test/groovy/org/justserve/client/UserClientSpec.groovy index f868993..5f0ffbb 100644 --- a/src/test/groovy/org/justserve/client/UserClientSpec.groovy +++ b/src/test/groovy/org/justserve/client/UserClientSpec.groovy @@ -19,7 +19,7 @@ class UserClientSpec extends JustServeSpec { userClient = ctx.getBean(UserClient) } - def "get tempPassword for #email and "() { + def "get tempPassword for #email"() { when: HttpResponse response = null def caughtException = null diff --git a/src/test/groovy/org/justserve/BaseCommandSpec.groovy b/src/test/groovy/org/justserve/command/BaseCommandSpec.groovy similarity index 67% rename from src/test/groovy/org/justserve/BaseCommandSpec.groovy rename to src/test/groovy/org/justserve/command/BaseCommandSpec.groovy index af504a7..200a627 100644 --- a/src/test/groovy/org/justserve/BaseCommandSpec.groovy +++ b/src/test/groovy/org/justserve/command/BaseCommandSpec.groovy @@ -1,7 +1,8 @@ -package org.justserve +package org.justserve.command import io.micronaut.configuration.picocli.PicocliRunner import io.micronaut.context.ApplicationContext +import org.justserve.JustServeSpec import org.justserve.cli.JustServeCommand import spock.lang.Shared import spock.lang.Unroll @@ -11,7 +12,7 @@ import java.util.regex.Pattern class BaseCommandSpec extends JustServeSpec { @Shared - Pattern cliVersion, ansiRegex, blankRegex, successRegex, errorRegex, tokenNotSetRegex + Pattern cliVersion, blankRegex, successRegex, errorRegex, tokenNotSetRegex def setupSpec() { def props = new Properties() @@ -35,31 +36,6 @@ class BaseCommandSpec extends JustServeSpec { "${ansi}Please define the environment variable \"JUSTSERVE_TOKEN\" and try again\\.${ansi}\\s*\$") } - @Unroll("command with args: calling 'justserve #flag #email' works as expected") - def "commands to query temporary password should behave as expected with or without authentication"() { - when: - def (outputStream, errorStream) = executeCommand(context as ApplicationContext, [flag, email] as String[]) - - then: - if (context == noAuthCtx) { - verifyAll { - outputStream.matches(blankRegex) - errorStream.matches(tokenNotSetRegex) - } - } else if (userEmail.equalsIgnoreCase(email as String)) { - verifyAll { - outputStream.matches(successRegex) - errorStream.matches(blankRegex) - } - } else { - verifyAll { - outputStream.matches(blankRegex) - errorStream.matches(errorRegex) - } - } - where: - [flag, email, context] << [['-e', '--email'], [userEmail, "notanemail@mail.moc"], [noAuthCtx, ctx]].combinations() - } @Unroll def "querying version returns current version, with or without authentication"() { @@ -73,7 +49,7 @@ class BaseCommandSpec extends JustServeSpec { } where: - [args, context] << [[['-v'], ['--version'], ['version']], [noAuthCtx, ctx]].combinations() + [args, context] << [[['-V'], ['--version']], [noAuthCtx, ctx]].combinations() } /** @@ -94,8 +70,4 @@ class BaseCommandSpec extends JustServeSpec { return new String[]{out.toString(), err.toString()} } - String stripColor(String string) { - return ansiRegex.matcher(string).replaceAll("") - } - } diff --git a/src/test/groovy/org/justserve/command/GetTempPasswordSpec.groovy b/src/test/groovy/org/justserve/command/GetTempPasswordSpec.groovy new file mode 100644 index 0000000..305d8da --- /dev/null +++ b/src/test/groovy/org/justserve/command/GetTempPasswordSpec.groovy @@ -0,0 +1,33 @@ +package org.justserve.command + +import io.micronaut.context.ApplicationContext +import spock.lang.Unroll + +class GetTempPasswordSpec extends BaseCommandSpec { + + @Unroll("command with args: calling 'justserve #flag #email' works as expected") + def "commands to query temporary password should behave as expected with or without authentication"() { + when: + def (outputStream, errorStream) = executeCommand(context as ApplicationContext, ["getTempPassword", flag, email] as String[]) + + then: + if (context == noAuthCtx) { + verifyAll { + outputStream.matches(blankRegex) + errorStream.matches(tokenNotSetRegex) + } + } else if (userEmail.equalsIgnoreCase(email as String)) { + verifyAll { + outputStream.matches(successRegex) + errorStream.matches(blankRegex) + } + } else { + verifyAll { + outputStream.matches(blankRegex) + errorStream.matches(errorRegex) + } + } + where: + [flag, email, context] << [['-e', '--email'], [userEmail, "notanemail@mail.moc"], [noAuthCtx, ctx]].combinations() + } +}