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 <[email protected]> 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')
