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]
