diff --git a/CHANGELOG.md b/CHANGELOG.md index cb6c567..4b0e8e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. ## Unreleased +- **Fix: `--env` / `--envfile` Reach Modules Run Through the Module Shortcut:** `lucli … --env=x`, `lucli --env=x …` and an aliased binary (`-Dlucli.binary.name=`, e.g. `wheels start --env=prod`) re-execute `modules run …` on the root command, which reset the root's options, so the module saw `LuCLI.getCurrentEnvironment() == null` (only `LUCLI_ENV` worked). The shortcut now re-injects `--env` / `--envfile` at the root position, as it already did for `--verbose` / `--debug` (after the module args). Upstream: cybersonic/LuCLI#136. Adds `ModuleShortcutArgsTest`. + - **Feature: Modules Can Tell an MCP `tools/call` From a Terminal Call:** During `tools/call`, the module function now receives a reserved `__lucliMcpCall=true` argument, so a module can apply a different policy to MCP clients (for example refusing a command meant for a person at the terminal). The argument is runtime-owned: any argument of that name (any case, with or without `--`) supplied by an MCP client or on the command line is removed before the module runs, so it can't be forged or suppressed, and a terminal invocation never carries it. The positional binder never fills a declared parameter of that name from a terminal `argN`. Documented in the MCP module servers guide. Adds coverage in `McpCommandTest` (`toolsCallCarriesARuntimeOwnedMcpMarker`, `terminalInvocationNeverCarriesTheMcpMarker`, `declaredMarkerParameterIsNeverBoundFromATerminalPositional`). - **Change: Dev Servers Listen on `127.0.0.1` by Default:** Servers used to listen on every network interface (Tomcat connectors had no `address`, Jetty no `jetty.http.host`, Docker published the port on all host interfaces), so a `lucli server start` was reachable from the whole local network. A new `lucee.json` key `bindAddress` (default `127.0.0.1`) now sets the HTTP and HTTPS connectors' `address` and that of every other connector (AJP, Nio2, APR, ...) that server.xml leaves without one, Jetty's `jetty.http.host`, the host side of Docker's `-p`, and JMX monitoring (`jmxremote.host` plus a matching RMI hostname and port). Unauthenticated JMX is refused on a non-loopback bind unless authentication is configured in `jvm.additionalArgs`, and the schema's documented `monitoring.enabled` default is corrected to `false`. Opt in to other interfaces with `"bindAddress": "0.0.0.0"`, `server start|run --host 0.0.0.0`, or the one-shot `bindAddress=0.0.0.0` override; the startup output shows the listen address and warns when it is all interfaces. `host` keeps its meaning (URL and certificate hostname). Adds coverage in `TomcatServerXmlPatcherTest`, `DockerRuntimeProviderTest`, `JettyBaseConfigGeneratorTest`, `LuceeServerConfigTest` and `ServerConfigHelperTest`. diff --git a/FORK.md b/FORK.md index 4336b50..bcb1f7f 100644 --- a/FORK.md +++ b/FORK.md @@ -83,6 +83,7 @@ No release is tagged for this queue yet; tagging needs the Wheels release owner' | Order | Change | Purpose | |---|---|---| | 8 | wheels-dev/LuCLI [#19](https://github.com/wheels-dev/LuCLI/pull/19) (issue [#17](https://github.com/wheels-dev/LuCLI/issues/17)) | A module function run by an MCP `tools/call` receives the runtime-owned `__lucliMcpCall=true` argument; any caller-supplied argument of that name is dropped, and a terminal call never carries it | +| 9 | wheels-dev/LuCLI [#21](https://github.com/wheels-dev/LuCLI/pull/21) (issue: upstream [cybersonic/LuCLI#136](https://github.com/cybersonic/LuCLI/issues/136)) | `--env` / `--envfile` reach a module run through the module shortcut or an aliased binary (re-injected at the root position) | Upstream (row 8): issue [cybersonic/LuCLI#133](https://github.com/cybersonic/LuCLI/issues/133), PR [cybersonic/LuCLI#134](https://github.com/cybersonic/LuCLI/pull/134) (a `cherry-pick -x` of diff --git a/src/main/java/org/lucee/lucli/LuCLI.java b/src/main/java/org/lucee/lucli/LuCLI.java index 318358c..60fe4b4 100644 --- a/src/main/java/org/lucee/lucli/LuCLI.java +++ b/src/main/java/org/lucee/lucli/LuCLI.java @@ -417,25 +417,45 @@ private Integer executeViaModulesCommand(String moduleName, String[] args) throw verbose("Executing module shortcut: " + moduleName + " (equivalent to 'lucli modules run " + moduleName + " " + String.join(" ", args) + "')"); + List cmdArgs = moduleShortcutArgs(moduleName, args, isVerbose(), isDebug(), envOption, envFileOption); + return spec.commandLine().execute(cmdArgs.toArray(new String[0])); + } + + /** + * The argument list the module shortcut re-executes on the root command: + * {@code [--env=] [--envfile=] modules run [--verbose] [--debug]}. + * + * Re-executing resets the root's options, so root-level flags the module must + * still see are re-injected. {@code --env} / {@code --envfile} go BEFORE + * {@code modules}, at the root position, where the execution strategy reads them + * into {@link #currentEnvironment} / {@link #envFilePath}; without them the + * module saw a null environment (upstream cybersonic/LuCLI#136). + * {@code --verbose} / {@code --debug} go after the module args, where the + * module's own arg parser reads them (e.g. the wheels module's + * {@code doctor --verbose}), and the subcommand stays the first positional. + */ + static List moduleShortcutArgs(String moduleName, String[] args, boolean verbose, boolean debug, + String env, String envFile) { List cmdArgs = new ArrayList<>(); + if (env != null && !env.isEmpty()) { + cmdArgs.add("--env=" + env); + } + if (envFile != null && !envFile.isEmpty()) { + cmdArgs.add("--envfile=" + envFile); + } cmdArgs.add("modules"); cmdArgs.add("run"); cmdArgs.add(moduleName); if (args != null && args.length > 0) { cmdArgs.addAll(Arrays.asList(args)); } - // Re-inject root-level flags that picocli consumed at the root before the - // module shortcut dispatched, so the module can see them (e.g. the wheels - // module's `doctor --verbose` / `stats --verbose` / `test --verbose`). - // Appended AFTER the module args so the subcommand stays the first - // positional; the module's arg parser reads --verbose/--debug from there. - if (isVerbose()) { + if (verbose) { cmdArgs.add("--verbose"); } - if (isDebug()) { + if (debug) { cmdArgs.add("--debug"); } - return spec.commandLine().execute(cmdArgs.toArray(new String[0])); + return cmdArgs; } /** diff --git a/src/test/java/org/lucee/lucli/ModuleShortcutArgsTest.java b/src/test/java/org/lucee/lucli/ModuleShortcutArgsTest.java new file mode 100644 index 0000000..28dd4a3 --- /dev/null +++ b/src/test/java/org/lucee/lucli/ModuleShortcutArgsTest.java @@ -0,0 +1,47 @@ +package org.lucee.lucli; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * The module shortcut ({@code lucli ...} and an aliased binary) re-executes + * {@code modules run ...} on the root command, which resets the root's + * options. Root flags the module must see are re-injected (cybersonic/LuCLI#136). + */ +class ModuleShortcutArgsTest { + + @Test + void noRootFlagsGiveThePlainModulesRunArgs() { + assertEquals( + List.of("modules", "run", "wheels", "start", "--dry-run"), + LuCLI.moduleShortcutArgs("wheels", new String[] {"start", "--dry-run"}, false, false, null, null) + ); + } + + @Test + void envAndEnvFileAreReinjectedAtTheRootPosition() { + assertEquals( + List.of("--env=prod", "--envfile=/tmp/x.env", "modules", "run", "wheels", "start"), + LuCLI.moduleShortcutArgs("wheels", new String[] {"start"}, false, false, "prod", "/tmp/x.env") + ); + } + + @Test + void verboseAndDebugStayAfterTheModuleArgs() { + assertEquals( + List.of("--env=staging", "modules", "run", "wheels", "doctor", "--verbose", "--debug"), + LuCLI.moduleShortcutArgs("wheels", new String[] {"doctor"}, true, true, "staging", "") + ); + } + + @Test + void emptyEnvIsNotReinjected() { + assertEquals( + List.of("modules", "run", "mod"), + LuCLI.moduleShortcutArgs("mod", new String[0], false, false, "", null) + ); + } +}