diff --git a/config/native/no-ldap.java.security b/config/native/no-ldap.java.security new file mode 100644 index 00000000..b45f29d1 --- /dev/null +++ b/config/native/no-ldap.java.security @@ -0,0 +1,16 @@ +security.provider.1=SUN +security.provider.2=SunRsaSign +security.provider.3=SunEC +security.provider.4=SunJSSE +security.provider.5=SunJCE +security.provider.6=SunJGSS +security.provider.7=SunSASL +security.provider.8=XMLDSig +security.provider.9=SunPCSC +security.provider.10=JdkSASL +security.provider.11=SunPKCS11 +keystore.type=pkcs12 +ssl.KeyManagerFactory.algorithm=SunX509 +ssl.TrustManagerFactory.algorithm=PKIX +securerandom.source=file:/dev/random +securerandom.strongAlgorithms=NativePRNGBlocking:SUN,DRBG:SUN diff --git a/pom.xml b/pom.xml index b4c0ecb8..ed784792 100644 --- a/pom.xml +++ b/pom.xml @@ -157,7 +157,6 @@ src/main/resources true - version.properties META-INF/native-image/dev.ayagmar/quarkusforge/resource-config.json @@ -165,7 +164,6 @@ src/main/resources false - version.properties META-INF/native-image/dev.ayagmar/quarkusforge/resource-config.json @@ -207,6 +205,8 @@ -Aproject=${project.groupId}/${project.artifactId} + -Adisable.reflect.config + -Adisable.resource.config -Adisable.proxy.config @@ -424,7 +424,7 @@ -Os --emit build-report -H:+ReportExceptionStackTraces - -H:+SharedArenaSupport + -Djava.security.properties==${project.basedir}/config/native/no-ldap.java.security --enable-url-protocols=http,https ${native.access.flag} @@ -434,6 +434,30 @@ + + unix-native-jline-trim + + + unix + + + + + + org.graalvm.buildtools + native-maven-plugin + + + --exclude-config + jline-.*\.jar + META-INF/native-image/org\.jline/(jansi|jline-native|jline-terminal-jansi|jline-terminal-jna|jline-terminal-jni)/.* + + + + + + + headless diff --git a/src/main/java/dev/ayagmar/quarkusforge/cli/CliCommandLineFactory.java b/src/main/java/dev/ayagmar/quarkusforge/cli/CliCommandLineFactory.java new file mode 100644 index 00000000..9049626a --- /dev/null +++ b/src/main/java/dev/ayagmar/quarkusforge/cli/CliCommandLineFactory.java @@ -0,0 +1,44 @@ +package dev.ayagmar.quarkusforge.cli; + +import picocli.CommandLine; + +final class CliCommandLineFactory { + private CliCommandLineFactory() {} + + static CommandLine create(T command) { + CommandLine commandLine = new CommandLine(command); + addRequestOptionsMixin(commandLine, command); + + GenerateCommand generateCommand = new GenerateCommand(command); + CommandLine generateCommandLine = new CommandLine(generateCommand); + generateCommandLine.addMixin("requestOptions", generateCommand.requestOptions()); + commandLine.addSubcommand("generate", generateCommandLine); + + commandLine.getCommandSpec().version(CliVersionProvider.resolveVersion()); + commandLine.setExecutionStrategy( + parseResult -> { + recordMatchedRequestOptions(parseResult); + return new CommandLine.RunLast().execute(parseResult); + }); + return commandLine; + } + + private static void addRequestOptionsMixin(CommandLine commandLine, HeadlessRunner command) { + if (command instanceof QuarkusForgeCli quarkusForgeCli) { + commandLine.addMixin("requestOptions", quarkusForgeCli.requestOptions()); + } + } + + private static void recordMatchedRequestOptions(CommandLine.ParseResult parseResult) { + for (CommandLine.ParseResult current = parseResult; + current != null; + current = current.subcommand()) { + Object userObject = current.commandSpec().userObject(); + if (userObject instanceof QuarkusForgeCli quarkusForgeCli) { + quarkusForgeCli.requestOptions().recordMatchedOptions(current); + } else if (userObject instanceof GenerateCommand generateCommand) { + generateCommand.requestOptions().recordMatchedOptions(current); + } + } + } +} diff --git a/src/main/java/dev/ayagmar/quarkusforge/cli/CliVersionProvider.java b/src/main/java/dev/ayagmar/quarkusforge/cli/CliVersionProvider.java index d31aad89..0522e17a 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/cli/CliVersionProvider.java +++ b/src/main/java/dev/ayagmar/quarkusforge/cli/CliVersionProvider.java @@ -1,47 +1,18 @@ package dev.ayagmar.quarkusforge.cli; -import java.io.IOException; -import java.io.InputStream; import java.util.Objects; -import java.util.Properties; -import picocli.CommandLine; -final class CliVersionProvider implements CommandLine.IVersionProvider { - private static final String VERSION_RESOURCE = "version.properties"; - private static final String VERSION_KEY = "version"; +final class CliVersionProvider { private static final String UNKNOWN_VERSION = "unknown"; - @Override - public String[] getVersion() { - return new String[] {resolveVersion()}; - } - static String resolveVersion() { String implementationVersion = CliVersionProvider.class.getPackage().getImplementationVersion(); if (isUsableVersion(implementationVersion)) { return implementationVersion.strip(); } - String resourceVersion = readVersionFromResource(); - if (isUsableVersion(resourceVersion)) { - return resourceVersion.strip(); - } return UNKNOWN_VERSION; } - private static String readVersionFromResource() { - try (InputStream inputStream = - CliVersionProvider.class.getClassLoader().getResourceAsStream(VERSION_RESOURCE)) { - if (inputStream == null) { - return null; - } - Properties properties = new Properties(); - properties.load(inputStream); - return properties.getProperty(VERSION_KEY); - } catch (IOException ignored) { - return null; - } - } - private static boolean isUsableVersion(String value) { return value != null && !value.isBlank() && !Objects.equals(value.strip(), UNKNOWN_VERSION); } diff --git a/src/main/java/dev/ayagmar/quarkusforge/cli/GenerateCommand.java b/src/main/java/dev/ayagmar/quarkusforge/cli/GenerateCommand.java index fb92c6ce..e5331fe6 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/cli/GenerateCommand.java +++ b/src/main/java/dev/ayagmar/quarkusforge/cli/GenerateCommand.java @@ -4,20 +4,19 @@ import dev.ayagmar.quarkusforge.forge.Forgefile; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.concurrent.Callable; import picocli.CommandLine.Command; -import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; -import picocli.CommandLine.ParentCommand; @Command( name = "generate", mixinStandardHelpOptions = true, description = "Generate a Quarkus project without starting the TUI") public final class GenerateCommand implements Callable { - @ParentCommand private HeadlessRunner rootCommand; + private final HeadlessRunner rootCommand; - @Mixin private RequestOptions requestOptions = new RequestOptions(); + private RequestOptions requestOptions = new RequestOptions(); @Option( names = {"-e", "--extension"}, @@ -64,6 +63,17 @@ public final class GenerateCommand implements Callable { description = "Validate full generation request without writing files") private boolean dryRun; + public GenerateCommand() { + this( + command -> { + throw new IllegalStateException("Generate command runner not configured"); + }); + } + + GenerateCommand(HeadlessRunner rootCommand) { + this.rootCommand = Objects.requireNonNull(rootCommand); + } + @Override public Integer call() { return rootCommand.runHeadlessGenerate(this); @@ -84,6 +94,10 @@ public void setCliPrefill(CliPrefill prefill) { requestOptions = RequestOptions.explicitFromCliPrefill(prefill); } + RequestOptions requestOptions() { + return requestOptions; + } + public List extensions() { return extensions; } diff --git a/src/main/java/dev/ayagmar/quarkusforge/cli/HeadlessCli.java b/src/main/java/dev/ayagmar/quarkusforge/cli/HeadlessCli.java index 7d84bece..62584896 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/cli/HeadlessCli.java +++ b/src/main/java/dev/ayagmar/quarkusforge/cli/HeadlessCli.java @@ -5,22 +5,16 @@ import dev.ayagmar.quarkusforge.runtime.RuntimeServices; import java.util.Objects; import java.util.concurrent.Callable; -import picocli.CommandLine; import picocli.CommandLine.Command; import picocli.CommandLine.Option; -import picocli.CommandLine.Spec; @Command( name = "quarkus-forge", mixinStandardHelpOptions = true, - versionProvider = CliVersionProvider.class, - subcommands = {GenerateCommand.class}, description = "Quarkus forge headless CLI") public final class HeadlessCli implements Callable, HeadlessRunner { private final RuntimeConfig runtimeConfig; - @Spec CommandLine.Model.CommandSpec spec; - @Option( names = "--verbose", defaultValue = "false", @@ -43,7 +37,7 @@ public final class HeadlessCli implements Callable, HeadlessRunner { @Override public Integer call() { - spec.commandLine().usage(System.out); + CliCommandLineFactory.create(this).usage(System.out); return ExitCodes.OK; } @@ -60,7 +54,7 @@ public static int runWithArgs(String[] args) { } static int runWithArgs(String[] args, RuntimeConfig runtimeConfig) { - return new CommandLine(new HeadlessCli(runtimeConfig)).execute(args); + return CliCommandLineFactory.create(new HeadlessCli(runtimeConfig)).execute(args); } public static void main(String[] args) { diff --git a/src/main/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCli.java b/src/main/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCli.java index 1c4d4bda..a39ea5f2 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCli.java +++ b/src/main/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCli.java @@ -23,22 +23,19 @@ import java.util.Objects; import java.util.concurrent.Callable; import java.util.function.Function; -import picocli.CommandLine; import picocli.CommandLine.Command; -import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; @Command( name = "quarkus-forge", - versionProvider = CliVersionProvider.class, - subcommands = {GenerateCommand.class}, + mixinStandardHelpOptions = true, description = "Quarkus forge terminal UI") public final class QuarkusForgeCli implements Callable, HeadlessRunner { private static final PostTuiActionExecutor POST_TUI_ACTION_EXECUTOR = new PostTuiActionExecutor(); private static final TuiBootstrapService TUI_BOOTSTRAP_SERVICE = new TuiBootstrapService(); - @Mixin private RequestOptions requestOptions = new RequestOptions(); + private RequestOptions requestOptions = new RequestOptions(); private final RuntimeConfig runtimeConfig; private final Function apiClientFactory; @@ -65,18 +62,6 @@ public final class QuarkusForgeCli implements Callable, HeadlessRunner @Option(names = "--interactive-smoke-test", hidden = true, defaultValue = "false") private boolean interactiveSmokeTest; - @Option( - names = {"-h", "--help"}, - usageHelp = true, - description = "Show this help message and exit.") - private boolean helpRequested; - - @Option( - names = {"-V", "--version"}, - versionHelp = true, - description = "Print version information and exit.") - private boolean versionRequested; - @Option( names = "--post-generate-hook", defaultValue = "", @@ -158,7 +143,7 @@ public static int runWithArgs(String[] args) { } static int runWithArgs(String[] args, RuntimeConfig runtimeConfig) { - return new CommandLine(new QuarkusForgeCli(runtimeConfig)).execute(args); + return CliCommandLineFactory.create(new QuarkusForgeCli(runtimeConfig)).execute(args); } public static void main(String[] args) { diff --git a/src/main/java/dev/ayagmar/quarkusforge/cli/RequestOptions.java b/src/main/java/dev/ayagmar/quarkusforge/cli/RequestOptions.java index 9f2bd57a..4da1288e 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/cli/RequestOptions.java +++ b/src/main/java/dev/ayagmar/quarkusforge/cli/RequestOptions.java @@ -6,18 +6,12 @@ import java.util.HashSet; import java.util.Objects; import java.util.Set; -import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Option; -import picocli.CommandLine.Spec; +import picocli.CommandLine.ParseResult; final class RequestOptions { private final Set prefilledOptions = new HashSet<>(); - - /** - * Injected by Picocli after parsing; null when the object is constructed manually (e.g. in tests - * or when calling {@link #defaults()}). - */ - @Spec CommandSpec spec; + private final Set matchedOptions = new HashSet<>(); static final String DEFAULT_GROUP_ID = ProjectInputDefaults.GROUP_ID; static final String DEFAULT_ARTIFACT_ID = ProjectInputDefaults.ARTIFACT_ID; @@ -181,15 +175,16 @@ private static RequestOptions fromCliPrefill(CliPrefill prefill, boolean markPre /** * Returns {@code true} when the user explicitly supplied the option on the command line. Falls - * back to value-equality detection when {@link #spec} is not available (tests, defaults()). + * back to value-equality detection when no parse result has been recorded yet (tests, + * defaults()). * * @param optionName canonical option name, e.g. {@code "--group-id"} * @param currentValue the current field value * @param defaultValue the compiled-in default value */ boolean isExplicitlySet(String optionName, String currentValue, String defaultValue) { - if (spec != null && spec.commandLine() != null && spec.commandLine().getParseResult() != null) { - return spec.commandLine().getParseResult().hasMatchedOption(optionName); + if (matchedOptions.contains(optionName)) { + return true; } if (prefilledOptions.contains(optionName)) { return false; @@ -205,6 +200,16 @@ private String explicitValue(String optionName, String currentValue, String defa return currentValue; } + void recordMatchedOptions(ParseResult parseResult) { + matchedOptions.clear(); + if (parseResult == null) { + return; + } + parseResult + .matchedOptions() + .forEach(optionSpec -> matchedOptions.add(optionSpec.longestName())); + } + private String applyCliValue( String optionName, String cliValue, String fallbackValue, boolean markPrefilled) { if (cliValue == null || cliValue.isBlank()) { diff --git a/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessGenerationService.java b/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessGenerationService.java index c282d22b..23268143 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessGenerationService.java +++ b/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessGenerationService.java @@ -5,13 +5,10 @@ import dev.ayagmar.quarkusforge.api.CatalogData; import dev.ayagmar.quarkusforge.api.CatalogDataService; import dev.ayagmar.quarkusforge.api.QuarkusApiClient; -import dev.ayagmar.quarkusforge.application.InputResolutionService; import dev.ayagmar.quarkusforge.archive.ProjectArchiveService; import dev.ayagmar.quarkusforge.cli.ExitCodes; import dev.ayagmar.quarkusforge.cli.GenerateCommand; import dev.ayagmar.quarkusforge.diagnostics.DiagnosticLogger; -import dev.ayagmar.quarkusforge.domain.ForgeUiState; -import dev.ayagmar.quarkusforge.domain.MetadataCompatibilityContext; import dev.ayagmar.quarkusforge.domain.ProjectRequest; import dev.ayagmar.quarkusforge.domain.ValidationReport; import dev.ayagmar.quarkusforge.persistence.ExtensionFavoritesStore; @@ -24,8 +21,8 @@ public final class HeadlessGenerationService implements AutoCloseable { private final HeadlessCatalogLoader catalogLoader; - private final HeadlessExtensionResolutionService extensionResolutionService; private final HeadlessForgefilePersistenceService forgefilePersistenceService; + private final HeadlessRequestPlanner requestPlanner; private final HeadlessGenerationExecutionService generationExecutionService; private final AutoCloseable closeOwner; @@ -64,8 +61,10 @@ private HeadlessGenerationService( HeadlessGenerationExecutionService generationExecutionService, AutoCloseable closeOwner) { this.catalogLoader = Objects.requireNonNull(catalogLoader); - this.extensionResolutionService = Objects.requireNonNull(extensionResolutionService); this.forgefilePersistenceService = Objects.requireNonNull(forgefilePersistenceService); + this.requestPlanner = + new HeadlessRequestPlanner( + Objects.requireNonNull(extensionResolutionService), forgefilePersistenceService); this.generationExecutionService = Objects.requireNonNull(generationExecutionService); this.closeOwner = Objects.requireNonNull(closeOwner); } @@ -116,28 +115,9 @@ public int run(GenerateCommand command, boolean globalDryRun, boolean verbose) { diagnostics); } - ForgeUiState validatedState = resolveValidatedState(inputs, catalogData); - if (!validatedState.canSubmit()) { - return validationFailure( - validatedState.validation(), - catalogData.sourceLabel(), - catalogData.detailMessage(), - diagnostics); - } - - List extensionIds; + HeadlessRequestPlanner.HeadlessGenerationPlan plan; try { - extensionIds = - extensionResolutionService.resolveExtensionIds( - validatedState.request().platformStream(), - inputs.extensionInputs(), - inputs.presetInputs(), - HeadlessExtensionResolutionService.knownExtensionIds(catalogData), - catalogTimeout); - if (inputs.lockCheck()) { - forgefilePersistenceService.validateLockDrift( - inputs, validatedState.request(), extensionIds); - } + plan = requestPlanner.plan(inputs, catalogData, catalogTimeout); } catch (ValidationException validationException) { return extensionValidationFailure(validationException, catalogData, diagnostics); } catch (Exception exception) { @@ -146,9 +126,9 @@ public int run(GenerateCommand command, boolean globalDryRun, boolean verbose) { } if (command.dryRun() || globalDryRun) { - return handleDryRun(inputs, validatedState.request(), extensionIds, catalogData, diagnostics); + return handleDryRun(inputs, plan.request(), plan.extensionIds(), catalogData, diagnostics); } - return executeGeneration(inputs, validatedState.request(), extensionIds, diagnostics); + return executeGeneration(inputs, plan.request(), plan.extensionIds(), diagnostics); } private CatalogData loadCatalogData(Duration catalogTimeout, DiagnosticLogger diagnostics) @@ -162,23 +142,6 @@ private CatalogData loadCatalogData(Duration catalogTimeout, DiagnosticLogger di return catalogData; } - private static ForgeUiState resolveValidatedState( - HeadlessGenerationInputs inputs, CatalogData catalogData) { - MetadataCompatibilityContext metadataCompatibility = - MetadataCompatibilityContext.success(catalogData.metadata()); - return InputResolutionService.resolveInitialState(inputs.template(), metadataCompatibility); - } - - private static int validationFailure( - ValidationReport validation, - String sourceLabel, - String detailMessage, - DiagnosticLogger diagnostics) { - diagnostics.error("generate.validation.failed", of("errorCount", validation.errors().size())); - HeadlessOutputPrinter.printValidationErrors(validation, sourceLabel, detailMessage); - return ExitCodes.VALIDATION; - } - private static int extensionValidationFailure( ValidationException validationException, CatalogData catalogData, diff --git a/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessRequestPlanner.java b/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessRequestPlanner.java new file mode 100644 index 00000000..e91c13b0 --- /dev/null +++ b/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessRequestPlanner.java @@ -0,0 +1,57 @@ +package dev.ayagmar.quarkusforge.headless; + +import dev.ayagmar.quarkusforge.api.CatalogData; +import dev.ayagmar.quarkusforge.application.InputResolutionService; +import dev.ayagmar.quarkusforge.domain.ForgeUiState; +import dev.ayagmar.quarkusforge.domain.MetadataCompatibilityContext; +import dev.ayagmar.quarkusforge.domain.ProjectRequest; +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +final class HeadlessRequestPlanner { + private final HeadlessExtensionResolutionService extensionResolutionService; + private final HeadlessForgefilePersistenceService forgefilePersistenceService; + + HeadlessRequestPlanner( + HeadlessExtensionResolutionService extensionResolutionService, + HeadlessForgefilePersistenceService forgefilePersistenceService) { + this.extensionResolutionService = Objects.requireNonNull(extensionResolutionService); + this.forgefilePersistenceService = Objects.requireNonNull(forgefilePersistenceService); + } + + HeadlessGenerationPlan plan( + HeadlessGenerationInputs inputs, CatalogData catalogData, Duration catalogTimeout) + throws Exception { + ForgeUiState validatedState = resolveValidatedState(inputs, catalogData); + if (!validatedState.canSubmit()) { + throw new ValidationException(validatedState.validation().errors()); + } + + List extensionIds = + extensionResolutionService.resolveExtensionIds( + validatedState.request().platformStream(), + inputs.extensionInputs(), + inputs.presetInputs(), + HeadlessExtensionResolutionService.knownExtensionIds(catalogData), + catalogTimeout); + if (inputs.lockCheck()) { + forgefilePersistenceService.validateLockDrift(inputs, validatedState.request(), extensionIds); + } + return new HeadlessGenerationPlan(validatedState.request(), extensionIds); + } + + private static ForgeUiState resolveValidatedState( + HeadlessGenerationInputs inputs, CatalogData catalogData) { + MetadataCompatibilityContext metadataCompatibility = + MetadataCompatibilityContext.success(catalogData.metadata()); + return InputResolutionService.resolveInitialState(inputs.template(), metadataCompatibility); + } + + record HeadlessGenerationPlan(ProjectRequest request, List extensionIds) { + HeadlessGenerationPlan { + Objects.requireNonNull(request); + extensionIds = List.copyOf(extensionIds); + } + } +} diff --git a/src/main/java/dev/ayagmar/quarkusforge/runtime/LoopbackHosts.java b/src/main/java/dev/ayagmar/quarkusforge/runtime/LoopbackHosts.java new file mode 100644 index 00000000..085a28cc --- /dev/null +++ b/src/main/java/dev/ayagmar/quarkusforge/runtime/LoopbackHosts.java @@ -0,0 +1,61 @@ +package dev.ayagmar.quarkusforge.runtime; + +import java.net.InetAddress; +import java.util.List; +import java.util.Locale; + +final class LoopbackHosts { + private LoopbackHosts() {} + + static boolean isLoopbackHost(String host) { + String normalizedHost = host.toLowerCase(Locale.ROOT); + if (normalizedHost.startsWith("[") && normalizedHost.endsWith("]")) { + normalizedHost = normalizedHost.substring(1, normalizedHost.length() - 1); + } + return normalizedHost.equals("localhost") + || normalizedHost.equals("::1") + || isIpv4LoopbackHost(normalizedHost) + || resolvesToLoopbackAddress(normalizedHost); + } + + static boolean isIpv4LoopbackHost(String host) { + List octets = List.of(host.split("\\.", -1)); + if (octets.size() != 4 || !octets.getFirst().equals("127")) { + return false; + } + for (int index = 1; index < octets.size(); index++) { + if (!isIpv4Octet(octets.get(index))) { + return false; + } + } + return true; + } + + static boolean isIpv4Octet(String value) { + if (value.isEmpty()) { + return false; + } + for (int index = 0; index < value.length(); index++) { + if (!Character.isDigit(value.charAt(index))) { + return false; + } + } + try { + int octet = Integer.parseInt(value); + return octet >= 0 && octet <= 255; + } catch (NumberFormatException ignored) { + return false; + } + } + + private static boolean resolvesToLoopbackAddress(String host) { + if (!host.contains(":")) { + return false; + } + try { + return InetAddress.getByName(host).isLoopbackAddress(); + } catch (RuntimeException | java.net.UnknownHostException ignored) { + return false; + } + } +} diff --git a/src/main/java/dev/ayagmar/quarkusforge/runtime/RuntimeConfig.java b/src/main/java/dev/ayagmar/quarkusforge/runtime/RuntimeConfig.java index 57924a3c..e47a8ac3 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/runtime/RuntimeConfig.java +++ b/src/main/java/dev/ayagmar/quarkusforge/runtime/RuntimeConfig.java @@ -2,7 +2,6 @@ import dev.ayagmar.quarkusforge.api.CatalogSnapshotCache; import dev.ayagmar.quarkusforge.api.ForgeDataPaths; -import java.net.InetAddress; import java.net.URI; import java.nio.file.Path; import java.util.Locale; @@ -57,62 +56,10 @@ private static URI validatedApiBaseUri(URI apiBaseUri) { if (host == null || host.isBlank()) { throw new IllegalArgumentException("apiBaseUri must include a host"); } - if (normalizedScheme.equals("http") && !isLoopbackHost(host)) { + if (normalizedScheme.equals("http") && !LoopbackHosts.isLoopbackHost(host)) { throw new IllegalArgumentException( "apiBaseUri must use https unless it targets localhost or a loopback address"); } return apiBaseUri.normalize(); } - - private static boolean isLoopbackHost(String host) { - String normalizedHost = host.toLowerCase(Locale.ROOT); - if (normalizedHost.startsWith("[") && normalizedHost.endsWith("]")) { - normalizedHost = normalizedHost.substring(1, normalizedHost.length() - 1); - } - return normalizedHost.equals("localhost") - || normalizedHost.equals("::1") - || isIpv4LoopbackHost(normalizedHost) - || resolvesToLoopbackAddress(normalizedHost); - } - - private static boolean isIpv4LoopbackHost(String host) { - String[] octets = host.split("\\.", -1); - if (octets.length != 4 || !octets[0].equals("127")) { - return false; - } - for (int index = 1; index < octets.length; index++) { - if (!isIpv4Octet(octets[index])) { - return false; - } - } - return true; - } - - private static boolean resolvesToLoopbackAddress(String host) { - if (!host.contains(":")) { - return false; - } - try { - return InetAddress.getByName(host).isLoopbackAddress(); - } catch (RuntimeException | java.net.UnknownHostException ignored) { - return false; - } - } - - private static boolean isIpv4Octet(String value) { - if (value.isEmpty()) { - return false; - } - for (int index = 0; index < value.length(); index++) { - if (!Character.isDigit(value.charAt(index))) { - return false; - } - } - try { - int octet = Integer.parseInt(value); - return octet >= 0 && octet <= 255; - } catch (NumberFormatException ignored) { - return false; - } - } } diff --git a/src/main/java/dev/ayagmar/quarkusforge/runtime/TerminalBackendPreference.java b/src/main/java/dev/ayagmar/quarkusforge/runtime/TerminalBackendPreference.java new file mode 100644 index 00000000..57d36d6c --- /dev/null +++ b/src/main/java/dev/ayagmar/quarkusforge/runtime/TerminalBackendPreference.java @@ -0,0 +1,60 @@ +package dev.ayagmar.quarkusforge.runtime; + +import java.util.Locale; + +final class TerminalBackendPreference { + private static final String BACKEND_PROPERTY_NAME = "tamboui.backend"; + private static final String BACKEND_ENV_NAME = "TAMBOUI_BACKEND"; + private static final String PANAMA_BACKEND = "panama"; + private static final String JLINE3_BACKEND = "jline3"; + + private TerminalBackendPreference() {} + + static String defaultBackendPreference() { + return defaultBackendPreference(System.getProperty("os.name", "")); + } + + static void configure() { + configure( + System.getProperty(BACKEND_PROPERTY_NAME), + System.getenv(BACKEND_ENV_NAME), + System.getProperty("os.name", "")); + } + + static void configure(String propertyValue, String envValue, String osName) { + if (isExplicitlyConfigured(propertyValue, envValue)) { + return; + } + System.setProperty(BACKEND_PROPERTY_NAME, defaultBackendPreference(osName)); + } + + static boolean isExplicitlyConfigured(String propertyValue, String envValue) { + if (propertyValue != null && !propertyValue.isBlank()) { + return true; + } + return envValue != null && !envValue.isBlank(); + } + + static String defaultBackendPreference(String osName) { + if (isWindowsOsName(osName)) { + return JLINE3_BACKEND; + } + return PANAMA_BACKEND; + } + + static void restore(String previousBackendPreference) { + if (previousBackendPreference == null) { + System.clearProperty(BACKEND_PROPERTY_NAME); + return; + } + System.setProperty(BACKEND_PROPERTY_NAME, previousBackendPreference); + } + + private static boolean isWindowsOsName(String osName) { + if (osName == null) { + return false; + } + String normalized = osName.strip().toLowerCase(Locale.ROOT); + return normalized.startsWith("windows"); + } +} diff --git a/src/main/java/dev/ayagmar/quarkusforge/runtime/TuiBootstrapService.java b/src/main/java/dev/ayagmar/quarkusforge/runtime/TuiBootstrapService.java index ba6e83d0..9bc21797 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/runtime/TuiBootstrapService.java +++ b/src/main/java/dev/ayagmar/quarkusforge/runtime/TuiBootstrapService.java @@ -22,7 +22,6 @@ import dev.tamboui.tui.bindings.Bindings; import java.time.Duration; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; @@ -30,9 +29,6 @@ public final class TuiBootstrapService { private static final String BACKEND_PROPERTY_NAME = "tamboui.backend"; - private static final String BACKEND_ENV_NAME = "TAMBOUI_BACKEND"; - private static final String PANAMA_BACKEND = "panama"; - private static final String JLINE3_BACKEND = "jline3"; private static final ReentrantLock BACKEND_PROPERTY_LOCK = new ReentrantLock(); public static final Duration STARTUP_SPLASH_MIN_DURATION = Duration.ofMillis(450); private static final Duration TUI_TICK_RATE = Duration.ofMillis(40); @@ -62,45 +58,7 @@ public static TuiConfig appTuiConfig() { } public static String defaultBackendPreference() { - return defaultBackendPreference(System.getProperty("os.name", "")); - } - - private static void configureTerminalBackendPreference() { - configureTerminalBackendPreference( - System.getProperty(BACKEND_PROPERTY_NAME), - System.getenv(BACKEND_ENV_NAME), - System.getProperty("os.name", "")); - } - - private static void configureTerminalBackendPreference( - String propertyValue, String envValue, String osName) { - if (isBackendPreferenceExplicitlyConfigured(propertyValue, envValue)) { - return; - } - System.setProperty(BACKEND_PROPERTY_NAME, defaultBackendPreference(osName)); - } - - private static boolean isBackendPreferenceExplicitlyConfigured( - String propertyValue, String envValue) { - if (propertyValue != null && !propertyValue.isBlank()) { - return true; - } - return envValue != null && !envValue.isBlank(); - } - - private static String defaultBackendPreference(String osName) { - if (isWindowsOsName(osName)) { - return JLINE3_BACKEND; - } - return PANAMA_BACKEND; - } - - private static boolean isWindowsOsName(String osName) { - if (osName == null) { - return false; - } - String normalized = osName.strip().toLowerCase(Locale.ROOT); - return normalized.startsWith("windows"); + return TerminalBackendPreference.defaultBackendPreference(); } public static void runHeadlessSmoke(RuntimeConfig runtimeConfig, DiagnosticLogger diagnostics) { @@ -122,7 +80,7 @@ public void runInteractiveSmoke( BACKEND_PROPERTY_LOCK.lock(); String previousBackendPreference = System.getProperty(BACKEND_PROPERTY_NAME); try { - configureTerminalBackendPreference(); + TerminalBackendPreference.configure(); TuiConfig tuiConfig = appTuiConfig(); try (var tui = TuiRunner.create(tuiConfig); RuntimeServices runtimeServices = RuntimeServices.open(runtimeConfig)) { @@ -159,7 +117,7 @@ public void runInteractiveSmoke( } } finally { try { - restoreTerminalBackendPreference(previousBackendPreference); + TerminalBackendPreference.restore(previousBackendPreference); } finally { BACKEND_PROPERTY_LOCK.unlock(); } @@ -179,7 +137,7 @@ public TuiSessionSummary run( BACKEND_PROPERTY_LOCK.lock(); String previousBackendPreference = System.getProperty(BACKEND_PROPERTY_NAME); try { - configureTerminalBackendPreference(); + TerminalBackendPreference.configure(); TuiConfig tuiConfig = appTuiConfig(); try (var tui = TuiRunner.create(tuiConfig); RuntimeServices runtimeServices = RuntimeServices.open(runtimeConfig)) { @@ -224,7 +182,7 @@ public TuiSessionSummary run( } } finally { try { - restoreTerminalBackendPreference(previousBackendPreference); + TerminalBackendPreference.restore(previousBackendPreference); } finally { BACKEND_PROPERTY_LOCK.unlock(); } @@ -345,14 +303,6 @@ void runInteractiveSmokeSession( sessionLoop); } - private static void restoreTerminalBackendPreference(String previousBackendPreference) { - if (previousBackendPreference == null) { - System.clearProperty(BACKEND_PROPERTY_NAME); - return; - } - System.setProperty(BACKEND_PROPERTY_NAME, previousBackendPreference); - } - private static String resolvePresetStreamKey( String currentStreamKey, dev.ayagmar.quarkusforge.api.MetadataDto metadata) { if (currentStreamKey != null && !currentStreamKey.isBlank()) { diff --git a/src/main/resources/META-INF/native-image/dev.ayagmar/quarkusforge/reflect-config.json b/src/main/resources/META-INF/native-image/dev.ayagmar/quarkusforge/reflect-config.json new file mode 100644 index 00000000..f294f9ea --- /dev/null +++ b/src/main/resources/META-INF/native-image/dev.ayagmar/quarkusforge/reflect-config.json @@ -0,0 +1,52 @@ +[ + { + "name": "dev.ayagmar.quarkusforge.cli.GenerateCommand", + "fields": [ + { "name": "dryRun" }, + { "name": "extensions" }, + { "name": "fromFile" }, + { "name": "lock" }, + { "name": "lockCheck" }, + { "name": "presets" }, + { "name": "saveAsFile" } + ] + }, + { + "name": "dev.ayagmar.quarkusforge.cli.HeadlessCli", + "fields": [ + { "name": "dryRun" }, + { "name": "verbose" } + ] + }, + { + "name": "dev.ayagmar.quarkusforge.cli.QuarkusForgeCli", + "fields": [ + { "name": "dryRun" }, + { "name": "interactiveSmokeTest" }, + { "name": "postGenerateHookCommand" }, + { "name": "searchDebounceMs" }, + { "name": "verbose" } + ] + }, + { + "name": "dev.ayagmar.quarkusforge.cli.RequestOptions", + "fields": [ + { "name": "artifactId" }, + { "name": "buildTool" }, + { "name": "groupId" }, + { "name": "javaVersion" }, + { "name": "outputDirectory" }, + { "name": "packageName" }, + { "name": "platformStream" }, + { "name": "version" } + ] + }, + { + "name": "picocli.CommandLine$AutoHelpMixin", + "allDeclaredConstructors": true, + "fields": [ + { "name": "helpRequested" }, + { "name": "versionRequested" } + ] + } +] diff --git a/src/main/resources/META-INF/native-image/dev.ayagmar/quarkusforge/resource-config.json b/src/main/resources/META-INF/native-image/dev.ayagmar/quarkusforge/resource-config.json index 574975d5..9dac0c9a 100644 --- a/src/main/resources/META-INF/native-image/dev.ayagmar/quarkusforge/resource-config.json +++ b/src/main/resources/META-INF/native-image/dev.ayagmar/quarkusforge/resource-config.json @@ -9,9 +9,6 @@ }, { "pattern": "${native.tui.bindings.resource.pattern}" - }, - { - "pattern": "version\\.properties$" } ] } diff --git a/src/main/resources/version.properties b/src/main/resources/version.properties deleted file mode 100644 index defbd482..00000000 --- a/src/main/resources/version.properties +++ /dev/null @@ -1 +0,0 @@ -version=${project.version} diff --git a/src/test/java/dev/ayagmar/quarkusforge/TuiBootstrapServiceTest.java b/src/test/java/dev/ayagmar/quarkusforge/TuiBootstrapServiceTest.java index f15de626..30e1f006 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/TuiBootstrapServiceTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/TuiBootstrapServiceTest.java @@ -203,15 +203,14 @@ void runHeadlessSmokePropagatesCatalogLoadFailure() { private static void invokeConfigureTerminalBackendPreference( String propertyValue, String envValue, String osName) throws Exception { Method method = - TuiBootstrapService.class.getDeclaredMethod( - "configureTerminalBackendPreference", String.class, String.class, String.class); + backendPreferenceClass() + .getDeclaredMethod("configure", String.class, String.class, String.class); method.setAccessible(true); method.invoke(null, propertyValue, envValue, osName); } private static void invokeConfigureTerminalBackendPreference() throws Exception { - Method method = - TuiBootstrapService.class.getDeclaredMethod("configureTerminalBackendPreference"); + Method method = backendPreferenceClass().getDeclaredMethod("configure"); method.setAccessible(true); method.invoke(null); } @@ -219,15 +218,15 @@ private static void invokeConfigureTerminalBackendPreference() throws Exception private static boolean invokeIsBackendPreferenceExplicitlyConfigured( String propertyValue, String envValue) throws Exception { Method method = - TuiBootstrapService.class.getDeclaredMethod( - "isBackendPreferenceExplicitlyConfigured", String.class, String.class); + backendPreferenceClass() + .getDeclaredMethod("isExplicitlyConfigured", String.class, String.class); method.setAccessible(true); return (boolean) method.invoke(null, propertyValue, envValue); } private static String invokeDefaultBackendPreference(String osName) throws Exception { Method method = - TuiBootstrapService.class.getDeclaredMethod("defaultBackendPreference", String.class); + backendPreferenceClass().getDeclaredMethod("defaultBackendPreference", String.class); method.setAccessible(true); return (String) method.invoke(null, osName); } @@ -243,13 +242,15 @@ private static String invokeResolvePresetStreamKey( private static void invokeRestoreTerminalBackendPreference(String previousBackendPreference) throws Exception { - Method method = - TuiBootstrapService.class.getDeclaredMethod( - "restoreTerminalBackendPreference", String.class); + Method method = backendPreferenceClass().getDeclaredMethod("restore", String.class); method.setAccessible(true); method.invoke(null, previousBackendPreference); } + private static Class backendPreferenceClass() throws ClassNotFoundException { + return Class.forName("dev.ayagmar.quarkusforge.runtime.TerminalBackendPreference"); + } + private static String captureStandardError(ThrowingRunnable action) { PrintStream originalErr = System.err; ByteArrayOutputStream stderr = new ByteArrayOutputStream(); diff --git a/src/test/java/dev/ayagmar/quarkusforge/cli/CliVersionProviderTest.java b/src/test/java/dev/ayagmar/quarkusforge/cli/CliVersionProviderTest.java index 9c06e038..de40a956 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/cli/CliVersionProviderTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/cli/CliVersionProviderTest.java @@ -13,20 +13,18 @@ void resolveVersionReturnsNonBlankValue() { } @Test - void resolveVersionFallsBackToResourceOrUnknown() { - // In test environment, implementation version from manifest is typically null, - // so it falls back to version.properties resource or "unknown" + void resolveVersionFallsBackToUnknownWhenImplementationVersionIsUnavailable() { String version = CliVersionProvider.resolveVersion(); assertThat(version).isNotNull(); - // Must be a version string or "unknown" assertThat(version).matches("(\\d+\\.\\d+\\.\\d+.*|unknown)"); } @Test - void getVersionReturnsNonEmptyArray() { - CliVersionProvider provider = new CliVersionProvider(); - String[] versions = provider.getVersion(); - assertThat(versions).hasSize(1); - assertThat(versions[0]).isNotBlank(); + void resolveVersionIsStableAcrossRepeatedCalls() { + String first = CliVersionProvider.resolveVersion(); + String second = CliVersionProvider.resolveVersion(); + + assertThat(first).isEqualTo(second); + assertThat(first).isNotBlank(); } } diff --git a/src/test/java/dev/ayagmar/quarkusforge/cli/HeadlessCliTest.java b/src/test/java/dev/ayagmar/quarkusforge/cli/HeadlessCliTest.java index f3802127..e67d7b9a 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/cli/HeadlessCliTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/cli/HeadlessCliTest.java @@ -41,7 +41,7 @@ void versionFlagExitsCleanly() { CliCommandTestSupport.runHeadlessCommand(config, "--version"); assertThat(result.exitCode()).isEqualTo(ExitCodes.OK); - assertThat(result.standardOut()).containsPattern("\\d+\\.\\d+"); + assertThat(result.standardOut()).contains(CliVersionProvider.resolveVersion()); } @Test diff --git a/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliPrefillTest.java b/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliPrefillTest.java index 86ca1e2b..6620d945 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliPrefillTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliPrefillTest.java @@ -9,7 +9,6 @@ import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import picocli.CommandLine; /** * Tests verifying that {@link QuarkusForgeCli} correctly detects explicitly-provided CLI options @@ -24,12 +23,14 @@ class QuarkusForgeCliPrefillTest { @TempDir Path tempDir; - // ── isExplicitlySet with real Picocli @Spec injection ───────────────────── + // ── Explicit-option detection from recorded Picocli parse results ───────────────────── @Test void picocliSpecDetectsOptionThatWasExplicitlyProvided() { QuarkusForgeCli cli = new QuarkusForgeCli(); - new CommandLine(cli).parseArgs("--dry-run", "--group-id", "com.example"); + cli.requestOptions() + .recordMatchedOptions( + CliCommandLineFactory.create(cli).parseArgs("--dry-run", "--group-id", "com.example")); assertThat( cli.requestOptions() @@ -42,7 +43,8 @@ void picocliSpecDetectsOptionThatWasExplicitlyProvided() { @Test void picocliSpecDetectsOptionThatWasNotProvided() { QuarkusForgeCli cli = new QuarkusForgeCli(); - new CommandLine(cli).parseArgs("--dry-run"); + cli.requestOptions() + .recordMatchedOptions(CliCommandLineFactory.create(cli).parseArgs("--dry-run")); // --group-id was NOT provided; value equals the default assertThat( @@ -60,7 +62,10 @@ void picocliSpecDetectsExplicitDefaultEqualValue() { // This is the core bug scenario: user passes --group-id org.acme (same as default) // and the system must recognise it as explicit input, NOT as "not provided". QuarkusForgeCli cli = new QuarkusForgeCli(); - new CommandLine(cli).parseArgs("--dry-run", "--group-id", RequestOptions.DEFAULT_GROUP_ID); + cli.requestOptions() + .recordMatchedOptions( + CliCommandLineFactory.create(cli) + .parseArgs("--dry-run", "--group-id", RequestOptions.DEFAULT_GROUP_ID)); assertThat( cli.requestOptions() @@ -75,8 +80,10 @@ void picocliSpecDetectsExplicitDefaultEqualValue() { @Test void picocliSpecDetectsMultipleExplicitOptions() { QuarkusForgeCli cli = new QuarkusForgeCli(); - new CommandLine(cli) - .parseArgs("--dry-run", "--group-id", "com.example", "--java-version", "21"); + cli.requestOptions() + .recordMatchedOptions( + CliCommandLineFactory.create(cli) + .parseArgs("--dry-run", "--group-id", "com.example", "--java-version", "21")); assertThat( cli.requestOptions() @@ -103,7 +110,10 @@ void picocliSpecDetectsMultipleExplicitOptions() { @Test void explicitCliPrefillRetainsExplicitDefaultEqualGroupId() { QuarkusForgeCli cli = new QuarkusForgeCli(); - new CommandLine(cli).parseArgs("--dry-run", "--group-id", RequestOptions.DEFAULT_GROUP_ID); + cli.requestOptions() + .recordMatchedOptions( + CliCommandLineFactory.create(cli) + .parseArgs("--dry-run", "--group-id", RequestOptions.DEFAULT_GROUP_ID)); assertThat(cli.requestOptions().toExplicitCliPrefill().groupId()) .as("Explicit --group-id must remain explicit even when it equals the default") @@ -113,7 +123,8 @@ void explicitCliPrefillRetainsExplicitDefaultEqualGroupId() { @Test void explicitCliPrefillLeavesOmittedDefaultsUnset() { QuarkusForgeCli cli = new QuarkusForgeCli(); - new CommandLine(cli).parseArgs("--dry-run"); + cli.requestOptions() + .recordMatchedOptions(CliCommandLineFactory.create(cli).parseArgs("--dry-run")); CliPrefill explicitPrefill = cli.requestOptions().toExplicitCliPrefill(); @@ -126,8 +137,10 @@ void explicitCliPrefillLeavesOmittedDefaultsUnset() { @Test void explicitCliPrefillPreservesMultipleExplicitOptions() { QuarkusForgeCli cli = new QuarkusForgeCli(); - new CommandLine(cli) - .parseArgs("--dry-run", "--group-id", "com.example", "--java-version", "21"); + cli.requestOptions() + .recordMatchedOptions( + CliCommandLineFactory.create(cli) + .parseArgs("--dry-run", "--group-id", "com.example", "--java-version", "21")); CliPrefill explicitPrefill = cli.requestOptions().toExplicitCliPrefill(); diff --git a/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliStartupMetadataTest.java b/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliStartupMetadataTest.java index 2e6308e7..fc2908ba 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliStartupMetadataTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliStartupMetadataTest.java @@ -14,7 +14,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import picocli.CommandLine; class QuarkusForgeCliStartupMetadataTest { @TempDir Path tempDir; @@ -275,7 +274,8 @@ private CliCommandTestSupport.CommandResult runSmoke( private CliCommandTestSupport.CommandResult runCommand(QuarkusForgeCli cli, String... args) { try { - return CliCommandTestSupport.captureCommandOutput(() -> new CommandLine(cli).execute(args)); + return CliCommandTestSupport.captureCommandOutput( + () -> CliCommandLineFactory.create(cli).execute(args)); } catch (RuntimeException runtimeException) { throw runtimeException; } catch (Exception exception) { diff --git a/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliTest.java b/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliTest.java index b9d354b3..a1da1e53 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliTest.java @@ -18,7 +18,6 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; -import picocli.CommandLine; class QuarkusForgeCliTest { private static final MetadataDto METADATA = @@ -139,7 +138,7 @@ void dryRunDelegatesExplicitOverridesToStartupService() throws Exception { CliCommandTestSupport.CommandResult result = CliCommandTestSupport.captureCommandOutput( () -> - new CommandLine(cli) + CliCommandLineFactory.create(cli) .execute( "--dry-run", "--group-id", diff --git a/src/test/java/dev/ayagmar/quarkusforge/runtime/RuntimeConfigTest.java b/src/test/java/dev/ayagmar/quarkusforge/runtime/RuntimeConfigTest.java index b7089d10..19103ae6 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/runtime/RuntimeConfigTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/runtime/RuntimeConfigTest.java @@ -203,7 +203,7 @@ void normalizesApiBaseUriAndFilePaths() { } private static boolean invokeBooleanHelper(String methodName, String value) throws Exception { - Method method = RuntimeConfig.class.getDeclaredMethod(methodName, String.class); + Method method = LoopbackHosts.class.getDeclaredMethod(methodName, String.class); method.setAccessible(true); return (boolean) method.invoke(null, value); }