You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/07/19 10:35:37 UTC
(grails-core) 01/01: Restore backwards compatibility for Grails 7 command plugins on Grails 8
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch feat/8.0.x-legacy-command-compat
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 9a8a9a11dd8aee50cbb6b116af4d71b079d91fb0
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Sun Jul 19 00:39:31 2026 -0400
Restore backwards compatibility for Grails 7 command plugins on Grails 8
PR #15948 split the CLI command tier off the application runtime classpath.
As part of that split the command contract moved from grails.dev.commands.*
to org.apache.grails.core.cli.* (with no forwarding alias) and command
registration moved from META-INF/grails.factories (key
grails.dev.commands.ApplicationCommand) to META-INF/grails-cli.factories.
The result is that an unchanged, already-published Grails 7 command plugin
silently loses all of its application commands on Grails 8: the command
classes no longer link and their factory entries are never read.
This adds a deprecated compatibility layer that lives entirely in the
grails-core-cli tier (so the runtime classpath split is preserved) and lets
unchanged Grails 7 command plugins keep working without a re-release:
- Restore the deprecated grails.dev.commands.* command contract
(ApplicationCommand, GrailsApplicationCommand, ExecutionContext and the
io/template helpers) so pre-compiled Grails 7 command classes link again.
- Adapt legacy commands to the new contract via LegacyApplicationCommandAdapter
/ LegacyApplicationCommandAware, and discover them from the legacy
META-INF/grails.factories key in ApplicationContextCommandRegistry. Each
legacy command is instantiated in isolation so one faulty command cannot
suppress the rest, and a new-contract command always wins a name clash. A
one-time deprecation warning nudges authors to migrate.
- Unwrap the adapter in GrailsApplicationContextCommandRunner so Spring
autowiring and the skipBootstrap lookup target the real legacy command that
is subsequently executed.
- Register per-command Gradle tasks for legacy commands found on the runtime
classpath, degrading gracefully when the classpath cannot be resolved at
configuration time (the generic runCommand task always works).
Separately, fix a Groovy 5 regression affecting create-script codegen scripts:
a hyphenated script filename (for example audit-quickstart) produced a class
name without the hyphen, so the command resolved under the wrong name.
GroovyScriptCommandFactory now uses the filename-derived command name.
Adds unit and integration tests (including exception-isolation and
adapter/runner coverage), a real end-to-end functional example under
grails-test-examples/legacy-commands that boots a Grails 8 application and
runs a legacy plugin's ApplicationCommand, GrailsApplicationCommand and
hyphenated script, and upgrade documentation describing the compatibility
path and which command mechanisms are affected.
Assisted-by: claude-code:claude-4.8-opus
---
.../GrailsApplicationContextCommandRunner.groovy | 22 +-
...railsApplicationContextCommandRunnerSpec.groovy | 26 ++
.../grails/dev/commands/ApplicationCommand.groovy | 77 ++++++
.../grails/dev/commands/ExecutionContext.groovy | 55 ++++
.../dev/commands/GrailsApplicationCommand.groovy | 50 ++++
.../dev/commands/io/FileSystemInteraction.groovy | 146 +++++++++++
.../commands/io/FileSystemInteractionImpl.groovy | 284 +++++++++++++++++++++
.../dev/commands/template/TemplateException.groovy | 32 +++
.../dev/commands/template/TemplateRenderer.groovy | 174 +++++++++++++
.../commands/template/TemplateRendererImpl.groovy | 236 +++++++++++++++++
.../cli/ApplicationContextCommandRegistry.groovy | 38 +++
.../core/cli/LegacyApplicationCommandAware.groovy | 28 ++
.../compat/LegacyApplicationCommandAdapter.groovy | 70 +++++
.../cli/LegacyCommandRegistryLoadingSpec.groovy | 225 ++++++++++++++++
.../LegacyApplicationCommandAdapterSpec.groovy | 64 +++++
.../src/en/guide/upgrading/upgrading80x.adoc | 60 +++--
.../plugin/commands/GrailsCliGradlePlugin.groovy | 71 +++++-
.../factory/GroovyScriptCommandFactory.groovy | 2 +
.../factory/LegacyPluginScriptCompatSpec.groovy | 107 ++++++++
.../legacy-commands-plugin/build.gradle | 45 ++++
.../commands/plugin/HelloLegacyAppCommand.groovy | 46 ++++
.../plugin/HelloLegacyGrailsCommand.groovy | 42 +++
.../plugin/LegacyCommandsPluginGrailsPlugin.groovy | 32 +++
.../src/main/resources/META-INF/grails.factories | 15 ++
.../src/main/scripts/hello-legacy-script.groovy | 24 ++
grails-test-examples/legacy-commands/build.gradle | 66 +++++
.../grails-app/conf/application.yml | 41 +++
.../legacy-commands/grails-app/conf/logback.xml | 31 +++
.../controllers/legacycommands/UrlMappings.groovy | 35 +++
.../init/legacycommands/Application.groovy | 30 +++
...egacyCommandCompatibilityIntegrationSpec.groovy | 82 ++++++
settings.gradle | 4 +
32 files changed, 2229 insertions(+), 31 deletions(-)
diff --git a/grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy b/grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy
index 1ea9064ed3..92ebb447c7 100644
--- a/grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy
+++ b/grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy
@@ -24,6 +24,7 @@ import org.springframework.context.ConfigurableApplicationContext
import grails.config.Settings
import org.apache.grails.core.cli.ApplicationContextCommandRegistry
import org.apache.grails.core.cli.ExecutionContext
+import org.apache.grails.core.cli.LegacyApplicationCommandAware
import grails.ui.support.DevelopmentGrailsApplication
import org.grails.build.parsing.CommandLine
import org.grails.build.parsing.CommandLineParser
@@ -45,8 +46,9 @@ class GrailsApplicationContextCommandRunner extends DevelopmentGrailsApplication
ConfigurableApplicationContext run(String... args) {
def command = ApplicationContextCommandRegistry.instance.findCommand(commandName)
if (command) {
+ Object autowireTarget = resolveAutowireTarget(command)
- Object skipBootstrap = command.hasProperty('skipBootstrap')?.getProperty(command)
+ Object skipBootstrap = autowireTarget.hasProperty('skipBootstrap')?.getProperty(autowireTarget)
if (skipBootstrap instanceof Boolean && !System.getProperty(Settings.SETTING_SKIP_BOOTSTRAP)) {
System.setProperty(Settings.SETTING_SKIP_BOOTSTRAP, skipBootstrap.toString())
}
@@ -73,7 +75,7 @@ class GrailsApplicationContextCommandRunner extends DevelopmentGrailsApplication
try {
// Parse the FULL args (including --options) for the command
CommandLine commandLine = new CommandLineParser().parse(args)
- ctx.autowireCapableBeanFactory.autowireBeanProperties(command, AutowireCapableBeanFactory.AUTOWIRE_BY_TYPE, false)
+ ctx.autowireCapableBeanFactory.autowireBeanProperties(autowireTarget, AutowireCapableBeanFactory.AUTOWIRE_BY_TYPE, false)
command.applicationContext = ctx
def result = command.handle(new ExecutionContext(commandLine))
result ? System.exit(0) : System.exit(1)
@@ -116,6 +118,22 @@ class GrailsApplicationContextCommandRunner extends DevelopmentGrailsApplication
args.findAll { it != null && !it.startsWith('--') } as String[]
}
+ /**
+ * Resolves the object that Spring should autowire and inspect for the {@code skipBootstrap}
+ * property. For a command loaded through the deprecated Grails 7 compatibility layer the
+ * registry returns a {@link LegacyApplicationCommandAware} adapter; the real legacy command it
+ * wraps - not the adapter - is what must be autowired and queried, because the adapter forwards
+ * {@code applicationContext} and {@code handle} to that same instance. Any other command is
+ * returned unchanged.
+ *
+ * @param command the command resolved from the registry
+ * @return the legacy target when {@code command} is a {@link LegacyApplicationCommandAware}, otherwise {@code command}
+ */
+ static Object resolveAutowireTarget(Object command) {
+ command instanceof LegacyApplicationCommandAware ?
+ ((LegacyApplicationCommandAware) command).legacyCommand : command
+ }
+
/**
* Main method to run an existing Application class
*
diff --git a/grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy b/grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy
index ab6aecc8c7..7e9202f176 100644
--- a/grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy
+++ b/grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy
@@ -16,6 +16,7 @@
*/
package grails.ui.command
+import org.apache.grails.core.cli.LegacyApplicationCommandAware
import spock.lang.Specification
import spock.lang.Unroll
@@ -133,4 +134,29 @@ class GrailsApplicationContextCommandRunnerSpec extends Specification {
// Custom ApplicationCommand with arbitrary --options
['my-command', '--customFlag', '--anotherOption=value'] | ['my-command']
}
+
+ def "resolveAutowireTarget unwraps a legacy command adapter"() {
+ given:
+ Object legacyCommand = new Object()
+ LegacyApplicationCommandAware adapter = Stub() {
+ getLegacyCommand() >> legacyCommand
+ }
+
+ when:
+ Object autowireTarget = GrailsApplicationContextCommandRunner.resolveAutowireTarget(adapter)
+
+ then:
+ autowireTarget.is(legacyCommand)
+ }
+
+ def "resolveAutowireTarget preserves a new-contract command"() {
+ given:
+ Object command = new Object()
+
+ when:
+ Object autowireTarget = GrailsApplicationContextCommandRunner.resolveAutowireTarget(command)
+
+ then:
+ autowireTarget.is(command)
+ }
}
diff --git a/grails-core/src/cli/groovy/grails/dev/commands/ApplicationCommand.groovy b/grails-core/src/cli/groovy/grails/dev/commands/ApplicationCommand.groovy
new file mode 100644
index 0000000000..6a2b8391ef
--- /dev/null
+++ b/grails-core/src/cli/groovy/grails/dev/commands/ApplicationCommand.groovy
@@ -0,0 +1,77 @@
+/*
+ * 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 grails.dev.commands
+
+import groovy.transform.CompileStatic
+
+import org.springframework.context.ConfigurableApplicationContext
+
+import grails.util.Described
+import grails.util.GrailsNameUtils
+import grails.util.Named
+
+/**
+ * Represents a command that runs with access to the
+ * {@link org.springframework.context.ApplicationContext}.
+ *
+ * @author Graeme Rocher
+ * @since 3.0
+ * @deprecated since 8.0, use {@link org.apache.grails.core.cli.ApplicationCommand}. Retained only for backwards compatibility with Grails 7 command plugins and slated for removal in a future major release.
+ */
+@Deprecated
+@CompileStatic
+trait ApplicationCommand implements Named, Described {
+
+ ConfigurableApplicationContext applicationContext
+
+ /**
+ * Calculates the command name as used on the command line.
+ * <p>Example:
+ * {@code UrlMappingsReportCommand} -> {@code url-mappings-report}
+ * @return The command line name of the command
+ */
+ @Override
+ String getName() {
+ GrailsNameUtils.getScriptName(
+ GrailsNameUtils.getLogicalName(
+ getClass().name,
+ 'Command'
+ )
+ )
+ }
+
+ /**
+ * The description of the command.
+ * By default this returns the name of the command.
+ * @return The description of the command
+ */
+ @Override
+ String getDescription() {
+ name
+ }
+
+ /**
+ * Executes the command.
+ *
+ * @param executionContext The execution context
+ * @return {@code true} if the command was successful; {@code false} otherwise
+ */
+ abstract boolean handle(ExecutionContext executionContext)
+
+}
diff --git a/grails-core/src/cli/groovy/grails/dev/commands/ExecutionContext.groovy b/grails-core/src/cli/groovy/grails/dev/commands/ExecutionContext.groovy
new file mode 100644
index 0000000000..f903dc9ae5
--- /dev/null
+++ b/grails-core/src/cli/groovy/grails/dev/commands/ExecutionContext.groovy
@@ -0,0 +1,55 @@
+/*
+ * 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 grails.dev.commands
+
+import groovy.transform.Canonical
+import groovy.transform.CompileStatic
+
+import grails.util.BuildSettings
+import org.grails.build.parsing.CommandLine
+
+/**
+ * A context command to pass to {@link ApplicationCommand} instances
+ *
+ * @author Graeme Rocher
+ * @since 3.0
+ * @deprecated since 8.0, use {@link org.apache.grails.core.cli.ExecutionContext}. Retained only for backwards compatibility with Grails 7 command plugins and slated for removal in a future major release.
+ */
+@Deprecated
+@CompileStatic
+@Canonical
+class ExecutionContext {
+
+ /**
+ * The command line instance
+ */
+ CommandLine commandLine
+ /**
+ * The base directory for the project
+ */
+ final File baseDir = BuildSettings.BASE_DIR
+ /**
+ * The classes directory of the project
+ */
+ final File classesDir = BuildSettings.CLASSES_DIR
+ /**
+ * The target directory of the project
+ */
+ final File targetDir = BuildSettings.TARGET_DIR
+}
diff --git a/grails-core/src/cli/groovy/grails/dev/commands/GrailsApplicationCommand.groovy b/grails-core/src/cli/groovy/grails/dev/commands/GrailsApplicationCommand.groovy
new file mode 100644
index 0000000000..c2c9e27715
--- /dev/null
+++ b/grails-core/src/cli/groovy/grails/dev/commands/GrailsApplicationCommand.groovy
@@ -0,0 +1,50 @@
+/*
+ * 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 grails.dev.commands
+
+import grails.codegen.model.ModelBuilder
+import grails.dev.commands.io.FileSystemInteraction
+import grails.dev.commands.io.FileSystemInteractionImpl
+import grails.dev.commands.template.TemplateRenderer
+import grails.dev.commands.template.TemplateRendererImpl
+
+/**
+ * @deprecated since 8.0, use {@link org.apache.grails.core.cli.GrailsApplicationCommand}. Retained only for backwards compatibility with Grails 7 command plugins and slated for removal in a future major release.
+ */
+@Deprecated
+trait GrailsApplicationCommand implements ApplicationCommand, ModelBuilder {
+
+ @Delegate TemplateRenderer templateRenderer
+ @Delegate FileSystemInteraction fileSystemInteraction
+ ExecutionContext executionContext
+
+ boolean handle(ExecutionContext executionContext) {
+ this.executionContext = executionContext
+ this.templateRenderer = new TemplateRendererImpl(executionContext.baseDir)
+ this.fileSystemInteraction = new FileSystemInteractionImpl(executionContext.baseDir)
+ handle()
+ }
+
+ List<String> getArgs() {
+ executionContext.commandLine.remainingArgs
+ }
+
+ abstract boolean handle()
+}
diff --git a/grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteraction.groovy b/grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteraction.groovy
new file mode 100644
index 0000000000..5590cc4389
--- /dev/null
+++ b/grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteraction.groovy
@@ -0,0 +1,146 @@
+/*
+ * 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 grails.dev.commands.io
+
+import org.grails.io.support.Resource
+
+/**
+ * Utility methods exposed to scripts for interacting with resources (found on the file system or jars) and the file system
+ *
+ * @author Graeme Rocher
+ * @since 3.0
+ * @deprecated since 8.0, use {@link org.apache.grails.core.cli.io.FileSystemInteraction}. Retained only for backwards compatibility with Grails 7 command plugins and slated for removal in a future major release.
+ */
+@Deprecated
+interface FileSystemInteraction {
+
+ /**
+ * Makes a directory
+ *
+ * @param path The path to the directory
+ */
+ FileSystemInteraction mkdir(path)
+ /**
+ * Deletes a file
+ *
+ * @param path The path to the file
+ */
+ FileSystemInteraction delete(path)
+ /**
+ * Allows Gradle style simple copy specs
+ *
+ * @param callable The callable
+ * @return this
+ */
+ FileSystemInteraction copy(@DelegatesTo(CopySpec) Closure callable)
+ /**
+ * Copies a resource to the target destination
+ *
+ * @param path The path
+ * @param destination The destination
+ */
+ FileSystemInteraction copy(path, destination)
+ /**
+ * Copies resources to the target destination
+ *
+ * @param path The path
+ * @param destination The destination
+ */
+ FileSystemInteraction copyAll(Iterable resources, destination)
+ /**
+ * Copy a Resource from the given location to the given directory or location
+ *
+ * @param from The resource to copy
+ * @param to The location to copy to
+ * @return The {@FileSystemInteraction} instance
+ */
+ FileSystemInteraction copy(Resource from, File to)
+ /**
+ * Obtain a file for the given path
+ *
+ * @param path The path
+ * @return The file
+ */
+ File file(Object path)
+ /**
+ * @return The target build directory
+ */
+ File getBuildDir()
+ /**
+ * @return The directory where resources are processed to
+ */
+ File getResourcesDir()
+ /**
+ * @return The directory where classes are compiled to
+ */
+ File getClassesDir()
+ /**
+ * Finds a source file for the given class name
+ * @param className The class name
+ * @return The source resource
+ */
+ Resource source(String className)
+ /**
+ * Obtain a resource for the given path
+ * @param path The path
+ * @return The resource
+ */
+ Resource resource(Object path)
+ /**
+ * Obtain resources for the given pattern
+ *
+ * @param pattern The pattern
+ * @return The resources
+ */
+ Collection<Resource> resources(String pattern)
+ /**
+ * Obtain the path of the resource relative to the current project
+ *
+ * @param path The path to inspect
+ * @return The relative path
+ */
+ String projectPath(Object path)
+
+ /**
+ * The class name of the given resource
+ *
+ * @param resource The resource
+ * @return The class name
+ */
+ String className(Resource resource)
+
+ /**
+ * Get files matching the given pattern
+ *
+ * @param pattern The pattern
+ * @return the files
+ */
+ Collection<File> files(String pattern)
+
+ static class CopySpec {
+ def from
+ def into
+ void from(path) {
+ this.from = path
+ }
+ void into(path) {
+ this.into = path
+ }
+ }
+}
diff --git a/grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteractionImpl.groovy b/grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteractionImpl.groovy
new file mode 100644
index 0000000000..5d8095c3f5
--- /dev/null
+++ b/grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteractionImpl.groovy
@@ -0,0 +1,284 @@
+/*
+ * 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 grails.dev.commands.io
+
+import groovy.transform.CompileStatic
+
+import grails.util.BuildSettings
+import org.grails.io.support.DefaultResourceLoader
+import org.grails.io.support.FileSystemResource
+import org.grails.io.support.GrailsResourceUtils
+import org.grails.io.support.PathMatchingResourcePatternResolver
+import org.grails.io.support.Resource
+import org.grails.io.support.ResourceLoader
+import org.grails.io.support.ResourceLocator
+import org.grails.io.support.SpringIOUtils
+
+/**
+ * Utility methods exposed to scripts for interacting with resources (found on the file system or jars) and the file system
+ *
+ * @author Graeme Rocher
+ * @since 3.0
+ * @deprecated since 8.0, use {@link org.apache.grails.core.cli.io.FileSystemInteractionImpl}. Retained only for backwards compatibility with Grails 7 command plugins and slated for removal in a future major release.
+ */
+@Deprecated
+@CompileStatic
+class FileSystemInteractionImpl implements FileSystemInteraction {
+
+ File baseDir
+ ResourceLoader resourceLoader
+ PathMatchingResourcePatternResolver resourcePatternResolver
+ ResourceLocator resourceLocator
+
+ FileSystemInteractionImpl(File baseDir, ResourceLoader resourceLoader = new DefaultResourceLoader()) {
+ this.baseDir = baseDir
+ this.resourceLoader = resourceLoader
+ this.resourceLocator = new ResourceLocator()
+ this.resourceLocator.setSearchLocation(baseDir.absolutePath)
+ this.resourcePatternResolver = new PathMatchingResourcePatternResolver(resourceLoader)
+ }
+
+ /**
+ * Makes a directory
+ *
+ * @param path The path to the directory
+ */
+ @Override
+ FileSystemInteractionImpl mkdir(path) {
+ file(path)?.mkdirs()
+ return this
+ }
+
+ /**
+ * Deletes a file
+ *
+ * @param path The path to the file
+ */
+ @Override
+ FileSystemInteractionImpl delete(path) {
+ file(path)?.delete()
+ return this
+ }
+
+ /**
+ * Allows Gradle style simple copy specs
+ *
+ * @param callable The callable
+ * @return this
+ */
+ @Override
+ FileSystemInteractionImpl copy(@DelegatesTo(FileSystemInteraction.CopySpec) Closure callable) {
+ FileSystemInteraction.CopySpec spec = new FileSystemInteraction.CopySpec()
+ callable.delegate = spec
+ callable.call()
+ if (spec.from && spec.into) {
+ if (spec.from instanceof Iterable) {
+ copyAll((Iterable) spec.from, spec.into)
+ }
+ else {
+ copy(spec.from, spec.into)
+ }
+ }
+ return this
+ }
+ /**
+ * Copies a resource to the target destination
+ *
+ * @param path The path
+ * @param destination The destination
+ */
+ @Override
+ FileSystemInteractionImpl copy(path, destination) {
+ def from = resource(path)
+ def to = file(destination)
+ copy(from, to)
+ return this
+ }
+
+ /**
+ * Copies resources to the target destination
+ *
+ * @param path The path
+ * @param destination The destination
+ */
+ @Override
+ FileSystemInteractionImpl copyAll(Iterable resources, destination) {
+ mkdir(destination)
+ for (path in resources) {
+ def from = resource(path)
+ def to = file(destination)
+ copy(from, to)
+ }
+ return this
+ }
+
+ /**
+ * Copy a Resource from the given location to the given directory or location
+ *
+ * @param from The resource to copy
+ * @param to The location to copy to
+ * @return The {@FileSystemInteraction} instance
+ */
+ @Override
+ FileSystemInteractionImpl copy(Resource from, File to) {
+ if (!to?.exists()) mkdir(to)
+ if (from && to) {
+ if (to.isDirectory()) {
+ mkdir(to)
+ to = new File(to, from.filename)
+ }
+ SpringIOUtils.copy(from, to)
+ println("Copied ${from.filename} to location ${to.canonicalPath}")
+ }
+ return this
+ }
+
+ /**
+ * Obtain a file for the given path
+ *
+ * @param path The path
+ * @return The file
+ */
+ @Override
+ File file(Object path) {
+ if (path instanceof File) return (File) path
+ else if (path instanceof Resource) return ((Resource) path).file
+ else {
+ new File(baseDir ?: new File('.'), path.toString())
+ }
+ }
+
+ /**
+ * @return The target build directory
+ */
+ @Override
+ File getBuildDir() {
+ BuildSettings.TARGET_DIR
+ }
+
+ /**
+ * @return The directory where resources are processed to
+ */
+ @Override
+ File getResourcesDir() {
+ BuildSettings.RESOURCES_DIR
+ }
+
+ /**
+ * @return The directory where classes are compiled to
+ */
+ @Override
+ File getClassesDir() {
+ BuildSettings.CLASSES_DIR
+ }
+
+ /**
+ * Finds a source file for the given class name
+ * @param className The class name
+ * @return The source resource
+ */
+ @Override
+ Resource source(String className) {
+ resourceLocator.findResourceForClassName(className)
+ }
+
+ /**
+ * The class name of the given resource
+ *
+ * @param resource The resource
+ * @return The class name
+ */
+ String className(Resource resource) {
+ GrailsResourceUtils.getClassName(resource)
+ }
+
+ /**
+ * Obtain a resource for the given path
+ * @param path The path
+ * @return The resource
+ */
+ @Override
+ Resource resource(Object path) {
+ if (!path) return null
+ if (path instanceof Resource) return (Resource) path
+ def f = file(path)
+ if (f?.exists() && f.isFile()) {
+ return new FileSystemResource(f)
+ }
+ else {
+ def pathStr = path.toString()
+ def resource = resourceLoader.getResource(pathStr)
+ if (resource.exists()) {
+ return resource
+ }
+ else {
+ def allResources = resources(pathStr)
+ if (allResources) {
+ return allResources[0]
+ }
+ else {
+ return resource
+ }
+ }
+ }
+ }
+
+ /**
+ * Obtain resources for the given pattern
+ *
+ * @param pattern The pattern
+ * @return The resources
+ */
+ @Override
+ Collection<Resource> resources(String pattern) {
+ try {
+ return resourcePatternResolver.getResources(pattern).toList()
+ } catch (e) {
+ return []
+ }
+ }
+
+ /**
+ * Obtain the path of the resource relative to the current project
+ *
+ * @param path The path to inspect
+ * @return The relative path
+ */
+ @Override
+ String projectPath(Object path) {
+ def file = file(path)
+ if (file) {
+ def basePath = baseDir.canonicalPath
+ return (file.canonicalPath - basePath).substring(1)
+ }
+ return ''
+ }
+
+ /**
+ * Get files matching the given pattern
+ *
+ * @param pattern The pattern
+ * @return the files
+ */
+ @Override
+ Collection<File> files(String pattern) {
+ resources(pattern).collect() { Resource res -> res.file }
+ }
+
+}
diff --git a/grails-core/src/cli/groovy/grails/dev/commands/template/TemplateException.groovy b/grails-core/src/cli/groovy/grails/dev/commands/template/TemplateException.groovy
new file mode 100644
index 0000000000..1940c8bff0
--- /dev/null
+++ b/grails-core/src/cli/groovy/grails/dev/commands/template/TemplateException.groovy
@@ -0,0 +1,32 @@
+/*
+ * 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 grails.dev.commands.template
+
+import groovy.transform.InheritConstructors
+
+/**
+ * Exception thrown when an error in template rendering occurs
+ *
+ * @author Graeme Rocher
+ * @since 3.0
+ * @deprecated since 8.0, use {@link org.apache.grails.core.cli.template.TemplateException}. Retained only for backwards compatibility with Grails 7 command plugins and slated for removal in a future major release.
+ */
+@Deprecated
+@InheritConstructors
+class TemplateException extends RuntimeException {}
diff --git a/grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRenderer.groovy b/grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRenderer.groovy
new file mode 100644
index 0000000000..24c8aa91b7
--- /dev/null
+++ b/grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRenderer.groovy
@@ -0,0 +1,174 @@
+/*
+ * 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 grails.dev.commands.template
+
+import groovy.transform.CompileDynamic
+
+import grails.codegen.model.Model
+import org.grails.io.support.Resource
+
+/**
+ * API for locating and rendering templates in the code generation layer
+ *
+ * @author Graeme Rocher
+ * @since 3.0
+ * @deprecated since 8.0, use {@link org.apache.grails.core.cli.template.TemplateRenderer}. Retained only for backwards compatibility with Grails 7 command plugins and slated for removal in a future major release.
+ */
+@Deprecated
+interface TemplateRenderer {
+
+ /**
+ * Render with the given named arguments
+ *
+ * @param namedArguments The named arguments are 'template', 'destination' and 'model'
+ */
+ @CompileDynamic
+ void render(Map<String, Object> namedArguments)
+ /**
+ * Render the given template to the give destination for the given model
+ *
+ * @param template The contents template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(CharSequence template, File destination, Model model)
+
+ /**
+ * Render the given template to the given destination
+ *
+ * @param template The contents of the template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(CharSequence template, File destination)
+
+ /**
+ * Render the given template to the given destination
+ *
+ * @param template The contents of the template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(CharSequence template, File destination, Map model)
+ /**
+ * Render the given template to the given destination
+ *
+ * @param template The contents of the template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(CharSequence template, File destination, Map model, boolean overwrite)
+
+ /**
+ * Render the given template to the give destination for the given model
+ *
+ * @param template The template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(File template, File destination, Model model)
+
+ /**
+ * Render the given template to the given destination
+ *
+ * @param template The template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(File template, File destination)
+
+ /**
+ * Render the given template to the given destination
+ *
+ * @param template The template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(File template, File destination, Map model)
+
+ /**
+ * Render the given template to the given destination
+ *
+ * @param template The template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(File template, File destination, Map model, boolean overwrite)
+
+ /**
+ * Render the given template to the give destination for the given model
+ *
+ * @param template The contents template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(Resource template, File destination, Model model)
+
+ /**
+ * Render the given template to the give destination for the given model
+ *
+ * @param template The contents template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(Resource template, File destination, Model model, boolean overwrite)
+
+ /**
+ * Render the given template to the given destination
+ *
+ * @param template The template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(Resource template, File destination)
+
+ /**
+ * Render the given template to the given destination
+ *
+ * @param template The template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(Resource template, File destination, Map model)
+
+ /**
+ * Render the given template to the given destination
+ *
+ * @param template The template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(Resource template, File destination, Map model, boolean overwrite)
+
+ /**
+ * Find templates matching the given pattern
+ *
+ * @param pattern The pattern
+ * @return The available templates
+ */
+ Iterable<Resource> templates(String pattern)
+
+ /**
+ * Find a template at the given location
+ *
+ * @param location The location
+ * @return The resource or null if it doesn't exist
+ */
+ Resource template(Object location)
+}
diff --git a/grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRendererImpl.groovy b/grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRendererImpl.groovy
new file mode 100644
index 0000000000..73981e9f6f
--- /dev/null
+++ b/grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRendererImpl.groovy
@@ -0,0 +1,236 @@
+/*
+ * 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 grails.dev.commands.template
+
+import groovy.text.GStringTemplateEngine
+import groovy.text.Template
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import grails.codegen.model.Model
+import grails.dev.commands.io.FileSystemInteraction
+import grails.dev.commands.io.FileSystemInteractionImpl
+import org.grails.io.support.DefaultResourceLoader
+import org.grails.io.support.Resource
+import org.grails.io.support.ResourceLoader
+
+/**
+ * API for locating and rendering templates in the code generation layer
+ *
+ * @author Graeme Rocher
+ * @since 3.2
+ * @deprecated since 8.0, use {@link org.apache.grails.core.cli.template.TemplateRendererImpl}. Retained only for backwards compatibility with Grails 7 command plugins and slated for removal in a future major release.
+ */
+@Deprecated
+@CompileStatic
+class TemplateRendererImpl implements TemplateRenderer {
+
+ @Delegate FileSystemInteraction fileSystemInteraction
+ protected Map<String, Template> templateCache = [:]
+
+ TemplateRendererImpl(File baseDir, ResourceLoader resourceLoader = new DefaultResourceLoader()) {
+ this.fileSystemInteraction = new FileSystemInteractionImpl(baseDir, resourceLoader)
+ }
+
+ /**
+ * Render with the given named arguments
+ *
+ * @param namedArguments The named arguments are 'template', 'destination' and 'model'
+ */
+ @Override
+ @CompileDynamic
+ void render(Map<String, Object> namedArguments) {
+ if (namedArguments?.template && namedArguments?.destination != null) {
+ def templateArg = namedArguments.template
+ Resource template = templateArg instanceof Resource ? templateArg : template(templateArg)
+ boolean overwrite = namedArguments.overwrite as Boolean ?: false
+ render(template, file(namedArguments.destination), namedArguments.model ?: [:], overwrite)
+ }
+ }
+
+ /**
+ * Render the given template to the give destination for the given model
+ *
+ * @param template The contents template
+ * @param destination The destination
+ * @param model The model
+ */
+ @Override
+ void render(CharSequence template, File destination, Model model) {
+ render(template, destination, model.asMap())
+ }
+
+ /**
+ * Render the given template to the given destination
+ *
+ * @param template The contents of the template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(CharSequence template, File destination, Map model = Collections.emptyMap(), boolean overwrite = false) {
+ if (template && destination != null) {
+ if (destination.exists() && !overwrite) {
+ println("Warning | Destination file ${projectPath(destination)} already exists, skipping...")
+ } else {
+ def templateEngine = new GStringTemplateEngine()
+ try {
+ def t = templateEngine.createTemplate(template.toString())
+ writeTemplateToDestination(t, model, destination)
+ } catch (e) {
+ destination.delete()
+ throw new TemplateException("Error rendering template to destination ${projectPath(destination)}: ${e.message}", e)
+ }
+ }
+ }
+ }
+
+ /**
+ * Render the given template to the give destination for the given model
+ *
+ * @param template The template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(File template, File destination, Model model) {
+ render(template, destination, model.asMap())
+ }
+ /**
+ * Render the given template to the given destination
+ *
+ * @param template The template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(File template, File destination, Map model = Collections.emptyMap(), boolean overwrite = false) {
+ if (template != null && destination != null) {
+ if (destination.exists() && !overwrite) {
+ println("Warning | Destination file ${projectPath(destination)} already exists, skipping...")
+ } else {
+ Template t = templateCache[template.absolutePath]
+ if (t == null) {
+ try {
+ def templateEngine = new GStringTemplateEngine()
+ t = templateEngine.createTemplate(template)
+ } catch (e) {
+ throw new TemplateException("Error rendering template [$template] to destination ${projectPath(destination)}: ${e.message}", e)
+ }
+ }
+ try {
+ writeTemplateToDestination(t, model, destination)
+ println("Rendered template ${template.name} to destination ${projectPath(destination)}")
+ } catch (Throwable e) {
+ destination.delete()
+ throw new TemplateException("Error rendering template [$template] to destination ${projectPath(destination)}: ${e.message}", e)
+ }
+ }
+ }
+ }
+
+ /**
+ * Render the given template to the give destination for the given model
+ *
+ * @param template The contents template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(Resource template, File destination, Model model, boolean overwrite = false) {
+ render(template, destination, model.asMap(), overwrite)
+ }
+ /**
+ * Render the given template to the given destination
+ *
+ * @param template The template
+ * @param destination The destination
+ * @param model The model
+ */
+ void render(Resource template, File destination, Map model = Collections.emptyMap(), boolean overwrite = false) {
+ if (template != null && destination != null) {
+ if (destination.exists() && !overwrite) {
+ println("Warning | Destination file ${projectPath(destination)} already exists, skipping...")
+ } else if (!template?.exists()) {
+ throw new TemplateException("Template [$template.filename] not found.")
+ } else {
+ Template t = templateCache[template.filename]
+ if (t == null) {
+
+ try {
+ def templateEngine = new GStringTemplateEngine()
+ def reader = new InputStreamReader(template.inputStream, 'UTF-8')
+ try {
+ t = templateEngine.createTemplate(reader)
+ } finally {
+ try {
+ reader.close()
+ } catch (e) {
+ // ignore
+ }
+ }
+ } catch (e) {
+ throw new TemplateException("Error rendering template [$template.filename] to destination ${projectPath(destination)}: ${e.message}", e)
+ }
+ }
+ if (t != null) {
+ try {
+ writeTemplateToDestination(t, model, destination)
+ println("Rendered template ${template.filename} to destination ${projectPath(destination)}")
+ } catch (Throwable e) {
+ destination.delete()
+ throw new TemplateException("Error rendering template [$template.filename] to destination ${projectPath(destination)}: ${e.message}", e)
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Find templates matching the given pattern
+ *
+ * @param pattern The pattern
+ * @return The available templates
+ */
+ Iterable<Resource> templates(String pattern) {
+ Collection<Resource> resList = []
+ resList.addAll(resources(pattern))
+ resList.addAll(resources("classpath*:META-INF/templates/$pattern"))
+ return resList.unique()
+ }
+
+ /**
+ * Find a template at the given location
+ *
+ * @param location The location
+ * @return The resource or null if it doesn't exist
+ */
+ Resource template(Object location) {
+ Resource f = resource(file("src/main/templates/$location"))
+ if (!f?.exists()) {
+ return resource('classpath*:META-INF/templates/' + location)
+ }
+ return resource(f)
+ }
+
+ protected static void writeTemplateToDestination(Template template, Map model, File destination) {
+ destination.parentFile.mkdirs()
+ destination.withWriter { BufferedWriter w ->
+ template.make(model).writeTo(w)
+ w.flush()
+ }
+ }
+}
diff --git a/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy
index cc4b75770d..959762608c 100644
--- a/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy
+++ b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy
@@ -18,7 +18,11 @@ package org.apache.grails.core.cli
import groovy.transform.CompileStatic
+import org.apache.grails.core.cli.compat.LegacyApplicationCommandAdapter
import org.grails.core.io.support.GrailsFactoriesLoader
+import org.grails.io.support.FactoriesLoaderSupport
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
/**
* A registry of {@link org.apache.grails.core.cli.ApplicationCommand} instances
@@ -29,7 +33,10 @@ import org.grails.core.io.support.GrailsFactoriesLoader
@Singleton(strict = false)
class ApplicationContextCommandRegistry {
+ private static final Logger LOG = LoggerFactory.getLogger(ApplicationContextCommandRegistry)
+
private final Map<String, ApplicationCommand> commands = [:]
+ private boolean legacyCommandWarningLogged
ApplicationContextCommandRegistry() {
for (ApplicationCommand cmd : GrailsFactoriesLoader.loadFactories(ApplicationCommand,
@@ -46,6 +53,37 @@ class ApplicationContextCommandRegistry {
commands[cmd.name] = cmd
}
}
+
+ loadLegacyCommands(ApplicationContextCommandRegistry.classLoader)
+ loadLegacyCommands(Thread.currentThread().contextClassLoader)
+ }
+
+ @SuppressWarnings('deprecation')
+ private void loadLegacyCommands(ClassLoader classLoader) {
+ // Instantiate each legacy command in isolation: a single stale Grails 7 command whose
+ // no-arg constructor (or getName()) throws under Grails 8 must be skipped with a warning,
+ // never abort the whole registry and take valid legacy and new-contract commands down with it.
+ List<Class<grails.dev.commands.ApplicationCommand>> legacyClasses = GrailsFactoriesLoader.loadFactoryClasses(
+ grails.dev.commands.ApplicationCommand, classLoader, FactoriesLoaderSupport.FACTORIES_RESOURCE_LOCATION)
+ for (Class<grails.dev.commands.ApplicationCommand> legacyClass : legacyClasses) {
+ try {
+ grails.dev.commands.ApplicationCommand legacyCommand = legacyClass.getDeclaredConstructor().newInstance()
+ ApplicationCommand command = new LegacyApplicationCommandAdapter(legacyCommand)
+ String name = command.name
+ if (commands.containsKey(name)) {
+ continue
+ }
+ commands[name] = command
+ if (!legacyCommandWarningLogged) {
+ LOG.warn("Command '{}' from a Grails 7 plugin was loaded through the deprecated grails.dev.commands compatibility layer. Ask the plugin author to migrate to the org.apache.grails.core.cli command API and publish a -cli companion artifact; this compatibility path will be removed in a future major release.", name)
+ legacyCommandWarningLogged = true
+ }
+ }
+ catch (Throwable e) {
+ LOG.warn("Failed to load a Grails 7 legacy command from class '{}' through the deprecated grails.dev.commands compatibility layer; skipping it. Cause: {}",
+ legacyClass?.name, e.message)
+ }
+ }
}
Collection<ApplicationCommand> findCommands() {
diff --git a/grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy b/grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy
new file mode 100644
index 0000000000..87708ef6f3
--- /dev/null
+++ b/grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy
@@ -0,0 +1,28 @@
+/*
+ * 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
+
+/**
+ * Implemented by adapters that wrap a legacy {@code grails.dev.commands.ApplicationCommand}.
+ * Lets a launcher unwrap the real command for Spring autowiring and property inspection.
+ */
+interface LegacyApplicationCommandAware {
+
+ Object getLegacyCommand()
+}
diff --git a/grails-core/src/cli/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy b/grails-core/src/cli/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy
new file mode 100644
index 0000000000..b572d46898
--- /dev/null
+++ b/grails-core/src/cli/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy
@@ -0,0 +1,70 @@
+/*
+ * 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.compat
+
+import groovy.transform.CompileStatic
+
+import org.apache.grails.core.cli.ApplicationCommand
+import org.apache.grails.core.cli.ExecutionContext
+import org.apache.grails.core.cli.LegacyApplicationCommandAware
+import org.springframework.context.ConfigurableApplicationContext
+
+/**
+ * Adapts a Grails 7 command contract to the Grails 8 CLI contract.
+ */
+@SuppressWarnings('deprecation')
+@CompileStatic
+class LegacyApplicationCommandAdapter implements ApplicationCommand, LegacyApplicationCommandAware {
+
+ private final grails.dev.commands.ApplicationCommand legacyCommand
+
+ LegacyApplicationCommandAdapter(grails.dev.commands.ApplicationCommand legacyCommand) {
+ this.legacyCommand = legacyCommand
+ }
+
+ @Override
+ Object getLegacyCommand() {
+ legacyCommand
+ }
+
+ @Override
+ String getName() {
+ legacyCommand.name
+ }
+
+ @Override
+ String getDescription() {
+ legacyCommand.description
+ }
+
+ @Override
+ void setApplicationContext(ConfigurableApplicationContext applicationContext) {
+ legacyCommand.applicationContext = applicationContext
+ }
+
+ @Override
+ ConfigurableApplicationContext getApplicationContext() {
+ legacyCommand.applicationContext
+ }
+
+ @Override
+ boolean handle(ExecutionContext executionContext) {
+ legacyCommand.handle(new grails.dev.commands.ExecutionContext(executionContext.commandLine))
+ }
+}
diff --git a/grails-core/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy b/grails-core/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy
new file mode 100644
index 0000000000..321633a65b
--- /dev/null
+++ b/grails-core/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy
@@ -0,0 +1,225 @@
+/*
+ * 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
+
+import grails.dev.commands.ExecutionContext as LegacyExecutionContext
+import org.apache.grails.core.cli.compat.LegacyApplicationCommandAdapter
+import org.grails.build.parsing.CommandLine
+import org.springframework.context.ConfigurableApplicationContext
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class LegacyCommandRegistryLoadingSpec extends Specification {
+
+ @TempDir
+ File tempDir
+
+ private ClassLoader originalContextClassLoader
+ private URLClassLoader factoryClassLoader
+
+ def cleanup() {
+ Thread.currentThread().contextClassLoader = originalContextClassLoader
+ factoryClassLoader?.close()
+ }
+
+ def "discovers adapts and executes a command registered under the legacy factory key"() {
+ given:
+ useFactoryResources('grails.dev.commands.ApplicationCommand=org.apache.grails.core.cli.LegacyRegistryTestCommand')
+ ConfigurableApplicationContext applicationContext = Mock()
+ CommandLine commandLine = Mock()
+
+ when:
+ ApplicationCommand command = new ApplicationContextCommandRegistry().findCommand('legacy-registry-command')
+
+ then:
+ command instanceof LegacyApplicationCommandAdapter
+ command instanceof LegacyApplicationCommandAware
+ LegacyApplicationCommandAdapter adapter = command as LegacyApplicationCommandAdapter
+ LegacyRegistryTestCommand legacyCommand = ((LegacyApplicationCommandAware) command).legacyCommand as LegacyRegistryTestCommand
+ adapter.legacyCommand.is(legacyCommand)
+
+ when:
+ adapter.applicationContext = applicationContext
+ boolean result = adapter.handle(new ExecutionContext(commandLine))
+
+ then:
+ result
+ legacyCommand.invoked
+ legacyCommand.applicationContext.is(applicationContext)
+ legacyCommand.executionContext instanceof LegacyExecutionContext
+ legacyCommand.executionContext.commandLine.is(commandLine)
+ }
+
+ def "discovers adapts and executes a legacy Grails application command"() {
+ given:
+ useFactoryResources('grails.dev.commands.ApplicationCommand=org.apache.grails.core.cli.LegacyRegistryGrailsApplicationTestCommand')
+ CommandLine commandLine = Mock()
+
+ when:
+ ApplicationCommand command = new ApplicationContextCommandRegistry().findCommand('legacy-registry-grails-application-command')
+
+ then:
+ command instanceof LegacyApplicationCommandAdapter
+ command instanceof LegacyApplicationCommandAware
+ LegacyApplicationCommandAdapter adapter = command as LegacyApplicationCommandAdapter
+ LegacyRegistryGrailsApplicationTestCommand legacyCommand = ((LegacyApplicationCommandAware) command).legacyCommand as LegacyRegistryGrailsApplicationTestCommand
+ adapter.legacyCommand.is(legacyCommand)
+
+ when:
+ boolean result = adapter.handle(new ExecutionContext(commandLine))
+
+ then:
+ result
+ legacyCommand.handleCalls == 1
+ legacyCommand.executionContext instanceof LegacyExecutionContext
+ legacyCommand.executionContext.commandLine.is(commandLine)
+ legacyCommand.templateRenderer != null
+ legacyCommand.fileSystemInteraction != null
+ }
+
+ def "prefers a new-contract command when legacy and new registrations share a name"() {
+ given:
+ useFactoryResources(
+ 'grails.dev.commands.ApplicationCommand=org.apache.grails.core.cli.LegacyRegistryCollisionCommand',
+ 'org.apache.grails.core.cli.ApplicationCommand=org.apache.grails.core.cli.ConfigReportCommand')
+
+ when:
+ ApplicationCommand command = new ApplicationContextCommandRegistry().findCommand('config-report')
+
+ then:
+ command instanceof ConfigReportCommand
+ !(command instanceof LegacyApplicationCommandAdapter)
+ }
+
+ def "continues loading commands when a legacy command constructor fails"() {
+ given:
+ useFactoryResources(
+ 'grails.dev.commands.ApplicationCommand=org.apache.grails.core.cli.ThrowingLegacyCommand,org.apache.grails.core.cli.LegacyRegistryTestCommand',
+ 'org.apache.grails.core.cli.ApplicationCommand=org.apache.grails.core.cli.ConfigReportCommand')
+
+ when:
+ ApplicationContextCommandRegistry registry = new ApplicationContextCommandRegistry()
+
+ then:
+ registry.findCommand('throwing-legacy-command') == null
+
+ and:
+ ApplicationCommand legacyCommand = registry.findCommand('legacy-registry-command')
+ legacyCommand instanceof LegacyApplicationCommandAware
+
+ and:
+ registry.findCommand('config-report') instanceof ConfigReportCommand
+ }
+
+ private void useFactoryResources(String legacyFactories, String cliFactories = null) {
+ File metaInf = new File(tempDir, 'META-INF')
+ assert metaInf.mkdirs()
+ new File(metaInf, 'grails.factories').text = legacyFactories
+ if (cliFactories != null) {
+ new File(metaInf, 'grails-cli.factories').text = cliFactories
+ }
+ originalContextClassLoader = Thread.currentThread().contextClassLoader
+ factoryClassLoader = new URLClassLoader([tempDir.toURI().toURL()] as URL[], getClass().classLoader)
+ Thread.currentThread().contextClassLoader = factoryClassLoader
+ }
+}
+
+class LegacyRegistryTestCommand implements grails.dev.commands.ApplicationCommand {
+
+ boolean invoked
+ LegacyExecutionContext executionContext
+
+ @Override
+ String getName() {
+ 'legacy-registry-command'
+ }
+
+ @Override
+ String getDescription() {
+ 'Legacy registry test command'
+ }
+
+ @Override
+ boolean handle(LegacyExecutionContext executionContext) {
+ invoked = true
+ this.executionContext = executionContext
+ true
+ }
+}
+
+class LegacyRegistryGrailsApplicationTestCommand implements grails.dev.commands.GrailsApplicationCommand {
+
+ int handleCalls
+
+ @Override
+ String getName() {
+ 'legacy-registry-grails-application-command'
+ }
+
+ @Override
+ String getDescription() {
+ 'Legacy registry Grails application test command'
+ }
+
+ @Override
+ boolean handle() {
+ handleCalls++
+ templateRenderer != null && fileSystemInteraction != null
+ }
+}
+
+class LegacyRegistryCollisionCommand implements grails.dev.commands.ApplicationCommand {
+
+ @Override
+ String getName() {
+ 'config-report'
+ }
+
+ @Override
+ String getDescription() {
+ 'Legacy registry collision command'
+ }
+
+ @Override
+ boolean handle(LegacyExecutionContext executionContext) {
+ true
+ }
+}
+
+class ThrowingLegacyCommand implements grails.dev.commands.ApplicationCommand {
+
+ ThrowingLegacyCommand() {
+ throw new RuntimeException('boom')
+ }
+
+ @Override
+ String getName() {
+ 'throwing-legacy-command'
+ }
+
+ @Override
+ String getDescription() {
+ 'Throwing legacy command'
+ }
+
+ @Override
+ boolean handle(LegacyExecutionContext executionContext) {
+ true
+ }
+}
diff --git a/grails-core/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy b/grails-core/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy
new file mode 100644
index 0000000000..6627d1f8a6
--- /dev/null
+++ b/grails-core/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy
@@ -0,0 +1,64 @@
+/*
+ * 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.compat
+
+import grails.dev.commands.ApplicationCommand
+import grails.dev.commands.ExecutionContext as LegacyExecutionContext
+import org.apache.grails.core.cli.ExecutionContext
+import org.grails.build.parsing.CommandLine
+import org.springframework.context.ConfigurableApplicationContext
+import spock.lang.Specification
+
+class LegacyApplicationCommandAdapterSpec extends Specification {
+
+ def "forwards command operations to the legacy command"() {
+ given:
+ TestLegacyCommand legacyCommand = new TestLegacyCommand()
+ LegacyApplicationCommandAdapter adapter = new LegacyApplicationCommandAdapter(legacyCommand)
+ ConfigurableApplicationContext applicationContext = Mock()
+ CommandLine commandLine = Mock()
+
+ when:
+ adapter.applicationContext = applicationContext
+ boolean result = adapter.handle(new ExecutionContext(commandLine))
+
+ then:
+ adapter.name == 'legacy-command'
+ adapter.description == 'Legacy command description'
+ adapter.applicationContext.is(applicationContext)
+ adapter.legacyCommand.is(legacyCommand)
+ result
+ legacyCommand.applicationContext.is(applicationContext)
+ legacyCommand.executionContext instanceof LegacyExecutionContext
+ legacyCommand.executionContext.commandLine.is(commandLine)
+ }
+
+ private static class TestLegacyCommand implements ApplicationCommand {
+
+ String name = 'legacy-command'
+ String description = 'Legacy command description'
+ LegacyExecutionContext executionContext
+
+ @Override
+ boolean handle(LegacyExecutionContext executionContext) {
+ this.executionContext = executionContext
+ true
+ }
+ }
+}
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index c8cda878b9..e934e31ea3 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -1695,31 +1695,35 @@ The renamed framework command packages are:
|`org.apache.grails.security.cli.*` (grails-spring-security-cli)
|===
-**Third-party plugins must be rebuilt against Grails 8 for their commands to be available.**
-Apply the new `org.apache.grails.gradle.grails-plugin-cli` Gradle plugin, move command sources from
-`grails-app/commands` to `src/cli/groovy`, and update the contract imports — recompiling then
-regenerates the `grails-cli.factories` registration automatically and the companion `-cli`
-artifact is published alongside the plugin (see
-xref:commandLine#creatingCustomCommands[Creating Custom Commands] for the complete workflow).
-Hand-authored command registrations must move from
-`src/main/resources/META-INF/grails.factories` to `.../grails-cli.factories`. A Grails 7 command
-plugin dropped onto a Grails 8 application unchanged will not have its commands discovered.
-
-**What this change means for upgrading an application.** Plugin re-releases are *not* a
-prerequisite for upgrading the application itself — the impact of the CLI split is bounded as
-follows:
-
-* *Plugins that ship no commands* are entirely unaffected by this change.
-* *Command-bearing plugins that have not yet been rebuilt for Grails 8* keep their runtime
- functionality — controllers, services, taglibs, and other artefacts continue to work exactly as
- before, because the runtime plugin jar never depended on the command contract. Only the plugin's
- *commands* are unavailable: their registrations in the legacy `grails.factories` location are
- ignored (the clean break above), so they disappear from the command list rather than fail. The
- commands return once the plugin publishes a Grails 8 release with a companion `-cli` artifact.
-* *The application's own commands* in `grails-app/commands` need only the import rename shown
- above; the build wiring is automatic.
-
-NOTE: The statements above are about the CLI split specifically. Independently of it, Grails 8
-removes APIs that were deprecated in Grails 7 — a Grails 7 plugin that avoided those deprecations
-generally continues to work on Grails 8 unchanged, while one that relied on them (or on other
-Grails 8 changes such as Spring Boot 4) needs an update for that reason.
+===== 35.1 Backwards Compatibility for Existing Command Plugins
+
+Grails 8 provides a deprecated compatibility layer for already-published Grails 7 command
+plugins. Application commands created with `create-command` that implement
+`grails.dev.commands.ApplicationCommand` or `grails.dev.commands.GrailsApplicationCommand` remain
+available without a plugin re-release. The deprecated `grails.dev.commands.*` types are retained,
+and registrations in `META-INF/grails.factories` under
+`grails.dev.commands.ApplicationCommand` continue to be discovered at runtime.
+
+Grails logs a one-time deprecation warning when it uses this compatibility layer. It is intended
+to keep existing published plugins working while plugin authors migrate; it is deprecated and will
+be removed in a future major release.
+
+**What just works:**
+
+* Existing published Grails 7 application-command plugins continue to work on Grails 8 without a
+ re-release.
+* Code generation scripts created with `create-script`, including scripts packaged as
+ `META-INF/commands/*.groovy` such as `audit-quickstart` and `s2-quickstart`, are unaffected.
+* YAML commands packaged as `META-INF/commands/*.yml` are unaffected.
+
+**What plugin authors must do:**
+
+* Migrate application commands to the `org.apache.grails.core.cli.*` API and register them in
+ `META-INF/grails-cli.factories`.
+* Publish commands in a companion `-cli` artifact for the CLI classpath split and per-command
+ Gradle task support. See xref:commandLine#creatingCustomCommands[Creating Custom Commands] for
+ the complete workflow.
+
+NOTE: The compatibility layer applies only to the legacy application-command contract. Independently
+of it, a Grails 7 plugin that relies on APIs removed in Grails 8, Spring Boot 4, or other Grails 8
+changes still requires an update.
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy
index b1e49a1717..9e4264304a 100644
--- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy
@@ -61,6 +61,14 @@ class GrailsCliGradlePlugin implements Plugin<Project> {
public static final String APPLICATION_CONTEXT_COMMAND_CLASS = 'org.apache.grails.core.cli.ApplicationCommand'
+ /**
+ * The deprecated Grails 7 command contract. Unchanged Grails 7 plugins register their commands
+ * under this key in {@code META-INF/grails.factories} inside their runtime jar (rather than the
+ * Grails 8 {@code META-INF/grails-cli.factories}); scanning for it lets those legacy commands
+ * keep their per-command Gradle tasks without a plugin re-release.
+ */
+ public static final String LEGACY_APPLICATION_CONTEXT_COMMAND_CLASS = 'grails.dev.commands.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
@@ -300,6 +308,11 @@ class GrailsCliGradlePlugin implements Plugin<Project> {
if (grails.cliAutoProvision.get()) {
commandClassNames.addAll(loadCommandNamesFromCliClasspath(project))
}
+ // Unchanged Grails 7 command plugins ship their commands in their normal runtime jar and
+ // register them under the deprecated grails.dev.commands.ApplicationCommand key. Register
+ // per-command tasks for those too, so `grails <command>` (routed through a Gradle task)
+ // keeps working for a legacy plugin without a re-release.
+ commandClassNames.addAll(loadLegacyCommandNamesFromRuntimeClasspath(project))
for (ctxCommand in commandClassNames) {
String taskName = GrailsNameUtils.getLogicalPropertyName(ctxCommand, 'Command')
String commandName = GrailsNameUtils.getScriptName(GrailsNameUtils.getLogicalName(ctxCommand, 'Command'))
@@ -359,7 +372,63 @@ class GrailsCliGradlePlugin implements Plugin<Project> {
}
}
catch (IOException ignored) {
- // unreadable jar — skip
+ // unreadable jar - skip
+ }
+ }
+ names
+ }
+
+ /**
+ * Loads legacy command class names from the {@code META-INF/grails.factories} files of the
+ * resolved {@code runtimeClasspath} jars, registered under the deprecated
+ * {@code grails.dev.commands.ApplicationCommand} key. Unchanged Grails 7 command plugins ship
+ * their commands (and this registration) in their normal runtime jar, so this backwards-compat
+ * scan registers their per-command Gradle tasks without requiring the plugin to be re-released
+ * or split into a {@code -cli} companion. Resolution is lenient; unreadable or unbuilt jars are
+ * skipped (the generic {@code runCommand} task can always execute those commands regardless).
+ */
+ @CompileDynamic
+ protected Collection<String> loadLegacyCommandNamesFromRuntimeClasspath(Project project) {
+ Set<String> names = new LinkedHashSet<String>()
+ Configuration runtimeClasspath = project.configurations.findByName('runtimeClasspath')
+ if (runtimeClasspath == null) {
+ return names
+ }
+ // Resolving runtimeClasspath at configuration time can race with the configuration of
+ // sibling source projects in a large multi-project build ("components not calculated yet").
+ // A real application resolves this against the module cache without that race and still
+ // gets its legacy per-command tasks; degrade gracefully (the generic runCommand task can
+ // always execute the command) rather than failing the whole build if resolution is not yet
+ // possible - matching the lenient, skip-on-failure handling used for the cli classpath.
+ Collection<File> files
+ try {
+ files = runtimeClasspath.incoming.artifactView { it.lenient(true) }.files.files
+ }
+ catch (Throwable t) {
+ project.logger.info('Skipping legacy command task discovery for project {}: runtimeClasspath is not resolvable at configuration time ({}). Legacy commands remain runnable via the runCommand task.',
+ project.name, t.message)
+ return names
+ }
+ for (File file : files) {
+ if (!file.isFile() || !file.name.endsWith('.jar')) {
+ continue
+ }
+ try (JarFile jarFile = new JarFile(file)) {
+ def entry = jarFile.getEntry(FactoriesLoaderSupport.FACTORIES_RESOURCE_LOCATION)
+ if (entry == null) {
+ continue
+ }
+ Properties properties = new Properties()
+ jarFile.getInputStream(entry).withCloseable { input ->
+ properties.load(input)
+ }
+ String factoryNames = properties.getProperty(LEGACY_APPLICATION_CONTEXT_COMMAND_CLASS)
+ if (factoryNames) {
+ names.addAll(factoryNames.split(',').toList()*.trim())
+ }
+ }
+ catch (IOException ignored) {
+ // unreadable jar - skip
}
}
names
diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/factory/GroovyScriptCommandFactory.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/factory/GroovyScriptCommandFactory.groovy
index b188479108..387dc65f88 100644
--- a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/factory/GroovyScriptCommandFactory.groovy
+++ b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/factory/GroovyScriptCommandFactory.groovy
@@ -86,6 +86,8 @@ class GroovyScriptCommandFactory extends ResourceResolvingCommandFactory<GroovyS
@Override
protected Command createCommand(Profile profile, String commandName, Resource resource, GroovyScriptCommand data) {
data.setProfile(profile)
+ data.name = commandName
+ data.description.name = commandName
return data
}
}
diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/profile/commands/factory/LegacyPluginScriptCompatSpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/profile/commands/factory/LegacyPluginScriptCompatSpec.groovy
new file mode 100644
index 0000000000..2c663180f4
--- /dev/null
+++ b/grails-shell-cli/src/test/groovy/org/grails/cli/profile/commands/factory/LegacyPluginScriptCompatSpec.groovy
@@ -0,0 +1,107 @@
+/*
+ * 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.cli.profile.commands.factory
+
+import java.net.URL
+import java.net.URLClassLoader
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+
+import org.grails.cli.profile.Command
+import org.grails.cli.profile.commands.script.GroovyScriptCommand
+import org.grails.io.support.Resource
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class LegacyPluginScriptCompatSpec extends Specification {
+
+ @TempDir
+ Path tempDir
+
+ URLClassLoader pluginClassLoader
+
+ def cleanup() {
+ pluginClassLoader?.close()
+ }
+
+ // A Grails 7 plugin script must retain its META-INF command name on Grails 8 without source changes after PR #15948.
+ void "Grails 7 plugin command scripts are discovered and compiled on Grails 8"() {
+ given: "an unchanged Grails 7-style plugin script on the plugin classpath"
+ Path commandsDirectory = Files.createDirectories(tempDir.resolve('META-INF').resolve('commands'))
+ Files.writeString(commandsDirectory.resolve('audit-quickstart.groovy'), '''
+description('Creates audit logging scaffolding') {
+ usage 'grails audit-quickstart [DOMAIN CLASS]'
+ argument name: 'domainClass', description: 'The audited domain class', required: true
+}
+
+if (!args) {
+ error 'A domain class name is required'
+ return false
+}
+
+def auditModel = model(args[0])
+render template: template('Audit.groovy'),
+ destination: file("grails-app/domain/${auditModel.simpleName}Audit.groovy"),
+ model: auditModel
+''', StandardCharsets.UTF_8)
+ pluginClassLoader = new URLClassLoader([tempDir.toUri().toURL()] as URL[], getClass().classLoader)
+ def resolver = new ClasspathCommandResourceResolver(['groovy'])
+ resolver.classLoader = pluginClassLoader
+ def factory = new ClasspathOnlyGroovyScriptCommandFactory(resolver)
+
+ when: "the plugin command resource is discovered and compiled"
+ Collection<Resource> resources = resolver.findCommandResources(null)
+ Resource scriptResource = resources.find { it.filename == 'audit-quickstart.groovy' }
+ GroovyScriptCommand compiledCommand = factory.compile(scriptResource)
+ Collection<Command> discoveredCommands = factory.findCommands(null, false)
+ Command discoveredCommand = discoveredCommands.find()
+
+ then: "the legacy script uses the Grails 8 script base class and transform"
+ scriptResource != null
+ compiledCommand != null
+ compiledCommand instanceof GroovyScriptCommand
+ compiledCommand.description.description == 'Creates audit logging scaffolding'
+ compiledCommand.description.usage == 'grails audit-quickstart [DOMAIN CLASS]'
+ compiledCommand.description.arguments*.name == ['domainClass']
+ discoveredCommands*.name == ['audit-quickstart']
+ discoveredCommand instanceof GroovyScriptCommand
+ discoveredCommand.name == 'audit-quickstart'
+ discoveredCommand.description.name == 'audit-quickstart'
+ discoveredCommand.description.description == 'Creates audit logging scaffolding'
+ }
+
+ private static class ClasspathOnlyGroovyScriptCommandFactory extends GroovyScriptCommandFactory {
+
+ private final CommandResourceResolver commandResourceResolver
+
+ ClasspathOnlyGroovyScriptCommandFactory(CommandResourceResolver commandResourceResolver) {
+ this.commandResourceResolver = commandResourceResolver
+ }
+
+ GroovyScriptCommand compile(Resource resource) {
+ readCommandFile(resource)
+ }
+
+ @Override
+ protected Collection<CommandResourceResolver> getCommandResolvers(boolean inherited) {
+ [commandResourceResolver]
+ }
+ }
+}
diff --git a/grails-test-examples/legacy-commands-plugin/build.gradle b/grails-test-examples/legacy-commands-plugin/build.gradle
new file mode 100644
index 0000000000..08b64595a0
--- /dev/null
+++ b/grails-test-examples/legacy-commands-plugin/build.gradle
@@ -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.
+ */
+
+plugins {
+ id 'org.apache.grails.buildsrc.properties'
+ id 'org.apache.grails.buildsrc.dependency-validator'
+ id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.vulnerability-scan'
+ id 'org.apache.grails.gradle.grails-plugin'
+}
+
+version = '0.0.1'
+group = 'legacy.commands.plugin'
+
+dependencies {
+ implementation platform(project(':grails-bom'))
+
+ // This fixture recompiles legacy command sources against Grails 8's grails-core-cli to
+ // validate discovery, adapter, registry, and runner wiring end-to-end. It does not
+ // re-validate a pre-compiled Grails 7 binary's Groovy-trait ABI. That relies on Groovy's
+ // stable trait encoding across 4->5 and could be strengthened later with a prebuilt Grails 7
+ // fixture jar.
+ compileOnly 'org.apache.grails:grails-core-cli'
+}
+
+apply {
+ from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle')
+ from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle')
+}
diff --git a/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyAppCommand.groovy b/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyAppCommand.groovy
new file mode 100644
index 0000000000..8fdfa06068
--- /dev/null
+++ b/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyAppCommand.groovy
@@ -0,0 +1,46 @@
+/*
+ * 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 legacy.commands.plugin
+
+import groovy.transform.CompileStatic
+
+import grails.dev.commands.ApplicationCommand
+import grails.dev.commands.ExecutionContext
+
+@SuppressWarnings('deprecation')
+@CompileStatic
+class HelloLegacyAppCommand implements ApplicationCommand {
+
+ @Override
+ String getName() {
+ 'hello-legacy-app'
+ }
+
+ @Override
+ String getDescription() {
+ 'Runs a legacy application command'
+ }
+
+ @Override
+ boolean handle(ExecutionContext executionContext) {
+ new File(executionContext.baseDir, 'hello-legacy-app.txt').text = 'RAN'
+ true
+ }
+
+}
diff --git a/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyGrailsCommand.groovy b/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyGrailsCommand.groovy
new file mode 100644
index 0000000000..003d1f13e3
--- /dev/null
+++ b/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyGrailsCommand.groovy
@@ -0,0 +1,42 @@
+/*
+ * 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 legacy.commands.plugin
+
+import grails.dev.commands.GrailsApplicationCommand
+
+@SuppressWarnings('deprecation')
+class HelloLegacyGrailsCommand implements GrailsApplicationCommand {
+
+ @Override
+ String getName() {
+ 'hello-legacy-grails'
+ }
+
+ @Override
+ String getDescription() {
+ 'Runs a legacy Grails application command'
+ }
+
+ @Override
+ boolean handle() {
+ new File(executionContext.baseDir, 'hello-legacy-grails.txt').text = 'RAN'
+ true
+ }
+
+}
diff --git a/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/LegacyCommandsPluginGrailsPlugin.groovy b/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/LegacyCommandsPluginGrailsPlugin.groovy
new file mode 100644
index 0000000000..0b1702ae6e
--- /dev/null
+++ b/grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/LegacyCommandsPluginGrailsPlugin.groovy
@@ -0,0 +1,32 @@
+/*
+ * 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 legacy.commands.plugin
+
+import grails.plugins.Plugin
+
+class LegacyCommandsPluginGrailsPlugin extends Plugin {
+
+ def grailsVersion = '8.0.0-SNAPSHOT > *'
+ def title = 'Legacy Commands Plugin'
+ def author = 'Grails Team'
+ def description = 'Exercises Grails 7 command plugin compatibility on Grails 8.'
+ def profiles = ['web']
+ def license = 'APACHE'
+
+}
diff --git a/grails-test-examples/legacy-commands-plugin/src/main/resources/META-INF/grails.factories b/grails-test-examples/legacy-commands-plugin/src/main/resources/META-INF/grails.factories
new file mode 100644
index 0000000000..8441e1b667
--- /dev/null
+++ b/grails-test-examples/legacy-commands-plugin/src/main/resources/META-INF/grails.factories
@@ -0,0 +1,15 @@
+# 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.
+grails.dev.commands.ApplicationCommand=legacy.commands.plugin.HelloLegacyAppCommand,legacy.commands.plugin.HelloLegacyGrailsCommand
diff --git a/grails-test-examples/legacy-commands-plugin/src/main/scripts/hello-legacy-script.groovy b/grails-test-examples/legacy-commands-plugin/src/main/scripts/hello-legacy-script.groovy
new file mode 100644
index 0000000000..1313d7c127
--- /dev/null
+++ b/grails-test-examples/legacy-commands-plugin/src/main/scripts/hello-legacy-script.groovy
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+description('Legacy demo script') {
+ usage 'grails hello-legacy-script [MESSAGE]'
+ argument name: 'message', description: 'A message for the legacy script', required: false
+}
+
+addStatus 'ran'
diff --git a/grails-test-examples/legacy-commands/build.gradle b/grails-test-examples/legacy-commands/build.gradle
new file mode 100644
index 0000000000..c3fe302b9c
--- /dev/null
+++ b/grails-test-examples/legacy-commands/build.gradle
@@ -0,0 +1,66 @@
+/*
+ * 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.
+ */
+plugins {
+ id 'org.apache.grails.buildsrc.properties'
+ id 'org.apache.grails.buildsrc.dependency-validator'
+ id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.vulnerability-scan'
+}
+
+version = '0.1'
+group = 'legacycommands'
+
+apply plugin: 'groovy'
+apply plugin: 'org.apache.grails.gradle.grails-web'
+
+dependencies {
+ implementation platform(project(':grails-bom'))
+
+ implementation project(':grails-test-examples-legacy-commands-plugin')
+ implementation 'org.apache.grails:grails-core'
+ implementation 'org.apache.grails:grails-logging'
+ implementation 'org.apache.grails:grails-databinding'
+ implementation 'org.apache.grails:grails-interceptors'
+ implementation 'org.apache.grails:grails-services'
+ implementation 'org.apache.grails:grails-url-mappings'
+ implementation 'org.apache.grails:grails-web-boot'
+ if (System.getenv('SITEMESH2_TESTING_ENABLED') == 'true') {
+ implementation 'org.apache.grails:grails-layout'
+ }
+ else {
+ implementation 'org.apache.grails:grails-sitemesh3'
+ }
+ implementation 'org.apache.grails:grails-data-hibernate5'
+ implementation 'org.springframework.boot:spring-boot-autoconfigure'
+ implementation 'org.springframework.boot:spring-boot-starter'
+ implementation 'org.springframework.boot:spring-boot-starter-logging'
+ implementation 'org.springframework.boot:spring-boot-starter-tomcat'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+
+ runtimeOnly 'com.h2database:h2'
+ runtimeOnly 'org.apache.tomcat:tomcat-jdbc'
+
+ testImplementation 'org.apache.grails:grails-testing-support-web'
+ testImplementation 'org.spockframework:spock-core'
+}
+
+apply {
+ from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle')
+ from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle')
+}
diff --git a/grails-test-examples/legacy-commands/grails-app/conf/application.yml b/grails-test-examples/legacy-commands/grails-app/conf/application.yml
new file mode 100644
index 0000000000..e7974edf54
--- /dev/null
+++ b/grails-test-examples/legacy-commands/grails-app/conf/application.yml
@@ -0,0 +1,41 @@
+# 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.
+
+---
+grails:
+ profile: web
+ codegen:
+ defaultPackage: legacycommands
+info:
+ app:
+ name: legacy-commands
+
+---
+server:
+ port: 0
+
+---
+dataSource:
+ pooled: true
+ jmxExport: true
+ driverClassName: org.h2.Driver
+ username: sa
+ password:
+
+environments:
+ test:
+ dataSource:
+ dbCreate: create-drop
+ url: jdbc:h2:mem:legacyCommandsTestDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
diff --git a/grails-test-examples/legacy-commands/grails-app/conf/logback.xml b/grails-test-examples/legacy-commands/grails-app/conf/logback.xml
new file mode 100644
index 0000000000..0c32cbb056
--- /dev/null
+++ b/grails-test-examples/legacy-commands/grails-app/conf/logback.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ 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.
+
+-->
+<configuration>
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+ <root level="error">
+ <appender-ref ref="STDOUT" />
+ </root>
+</configuration>
diff --git a/grails-test-examples/legacy-commands/grails-app/controllers/legacycommands/UrlMappings.groovy b/grails-test-examples/legacy-commands/grails-app/controllers/legacycommands/UrlMappings.groovy
new file mode 100644
index 0000000000..f1a4806b79
--- /dev/null
+++ b/grails-test-examples/legacy-commands/grails-app/controllers/legacycommands/UrlMappings.groovy
@@ -0,0 +1,35 @@
+/*
+ * 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 legacycommands
+
+class UrlMappings {
+
+ static mappings = {
+ "/$controller/$action?/$id?(.$format)?" {
+ constraints {
+ // apply constraints here
+ }
+ }
+
+ "/"(view: '/index')
+ "500"(view: '/error')
+ "404"(view: '/notFound')
+ }
+
+}
diff --git a/grails-test-examples/legacy-commands/grails-app/init/legacycommands/Application.groovy b/grails-test-examples/legacy-commands/grails-app/init/legacycommands/Application.groovy
new file mode 100644
index 0000000000..b7f56558e5
--- /dev/null
+++ b/grails-test-examples/legacy-commands/grails-app/init/legacycommands/Application.groovy
@@ -0,0 +1,30 @@
+/*
+ * 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 legacycommands
+
+import grails.boot.GrailsApp
+import grails.boot.config.GrailsAutoConfiguration
+
+class Application extends GrailsAutoConfiguration {
+
+ static void main(String[] args) {
+ GrailsApp.run(Application)
+ }
+
+}
diff --git a/grails-test-examples/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy b/grails-test-examples/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy
new file mode 100644
index 0000000000..9b114345ae
--- /dev/null
+++ b/grails-test-examples/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy
@@ -0,0 +1,82 @@
+/*
+ * 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 legacycommands
+
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.ConfigurableApplicationContext
+
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.core.cli.ApplicationCommand
+import org.apache.grails.core.cli.ApplicationContextCommandRegistry
+import org.apache.grails.core.cli.ExecutionContext
+import org.apache.grails.core.cli.LegacyApplicationCommandAware
+import org.grails.build.parsing.CommandLine
+import spock.lang.Specification
+
+@Integration
+class LegacyCommandCompatibilityIntegrationSpec extends Specification {
+
+ @Autowired
+ ConfigurableApplicationContext applicationContext
+
+ def "discovers adapts and executes legacy application commands from a Grails 7 plugin"() {
+ given: 'the registry created from the application classpath'
+ ApplicationContextCommandRegistry registry = ApplicationContextCommandRegistry.instance
+ ApplicationCommand applicationCommand = registry.findCommand('hello-legacy-app')
+ ApplicationCommand grailsApplicationCommand = registry.findCommand('hello-legacy-grails')
+ ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine))
+ File applicationMarkerFile = new File(executionContext.baseDir, 'hello-legacy-app.txt')
+ File grailsApplicationMarkerFile = new File(executionContext.baseDir, 'hello-legacy-grails.txt')
+ applicationMarkerFile.delete()
+ grailsApplicationMarkerFile.delete()
+
+ expect: 'both legacy factory registrations are discovered and adapted'
+ applicationCommand != null
+ applicationCommand instanceof LegacyApplicationCommandAware
+ grailsApplicationCommand != null
+ grailsApplicationCommand instanceof LegacyApplicationCommandAware
+
+ when: 'the legacy ApplicationCommand runs through its Grails 8 adapter'
+ applicationCommand.applicationContext = applicationContext
+ boolean applicationCommandResult = applicationCommand.handle(executionContext)
+
+ then: 'the command succeeds and preserves its Grails 7 side effect'
+ applicationCommandResult
+ applicationMarkerFile.text == 'RAN'
+
+ when: 'the legacy GrailsApplicationCommand runs through its Grails 8 adapter'
+ grailsApplicationCommand.applicationContext = applicationContext
+ boolean grailsApplicationCommandResult = grailsApplicationCommand.handle(executionContext)
+
+ then: 'the subtype initializes its legacy execution context and succeeds'
+ grailsApplicationCommandResult
+ grailsApplicationMarkerFile.text == 'RAN'
+
+ cleanup:
+ applicationMarkerFile.delete()
+ grailsApplicationMarkerFile.delete()
+ }
+
+ def "packages a hyphenated legacy plugin command script at its original command path"() {
+ expect: 'the script is available for GroovyScriptCommandFactory discovery'
+ // LegacyPluginScriptCompatSpec verifies factory discovery and hyphen-preserving name resolution.
+ getClass().classLoader.getResource('META-INF/commands/hello-legacy-script.groovy') != null
+ }
+
+}
diff --git a/settings.gradle b/settings.gradle
index 011380687f..1b1889674b 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -565,6 +565,8 @@ include(
'grails-test-examples-undertow',
'grails-test-examples-views-functional-tests',
'grails-test-examples-views-functional-tests-plugin',
+ 'grails-test-examples-legacy-commands',
+ 'grails-test-examples-legacy-commands-plugin',
)
project(':grails-test-examples-async-events-pubsub-demo').projectDir = file('grails-test-examples/async-events-pubsub-demo')
project(':grails-test-examples-app1').projectDir = file('grails-test-examples/app1')
@@ -603,6 +605,8 @@ project(':grails-test-examples-scaffolding').projectDir = file('grails-test-exam
project(':grails-test-examples-scaffolding-fields').projectDir = file('grails-test-examples/scaffolding-fields')
project(':grails-test-examples-views-functional-tests').projectDir = file('grails-test-examples/views-functional-tests')
project(':grails-test-examples-views-functional-tests-plugin').projectDir = file('grails-test-examples/views-functional-tests-plugin')
+project(':grails-test-examples-legacy-commands').projectDir = file('grails-test-examples/legacy-commands')
+project(':grails-test-examples-legacy-commands-plugin').projectDir = file('grails-test-examples/legacy-commands-plugin')
project(':grails-test-examples-test-phases').projectDir = file('grails-test-examples/test-phases')
project(':grails-test-examples-jetty').projectDir = file('grails-test-examples/jetty')
project(':grails-test-examples-undertow').projectDir = file('grails-test-examples/undertow')