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]

Reply via email to