codeconsole commented on code in PR #15948: URL: https://github.com/apache/grails-core/pull/15948#discussion_r3557184858
########## grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy: ########## @@ -0,0 +1,435 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.gradle.plugin.commands + +import java.util.jar.JarFile + +import groovy.transform.CompileDynamic +import groovy.transform.CompileStatic + +import org.gradle.api.NamedDomainObjectProvider +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.ConfigurationContainer +import org.gradle.api.artifacts.Dependency +import org.gradle.api.artifacts.DependencySet +import org.gradle.api.artifacts.component.ProjectComponentIdentifier +import org.gradle.api.artifacts.result.ResolvedArtifactResult +import org.gradle.api.file.FileCollection +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.SourceSet +import org.gradle.api.tasks.TaskContainer +import org.gradle.api.tasks.TaskProvider + +import grails.util.Environment +import grails.util.GrailsNameUtils +import org.grails.gradle.plugin.core.GrailsExtension +import org.grails.gradle.plugin.core.GrailsGradlePlugin +import org.grails.gradle.plugin.util.ClasspathUtils +import org.grails.gradle.plugin.util.SourceSets +import org.grails.io.support.FactoriesLoaderSupport +import org.grails.build.parsing.CommandLineParser + +/** + * Configures the CLI tier of a Grails application or plugin project: the {@code grailsCli} + * configurations (with automatic discovery of companion {@code -cli} artifacts advertised by the + * dependency graph), the per-command tasks, the generic {@code runCommand}/{@code runScript} + * tasks, and the interactive {@code console}/{@code shell} tasks. Applied automatically by the + * Grails Gradle plugin. + * + * @since 8.0 + */ +@CompileStatic +class GrailsCliGradlePlugin implements Plugin<Project> { + + public static final String APPLICATION_CONTEXT_COMMAND_CLASS = 'org.apache.grails.core.cli.ApplicationCommand' + + /** + * The dependency bucket carrying CLI-only dependencies (Grails commands and their libraries): + * compile-visible so `grails-app/commands` sources compile against the cli-only contract, on + * the command-runner classpath, but never on `runtimeClasspath`, `bootRun`, or packaged + * artifacts. + */ + public static final String GRAILS_CLI_CONFIGURATION = 'grailsCli' + + /** The resolvable view of {@link #GRAILS_CLI_CONFIGURATION} used by the command-runner tasks */ + public static final String GRAILS_CLI_CLASSPATH_CONFIGURATION = 'grailsCliClasspath' + + /** + * Set this project property to {@code false} to stop the plugin from auto-provisioning the + * CLI tier onto {@link #GRAILS_CLI_CONFIGURATION}; equivalent to + * {@code grails { cliAutoProvision = false }}. + */ + public static final String GRAILS_CLI_AUTO_PROVISION_PROPERTY = 'grailsCliAutoProvision' + + /** The internal probe configuration used to detect companion `-cli` modules */ + public static final String GRAILS_CLI_DETECT_CONFIGURATION = 'grailsCliDetect' + + @Override + void apply(Project project) { + // self-sufficient when applied standalone: the auto-provisioning behavior is configured + // through the `grails` extension, normally registered by the Grails Gradle plugin + if (project.extensions.findByName('grails') == null) { + project.extensions.create('grails', GrailsExtension, project) + } + + configureGrailsCliConfiguration(project) + + configureConsoleTask(project) + + configureApplicationCommands(project) + + configureRunScript(project) + + configureRunCommand(project) + } + + /** + * Registers the `grailsCli` dependency bucket and its resolvable `grailsCliClasspath` view. + * `grailsCli` carries the CLI tier — command companion artifacts (`<artifactId>-cli`) and the + * libraries they need. It extends the compile classpaths (the same wiring `compileOnly` uses) + * so `grails-app/commands` sources compile against the cli-only contract, while staying off + * `runtimeClasspath` — and therefore out of `bootRun`, `bootJar`, and `bootWar`. + */ + protected void configureGrailsCliConfiguration(Project project) { + ConfigurationContainer configurations = project.configurations + if (configurations.names.contains(GRAILS_CLI_CONFIGURATION)) { + return + } + + Configuration grailsCli = configurations.create(GRAILS_CLI_CONFIGURATION) + grailsCli.canBeResolved = false + grailsCli.canBeConsumed = false + grailsCli.description = 'CLI-only dependencies (Grails commands and the libraries they need); compile-visible and on the command-runner classpath, but never on runtimeClasspath, bootRun, or packaged artifacts.' + + // compile visibility for grails-app/commands sources plus the TEST classpaths (tests + // exercise commands inside the test JVM), while the main runtimeClasspath — and therefore + // bootRun, bootJar, and bootWar — never sees the cli tier; matching configurations that + // appear later (e.g. the integrationTest pair) are included as they are created + configurations.matching { Configuration it -> + it.name in ['compileClasspath', 'testCompileClasspath', 'testRuntimeClasspath', + 'integrationTestCompileClasspath', 'integrationTestRuntimeClasspath'] + }.configureEach { Configuration it -> + it.extendsFrom(grailsCli) + } + + Configuration grailsCliClasspath = configurations.create(GRAILS_CLI_CLASSPATH_CONFIGURATION) + grailsCliClasspath.extendsFrom(grailsCli) + grailsCliClasspath.canBeResolved = true + grailsCliClasspath.canBeConsumed = false + grailsCliClasspath.description = 'Resolvable view of grailsCli used by the command-runner tasks.' + + Configuration grailsCliDetect = configurations.create(GRAILS_CLI_DETECT_CONFIGURATION) + grailsCliDetect.canBeResolved = true + grailsCliDetect.canBeConsumed = false + grailsCliDetect.visible = false + grailsCliDetect.description = 'Internal probe used to discover companion -cli artifacts advertised by dependencies.' + for (String bucket : ['api', 'implementation', 'runtimeOnly']) { + configurations.matching { Configuration it -> it.name == bucket }.configureEach { Configuration it -> + grailsCliDetect.extendsFrom(it) + } + } + + // computed when the configuration is first resolved, so every dependency (and the + // extension configuration) declared by the build script is visible + grailsCli.withDependencies { DependencySet dependencies -> + autoProvisionCliDependencies(project, dependencies) + } + } + + /** + * Auto-provisions the CLI tier onto {@code grailsCli}: the command contract and runner, plus + * every companion {@code -cli} artifact advertised by a dependency of the application. A + * module advertises its companion through the {@code Grails-Cli-Artifact} manifest attribute + * of its runtime jar (stamped by the framework's cli-artifact build convention; third-party + * plugins set it on their jar task). Discovery walks the full dependency graph (including + * transitive plugins) through a lenient resolution of an internal probe configuration. + * Disable with {@code grails { cliAutoProvision = false }}. + */ + @CompileDynamic + protected void autoProvisionCliDependencies(Project project, DependencySet dependencies) { + GrailsExtension grails = project.extensions.getByType(GrailsExtension) + if (!grails.cliAutoProvision.get()) { + return + } + + // command authoring and execution work out of the box: the command contract + the runner + dependencies.add(project.dependencies.create('org.apache.grails:grails-core-cli')) + dependencies.add(project.dependencies.create('org.apache.grails:grails-console')) + + Configuration probe = project.configurations.getByName(GRAILS_CLI_DETECT_CONFIGURATION) + Set<String> companions = [] as Set + def lenientArtifacts = probe.incoming.artifactView { it.lenient(true) }.artifacts + for (ResolvedArtifactResult artifact : lenientArtifacts.artifacts) { + String companion = findAdvertisedCliArtifact(project, artifact) + if (companion) { + companions.add(companion) + } + } + + for (String companion : companions) { + List<String> coordinate = companion.tokenize(':') + if (coordinate.size() != 2) { + project.logger.warn('Ignoring malformed Grails-Cli-Artifact value [{}] found in the dependencies of project {}', companion, project.name) + continue + } + boolean alreadyDeclared = dependencies.any { Dependency existing -> + existing.group == coordinate[0] && existing.name == coordinate[1] + } + if (!alreadyDeclared) { + project.logger.info('Detected cli companion artifact {}, adding it to the {} configuration of project {}', + companion, GRAILS_CLI_CONFIGURATION, project.name) + dependencies.add(project.dependencies.create(companion)) + } + } + } + + /** + * Returns the companion cli coordinate ({@code group:artifactId}) advertised by the given + * resolved artifact, or {@code null}. For artifacts produced by a project of the same build + * the (possibly not yet built) jar is not read — the coordinate comes from the project's + * {@code cliArtifactId} property exported by the cli-artifact convention. + */ + @CompileDynamic + protected String findAdvertisedCliArtifact(Project project, ResolvedArtifactResult artifact) { + def componentIdentifier = artifact.id.componentIdentifier + if (componentIdentifier instanceof ProjectComponentIdentifier) { + Project target = project.rootProject.findProject(((ProjectComponentIdentifier) componentIdentifier).projectPath) + def cliArtifactId = target?.findProperty('cliArtifactId') + return cliArtifactId ? "${target.group}:${cliArtifactId}" as String : null Review Comment: **Blocking:** companions advertised by a same-build project dependency are wired as unversioned *external module* dependencies, so auto-discovery is effectively broken for the plugin-development workflow. Both branches of `findAdvertisedCliArtifact` return a bare `group:artifactId` string, and `autoProvisionCliDependencies` always adds it via `dependencies.create(companion)`. For a `ProjectComponentIdentifier` result this means Gradle tries to resolve the companion from a repository instead of binding to the sibling project's `cli` feature variant. Concretely: a multi-project build with `include 'app', 'my-plugin'` where `my-plugin` applies `grails-plugin-cli` and is unpublished — resolving `app`'s classpath fails (or silently picks up a stale previously-published artifact of the same coordinate). The framework's own build corroborates the gap: root `build.gradle` disables auto-provisioning for every framework module, and `gradle/functional-test-config.gradle` adds a bespoke `dependencySubstitution` rule that performs exactly the project+capability wiring this method should produce itself. Composite builds (`includeBuild`) hit a related edge: the included build's component is a `ProjectComponentIdentifier` that `project.rootProject.findProject(projectPath)` either fails to resolve (companion silently skipped) or resolves to a same-named project in the *wrong* build. Suggested fix: when the component is a project of the current build, add `project.dependencies.project(path)` with `capabilities { requireCapability(...) }`; keep the coordinate path only for `ModuleComponentIdentifier` results. Alternatively, if in-build discovery is intentionally out of scope, document that and point at a substitution recipe. ########## grails-bom/base/build.gradle: ########## @@ -97,6 +97,23 @@ dependencies { } } +// Companion cli artifacts (published by the cli-artifact convention plugin) are additional +// publications of existing projects, so the subproject enumeration above cannot see them. Each +// applying project exports its companion coordinate via the `cliArtifactId` extra property, which +// only exists once that project has been evaluated — so the constraints are computed lazily, in +// the mutation window Gradle provides right before the configuration is first observed. +configurations.named('api').configure { apiConfiguration -> + apiConfiguration.withDependencies { + for (Project subproject : rootProject.subprojects) { + def cliArtifactId = subproject.findProperty('cliArtifactId') + if (cliArtifactId) { + apiConfiguration.dependencyConstraints.add( + project.dependencies.constraints.create("${subproject.group}:${cliArtifactId}:${projectVersion}")) Review Comment: Related to the `grails-publish` SNAPSHOT in `dependencies.gradle`: note that `validateNoSnapshotDependencies` (below in this file) only iterates `config.allDependencies` — constraints created here via `dependencies.constraints.create(...)` (and the `constraints { api ... }` block above) live in `allDependencyConstraints`, so the release guard has a blind spot for exactly this kind of leak and would let a SNAPSHOT constraint through a release build unflagged. Worth extending the guard to scan constraints as part of this PR, since the new lazy constraint mechanism widens that surface. ########## grails-core/src/test/groovy/org/apache/grails/core/cli/compiler/CommandFactoriesTransformationSpec.groovy: ########## @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.core.cli.compiler + +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Commands shipped in a companion cli artifact compile from the {@code cli} source set + * ({@code src/cli/groovy}), which is not a standard project-source location — the transformation + * must still register them in {@code META-INF/grails-cli.factories}. + */ +class CommandFactoriesTransformationSpec extends Specification { Review Comment: This spec only exercises the `isCliSource(URL)` helper. Nothing drives the transformation end-to-end: compiling a direct `ApplicationCommand` implementor, an indirect one via the `GrailsApplicationCommand` trait, and an abstract base class (which must be excluded), then asserting what lands in `META-INF/grails-cli.factories`. `ApplicationContextCommandRegistry` also has no spec pinning the intentional clean-break behavior — that a stale `ApplicationCommand` entry left in a legacy jar's `grails.factories` is silently ignored rather than loaded or erroring. Since the clean break is the headline breaking change, it deserves a test that locks it in. ########## grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy: ########## @@ -0,0 +1,435 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.gradle.plugin.commands + +import java.util.jar.JarFile + +import groovy.transform.CompileDynamic +import groovy.transform.CompileStatic + +import org.gradle.api.NamedDomainObjectProvider +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.ConfigurationContainer +import org.gradle.api.artifacts.Dependency +import org.gradle.api.artifacts.DependencySet +import org.gradle.api.artifacts.component.ProjectComponentIdentifier +import org.gradle.api.artifacts.result.ResolvedArtifactResult +import org.gradle.api.file.FileCollection +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.SourceSet +import org.gradle.api.tasks.TaskContainer +import org.gradle.api.tasks.TaskProvider + +import grails.util.Environment +import grails.util.GrailsNameUtils +import org.grails.gradle.plugin.core.GrailsExtension +import org.grails.gradle.plugin.core.GrailsGradlePlugin +import org.grails.gradle.plugin.util.ClasspathUtils +import org.grails.gradle.plugin.util.SourceSets +import org.grails.io.support.FactoriesLoaderSupport +import org.grails.build.parsing.CommandLineParser + +/** + * Configures the CLI tier of a Grails application or plugin project: the {@code grailsCli} + * configurations (with automatic discovery of companion {@code -cli} artifacts advertised by the + * dependency graph), the per-command tasks, the generic {@code runCommand}/{@code runScript} + * tasks, and the interactive {@code console}/{@code shell} tasks. Applied automatically by the + * Grails Gradle plugin. + * + * @since 8.0 + */ +@CompileStatic +class GrailsCliGradlePlugin implements Plugin<Project> { + + public static final String APPLICATION_CONTEXT_COMMAND_CLASS = 'org.apache.grails.core.cli.ApplicationCommand' + + /** + * The dependency bucket carrying CLI-only dependencies (Grails commands and their libraries): + * compile-visible so `grails-app/commands` sources compile against the cli-only contract, on + * the command-runner classpath, but never on `runtimeClasspath`, `bootRun`, or packaged + * artifacts. + */ + public static final String GRAILS_CLI_CONFIGURATION = 'grailsCli' + + /** The resolvable view of {@link #GRAILS_CLI_CONFIGURATION} used by the command-runner tasks */ + public static final String GRAILS_CLI_CLASSPATH_CONFIGURATION = 'grailsCliClasspath' + + /** + * Set this project property to {@code false} to stop the plugin from auto-provisioning the + * CLI tier onto {@link #GRAILS_CLI_CONFIGURATION}; equivalent to + * {@code grails { cliAutoProvision = false }}. + */ + public static final String GRAILS_CLI_AUTO_PROVISION_PROPERTY = 'grailsCliAutoProvision' + + /** The internal probe configuration used to detect companion `-cli` modules */ + public static final String GRAILS_CLI_DETECT_CONFIGURATION = 'grailsCliDetect' + + @Override + void apply(Project project) { + // self-sufficient when applied standalone: the auto-provisioning behavior is configured + // through the `grails` extension, normally registered by the Grails Gradle plugin + if (project.extensions.findByName('grails') == null) { + project.extensions.create('grails', GrailsExtension, project) + } + + configureGrailsCliConfiguration(project) + + configureConsoleTask(project) + + configureApplicationCommands(project) + + configureRunScript(project) + + configureRunCommand(project) + } + + /** + * Registers the `grailsCli` dependency bucket and its resolvable `grailsCliClasspath` view. + * `grailsCli` carries the CLI tier — command companion artifacts (`<artifactId>-cli`) and the + * libraries they need. It extends the compile classpaths (the same wiring `compileOnly` uses) + * so `grails-app/commands` sources compile against the cli-only contract, while staying off + * `runtimeClasspath` — and therefore out of `bootRun`, `bootJar`, and `bootWar`. + */ + protected void configureGrailsCliConfiguration(Project project) { + ConfigurationContainer configurations = project.configurations + if (configurations.names.contains(GRAILS_CLI_CONFIGURATION)) { + return + } + + Configuration grailsCli = configurations.create(GRAILS_CLI_CONFIGURATION) + grailsCli.canBeResolved = false + grailsCli.canBeConsumed = false + grailsCli.description = 'CLI-only dependencies (Grails commands and the libraries they need); compile-visible and on the command-runner classpath, but never on runtimeClasspath, bootRun, or packaged artifacts.' + + // compile visibility for grails-app/commands sources plus the TEST classpaths (tests + // exercise commands inside the test JVM), while the main runtimeClasspath — and therefore + // bootRun, bootJar, and bootWar — never sees the cli tier; matching configurations that + // appear later (e.g. the integrationTest pair) are included as they are created + configurations.matching { Configuration it -> + it.name in ['compileClasspath', 'testCompileClasspath', 'testRuntimeClasspath', + 'integrationTestCompileClasspath', 'integrationTestRuntimeClasspath'] + }.configureEach { Configuration it -> + it.extendsFrom(grailsCli) + } + + Configuration grailsCliClasspath = configurations.create(GRAILS_CLI_CLASSPATH_CONFIGURATION) + grailsCliClasspath.extendsFrom(grailsCli) + grailsCliClasspath.canBeResolved = true + grailsCliClasspath.canBeConsumed = false + grailsCliClasspath.description = 'Resolvable view of grailsCli used by the command-runner tasks.' + + Configuration grailsCliDetect = configurations.create(GRAILS_CLI_DETECT_CONFIGURATION) + grailsCliDetect.canBeResolved = true + grailsCliDetect.canBeConsumed = false + grailsCliDetect.visible = false + grailsCliDetect.description = 'Internal probe used to discover companion -cli artifacts advertised by dependencies.' + for (String bucket : ['api', 'implementation', 'runtimeOnly']) { + configurations.matching { Configuration it -> it.name == bucket }.configureEach { Configuration it -> + grailsCliDetect.extendsFrom(it) + } + } + + // computed when the configuration is first resolved, so every dependency (and the + // extension configuration) declared by the build script is visible + grailsCli.withDependencies { DependencySet dependencies -> + autoProvisionCliDependencies(project, dependencies) + } + } + + /** + * Auto-provisions the CLI tier onto {@code grailsCli}: the command contract and runner, plus + * every companion {@code -cli} artifact advertised by a dependency of the application. A + * module advertises its companion through the {@code Grails-Cli-Artifact} manifest attribute + * of its runtime jar (stamped by the framework's cli-artifact build convention; third-party + * plugins set it on their jar task). Discovery walks the full dependency graph (including + * transitive plugins) through a lenient resolution of an internal probe configuration. + * Disable with {@code grails { cliAutoProvision = false }}. + */ + @CompileDynamic + protected void autoProvisionCliDependencies(Project project, DependencySet dependencies) { + GrailsExtension grails = project.extensions.getByType(GrailsExtension) + if (!grails.cliAutoProvision.get()) { + return + } + + // command authoring and execution work out of the box: the command contract + the runner + dependencies.add(project.dependencies.create('org.apache.grails:grails-core-cli')) + dependencies.add(project.dependencies.create('org.apache.grails:grails-console')) + + Configuration probe = project.configurations.getByName(GRAILS_CLI_DETECT_CONFIGURATION) + Set<String> companions = [] as Set + def lenientArtifacts = probe.incoming.artifactView { it.lenient(true) }.artifacts + for (ResolvedArtifactResult artifact : lenientArtifacts.artifacts) { + String companion = findAdvertisedCliArtifact(project, artifact) + if (companion) { + companions.add(companion) + } + } + + for (String companion : companions) { + List<String> coordinate = companion.tokenize(':') + if (coordinate.size() != 2) { + project.logger.warn('Ignoring malformed Grails-Cli-Artifact value [{}] found in the dependencies of project {}', companion, project.name) + continue + } + boolean alreadyDeclared = dependencies.any { Dependency existing -> + existing.group == coordinate[0] && existing.name == coordinate[1] + } + if (!alreadyDeclared) { + project.logger.info('Detected cli companion artifact {}, adding it to the {} configuration of project {}', + companion, GRAILS_CLI_CONFIGURATION, project.name) + dependencies.add(project.dependencies.create(companion)) + } + } + } + + /** + * Returns the companion cli coordinate ({@code group:artifactId}) advertised by the given + * resolved artifact, or {@code null}. For artifacts produced by a project of the same build + * the (possibly not yet built) jar is not read — the coordinate comes from the project's + * {@code cliArtifactId} property exported by the cli-artifact convention. + */ + @CompileDynamic + protected String findAdvertisedCliArtifact(Project project, ResolvedArtifactResult artifact) { + def componentIdentifier = artifact.id.componentIdentifier + if (componentIdentifier instanceof ProjectComponentIdentifier) { + Project target = project.rootProject.findProject(((ProjectComponentIdentifier) componentIdentifier).projectPath) + def cliArtifactId = target?.findProperty('cliArtifactId') + return cliArtifactId ? "${target.group}:${cliArtifactId}" as String : null + } + + File file = artifact.file + if (file == null || !file.isFile() || !file.name.endsWith('.jar')) { + return null + } + try (JarFile jarFile = new JarFile(file)) { + return jarFile.manifest?.mainAttributes?.getValue('Grails-Cli-Artifact') + } + catch (IOException ignored) { + return null + } + } + + @CompileDynamic + protected void configureApplicationCommands(Project project) { + def applicationContextCommands = FactoriesLoaderSupport.loadFactoryNames(APPLICATION_CONTEXT_COMMAND_CLASS, + FactoriesLoaderSupport.classLoader, FactoriesLoaderSupport.CLI_FACTORIES_RESOURCE_LOCATION) + project.afterEvaluate { + FileCollection fileCollection = ClasspathUtils.buildClasspath(project, project.configurations.runtimeClasspath, project.configurations.grailsCliClasspath) + // commands come from the buildscript classloader (legacy placement) and from the + // project's cli tier, so auto-provisioned command artifacts register their tasks + // without any buildscript classpath entry + Set<String> commandClassNames = new LinkedHashSet<String>() + if (applicationContextCommands) { + commandClassNames.addAll(applicationContextCommands as List) + } + GrailsExtension grails = project.extensions.getByType(GrailsExtension) + if (grails.cliAutoProvision.get()) { + commandClassNames.addAll(loadCommandNamesFromCliClasspath(project)) + } + for (ctxCommand in commandClassNames) { + String taskName = GrailsNameUtils.getLogicalPropertyName(ctxCommand, 'Command') + String commandName = GrailsNameUtils.getScriptName(GrailsNameUtils.getLogicalName(ctxCommand, 'Command')) + if (!project.tasks.names.contains(taskName)) { + project.tasks.register(taskName, ApplicationContextCommandTask).configure { + it.classpath = fileCollection + it.command = commandName + it.systemProperty(Environment.KEY, System.getProperty(Environment.KEY, Environment.DEVELOPMENT.getName())) + List<Object> args = [] + def otherArgs = project.findProperty('args') + if (otherArgs) { + args.addAll(CommandLineParser.translateCommandline(otherArgs as String)) + } + + def appClassProvider = GrailsGradlePlugin.getMainClassProvider(project) + + it.doFirst { + args << appClassProvider.get() + it.args(args) + } + } + } + } + } + } + + /** + * Loads command class names from the {@code META-INF/grails-cli.factories} files of the + * resolved {@code grailsCliClasspath} jars, so per-command Gradle tasks (e.g. {@code dbmUpdate}) + * are registered for auto-provisioned cli artifacts. Resolution is lenient and jars that are + * not built yet (project dependencies within a composite) are skipped — the generic + * {@code runCommand} task can always execute those commands regardless. + */ + @CompileDynamic + protected Collection<String> loadCommandNamesFromCliClasspath(Project project) { + Set<String> names = new LinkedHashSet<String>() + Configuration cliClasspath = project.configurations.findByName(GRAILS_CLI_CLASSPATH_CONFIGURATION) + if (cliClasspath == null) { + return names + } + for (File file : cliClasspath.incoming.artifactView { it.lenient(true) }.files) { Review Comment: This forces full resolution of `grailsCliClasspath` (and, transitively, the `grailsCliDetect` probe via `withDependencies`) inside `project.afterEvaluate` — i.e. at configuration time, on every invocation. `./gradlew help` or an unrelated test run pays for resolving `grails-core-cli`, `grails-console`, and every discovered companion, and an offline/registry failure silently drops per-command task registration rather than failing loudly. It is also likely hostile to the configuration cache. I realize task names must be known at configuration time to `tasks.register(...)`, so some of this is a genuine design tension — but the resolution result should at least be cached/scoped so it is not repeated for builds that never touch a CLI task, and the lenient-swallow failure mode deserves a warning log at minimum. ########## grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/commands/CliCompanionPublishingSpec.groovy: ########## @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.gradle.plugin.commands + +import groovy.json.JsonSlurper + +import org.grails.gradle.plugin.core.GradleSpecification + +/** + * Verifies that cli-tier dependencies declared with a capability + * ({@code project(':owner') { capabilities { requireCapability('g:owner-cli') } }}) are published + * as plain dependencies on the companion coordinate ({@code owner-cli}) in both the pom and the + * Gradle Module Metadata, and that the published artifacts resolve from a Maven repository. + * + * A published capability request cannot be resolved against the published component tree: the + * owner's cli variant and its {@code available-at} redirect target both provide the capability, + * so consumers fail with a capability self-conflict (the grails-forge generated-app failure). + */ +class CliCompanionPublishingSpec extends GradleSpecification { Review Comment: The publishing side is well covered here (publish → parse `.module`/`.pom` → resolve from a fresh repo — nice), but the discovery/wiring half of the feature has no coverage at all: nothing tests `autoProvisionCliDependencies`/`findAdvertisedCliArtifact`, the guarantee that `grailsCli` never leaks into `runtimeClasspath`/`bootJar`/`bootWar`, per-command task registration, or the `cliAutoProvision` opt-out. That gap is not hypothetical — the one scenario that would have caught the project-dependency wiring bug (an app resolving a companion advertised by a project dependency, with `cliAutoProvision` at its default) is never exercised anywhere; the framework build globally disables the feature for its own modules. A functional test with an in-build plugin + consuming app would cover both the bug and the regression. ########## dependencies.gradle: ########## @@ -30,7 +30,7 @@ ext { 'directory-watcher.version' : '0.19.1', 'gradle-groovy.version' : '4.0.32', 'gradle-spock.version' : '2.4-groovy-4.0', - 'grails-publish-plugin.version' : '1.0.0-M1', + 'grails-publish-plugin.version' : '1.0.0-SNAPSHOT', Review Comment: **Blocking for release:** this `1.0.0-SNAPSHOT` flows into `gradleBomDependencies` and from there into the `constraints { api ... }` block of `grails-bom/base`, i.e. into the published `<dependencyManagement>` of `org.apache.grails:grails-bom` that every application consumes. A staged 8.0.x release would ship a BOM recommending a SNAPSHOT of `org.apache.grails.gradle:grails-publish`. I understand this is pending apache/grails-gradle-publish#34 shipping — but the PR should be explicitly marked blocked on that release, with the version reverted to a GA coordinate before merge (or at latest before the first 8.0.x release candidate). ########## grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliArtifactGradlePlugin.groovy: ########## @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.gradle.plugin.commands + +import groovy.transform.CompileStatic + +import org.gradle.api.GradleException +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.PublishArtifact +import org.gradle.api.component.AdhocComponentWithVariants +import org.gradle.api.component.ConfigurationVariantDetails +import org.gradle.api.component.SoftwareComponentFactory +import org.gradle.api.tasks.SourceSet +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.bundling.Jar +import org.gradle.api.plugins.JavaPluginExtension + +import javax.inject.Inject + +import org.apache.grails.gradle.publish.AdditionalPublication +import org.apache.grails.gradle.publish.GrailsPublishExtension + +/** + * Configures a Grails plugin project to ship its CLI commands as a companion artifact under its + * own Maven coordinate ({@code <artifactId>-cli}), keeping command code and CLI-only dependencies + * off the runtime classpath of consuming applications. + * + * Applying the plugin: + * <ul> + * <li>creates a {@code cli} source set ({@code src/cli/groovy}) exposed as a feature variant;</li> + * <li>adds {@code org.apache.grails:grails-core-cli} (the command contract) and the plugin's own + * runtime classes to the cli compile classpath, so commands compile with no additional + * configuration and are registered automatically in {@code META-INF/grails-cli.factories};</li> + * <li>keeps the cli variants out of the plugin's default publication and exposes them through a + * dedicated {@code cli} software component;</li> + * <li>stamps the runtime jar with the {@code Grails-Cli-Artifact} manifest attribute, so the + * Grails Gradle plugin adds the companion to a consuming application's {@code grailsCli} + * configuration automatically;</li> + * <li>registers the companion publication with the Grails publish plugin + * ({@code org.apache.grails.gradle.grails-publish}) when it is applied.</li> + * </ul> + * + * Configure through the {@code cliArtifact} extension: + * <pre> + * cliArtifact { + * automaticModuleName = 'com.example.myplugin.cli' + * } + * </pre> + * + * @since 8.0 + */ +@CompileStatic +abstract class GrailsCliArtifactGradlePlugin implements Plugin<Project> { + + public static final String CLI_SOURCE_SET_NAME = 'cli' + public static final String CLI_COMPONENT_NAME = 'cli' + public static final String CLI_PUBLICATION_NAME = 'cli' + + /** The runtime-jar manifest attribute advertising the companion cli coordinate */ + public static final String CLI_ARTIFACT_MANIFEST_ATTRIBUTE = 'Grails-Cli-Artifact' + + @Inject + abstract SoftwareComponentFactory getSoftwareComponentFactory() + + @Override + void apply(Project project) { + if (!project.pluginManager.hasPlugin('java')) { + throw new GradleException("The Grails cli-artifact plugin requires the `java` plugin (or a plugin that applies it, e.g. the Grails plugin plugin) to be applied to project `${project.name}` first.") + } + + CliArtifactExtension extension = project.extensions.create('cliArtifact', CliArtifactExtension) + extension.artifactId.convention(project.provider { "${project.name}-cli" as String }) + extension.defaultDependencies.convention(true) + + SourceSetContainer sourceSets = project.extensions.getByType(SourceSetContainer) + SourceSet cliSourceSet = sourceSets.create(CLI_SOURCE_SET_NAME) + + JavaPluginExtension java = project.extensions.getByType(JavaPluginExtension) + java.registerFeature(CLI_SOURCE_SET_NAME) { spec -> + // no explicit capability: the default (`${group}:${project.name}-cli:${version}`) Review Comment: Latent contract mismatch: `registerFeature` here keeps the Gradle-computed default capability (`${group}:${project.name}-cli`), but `CliArtifactExtension.artifactId` is documented as customizable and renames the published jar and advertised coordinate. A consumer who sets `cliArtifact { artifactId = 'custom-name' }` and then requests `requireCapability("$group:custom-name")` in-build will fail to resolve, because the in-build capability never followed the extension. Nothing in the framework customizes `artifactId` today, so this is unexercised — but either wire `spec.capability(...)` from the extension value or document the limitation on `CliArtifactExtension.artifactId`. -- 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]
