You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@grails.apache.org by "jdaugherty (via GitHub)" <gi...@apache.org> on 2026/07/05 15:55:56 UTC
Re: [I] Grails 8 - grails-data-hibernate5-dbmigration bootWar: Cannot invoke "String.split(String)" because "sources" is null - In a classic servlet-container WAR deployment [grails-core]
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: notifications-unsubscribe@grails.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org