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')