jdaugherty commented on issue #15377: URL: https://github.com/apache/grails-core/issues/15377#issuecomment-4886642776
@jamesfredley I think we need to consider actually splitting these libraries. I don't think we should bandaid package this since every other plugin that includes the shell-cli will have the same problem (https://github.com/apache/grails-core/pull/15815). The main issue is there is command infrastructure in both the shell-cli & in grails-core. Do we force a split only when shell-cli is included or do we split the base commands from core? My initial reaction is to split both scenarios. I iterated with AI a bit, and here's a proposal (plan) we could use to do this work: # Proposal: Move Grails CLI commands into `cli` classifier artifacts (Grails 8.0.x) ## Summary Grails ships its dev/CLI commands (`grails.dev.commands.ApplicationCommand`) **inside runtime libraries** — `grails-core`, `grails-web-url-mappings`, the Hibernate plugins, `grails-scaffolding`, and the two `dbmigration` plugins. Some of these command sets drag heavyweight, CLI-only dependencies (notably `grails-shell` / `grails-shell-cli`) onto the **application runtime classpath**, causing two classes of failure: dependency-version conflicts (a mismatched Groovy) **and** incorrect framework behavior — e.g. a CLI-only Spring Boot servlet initializer that breaks WAR deployment (https://github.com/apache/grails-core/issues/15377). This proposal removes command code from every runtime artifact by publishing commands as a **`cli` classifier** of each module (via Gradle **feature variants**), keyed off a dedicated **`META-INF/grails-cli.factories`** file, and consumed through a new, purpose-built Gradle configuration **`grailsCli`** (with existing `buildscript { classpath }` placement still supported). The entire `grails/dev/commands/**` package (command contract + registry + infrastructure + `ConfigReportCommand`) moves into `grails-core:cli`, **renamed to `org.apache.grails.core.cli.*`** on a JPMS-safe, per-artifact-unique package scheme, so the default `grails-core` jar — and every default plugin jar — ships **zero** command code and **zero** command contract. No new subprojects are created. This lands in the **Grails 8.0.x major release** as a clean break (no backward-compatibility shim). ## Background & problem The Hibernate 5/7 database-migration plugins depend on `grails-shell-cli` purely so their `dbm-*` commands compile and can be located by the shell. Because each plugin is a **single artifact used on both the build and runtime classpaths**, that dependency (and its mismatched Groovy) leaks into applications. The current workaround is an explicit exclusion + TODO in `grails-data-hibernate{5,7}/dbmigration/build.gradle`: ```groovy implementation(project(':grails-shell-cli')) { exclude group: 'org.slf4j', module: 'slf4j-simple' // TODO: the shell cli is exporting groovy 3, while this project is expected to use groovy 4 // this plugin needs split into commands & the plugin itself so that different versions // of groovy can be used exclude group: 'org.codehaus.groovy' } ``` The same structural issue — commands living in runtime libraries — applies across the framework. **This is not only a dependency-version problem; CLI code on the runtime classpath also causes incorrect framework behavior.** See https://github.com/apache/grails-core/issues/15377: the dbmigration plugin transitively drags `grails-shell` / Spring Boot CLI onto a consumer's classpath, including `org.grails.cli.boot.SpringApplicationWebApplicationInitializer`. In a classic servlet-container WAR deployment, the container auto-discovers that `WebApplicationInitializer` and invokes it; because a plain WAR has no `Start-Class` in its `MANIFEST.MF`, `sources` is `null` and startup fails with `Cannot invoke "String.split(String)" because "sources" is null`. In other words, Spring behaves differently — and breaks — precisely because CLI components that assume a standalone executable JAR are on the runtime classpath. The current workaround is a manual `bootWar { classpath = classpath.filter { … } }` exclusion. Removing CLI code from the runtime classpath entirely — as this proposal does — eliminates that whole class of failure, not just the specific NPE. ## Root cause (evidence) Commands are only ever executed by the CLI, and the framework is **already loosely coupled** at every integration seam: | Seam | How it references the command API | Coupling | |---|---|---| | Core AST transform `GlobalGrailsClassInjectorTransformation` | `ClassHelper.make('grails.dev.commands.ApplicationCommand')` + name-based `isSubclassOfOrImplementsInterface` | string only | | Grails Gradle plugin `configureApplicationCommands` | `'grails.dev.commands.ApplicationCommand'` string constant | string only | | `grails-shell-cli` `ApplicationContextCommandFactory` | `classLoader.loadClass('grails.dev.commands.ApplicationContextCommandRegistry')` | reflection | | `grails-console` `GrailsApplicationContextCommandRunner` | `ApplicationContextCommandRegistry.instance.findCommand(...)` | CLI runner only | `grails-web-boot` and the core plugin manager contain **zero** references to `grails.dev.commands`; a normal `bootRun`/request cycle never loads the command infrastructure, and commands are **not** a scanned artefact type (they are registered via a factories file, not artefact scanning), so command classes are never class-loaded at boot. Removing the command contract from the runtime classpath is therefore safe — no `NoClassDefFoundError` on any non-command path. The command package itself (`grails/dev/commands/**`) depends only on lightweight core/bootstrap types (`grails.codegen.*`, `grails.util.*`, `org.grails.build.parsing.CommandLine`, `org.grails.io.support.*`) — **not** on `grails-shell-cli`. Conclusion: commands are logically CLI-only; the machinery references them by name/reflection, so relocating them requires no change to the machinery. Only command *implementors* hard-depend on the contract. ## Goals - No CLI-only dependency (e.g. `grails-shell`/`grails-shell-cli`) on any application **runtime** classpath — including `bootRun` and packaged `bootJar`/`bootWar` artifacts. This fixes both the Groovy-version conflict and runtime-correctness failures such as the WAR-deployment NPE in https://github.com/apache/grails-core/issues/15377 (CLI-only Spring Boot components must not be present when the app runs). - Default runtime artifacts ship no command code and no command contract. - Commands compile and run through an explicit, opt-in dependency; apps that don't want them omit it. - No proliferation of published modules; reuse existing module coordinates. - A general, documented pattern for third-party plugin authors. ## Non-goals - Changing command behavior, names, or invocation (`grails dbm-update`, `grails url-mappings-report`, `grails generate-controller`, etc. are unchanged). - Moving user-authored application commands out of the app's `grails-app/commands` source location. - Reworking `grails-shell-cli` profile commands (`create-app`, etc.), which are already build/CLI-time. ## Proposed design ### 1. Commands become a `cli` classifier via Gradle feature variants (no subprojects) Each command-bearing module keeps its coordinate and gains a **feature variant** whose source set holds only the commands. Gradle publishes it as a `…-cli.jar` classifier **with its own dependency set in Gradle Module Metadata**, so the `cli` variant's dependencies (e.g. `grails-shell-cli`) do not attach to the default variant. > **Why a feature variant, not a bare classifier.** A plain `artifact(classifier: 'cli')` publishes > the extra jar but staples its dependencies onto the single shared POM, so `grails-shell-cli` would > still reach consumers of the default jar. A feature variant is the form of `cli` classifier that > also keeps the two artifacts' dependency graphs separate. This build already relies on the same > mechanism via `java-test-fixtures` (e.g. `testFixtures("org.apache.grails:grails-geb")` consumed in > `grails-test-examples/scaffolding`). ```groovy sourceSets { cli } // src/cli/groovy — dbmigration: relocate grails-app/commands here java { registerFeature('cli') { usingSourceSet(sourceSets.cli) capability('org.apache.grails', 'grails-scaffolding-cli', project.version) } } dependencies { cliImplementation project(path) // the module's own default variant cliImplementation 'org.apache.grails:grails-core:cli' // command contract (see §2) cliImplementation('org.apache.grails:grails-shell-cli') // CLI-only; off the default graph } ``` ### 2. The whole `dev.commands` package becomes `grails-core:cli` Moving the `ApplicationCommand` contract to the `cli` tier pulls its collaborators with it (`ApplicationContextCommandRegistry` imports the contract). So the **entire `grails/dev/commands/**` package** relocates into the `grails-core:cli` feature variant: - `ApplicationCommand`, `GrailsApplicationCommand`, `ExecutionContext` - `ApplicationContextCommandRegistry` - `io/FileSystemInteraction(+Impl)`, `template/TemplateRenderer(+Impl)`, `template/TemplateException` - `ConfigReportCommand` The default `grails-core` jar then contains **no** command package at all — only the AST transform's *string* reference remains (no compile dependency). `grails-core:cli` is the shared contract every other `cli` variant depends on. ### 3. Dedicated `META-INF/grails-cli.factories` (clean break) Command registrations move out of `grails.factories` into their own file, so command metadata only ever lives in `cli` jars and tooling can look it up specifically: - `META-INF/grails.factories` → runtime extension registrations (`ArtefactHandler`, `TraitInjector`). - `META-INF/grails-cli.factories` → `grails.dev.commands.ApplicationCommand=…`. Because this is a major release, there is **no dual read**: readers target `grails-cli.factories` only; the legacy `grails.factories` is not consulted for commands. | Side | Today | Change | |---|---|---| | Write (compile) | the core transform's command branch writes `'META-INF/grails.factories'` | new `CommandFactoriesTransformation` (in `grails-core:cli`, §4) writes `'META-INF/grails-cli.factories'` into the `cli` source set output | | Read (runtime) | `ApplicationContextCommandRegistry` → `GrailsFactoriesLoader.loadFactories(ApplicationCommand)` | location-parameterized overload; registry passes the new location | | Read (build) | `FactoriesLoaderSupport.FACTORIES_RESOURCE_LOCATION` (single class in `grails-gradle/model`, extended by the runtime `GrailsFactoriesLoader` **and** used by the Gradle plugin) | location-aware overload; command lookups pass `META-INF/grails-cli.factories` | | Read (shell) | reflective load of the registry | no change | ### 4. Command registration moves to a dedicated transform in `grails-core:cli` Today `GlobalGrailsClassInjectorTransformation` (a global transform in `grails-core`) auto-registers commands: it detects any compiled class implementing the command interface — by **name**, via `isSubclassOfOrImplementsInterface` against the `APPLICATION_CONTEXT_COMMAND_CLASS` string constant — and appends it to `grails.factories`. That is *why* it references the command class name: name-based auto-detection. Once the contract moves to `grails-core:cli`, leaving that branch in `grails-core` would keep a hardcoded reference to a class that no longer lives in `grails-core` — a layering leak. So the command branch is **extracted into its own global transform, `CommandFactoriesTransformation`, shipped in `grails-core:cli`** (registered via that artifact's own `META-INF/services/org.codehaus.groovy.transform.ASTTransformation`). - **Precedent:** `grails-datamapping-core` already ships two of its own global transforms this way; global transforms compose across jars on the compile classpath. - **Activation is exactly scoped:** the transform runs only when `grails-core:cli` is on the compile classpath — which is guaranteed whenever a command compiles, since a class implementing `org.apache.grails.core.cli.ApplicationCommand` can only compile if that contract resolves. The detector therefore exists precisely when it can match (tighter than today, where the core transform runs everywhere and usually matches nothing). - **No write contention:** it emits `META-INF/grails-cli.factories` (the separate file, §3), so it never touches `grails.factories`. Both remain global transforms with defined `TransformWithPriority`/`GroovyTransformOrder` slots. - **Shared helper:** the generic factories-writing logic (`updateGrailsFactoriesWithType`, `loadFromFile`, `resolveCompilationTargetDirectory`, using `PropertyFileUtils` from `grails-gradle/common`) is extracted into a marker-agnostic `FactoriesFileWriter` util **kept in `grails-core`** and reused by both transforms (`grails-core:cli` already depends on `grails-core`, so this adds no improper coupling; the util knows nothing about commands). **Net:** `grails-core`'s transform drops the command branch *and* the `APPLICATION_CONTEXT_COMMAND_CLASS` constant — `grails-core` no longer references any command type. Two references legitimately remain, both in **CLI/build tooling** (not `grails-core` runtime): the Grails Gradle plugin's command-class string (build-time task discovery) and shell-cli's reflective registry lookup. ### 5. New `grailsCli` Gradle configuration (not `console`, not `developmentOnly`) The command tier needs a classpath shape no stock configuration provides: **compile-visible** (so `grails-app/commands/**` compile against the cli-only contract) **+** present on the **command-runner JVM** **+** scanned for `grails-cli.factories` **+** on **neither** `runtimeClasspath` **nor** `bootRun`. The Grails Gradle plugin adds a purpose-built `grailsCli` configuration with exactly these properties: 1. Register `grailsCli` and wire it (validated against `GrailsGradlePlugin`): create a dependency bucket `grailsCli` (`canBeResolved=false`, `canBeConsumed=false`); `compileClasspath.extendsFrom (grailsCli)` (+ `testCompileClasspath`) for compile visibility — the same trick `compileOnly` uses; do **not** let `runtimeClasspath` extend it (so it is excluded from `bootJar`/`bootWar`, which package `runtimeClasspath`). `bootRun` is unaffected — the plugin's `BootRun.configureEach` blocks never modify the classpath, so `bootRun` uses Spring Boot's `main.runtimeClasspath + developmentOnly`. For command tasks, add a resolvable view (e.g. `grailsCliClasspath` extending `grailsCli`) — mirroring how `console` is resolved as `runtimeClasspath + console` today. 2. Reroute command tasks off `console`: `configureApplicationCommands` builds its classpath from `buildClasspath(project, runtimeClasspath, grailsCli)` instead of `console` (`GrailsGradlePlugin` line 753); the dbm-style command-runner classpaths (lines 1243/1274) that append `console` switch to `grailsCli`. The `console`/`shell` tasks keep `console` untouched. 3. Command execution (the generic `runCommand`/`runScript` tasks and the interactive shell) resolves from `grailsCli` at execution time via `GrailsApplicationContextCommandRegistry`. Per-command *named* tasks keep discovering names from the buildscript classloader (see §7); the transform now emits `grails-cli.factories`, so update `FactoriesLoaderSupport`'s command lookup to that filename. 4. Auto-provision `grails-core:cli` (and applied plugins' `:cli` classifiers) onto `grailsCli` so command authoring works out of the box in generated apps. `console` remains solely about the console/shell tools; `grailsCli` is solely about CLI commands. ### 6. Package rename to `org.apache.grails.*` (JPMS-safe) Since the code is moving, the command packages are renamed to the repo's canonical `org.apache.grails.*` namespace (this build is already mid-migration to it), replacing the legacy `grails.dev.commands.*`. Each `:cli` variant gets a **unique leaf package** so no package is ever split across artifacts (JPMS forbids split packages), and each `:cli` jar sets a distinct `Automatic-Module-Name` (required because a module and its `:cli` classifier share one coordinate; no `Automatic-Module-Name` is set today, so names currently derive from filenames). Note `org.apache.grails.cli` is already owned by `grails-forge/grails-cli` and must not be reused. | `:cli` variant | new package | `Automatic-Module-Name` | |---|---|---| | `grails-core:cli` | `org.apache.grails.core.cli` (whole `dev.commands` package) | `org.apache.grails.core.cli` | | `grails-web-url-mappings:cli` | `org.apache.grails.web.mapping.cli` | `org.apache.grails.web.mapping.cli` | | `grails-scaffolding:cli` | `org.apache.grails.scaffolding.cli` | `org.apache.grails.scaffolding.cli` | | `grails-data-hibernate5/grails-plugin:cli` | `org.apache.grails.data.hibernate5.cli` | `…hibernate5.cli` | | `grails-data-hibernate7/grails-plugin:cli` | `org.apache.grails.data.hibernate7.cli` | `…hibernate7.cli` | | `grails-data-hibernate5/dbmigration:cli` | `org.apache.grails.data.hibernate5.dbmigration.cli` | `…hibernate5.dbmigration.cli` | | `grails-data-hibernate7/dbmigration:cli` | `org.apache.grails.data.hibernate7.dbmigration.cli` | `…hibernate7.dbmigration.cli` | The rename cascades to the name/reflection references (all updated to the new FQNs): the AST transform's command-class string constant, the Gradle plugin's `APPLICATION_CONTEXT_COMMAND_CLASS` constant, shell-cli's reflective `loadClass('org.apache.grails.core.cli.ApplicationContextCommandRegistry')`, and the profile `Command.groovy` template import. The upgrade guide documents the user-facing mapping `grails.dev.commands.* → org.apache.grails.core.cli.*` for application command classes. ### 7. Consumer model — two surfaces, both supported Command consumption has **two surfaces** today, and the design must serve both: | Surface | Discovers commands from | cli lib goes on | |---|---|---| | Gradle command-task *registration* (`dbmUpdate`, …) | the Grails Gradle plugin's own (buildscript) classloader — `configureApplicationCommands` calls `FactoriesLoaderSupport.loadFactoryNames(...)` with the default classloader | `buildscript { dependencies { classpath … } }` | | Command *execution* + interactive shell | the project classpath (`runtimeClasspath + console` today) | project `dependencies` | This is why command libraries often have to be on the `buildscript` classpath: task *names* are registered at configuration time by scanning the plugin's own classloader. Target design (validated against `GrailsGradlePlugin` + the configuration cache): - **Running commands is driven by `grailsCli`.** The generic `runCommand`/`runScript` tasks (and the interactive `grails <cmd>` shell) launch `GrailsApplicationContextCommandRunner`, which discovers the command at **execution time** via `ApplicationContextCommandRegistry` scanning the classpath. Their classpath is a **lazy `FileCollection`**; rerouting it from `console` to `grailsCli` is CC-safe. So a single `grailsCli 'group:artifact:cli'` in `dependencies { }` is sufficient to **run** any command (`grails <cmd>` or `./gradlew runCommand -Pargs="<cmd>"`), with full configuration-cache support. - **Per-command *named* tasks (`dbmUpdate`) stay discovered from the buildscript classloader** — unchanged from today (`FactoriesLoaderSupport.loadFactoryNames(...)` at line 751 reads plugin- classpath resources, resolving no project configuration). Getting these convenience tasks therefore requires the `:cli` jar on `buildscript { classpath … }`. - **Do NOT teach task registration to resolve `grailsCli`.** Deriving per-command task names from the project `grailsCli` configuration would force resolving it (and reading inside its jars) at configuration time — eager and CC-hostile for consumer apps — and it is unnecessary, since running commands already works via the generic runner + shell. ```groovy // Recommended: single declaration in dependencies dependencies { implementation 'org.apache.grails:grails-core' // runtime — no commands, no contract grailsCli 'org.apache.grails:grails-core:cli' // CLI tier (auto-added by the plugin) implementation 'org.apache.grails:grails-data-hibernate7-dbmigration' // runtime plugin — no shell-cli grailsCli 'org.apache.grails:grails-data-hibernate7-dbmigration:cli' // dbm-* commands, build/CLI only } // Still supported: buildscript placement for task registration buildscript { dependencies { classpath 'org.apache.grails:grails-data-hibernate7-dbmigration:cli' } } ``` The app builds and ships normally; the `:cli` classifier is available for compiling/running commands via the CLI but is absent from `runtimeClasspath`/`bootRun`, so it is not in the boot/war artifact. Opt-in is explicit: an app that wants no commands omits the `grailsCli` entries (auto-provisioning can be disabled). > **Configuration-cache validation (done).** Verified against `GrailsGradlePlugin`: today all command > task classpaths are lazy `FileCollection`s (resolved at execution), and per-command task *names* come > from the buildscript classloader (line 751) without resolving a project configuration. Rerouting the > execution classpath to `grailsCli` preserves that laziness (CC-safe). Running commands via the > generic `runCommand`/`runScript` tasks and the shell needs no config-time resolution. The design > therefore keeps named-task discovery on the buildscript classloader and does **not** resolve > `grailsCli` at configuration time — so consumer apps retain configuration-cache compatibility. ### 8. Compatibility — clean break in 8.0.x (no dual read) - **Third-party command plugins must be rebuilt against Grails 8.** Recompilation regenerates `grails-cli.factories` automatically via the transform — no source change needed for convention-based commands. - **Hand-authored registrations must migrate** from `src/main/resources/META-INF/grails.factories` to `.../grails-cli.factories`. - **Commands ship in a `:cli` classifier** consumed via `grailsCli`; the default plugin jar no longer carries commands. - A Grails 7 command plugin dropped onto a Grails 8 app unchanged will **not be discovered** — this is intentional and documented in the upgrade guide. ## Full command inventory Verified implementors of `ApplicationCommand` / `GrailsApplicationCommand` (production sources): - `grails-core` — `ConfigReportCommand` (`GrailsApplicationCommand` is the interface, not a command) - `grails-web-url-mappings` — `UrlMappingsReportCommand` - `grails-data-hibernate5/grails-plugin` — `SchemaExportCommand` - `grails-data-hibernate7/grails-plugin` — `SchemaExportCommand` - `grails-scaffolding` — 9 commands (`GenerateAll`, `GenerateController`, `GenerateAsyncController`, `GenerateService`, `GenerateViews`, `GenerateScaffoldAll`, `CreateScaffoldController`, `CreateScaffoldService`, `InstallTemplates`) - `grails-data-hibernate5/dbmigration` — 29 `dbm-*` commands - `grails-data-hibernate7/dbmigration` — 29 `dbm-*` commands Not commands (no action): `grails-profiles/base/templates/artifacts/Command.groovy` is the app scaffolding *template*; `grails-shell-cli` profile commands are already build/CLI-time. ## Per-module changes (`cli` feature variant contents) | Module | `cli` classifier contents | `cli` deps beyond contract | |---|---|---| | `grails-core` | entire `grails/dev/commands/**` (contract + registry + infra + `ConfigReportCommand`) | — | | `grails-web-url-mappings` | `UrlMappingsReportCommand` | `UrlMappingsHolder` (own default variant) | | `grails-data-hibernate5/grails-plugin` | `SchemaExportCommand` | Hibernate 5 runtime (own default variant) | | `grails-data-hibernate7/grails-plugin` | `SchemaExportCommand` | Hibernate 7 runtime (own default variant) | | `grails-scaffolding` | 9 commands + command-only helpers (`CommandLineHelper`, `SkipBootstrap`) | `Model` (own default variant) | | `grails-data-hibernate5/dbmigration` | 29 `dbm-*` + command traits + `src/main/scripts/*` | `grails-shell-cli`, plugin's default variant | | `grails-data-hibernate7/dbmigration` | 29 `dbm-*` + command traits + `src/main/scripts/*` | `grails-shell-cli`, plugin's default variant | For dbmigration the boundary was verified acyclic — runtime classes (plugin descriptor, `liquibase/**`, shared support) never reference the `command` package — so the runtime (default) variant drops `grails-shell-cli` entirely. ## Work breakdown (phased) **Phase 0 — `grails-core:cli` + the factories file** 1. Add a `cli` source set to `grails-core`; move `grails/dev/commands/**` into it, **renamed to `org.apache.grails.core.cli.**`**; `registerFeature('cli')` with `Automatic-Module-Name`. 2. Add a location-parameterized overload to `FactoriesLoaderSupport` (+ `GrailsFactoriesLoader`); point `ApplicationContextCommandRegistry` at `grails-cli.factories`. 3. Extract a marker-agnostic `FactoriesFileWriter` helper in `grails-core`; **remove** the command branch + `APPLICATION_CONTEXT_COMMAND_CLASS` constant from `GlobalGrailsClassInjectorTransformation`; add `CommandFactoriesTransformation` (+ its service registration) to `grails-core:cli`, writing `grails-cli.factories` via the shared helper. Update the Gradle plugin constant, the shell-cli reflective class name, and the profile template import to the new FQNs. **Phase 1 — `grailsCli` configuration** 4. Register `grailsCli`; wire compile visibility; keep it off `runtimeClasspath`/`bootRun`. 5. Reroute the command execution classpaths (`configureApplicationCommands` per-command tasks, `runCommand`, `runScript`, and dbm command-runner tasks) from `console` to `grailsCli`, keeping the lazy-`FileCollection` pattern. Leave per-command name discovery on the buildscript classloader (do not resolve `grailsCli` at config time — preserves configuration-cache compatibility for apps). Auto-provision `grails-core:cli` (+ applied plugins' `:cli`). **Phase 2 — dbmigration (the original driver)** 6. Add `cli` feature to each `dbmigration` plugin; move `grails-app/commands`, command traits, and `src/main/scripts/*` (+ their tests) into the `cli` source set. Remove `grails-shell-cli` from the default variant. Update example apps to `implementation` (runtime) + `grailsCli` (`:cli`). **Phase 3 — remaining framework commands** 7. Add `cli` features to `grails-scaffolding`, `grails-web-url-mappings`, and the two Hibernate `grails-plugin`s (schema-export). Move commands + command-only helpers; update example apps. **Phase 4 — docs & verification** 8. What's New entry; Upgrade guide section (breaking: `:cli` classifier on `grailsCli`; `grails-cli.factories` clean break; Grails 7 command plugins must be rebuilt/migrated). 9. Plugin best-practices section: "Ship CLI commands as a `cli` classifier" pattern. 10. Full build + violations + module test suites; verify commands resolve in the shell and are absent from runtime artifacts and from `bootRun`. ## Change surface (key files) - `grails-core/src/main/groovy/grails/dev/commands/**` → `grails-core` `cli` source set, renamed to `org.apache.grails.core.cli.**` - `grails-core/.../GlobalGrailsClassInjectorTransformation.groovy` — **remove** the command branch and the `APPLICATION_CONTEXT_COMMAND_CLASS` constant - `grails-core/.../FactoriesFileWriter` (new) — extract the marker-agnostic factories-writing helper; reused by both transforms - `grails-core:cli` `CommandFactoriesTransformation` (new) + its `META-INF/services/org.codehaus.groovy.transform.ASTTransformation` — writes `grails-cli.factories` - `grails-shell-cli/.../ApplicationContextCommandFactory.groovy` — reflective `loadClass('org.apache.grails.core.cli.ApplicationContextCommandRegistry')` - `grails-profiles/base/templates/artifacts/Command.groovy` — import → `org.apache.grails.core.cli.GrailsApplicationCommand` - each `:cli` variant — unique `org.apache.grails.*.cli` package + explicit `Automatic-Module-Name` - `grails-gradle/model/.../FactoriesLoaderSupport.groovy` — location-parameterized lookup - `grails-core/.../GrailsFactoriesLoader.groovy` — location-aware overload - `grails-gradle/plugins/.../GrailsGradlePlugin.groovy` — `grailsCli` config; reroute command tasks off `console` (lines 753, 1243, 1274); read `grails-cli.factories`; auto-provision `:cli` - `build.gradle` of each command-bearing module — `cli` source set + `registerFeature('cli')` + `cli*` deps - example apps under `grails-test-examples/**` — `implementation` + `grailsCli` wiring - `grails-doc/src/en/guide/introduction/whatsNew.adoc` - `grails-doc/src/en/guide/upgrading/upgrading80x.adoc` - `grails-doc/src/en/guide/plugins/creatingAndInstallingPlugins.adoc` ## Rejected alternatives - **New `grails-<module>-cli` subprojects (+ a `grails-cli-core` module).** Works, but proliferates published coordinates, `settings.gradle` entries and BOM entries. The `cli` classifier achieves the same isolation on existing coordinates. - **Plain Maven classifier (`artifact(classifier: 'cli')`).** Separates the jar but not its dependencies — `grails-shell-cli` would still leak via the shared POM. Feature variants fix this. - **Reuse the `console` configuration.** `console` is for the interactive console/shell tools; overloading it conflates two concerns and it is not compile-visible for command authoring. - **`developmentOnly`.** Not on the compile classpath (breaks command authoring against the cli-only contract) **and** it *is* on the `bootRun` classpath (re-adds `grails-shell-cli` + mismatched Groovy to the running dev app — the original bug). Fails on both counts. ## Risks & open questions - **Gradle-plugin change required.** `grailsCli` and the command-task reroute are additions to `GrailsGradlePlugin`. Larger blast radius than a pure library change; needs its own tests. *(Validated: `grailsCli` as a bucket + `compileClasspath.extendsFrom` + a resolvable view gives compile visibility without `runtimeClasspath`/`bootRun`/`bootJar` — see §5.)* - **`grails-app/commands` source-set relocation (validated).** `configureGrailsSourceDirs` (line 781) auto-adds every `grails-app/<subdir>` to *main*. This convention is unchanged for **apps** (their commands stay in `main`). For **framework plugins** only, relocate command sources to the `cli` source set (`src/cli/groovy`) — since `grails-app/commands` then no longer exists, `main` won't pick it up; alternatively add `'commands'` to the mutable `excludedGrailsAppSourceDirs`. Ensure the `cli` source set has `grails-core` on its `compileClasspath` so the global AST transform runs and emits `grails-cli.factories` into the `cli` jar. - **Feature-variant consumption ergonomics.** Consuming a `:cli` classifier is a touch more verbose than a standalone module; mitigated by the plugin auto-provisioning `grails-core:cli` and by docs. - **Clean break (by design).** Grails 7 command plugins are not discovered until rebuilt/migrated; mitigation is documentation, not a shim. - **Contract at command-execution time.** The command-runner JVM (`runtimeClasspath + grailsCli`) must include `grails-core:cli`; confirm the plugin wires `grailsCli` into every command-runner task. - **Second global AST transform.** `CommandFactoriesTransformation` in `grails-core:cli` adds a global transform alongside `grails-core`'s. Precedent exists (`grails-datamapping-core` ships two), and the two write different files so there is no contention; still, give it an explicit `TransformWithPriority` order and verify it activates on both framework `cli` source sets and app `grails-app/commands` compilation (both have `grails-core:cli` on the compile classpath). ## Naming / conventions (decided) - **`<module>:cli`** — a feature-variant classifier containing that module's commands (and command-only helpers). `grails-core:cli` additionally holds the shared command contract/registry. - **`grailsCli`** — the app/plugin Gradle configuration that carries `:cli` classifiers: compile-visible and on the command-runner classpath, but not on `runtimeClasspath`/`bootRun`. Both `grailsCli` (project dependencies) and `buildscript { classpath }` placement register command tasks. - **`org.apache.grails.<area>.cli`** — the package for each `:cli` variant; unique per artifact (JPMS: no split packages), replacing legacy `grails.dev.commands.*`. Each `:cli` jar sets a matching `Automatic-Module-Name`. `org.apache.grails.cli` is reserved (grails-forge/grails-cli). - **`META-INF/grails-cli.factories`** — command registrations, separate from `grails.factories`. ## References - https://github.com/apache/grails-core/issues/15377 — WAR-deployment NPE from CLI-only Spring Boot components (`SpringApplicationWebApplicationInitializer`) transitively on the runtime classpath via dbmigration → `grails-shell`. - `grails-data-hibernate{5,7}/dbmigration/build.gradle` — the existing `grails-shell-cli` Groovy exclusion + TODO calling for this split. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
