jdaugherty commented on issue #15377:
URL: https://github.com/apache/grails-core/issues/15377#issuecomment-4929004757

   This was the updated plan that I initially used to create the associated PR
   
   # Proposal: Move Grails CLI commands into companion `-cli` 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 
each module's commands
   as a **companion artifact with its own Maven coordinate**, suffixed `-cli`: 
the module's `main`
   source set publishes as today (`org.apache.grails:grails-core` → 
`grails-core.jar`) and a new `cli`
   source set **in the same Gradle project** publishes as a second publication 
under
   **`org.apache.grails:grails-core-cli`** → `grails-core-cli.jar`, with its 
**own POM/module metadata
   and its own dependency graph**. Commands are 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 Gradle subprojects are
   created; the new coordinates are additional publications of the existing 
projects.
   
   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.
   - CLI artifacts are **plain Maven coordinates** 
(`org.apache.grails:<module>-cli`) — resolvable by any
     build tool without Gradle Module Metadata, classifiers, or capability 
syntax.
   - No proliferation of Gradle subprojects; the `-cli` artifacts are 
additional publications of
     existing projects.
   - 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 companion `-cli` artifact (own coordinate, same 
Gradle project)
   
   Each command-bearing module keeps its coordinate and gains a **`cli` source 
set**
   (`src/cli/groovy`) holding only the commands. That source set is published 
as a **second
   publication of the same project** under its own coordinate — the module's 
artifactId with a `-cli`
   suffix — carrying its **own dependency set** in its own POM and Gradle 
Module Metadata. The
   CLI-only dependencies (e.g. `grails-shell-cli`) therefore never appear in 
the default artifact's
   metadata at all.
   
   Mechanically (encapsulated in a build-logic convention plugin, see §1a):
   
   ```groovy
   sourceSets { cli }   // src/cli/groovy — dbmigration: relocate 
grails-app/commands here
   java {
       registerFeature('cli') {
           usingSourceSet(sourceSets.cli)
           // capability == the published -cli coordinate, so in-repo variant 
selection
           // and the published GMM agree
           capability('org.apache.grails', "${project.name}-cli", 
project.version)
       }
   }
   dependencies {
       cliApi project(path)                                          // the 
module's own default artifact
       cliApi(project(':grails-core')) {                             // command 
contract (see §2)
           capabilities { 
requireCapability('org.apache.grails:grails-core-cli') }
       }
       cliImplementation(project(':grails-shell-cli'))               // 
CLI-only; off the default graph
   }
   ```
   
   - The feature's variants are **removed from the `java` component**
     
(`components.java.withVariantsFromConfiguration(cliApiElements|cliRuntimeElements)
 { skip() }`),
     so the default publication (`org.apache.grails:grails-core`) contains no 
trace of the cli tier —
     no classifier jar, no `<optional>` POM pollution, no extra GMM variants.
   - A dedicated **adhoc software component** 
(`softwareComponentFactory.adhoc('cli')`) is built from
     `cliApiElements`/`cliRuntimeElements` (+ cli sources/javadoc jars) and 
published as a second
     `MavenPublication` with `artifactId = "${pomArtifactId ?: 
project.name}-cli"`.
   - In-repo, other modules consume a project's cli tier via capability-based 
variant selection (the
     `requireCapability` form above); Gradle maps that project dependency to the
     `org.apache.grails:<module>-cli` coordinate in the published POM/GMM 
because the publication owns
     that capability. External consumers just use the plain coordinate.
   
   > **Why a second coordinate, not a `cli` classifier / feature-variant 
classifier.** A classifier
   > published from a feature variant separates the dependency graphs **only in 
Gradle Module
   > Metadata**; the shared POM lists the cli dependencies as `<optional>` 
entries on the *main*
   > artifact, and Maven-based consumers of the cli jar get no usable 
dependency graph at all.
   > Consumption also requires classifier/capability syntax. A plain coordinate 
is a first-class
   > citizen everywhere: Maven consumers, the BOM, dependabot, and docs all 
handle
   > `org.apache.grails:grails-core-cli` with zero special cases. See Rejected 
alternatives.
   
   ### 1a. Publishing wiring (grails-publish + build-logic + BOM)
   
   Publishing in this build is layered: the external 
**`org.apache.grails.gradle:grails-publish`**
   plugin creates the (single) `maven` publication per project and handles 
signing/Nexus staging, and
   the in-repo `org.apache.grails.buildsrc.publish` convention configures the 
`GrailsPublishExtension`
   plus checksum/artifact-list tasks. Today grails-publish assumes **one 
publication per project**
   (one `artifactId`, one component). This proposal extends it — we own 
grails-publish, so it will be
   enhanced rather than bypassed:
   
   1. **grails-publish enhancement** (in `grails-gradle-publish`, 8.x line; 
grails-core then bumps its
      pinned version in `dependencies.gradle`). Signing 
(`sign(publishing.publications)`), Nexus
      staging, repositories, and the `install` alias are already 
publication-agnostic — a second
      publication is covered for free. What is hardcoded to the single 
publication and must change:
      - `GrailsPublishExtension` gains an *additional publications* API — each 
registration pairs an
        artifactId with a named software component (e.g.
        `additionalPublication('cli') { artifactId = "${name}-cli"; 
componentName = 'cli' }`).
      - The publication-creation block 
(`publications.create(gpe.publicationName...)` +
        `doAddArtefact` attaching `components.java`) loops over primary + 
additional registrations;
        the inline POM-metadata block (license/developers/scm/`withXml`) is 
extracted into a method
        applied to every publication.
      - `versionMapping` hardcodes `fromResolutionOf('runtimeClasspath')` — the 
resolution
        configuration becomes per-publication (`cliRuntimeClasspath` for the 
cli publication).
      - `setDependencyVersions` resolves only 
`compileClasspath`/`runtimeClasspath`
        (+ testFixtures) to patch missing POM versions — it must also resolve 
the additional
        publication's classpaths (`cliCompileClasspath`/`cliRuntimeClasspath`), 
or cli-only deps fail
        with "No version found for dependency".
      - Sources/javadoc: Maven Central requires sources + javadoc per 
coordinate, so each additional
        publication gets its own sources/javadoc jars built from its source 
set. **Latent leak to
        fix:** the existing `sourcesJar` does `jar.from sourceSets.collect { 
it.allSource }` (all
        source sets), which would put cli sources into the *main* sources jar — 
exclude source sets
        owned by additional publications.
      - `getDefaultExtraArtifact` (the `grails-plugin.xml`/`plugin`-classifier 
artifact) stays
        primary-publication-only.
   2. **Shipped Gradle plugin `org.apache.grails.gradle.grails-plugin-cli`**
      (`GrailsCliArtifactGradlePlugin` in grails-gradle/plugins), applied by 
each command-bearing
      module — the framework dogfoods the same plugin third-party authors use. 
It encapsulates
      everything in §1: the `cli` source set, feature registration, skipping 
the cli variants from the
      `java` component, the adhoc `cli` component, the `Grails-Cli-Artifact` 
runtime-jar manifest
      marker, default cli dependencies (opt-out via `cliArtifact { 
defaultDependencies = false }`,
      which framework modules use to wire project dependencies instead), 
registration with
      grails-publish, and the `cliArtifactId` ext tag downstream build logic 
uses to discover the
      extra coordinate. *(A build-logic wrapper was tried and deleted — it 
reduced to two lines of
      configuration each module now sets directly.)*
   3. **BOM.** `grails-bom/base` auto-enumerates published subprojects (`api 
project(...)`) — that only
      captures each project's *primary* coordinate. Extend the enumeration to 
also add a constraint for
      `org.apache.grails:${cliArtifactId}:${projectVersion}` for every project 
tagged by the convention
      plugin, and extend the `projectArtifactIds`/`projectCoordinateProperties` 
maps used by
      `ExtractDependenciesTask` accordingly. No hand-maintained list.
   4. **Existing tasks need no change.** `savePublishedArtifacts`, 
`publishedChecksums`, and the
      version-mapping block in `PublishPlugin` already iterate 
`publications.withType(MavenPublication)`
      generically; `ensureJarContainsASFFiles` already covers every `Jar` task 
(the cli jar gets
      LICENSE/NOTICE for free). `publish-root-config.gradle`'s 
`publishedProjects` list is unchanged —
      the cli publications live in projects already on it.
   
   ### 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 `cli` source set of `grails-core`, published as
   `org.apache.grails:grails-core-cli`:
   
   - `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` artifact 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 `org.apache.grails:grails-core-cli` (and applied plugins' 
`-cli` companions) 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` artifact gets a **unique leaf package** 
so no package is ever
   split across artifacts (JPMS forbids split packages — e.g. the module and 
its `-cli` companion must
   not both contain classes in the module's package), and each `-cli` jar sets 
an explicit
   `Automatic-Module-Name` matching its package (no `Automatic-Module-Name` is 
set anywhere today, so
   names currently derive from filenames; setting it explicitly makes the 
module name stable and
   intentional).
   
   Note `org.apache.grails.cli` is already owned by `grails-forge/grails-cli` 
and must not be reused.
   
   | `-cli` artifact | 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-cli` | `org.apache.grails.data.hibernate5.cli` | 
`…hibernate5.cli` |
   | `grails-data-hibernate7-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 'org.apache.grails:<module>-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: declare only the runtime plugins — the cli tier is 
discovered automatically.
   // A command-bearing plugin advertises its companion via the 
`Grails-Cli-Artifact` manifest
   // attribute of its runtime jar (stamped by the cli-artifact convention; one 
manifest line for
   // third-party plugins). The Grails Gradle plugin adds grails-core-cli + 
grails-console to
   // grailsCli, walks the resolved dependency graph (including transitive 
plugins) for advertised
   // companions via a lenient artifactView over the internal grailsCliDetect 
configuration, and also
   // registers the per-command Gradle tasks (dbmUpdate, …) from the discovered 
companions'
   // grails-cli.factories — no buildscript classpath entry needed. Opt out with
   // grails { cliAutoProvision = false }.
   dependencies {
       implementation 'org.apache.grails:grails-core'                           
    // runtime — no commands, no contract
       implementation 'org.apache.grails:grails-data-hibernate7-dbmigration'    
    // runtime plugin — no shell-cli
                                                                                
    // → …-dbmigration-cli auto-discovered
   }
   
   // Still supported: buildscript placement for task registration
   buildscript {
       dependencies {
           classpath 'org.apache.grails:grails-data-hibernate7-dbmigration-cli'
       }
   }
   ```
   
   Both forms are **plain coordinates** — no classifier or capability syntax 
anywhere in a consumer
   build. The app builds and ships normally; the `-cli` companion 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 separate `-cli` artifact** 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` artifact contents)
   
   | Module | `-cli` artifact contents | `-cli` deps beyond contract |
   |---|---|---|
   | `grails-core` → `grails-core-cli` | entire `grails/dev/commands/**` 
(contract + registry + infra + `ConfigReportCommand`) | — |
   | `grails-web-url-mappings` → `…-cli` | `UrlMappingsReportCommand` | 
`UrlMappingsHolder` (own default artifact) |
   | `grails-data-hibernate5` → `…-cli` | `SchemaExportCommand` | Hibernate 5 
runtime (own default artifact) |
   | `grails-data-hibernate7` → `…-cli` | `SchemaExportCommand` | Hibernate 7 
runtime (own default artifact) |
   | `grails-scaffolding` → `…-cli` | 9 commands + command-only helpers 
(`CommandLineHelper`, `SkipBootstrap`) | `Model` (own default artifact) |
   | `grails-data-hibernate5-dbmigration` → `…-cli` | 29 `dbm-*` + command 
traits + `src/main/scripts/*` | `grails-shell-cli`, plugin's default artifact |
   | `grails-data-hibernate7-dbmigration` → `…-cli` | 29 `dbm-*` + command 
traits + `src/main/scripts/*` | `grails-shell-cli`, plugin's default artifact |
   
   For dbmigration the boundary was verified acyclic — runtime classes (plugin 
descriptor, `liquibase/**`,
   shared support) never reference the `command` package — so the runtime 
(default) artifact drops
   `grails-shell-cli` entirely.
   
   ## Work breakdown (phased)
   
   **Phase 0 — publishing infrastructure**
   1. Extend **grails-publish** (`org.apache.grails.gradle:grails-publish`) to 
support additional
      publications per project (component + artifactId registration; POM 
metadata, signing,
      sources/javadoc, staging parity with the primary publication). 
Release/consume the updated plugin.
   2. Add the **`org.apache.grails.gradle.grails-plugin-cli`** shipped plugin 
(§1a): `cli`
      source set, feature, skip cli variants from the `java` component, adhoc 
`cli`
      component, second publication `${artifactId}-cli`, 
`Automatic-Module-Name`, manifest marker,
      `cliArtifactId` tag.
   3. Extend `grails-bom/base` enumeration (+ `ExtractDependenciesTask` maps) 
to emit constraints for
      every tagged `-cli` coordinate; verify `validateDependencyVersions` 
passes.
   
   **Phase 1 — `grails-core-cli` + the factories file**
   4. Apply the convention plugin to `grails-core`; move 
`grails/dev/commands/**` into the `cli` source
      set, **renamed to `org.apache.grails.core.cli.**`**.
   5. Add a location-parameterized overload to `FactoriesLoaderSupport` (+ 
`GrailsFactoriesLoader`);
      point `ApplicationContextCommandRegistry` at `grails-cli.factories`.
   6. 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.
   7. Verify the published output: `grails-core.pom`/`.module` carry no cli 
variant or dependency;
      `grails-core-cli.pom`/`.module` carry the cli graph with in-repo 
capability deps mapped to the
      `-cli` coordinates.
   
   **Phase 2 — `grailsCli` configuration**
   8. Register `grailsCli`; wire compile visibility; keep it off 
`runtimeClasspath`/`bootRun`.
   9. 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` companions).
   
   **Phase 3 — dbmigration (the original driver)**
   10. Apply the convention plugin 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 artifact. Update example apps to 
`implementation` (runtime) +
       `grailsCli` (`…-dbmigration-cli`).
   
   **Phase 4 — remaining framework commands**
   11. Apply the convention plugin 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 5 — docs & verification**
   12. What's New entry; Upgrade guide section (breaking: commands move to 
`<module>-cli` artifacts on
       `grailsCli`; `grails-cli.factories` clean break; Grails 7 command 
plugins must be
       rebuilt/migrated).
   13. Plugin best-practices section: "Ship CLI commands as a companion `-cli` 
artifact" pattern.
   14. Full build + violations + module test suites; verify commands resolve in 
the shell and are absent
       from runtime artifacts and from `bootRun`; verify a local 
`publishToMavenLocal`/staging run
       produces signed, checksummed `-cli` artifacts alongside the primary ones.
   
   ## Change surface (key files)
   
   - **grails-publish** (external repo) — additional-publication support in the 
plugin +
     `GrailsPublishExtension`
   - `grails-gradle/plugins/.../GrailsCliArtifactGradlePlugin` (new shipped 
plugin) + tests
   - `grails-bom/base/build.gradle` — enumerate tagged `-cli` coordinates; 
`ExtractDependenciesTask`
     artifactId/coordinate maps
   - `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` artifact — 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` companions
   - `build.gradle` of each command-bearing module — apply the `cli-artifact` 
convention plugin + `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
   
   - **`cli` classifier via feature variants (previous draft of this 
proposal).** Publishes the commands
     as a `…-cli.jar` classifier of the module's own coordinate, relying on 
Gradle Module Metadata to
     keep the dependency graphs apart. Rejected because the separation is 
**Gradle-only**: the shared
     POM lists the cli dependencies as `<optional>` on the main artifact, Maven 
consumers of the cli
     classifier get no dependency graph at all, and every consumer (including 
`buildscript { classpath }`
     placement and third-party docs) must use classifier/capability syntax. 
Separate coordinates make
     the cli tier a first-class citizen for Maven, the BOM, and dependency 
tooling.
   - **New `grails-<module>-cli` Gradle subprojects (+ a `grails-cli-core` 
module).** Achieves the same
     published coordinates, but proliferates `settings.gradle` entries and 
project scaffolding, and
     moves command sources away from the module they belong to. The chosen 
design gets identical
     coordinates from a `cli` source set inside the existing project.
   - **Plain second `artifact(classifier: 'cli')` on the main publication.** 
Separates the jar but not
     its dependencies — `grails-shell-cli` would still leak via the shared POM.
   - **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
   
   - **grails-publish enhancement is a prerequisite.** The plugin creates one 
publication per project;
     the concrete change list is in §1a (verified against the 
`grails-gradle-publish` 8.x source —
     signing and Nexus staging are already publication-agnostic; publication 
creation, POM metadata,
     version-mapping, `setDependencyVersions`, and per-coordinate 
sources/javadoc jars are not). This
     adds a cross-repo release-ordering dependency: grails-gradle-publish ships 
first, grails-core
     bumps `grails-publish-plugin.version` in `dependencies.gradle`, then the 
`cli-artifact`
     convention plugin builds on it. The in-repo `PublishPlugin` tasks 
(`savePublishedArtifacts`,
     `publishedChecksums`) already iterate all publications and need no 
structural change.
   - **Project-dependency → coordinate mapping in published metadata (partially 
resolved).**
     Implemented and validated in grails-publish: a project publishing multiple 
coordinates must form a
     **single component tree** — grails-publish now wraps the primary component 
in a
     `GrailsRootSoftwareComponent` (`ComponentWithVariants`) with each 
additional publication's
     component as a child (the Kotlin-Multiplatform model; without it Gradle 
fails with "Publishing is
     not able to resolve a dependency on a project with multiple publications 
that have different
     coordinates"). Consequences: the primary GMM lists the cli variants as 
capability-gated
     `available-at` redirects (inert for normal consumers), and self/project 
dependencies resolve to the
     primary coordinate. **Still open for grails-core:** a *consumer POM* (e.g.
     `grails-data-hibernate7-dbmigration-cli` depending on `grails-core-cli` via
     `requireCapability`) records component-level coordinates (`grails-core`) — 
correct for Gradle
     consumers via the `available-at` redirect, but wrong for Maven consumers. 
The `cli-artifact`
     convention plugin must enable Gradle's incubating dependency mapping
     (`ConfigurationVariantDetails.dependencyMapping { 
publishResolvedCoordinates = true }`) on the cli
     variants so consumer POMs record the `-cli` coordinates; validate the 
generated `.pom` files in
     Phase 1 step 7.
   - **BOM enumeration and `validateDependencyVersions`.** The `-cli` 
coordinates are not auto-picked-up
     by `grails-bom`'s subproject scan; the tagged-project enumeration (§1a) 
must cover them, and
     `validateDependencyVersions` must see the BOM managing them.
   - **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.
   - **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)
   
   - **`<artifactId>-cli`** — the companion Maven coordinate 
(`org.apache.grails` group, same version)
     containing that module's commands (and command-only helpers), published as 
a second publication of
     the same Gradle project from its `cli` source set. `grails-core-cli` 
additionally holds the shared
     command contract/registry.
   - **`grailsCli`** — the app/plugin Gradle configuration that carries `-cli` 
artifacts: 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` artifact; 
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