diff --git a/build.gradle b/build.gradle index d7e707075e..edd57aea40 100644 --- a/build.gradle +++ b/build.gradle @@ -79,6 +79,7 @@ allprojects { } repositories { + mavenLocal() mavenCentral() maven { url 'https://repo.eclipse.org/content/groups/releases' } maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } diff --git a/modules/nextflow/build.gradle b/modules/nextflow/build.gradle index 29ab0eab24..fb47286c55 100644 --- a/modules/nextflow/build.gradle +++ b/modules/nextflow/build.gradle @@ -51,6 +51,7 @@ dependencies { api ('org.jsoup:jsoup:1.15.4') api 'jline:jline:2.9' api 'org.pf4j:pf4j:3.12.0' + api 'io.nextflow:nextflow-plugin-gradle:1.0.0-beta.6' api 'dev.failsafe:failsafe:3.1.0' api 'io.seqera:lib-trace:0.1.0' diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CommandExtensionPoint.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CommandExtensionPoint.groovy new file mode 100644 index 0000000000..86ddc118df --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CommandExtensionPoint.groovy @@ -0,0 +1,69 @@ +/* + * Copyright 2013-2024, Seqera Labs + * + * Licensed 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 + * + * http://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 nextflow.cli + +import org.pf4j.ExtensionPoint + +/** + * Extension point interface for plugins that want to register top-level CLI commands. + * + * Plugins implementing this interface can provide commands that appear as first-class + * citizens in the Nextflow CLI, accessible directly as 'nextflow ' rather than + * through the legacy 'nextflow plugin :' syntax. + * + * Example: + * - Instead of: nextflow plugin nf-wave:get-container + * - Enable: nextflow wave get-container + * + * @author Edmund Miller + */ +interface CommandExtensionPoint extends ExtensionPoint { + + /** + * Get the primary command name that this plugin registers. + * This will be the top-level command name (e.g., "wave", "launch"). + * + * @return The command name that will be available at the top level + */ + String getCommandName() + + /** + * Get the command description for help output. + * This description will appear in the main help listing. + * + * @return A brief description of what this command does + */ + String getCommandDescription() + + /** + * Get the priority for this command extension. + * Higher priority commands will take precedence in case of name conflicts. + * Built-in commands have priority 1000 by default. + * + * @return Priority value (higher = higher priority), defaults to 100 + */ + default int getPriority() { return 100 } + + /** + * Create the actual command instance that will handle execution. + * This method is called when the command needs to be instantiated. + * + * @return A CmdBase instance that will handle the command execution + */ + CmdBase createCommand() + +} \ No newline at end of file diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy index 6afda06942..4b370511d6 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy @@ -89,7 +89,26 @@ class Launcher { } protected void init() { - allCommands = (List)[ + // Initialize built-in commands + allCommands = createBuiltInCommands() + + // Add plugin commands dynamically + addPluginCommands() + + options = new CliOptions() + jcommander = new JCommander(options) + for( CmdBase cmd : allCommands ) { + cmd.launcher = this; + jcommander.addCommand(cmd.name, cmd, aliases(cmd)) + } + jcommander.setProgramName( APP_NAME ) + } + + /** + * Create the list of built-in commands + */ + private List createBuiltInCommands() { + final commands = (List)[ new CmdClean(), new CmdClone(), new CmdConsole(), @@ -113,20 +132,56 @@ class Launcher { ] if(SecretsLoader.isEnabled()) - allCommands.add(new CmdSecret()) + commands.add(new CmdSecret()) // legacy command final cmdCloud = SpuriousDeps.cmdCloud() if( cmdCloud ) - allCommands.add(cmdCloud) + commands.add(cmdCloud) - options = new CliOptions() - jcommander = new JCommander(options) - for( CmdBase cmd : allCommands ) { - cmd.launcher = this; - jcommander.addCommand(cmd.name, cmd, aliases(cmd)) + return commands + } + + /** + * Discover and add plugin commands to the command list + */ + private void addPluginCommands() { + log.debug("Starting plugin command discovery...") + try { + // Ensure plugins are initialized for command discovery + PluginCommandDiscovery.ensurePluginsInitialized() + + // Discover plugin commands + final pluginCommandMap = PluginCommandDiscovery.getCommandMap() + log.debug("Plugin command map contains ${pluginCommandMap.size()} commands") + + // Check for conflicts between built-in and plugin commands + final builtInNames = allCommands.collect { it.name }.toSet() + + pluginCommandMap.each { commandName, extensionPoint -> + log.debug("Processing plugin command: ${commandName}") + if (builtInNames.contains(commandName)) { + log.warn("Plugin command '${commandName}' conflicts with built-in command - skipping plugin version") + } + else { + try { + final pluginCommand = extensionPoint.createCommand() + if (pluginCommand) { + allCommands.add(pluginCommand) + log.debug("Added plugin command: ${commandName}") + } + } + catch (Exception e) { + log.warn("Failed to create plugin command '${commandName}': ${e.message}") + } + } + } + + log.debug("Discovered ${pluginCommandMap.size()} plugin commands") + } + catch (Exception e) { + log.warn("Plugin command discovery failed: ${e.message}", e) } - jcommander.setProgramName( APP_NAME ) } private static final String[] EMPTY = new String[0] @@ -420,11 +475,21 @@ class Launcher { int len = 0 def all = new TreeMap() - new ArrayList(commands).each { - def description = it.getClass().getAnnotation(Parameters)?.commandDescription() + new ArrayList(commands).each { cmd -> + String description = null + + // Check if it's a plugin command with CommandExtensionPoint interface + if (cmd instanceof CommandExtensionPoint) { + description = ((CommandExtensionPoint) cmd).commandDescription + } + else { + // For built-in commands, use the @Parameters annotation + description = cmd.getClass().getAnnotation(Parameters)?.commandDescription() + } + if( description ) { - all[it.name] = description - if( it.name.size()>len ) len = it.name.size() + all[cmd.name] = description + if( cmd.name.size()>len ) len = cmd.name.size() } } diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/PluginCommandBase.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/PluginCommandBase.groovy new file mode 100644 index 0000000000..721ac894d4 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/cli/PluginCommandBase.groovy @@ -0,0 +1,148 @@ +/* + * Copyright 2013-2024, Seqera Labs + * + * Licensed 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 + * + * http://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 nextflow.cli + +import java.nio.file.Paths + +import groovy.transform.CompileStatic +import nextflow.Session +import nextflow.config.ConfigBuilder +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * Abstract base class for plugin commands that want to register as top-level CLI commands. + * + * This class provides the foundation for plugin commands to integrate seamlessly with + * Nextflow's CLI system, including session management and configuration handling. + * + * Plugin commands extending this class will be discoverable and executable as first-class + * CLI commands (e.g., 'nextflow wave' instead of 'nextflow plugin nf-wave:command'). + * + * @author Edmund Miller + */ +@CompileStatic +abstract class PluginCommandBase extends CmdBase implements CommandExtensionPoint { + + private static Logger log = LoggerFactory.getLogger(PluginCommandBase) + + protected Session session + protected String pluginId + + /** + * Constructor that requires the plugin ID for context + * + * @param pluginId The ID of the plugin providing this command + */ + PluginCommandBase(String pluginId) { + this.pluginId = pluginId + } + + @Override + String getName() { + return getCommandName() + } + + @Override + CmdBase createCommand() { + return this + } + + @Override + final void run() { + log.debug("Executing plugin command: ${getCommandName()} from plugin: ${pluginId}") + + try { + // Create session for plugin command + initializeSession() + + // Execute the actual command logic + execute() + } + catch (Exception e) { + log.error("Error executing plugin command ${getCommandName()}: ${e.message}", e) + throw e + } + finally { + // Clean up session + destroySession() + } + } + + /** + * Initialize the Nextflow session for the plugin command. + * This mirrors the session creation pattern from PluginAbstractExec. + */ + protected void initializeSession() { + if (!session) { + final config = new ConfigBuilder() + .setOptions(getLauncher().options) + .setBaseDir(Paths.get('.')) + .build() + + this.session = new Session(config) + log.debug("Session initialized for plugin command: ${getCommandName()}") + } + } + + /** + * Clean up the session when command execution is complete. + */ + protected void destroySession() { + if (session) { + session.destroy() + session = null + log.debug("Session destroyed for plugin command: ${getCommandName()}") + } + } + + /** + * Get the current session. Session will be initialized if not already created. + * + * @return The current Nextflow session + */ + protected Session getSession() { + if (!session) { + initializeSession() + } + return session + } + + /** + * Get the plugin ID that provides this command + * + * @return The plugin identifier + */ + String getPluginId() { + return pluginId + } + + /** + * Abstract method that subclasses must implement to define their command logic. + * This is called within the session lifecycle management. + */ + abstract protected void execute() + + /** + * Default priority for plugin commands. Can be overridden by subclasses. + */ + @Override + int getPriority() { + return 100 + } + +} \ No newline at end of file diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/PluginCommandDiscovery.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/PluginCommandDiscovery.groovy new file mode 100644 index 0000000000..12b5697123 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/cli/PluginCommandDiscovery.groovy @@ -0,0 +1,199 @@ +/* + * Copyright 2013-2024, Seqera Labs + * + * Licensed 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 + * + * http://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 nextflow.cli + +import groovy.transform.CompileStatic +import groovy.transform.CompileDynamic +import groovy.util.logging.Slf4j +import nextflow.plugin.Plugins +import org.pf4j.PluginWrapper + +/** + * Utility class for discovering and managing plugin commands that register as top-level CLI commands. + * + * This class works with the plugin system to find CommandExtensionPoint implementations + * and make them available as first-class CLI commands. + * + * @author Edmund Miller + */ +@Slf4j +@CompileStatic +class PluginCommandDiscovery { + + /** + * Discover all plugin commands from loaded plugins + * + * @return List of CommandExtensionPoint instances from all loaded plugins + */ + static List discoverPluginCommands() { + try { + // Get all CommandExtensionPoint extensions from loaded plugins + final extensions = Plugins.getExtensions(CommandExtensionPoint) + log.debug("Found ${extensions.size()} CommandExtensionPoint extensions") + extensions.each { ext -> + log.debug(" - ${ext.class.name} providing command '${ext.commandName}'") + } + return extensions + } + catch (Exception e) { + log.debug("Error discovering plugin commands: ${e.message}") + return [] + } + } + + /** + * Create a map of command names to their extension points, handling conflicts by priority + * + * @return Map from command name to CommandExtensionPoint, with conflicts resolved by priority + */ + static Map getCommandMap() { + try { + final commands = discoverPluginCommands() + log.debug("Processing ${commands.size()} discovered commands") + final commandMap = [:] as Map + + // Group commands by name and resolve conflicts by priority + log.debug("Grouping commands by name...") + commands.each { cmd -> + log.debug("Processing command extension: ${cmd?.class?.name}") + try { + log.debug("Getting command name from ${cmd}...") + final name = cmd.commandName + log.debug("Command name: ${name}") + } catch (Exception e) { + log.debug("Error getting command name: ${e.message}") + throw e + } + } + + final grouped = commands.groupBy { it.commandName } + + grouped.each { commandName, extensionPoints -> + if (extensionPoints.size() == 1) { + commandMap[commandName] = extensionPoints[0] + } + else { + // Multiple plugins providing the same command name - choose by priority + final winner = extensionPoints.max { it.priority } + commandMap[commandName] = winner + + // Log warning about conflict resolution + log.warn("Command name conflict for '${commandName}': " + + "Choosing plugin with priority ${winner.priority}. " + + "Other candidates: ${extensionPoints.findAll { it != winner }.collect { "${it.class.name} (priority: ${it.priority})" }.join(', ')}") + } + } + + return commandMap + } catch (Exception e) { + log.debug("Error in getCommandMap: ${e.message}") + return [:] + } + } + + /** + * Get all available plugin command names + * + * @return Set of command names provided by plugins + */ + static Set getAvailableCommandNames() { + return getCommandMap().keySet() + } + + /** + * Create a CmdBase instance for the given command name + * + * @param commandName The command name to create + * @return CmdBase instance or null if command not found + */ + static CmdBase createCommand(String commandName) { + final commandMap = getCommandMap() + final extensionPoint = commandMap[commandName] + + if (!extensionPoint) { + log.debug("No plugin command found for: ${commandName}") + return null + } + + try { + final command = extensionPoint.createCommand() + log.debug("Created plugin command: ${commandName} from ${extensionPoint.class.name}") + return command + } + catch (Exception e) { + log.error("Failed to create plugin command ${commandName}: ${e.message}", e) + return null + } + } + + /** + * Check if a command name is provided by a plugin + * + * @param commandName The command name to check + * @return true if the command is provided by a plugin + */ + static boolean isPluginCommand(String commandName) { + return getAvailableCommandNames().contains(commandName) + } + + /** + * Get command descriptions for help output + * + * @return Map from command name to description for all plugin commands + */ + static Map getCommandDescriptions() { + final commandMap = getCommandMap() + final descriptions = [:] as Map + + commandMap.each { commandName, extensionPoint -> + descriptions[commandName] = extensionPoint.commandDescription + } + + return descriptions + } + + /** + * Initialize plugin system if not already initialized. + * This ensures plugins are loaded and started before command discovery. + */ + static void ensurePluginsInitialized() { + try { + // Initialize plugins if not already done + if (!Plugins.manager) { + log.debug("Initializing plugin system for command discovery") + Plugins.init() + } + + final allPlugins = Plugins.manager.getPlugins() + log.debug("Found ${allPlugins.size()} plugins for command discovery") + + // Start all plugins so their extensions become available + // This is critical because PF4J only makes extensions discoverable after plugins are started + allPlugins.each { pluginWrapper -> + log.debug("Plugin ${pluginWrapper.pluginId} state: ${pluginWrapper.pluginState}") + if (pluginWrapper.pluginState != org.pf4j.PluginState.STARTED) { + log.debug("Starting plugin: ${pluginWrapper.pluginId}") + Plugins.manager.startPlugin(pluginWrapper.pluginId) + } + } + } + catch (Exception e) { + log.debug("Plugin system initialization failed: ${e.message}") + } + } + +} \ No newline at end of file diff --git a/modules/nextflow/src/main/groovy/nextflow/plugin/extension/ChannelFactoryInstance.groovy b/modules/nextflow/src/main/groovy/nextflow/plugin/extension/ChannelFactoryInstance.groovy index 1adff3a49c..309667b4b3 100644 --- a/modules/nextflow/src/main/groovy/nextflow/plugin/extension/ChannelFactoryInstance.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/plugin/extension/ChannelFactoryInstance.groovy @@ -25,6 +25,7 @@ import nextflow.Channel import nextflow.Global import nextflow.Session import nextflow.dag.NodeMarker +import io.nextflow.gradle.extensions.PluginExtensionPoint /** * Object holding a set of {@link PluginExtensionPoint} instances @@ -59,7 +60,7 @@ class ChannelFactoryInstance implements ChannelFactory { private Object invoke0(String methodName, Object[] args) { final meta = target.metaClass.getMetaMethod(methodName, args) if( meta && meta.isPublic() ) { - target.checkInit((Session)Global.session) + target.checkInit(Global.session as Object) final method = target.getClass().getMethod(methodName, meta.getNativeParameterTypes()) // fix issue casting issue when argument is a GString but target method expects a String for( int i=0; i - * - */ -import java.lang.annotation.ElementType -import java.lang.annotation.Retention -import java.lang.annotation.RetentionPolicy -import java.lang.annotation.Target - -@Retention(RetentionPolicy.RUNTIME) -@Target([ElementType.METHOD]) -@interface Factory { - -} diff --git a/modules/nextflow/src/main/groovy/nextflow/plugin/extension/Function.groovy b/modules/nextflow/src/main/groovy/nextflow/plugin/extension/Function.groovy deleted file mode 100644 index 3e8fabdec2..0000000000 --- a/modules/nextflow/src/main/groovy/nextflow/plugin/extension/Function.groovy +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2013-2024, Seqera Labs - * - * Licensed 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 - * - * http://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 nextflow.plugin.extension - -/** - * An annotation interface for functions that the plugin want to expose - * Nextflow will search for all methods annotated with @Functions in the ExtensionPoint and allow to the user imported them - * - * @author : jorge - * - */ -import java.lang.annotation.ElementType -import java.lang.annotation.Retention -import java.lang.annotation.RetentionPolicy -import java.lang.annotation.Target - -@Retention(RetentionPolicy.RUNTIME) -@Target([ElementType.METHOD]) -@interface Function { - -} diff --git a/modules/nextflow/src/main/groovy/nextflow/plugin/extension/Operator.groovy b/modules/nextflow/src/main/groovy/nextflow/plugin/extension/Operator.groovy deleted file mode 100644 index 6a657f564c..0000000000 --- a/modules/nextflow/src/main/groovy/nextflow/plugin/extension/Operator.groovy +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2013-2024, Seqera Labs - * - * Licensed 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 - * - * http://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 nextflow.plugin.extension - -/** - * An annotation interface for operators that the plugin want to expose - * Nextflow will search for all methods annotated with @Operators in the ExtensionPoint and allow to the user imported them - * - * @author : jorge - * - */ -import java.lang.annotation.ElementType -import java.lang.annotation.Retention -import java.lang.annotation.RetentionPolicy -import java.lang.annotation.Target - -@Retention(RetentionPolicy.RUNTIME) -@Target([ElementType.METHOD]) -@interface Operator { - -} diff --git a/modules/nextflow/src/main/groovy/nextflow/plugin/extension/PluginExtensionPoint.groovy b/modules/nextflow/src/main/groovy/nextflow/plugin/extension/PluginExtensionPoint.groovy deleted file mode 100644 index c6ffd3c8ba..0000000000 --- a/modules/nextflow/src/main/groovy/nextflow/plugin/extension/PluginExtensionPoint.groovy +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2013-2024, Seqera Labs - * - * Licensed 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 - * - * http://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 nextflow.plugin.extension - -import groovy.transform.PackageScope -import nextflow.Session -import org.pf4j.ExtensionPoint -/** - * Define plugin extension points. A plugin can provide extension methods - * to add custom channel factories, channel operators and custom function - * in the including context. - * - * The extending subclass should mark the extension methods with the - * corresponding annotation {@link Factory}, {@link Operator} and {@link Function} - * - * @author Paolo Di Tommaso - */ -abstract class PluginExtensionPoint implements ExtensionPoint { - - private boolean initialised - - @PackageScope - synchronized void checkInit(Session session) { - if( !initialised ) { - init(session) - initialised = true - } - } - - /** - * Channel factory initialization. This method is invoked one and only once before - * the before target extension method is called. - * - * @param session The current nextflow {@link Session} - */ - abstract protected void init(Session session) - -} diff --git a/modules/nextflow/src/main/groovy/nextflow/plugin/extension/PluginExtensionProvider.groovy b/modules/nextflow/src/main/groovy/nextflow/plugin/extension/PluginExtensionProvider.groovy index 31ad82587e..0e77bc4fff 100644 --- a/modules/nextflow/src/main/groovy/nextflow/plugin/extension/PluginExtensionProvider.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/plugin/extension/PluginExtensionProvider.groovy @@ -28,6 +28,10 @@ import groovyx.gpars.dataflow.DataflowReadChannel import groovyx.gpars.dataflow.DataflowWriteChannel import nextflow.Global import nextflow.Session +import io.nextflow.gradle.extensions.PluginExtensionPoint +import io.nextflow.gradle.extensions.Function +import io.nextflow.gradle.extensions.Operator +import io.nextflow.gradle.extensions.Factory import nextflow.exception.AbortOperationException import nextflow.extension.OpCall import nextflow.extension.OperatorImpl @@ -155,7 +159,7 @@ class PluginExtensionProvider implements ExtensionProvider { } } // initialise the plugin session - ext.checkInit((Session)Global.session) + ext.checkInit(Global.session as Object) return instance = this } @@ -276,7 +280,7 @@ class PluginExtensionProvider implements ExtensionProvider { throw new IllegalStateException("Missing target class for operator '$method'") method = operatorExtensions.get(method)?.method if( target.target instanceof PluginExtensionPoint ) - ((PluginExtensionPoint)target.target).checkInit(getSession()) + ((PluginExtensionPoint)target.target).checkInit(getSession() as Object) new OpCall(target.target,channel,method,args).call() } diff --git a/modules/nextflow/src/test/groovy/nextflow/extension/plugin/ChannelFactoryInstanceTest.groovy b/modules/nextflow/src/test/groovy/nextflow/extension/plugin/ChannelFactoryInstanceTest.groovy index ea2fb42193..6fb325869a 100644 --- a/modules/nextflow/src/test/groovy/nextflow/extension/plugin/ChannelFactoryInstanceTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/extension/plugin/ChannelFactoryInstanceTest.groovy @@ -25,7 +25,7 @@ import nextflow.Global import nextflow.NextflowMeta import nextflow.Session import nextflow.extension.CH -import nextflow.plugin.extension.PluginExtensionPoint +import io.nextflow.gradle.extensions.PluginExtensionPoint import nextflow.plugin.extension.PluginExtensionProvider import spock.lang.Specification import test.MockScriptRunner diff --git a/modules/nf-commons/src/testFixtures/groovy/nextflow/plugin/hello/HelloExtension.groovy b/modules/nf-commons/src/testFixtures/groovy/nextflow/plugin/hello/HelloExtension.groovy index bbfc82a514..9382fdf9f7 100644 --- a/modules/nf-commons/src/testFixtures/groovy/nextflow/plugin/hello/HelloExtension.groovy +++ b/modules/nf-commons/src/testFixtures/groovy/nextflow/plugin/hello/HelloExtension.groovy @@ -28,9 +28,9 @@ import nextflow.NF import nextflow.Session import nextflow.extension.CH import nextflow.extension.DataflowHelper -import nextflow.plugin.extension.Function -import nextflow.plugin.extension.Operator -import nextflow.plugin.extension.PluginExtensionPoint +import io.nextflow.gradle.extensions.Function +import io.nextflow.gradle.extensions.Operator +import io.nextflow.gradle.extensions.PluginExtensionPoint /** * @author : jorge @@ -54,8 +54,8 @@ class HelloExtension extends PluginExtensionPoint { * @param session */ @Override - protected void init(Session session) { - this.session = session + protected void init(Object session) { + this.session = session as Session this.initialized = true } diff --git a/plugins/nf-wave/src/main/io/seqera/wave/plugin/WavePlugin.groovy b/plugins/nf-wave/src/main/io/seqera/wave/plugin/WavePlugin.groovy index f783e27de8..42405bfa2f 100644 --- a/plugins/nf-wave/src/main/io/seqera/wave/plugin/WavePlugin.groovy +++ b/plugins/nf-wave/src/main/io/seqera/wave/plugin/WavePlugin.groovy @@ -17,23 +17,18 @@ package io.seqera.wave.plugin -import io.seqera.wave.plugin.cli.WaveCmdEntry -import nextflow.cli.PluginExecAware import nextflow.plugin.BasePlugin import org.pf4j.PluginWrapper + /** - * Wave plugin entrypoint + * Wave plugin entrypoint - provides core Wave container functionality * * @author Paolo Di Tommaso */ -class WavePlugin extends BasePlugin implements PluginExecAware { - - @Delegate - private WaveCmdEntry cmd +class WavePlugin extends BasePlugin { WavePlugin(PluginWrapper wrapper) { super(wrapper) - this.cmd = new WaveCmdEntry() } } diff --git a/plugins/nf-wave/src/main/io/seqera/wave/plugin/cli/WaveCmdEntry.groovy b/plugins/nf-wave/src/main/io/seqera/wave/plugin/cli/WaveCmdEntry.groovy deleted file mode 100644 index 1d9e7ef300..0000000000 --- a/plugins/nf-wave/src/main/io/seqera/wave/plugin/cli/WaveCmdEntry.groovy +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2013-2024, Seqera Labs - * - * Licensed 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 - * - * http://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 io.seqera.wave.plugin.cli - -import java.nio.file.Files -import java.nio.file.Path - -import groovy.json.JsonOutput -import groovy.transform.CompileStatic -import groovy.util.logging.Slf4j -import io.seqera.wave.plugin.ContainerConfig -import io.seqera.wave.plugin.packer.Packer -import io.seqera.wave.plugin.util.BasicCliOpts -import nextflow.cli.PluginAbstractExec -import nextflow.exception.AbortOperationException -import nextflow.io.BucketParser -/** - * Implements Wave CLI tool - * - * @author Paolo Di Tommaso - */ -@Slf4j -@CompileStatic -class WaveCmdEntry implements PluginAbstractExec { - - List getCommands() { ['get-container','run-container', 'debug-task', 'pack'] } - - @Override - int exec(String cmd, List args) { - - switch (cmd) { - case 'get-container': - new WaveRunCmd(session).getContainer(args) - break - case 'run-container': - new WaveRunCmd(session).runContainer(args) - break - case 'pack': - println packContainer(args) - break - case 'debug-task': - new WaveDebugCmd(session).taskDebug(args) - break - default: - throw new AbortOperationException("Unknown wave command: $cmd") - } - - return 0 - } - - protected String packContainer(List args) { - final cli = BasicCliOpts.parse(args) - final packer = new Packer() - if( !cli.args ) - throw new AbortOperationException("Missing pack target directory") - final root = Path.of(cli.args.pop()) - if( !Files.exists(root) ) - throw new AbortOperationException("Pack target path does not exist: $root") - if( !Files.isDirectory(root) ) - throw new AbortOperationException("Pack target path is not a directory: $root") - // determine target location form CLI option - final location = cli.options.location - // pack the layer - final containerConfig = new ContainerConfig() - final layer = packer.createContainerPack(root, baseName(location)) - containerConfig.appendLayer(layer) - // set the target location - if( location ) - layer.location = location - // set entrypoint - if( cli.options.entrypoint ) - containerConfig.entrypoint = [cli.options.entrypoint] - // set work dir - if( cli.options.workingDir ) - containerConfig.workingDir = cli.options.workingDir - // set entrypoint - if( cli.options.cmd ) - containerConfig.cmd = [cli.options.cmd] - // render final object - return JsonOutput.prettyPrint(JsonOutput.toJson(containerConfig)) - } - - static protected String baseName(String path) { - if( !path ) - return null - def name = BucketParser.from(path).getPath().getName() - if( !name ) - return null - return name - .replaceAll(/\.gz$/,'') - .replaceAll(/\.gzip$/,'') - .replaceAll(/\.tar$/,'') - .replaceAll(/\.json$/,'') - } - -} diff --git a/plugins/nf-wave/src/main/io/seqera/wave/plugin/cli/WaveDebugCmd.groovy b/plugins/nf-wave/src/main/io/seqera/wave/plugin/cli/WaveDebugCmd.groovy deleted file mode 100644 index 0a1c3ef3fc..0000000000 --- a/plugins/nf-wave/src/main/io/seqera/wave/plugin/cli/WaveDebugCmd.groovy +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright 2013-2024, Seqera Labs - * - * Licensed 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 - * - * http://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 io.seqera.wave.plugin.cli - -import java.nio.file.Path - -import com.google.common.hash.HashCode -import groovy.transform.CompileStatic -import groovy.util.logging.Slf4j -import nextflow.Session -import nextflow.SysEnv -import nextflow.cache.CacheDB -import nextflow.cache.CacheFactory -import nextflow.exception.AbortOperationException -import nextflow.fusion.FusionHelper -import nextflow.file.FileHelper -import nextflow.trace.TraceRecord -import nextflow.util.HistoryFile - -import static nextflow.fusion.FusionConfig.FUSION_PATH - -import static nextflow.util.StringUtils.* - -/** - * Implement wave container task debug - * - * @author Paolo Di Tommaso - */ -@Slf4j -@CompileStatic -class WaveDebugCmd { - - private CacheDB cacheDb - - private Session session - - WaveDebugCmd(Session session) { - this.session = session - } - - protected void taskDebug(List args) { - if( !args ) - throw new AbortOperationException("Missing id of the task to debug or remote task path") - - final criteria = args.pop() - - if ( isRemotePath(criteria) && args ) { - final image = args.pop() - runRemoteTask(criteria, image) - } - else { - runCacheTask(criteria) - } - - } - - protected runRemoteTask(String workDir, String image) { - final path = FileHelper.asPath(workDir) - final cmd = buildWaveRunCmd(path.scheme) - if ( image.startsWith("wave.seqera.io/") ) { - // Run a waved container - cmd.runContainer(image, buildCommand(path)) - } - else { - // Wave it before running - List args = [image] + buildCommand(path) - cmd.runContainer(args) - } - } - - protected runCacheTask(String criteria) { - HistoryFile history = HistoryFile.DEFAULT - HistoryFile.Record runRecord - if( !history.exists() || history.empty() || !(runRecord=history.findByIdOrName('last')[0]) ) - throw new AbortOperationException("It looks no pipeline was executed in this folder (or execution history is empty)") - - this.cacheDb = CacheFactory.create(runRecord.sessionId, runRecord.runName) - cacheDb.openForRead() - try { - final trace = getOrFindTrace(criteria) - if( trace==null ) - throw new AbortOperationException("Cannot find any task with id: '$criteria'") - if( !isRemotePath(trace.workDir) ) - throw new AbortOperationException("Cannot run non-fusion enabled task - Task work dir: $trace.workDir") - log.info "Launching debug session for task '${trace.get('name')}' - work directory: ${trace.workDir}" - final cmd = buildWaveRunCmd(getUrlProtocol(trace.workDir)) - cmd.runContainer(trace.get('container')?.toString(), buildCommand(trace.workDir)) - } - finally { - cacheDb.close() - } - } - - protected static List buildCommand(String workDir) { - final workPath = FileHelper.asPath(workDir) - return buildCommand(workPath) - } - - protected static List buildCommand(Path workPath) { - final fusionPath = FusionHelper.toContainerMount(workPath, workPath.scheme) - return [FUSION_PATH, 'sh', '-c', "\'cd $fusionPath && PS1=\"[fusion] \" exec bash --norc\'".toString()] - } - - protected WaveRunCmd buildWaveRunCmd(String scheme) { - final result = new WaveRunCmd(session) - result.withContainerParams([tty:true, privileged: true]) - if( scheme=='s3' ) { - result.withEnvironment('AWS_ACCESS_KEY_ID') - result.withEnvironment('AWS_SECRET_ACCESS_KEY') - } - else if( scheme=='gs' && SysEnv.containsKey('GOOGLE_APPLICATION_CREDENTIALS') ) { - final path = Path.of(SysEnv.get('GOOGLE_APPLICATION_CREDENTIALS')) - result.withMounts(List.of(path)) - result.withEnvironment('GOOGLE_APPLICATION_CREDENTIALS') - } - return result - } - - protected TraceRecord getOrFindTrace(String criteria) { - TraceRecord result = null - final norm = criteria.replace('/','') - if( norm.size()==32 ) try { - final hash = HashCode.fromString(norm) - result = cacheDb.getTraceRecord(hash) - } - catch (IllegalArgumentException e) { - log.debug "Not a valid task hash: $criteria" - } - - if( !result ) { - final condition = { TraceRecord trace -> - final hash = trace.get('hash') as String - final name = trace.get('name') as String - return hash?.contains(criteria) - || hash.replace('/','')?.contains(criteria) - || name?.contains(criteria) - || trace.workDir?.contains(criteria) - } - result = cacheDb.findTraceRecord(condition) - } - - return result - } - - static boolean isRemotePath(String path) { - if (!path) return false - final result = getUrlProtocol(path) - return result!=null && result!='file' - } -} diff --git a/plugins/nf-wave/src/main/io/seqera/wave/plugin/cli/WaveRunCmd.groovy b/plugins/nf-wave/src/main/io/seqera/wave/plugin/cli/WaveRunCmd.groovy deleted file mode 100644 index 4edff020cb..0000000000 --- a/plugins/nf-wave/src/main/io/seqera/wave/plugin/cli/WaveRunCmd.groovy +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2013-2024, Seqera Labs - * - * Licensed 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 - * - * http://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 io.seqera.wave.plugin.cli - -import java.nio.file.Path - -import groovy.transform.CompileStatic -import groovy.util.logging.Slf4j -import io.seqera.wave.plugin.WaveClient -import nextflow.Session -import nextflow.container.ContainerBuilder -import nextflow.exception.AbortOperationException - -/** - * Implements Wave debug command - * - * @author Paolo Di Tommaso - */ -@Slf4j -@CompileStatic -class WaveRunCmd { - - private Session session - - private Map containerParams - - private List containerMounts - - private Set environment = new HashSet<>() - - WaveRunCmd(Session session) { this.session=session } - - WaveRunCmd withContainerParams(Map params) { - this.containerParams = params - return this - } - - WaveRunCmd withMounts(List path) { - this.containerMounts = path - return this - } - - WaveRunCmd withEnvironment(String... envs) { - this.environment.addAll(envs) - return this - } - - void runContainer(List args) { - if( !args ) - throw new AbortOperationException("Missing container image - usage: nextflow plugin nf-wave:run-container ") - final image = args.pop() - final target = resolveTargetImage(image) - log.info "Resolved image: '$image' => '$target'" - runContainer(target, args) - } - - void runContainer(String image, List args=Collections.emptyList()) { - final containerConfig = session.getContainerConfig() - final containerBuilder = ContainerBuilder.create(containerConfig, image) - .addMountWorkDir(false) - .addRunOptions('--rm') - .addMounts(containerMounts) - .params(containerParams) - - // add env variables - environment.addAll( containerConfig.getEnvWhitelist() ) - for( String env : environment ) - containerBuilder.addEnv(env) - - // assemble the final command - final containerCmd = containerBuilder - .build() - .getRunCommand(args.join(' ')) - .replaceAll('-w "\\$NXF_TASK_WORKDIR" ','') // <-- hack to remove the PWD work dir - - log.debug "Running: $containerCmd" - final process = new ProcessBuilder() - .command(['sh','-c',containerCmd]) - .directory(Path.of(".").toFile()) - .inheritIO() - .start() - - process.waitFor() - } - - protected String resolveTargetImage(String image) { - final resp = new WaveClient(session).sendRequest(image) - return resp?.targetImage - } - - void getContainer(List args) { - if( !args ) - throw new AbortOperationException("Missing container image - usage: nextflow plugin nf-wave:get-container ") - final image = args.pop() - final target = resolveTargetImage(image) - log.info """\ - Source container: $image - Waved container: $target""".stripIndent(true) - } -} diff --git a/plugins/nf-wave/src/test/io/seqera/wave/plugin/cli/WaveCmdEntryTest.groovy b/plugins/nf-wave/src/test/io/seqera/wave/plugin/cli/WaveCmdEntryTest.groovy deleted file mode 100644 index 968af40220..0000000000 --- a/plugins/nf-wave/src/test/io/seqera/wave/plugin/cli/WaveCmdEntryTest.groovy +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2013-2024, Seqera Labs - * - * Licensed 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 - * - * http://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 io.seqera.wave.plugin.cli - -import java.nio.file.Files -import java.nio.file.Path - -import groovy.json.JsonSlurper -import io.seqera.wave.plugin.packer.TarHelper -import nextflow.extension.FilesEx -import nextflow.file.FileHelper -import org.junit.Rule -import spock.lang.Shared -import spock.lang.Specification -import spock.lang.TempDir -import spock.lang.Unroll -import test.OutputCapture -/** - * - * @author Paolo Di Tommaso - */ -class WaveCmdEntryTest extends Specification implements TarHelper { - - @Shared - @TempDir - Path folder - - @Rule - OutputCapture capture = new OutputCapture() - - def 'should create create pack' () { - given: - def rootPath = folder.resolve('bundle'); rootPath.mkdir() - def untarPath = folder.resolve('untar'); untarPath.mkdir() - rootPath.resolve('main.nf').text = "I'm the main file" - rootPath.resolve('this/that').mkdirs() - Files.write(rootPath.resolve('this/hola.txt'), "Hola".bytes) - Files.write(rootPath.resolve('this/hello.txt'), "Hello".bytes) - Files.write(rootPath.resolve('this/that/ciao.txt'), "Ciao".bytes) - and: - FileHelper.visitFiles([type:'any'], rootPath, '**', { - final mode = it.isDirectory() ? 0700 : 0600 - FilesEx.setPermissionsMode(it, mode) - }) - and: - def cmd = new WaveCmdEntry() - - when: - def result = cmd.packContainer( [rootPath.toString()] ) - then: - def gzipFile = folder.resolve('bundle.tar.gz') - gzipFile.exists() - and: - def json = new JsonSlurper().parseText(result) - and: - json.layers[0].gzipSize == Files.size(gzipFile) - json.layers[0].location == gzipFile.toUri().toString() - json.layers[0].tarDigest == 'sha256:f556b94e9b6f5f72b86e44833614b465df9f65cb4210e3f4416292dca1618360' - json.layers[0].gzipDigest == 'sha256:e58685a82452a11faa926843e7861c94bdb93e2c8f098b5c5354ec9b6fee2b68' - - when: - def tar = uncompress(Files.readAllBytes(gzipFile)) - untar( new ByteArrayInputStream(tar), untarPath ) - then: - untarPath.resolve('main.nf').text == rootPath.resolve('main.nf').text - untarPath.resolve('this/hola.txt').text == rootPath.resolve('this/hola.txt').text - untarPath.resolve('this/hello.txt').text == rootPath.resolve('this/hello.txt').text - untarPath.resolve('this/that/ciao.txt').text == rootPath.resolve('this/that/ciao.txt').text - } - - @Unroll - def 'should find base name' () { - expect: - WaveCmdEntry.baseName(PATH) == EXPECTED - - where: - PATH | EXPECTED - null | null - '/some/name' | 'name' - 'http://some/name.tar' | 'name' - '/some/name.tar.gz' | 'name' - '/some/name.tar.gzip' | 'name' - 'http://some/name.tar.gz' | 'name' - 'http://some/' | null - 'http://some' | null - } -} diff --git a/plugins/nf-wave/src/test/io/seqera/wave/plugin/cli/WaveDebugCmdTest.groovy b/plugins/nf-wave/src/test/io/seqera/wave/plugin/cli/WaveDebugCmdTest.groovy deleted file mode 100644 index ccd57623a2..0000000000 --- a/plugins/nf-wave/src/test/io/seqera/wave/plugin/cli/WaveDebugCmdTest.groovy +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2013-2024, Seqera Labs - * - * Licensed 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 - * - * http://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 io.seqera.wave.plugin.cli - -import spock.lang.Specification -import spock.lang.Unroll - -/** - * - * @author Paolo Di Tommaso - */ -class WaveDebugCmdTest extends Specification { - - @Unroll - def 'should check remote path' () { - expect: - WaveDebugCmd.isRemotePath(PATH) == EXPECTED - where: - PATH | EXPECTED - null | false - 'foo' | false - '/some/file' | false - and: - 's3://foo/bar' | true - 'gs://foo/bar' | true - and: - 'file:/foo/bar' | false - 'file://foo/bar' | false - 'file:///foo/bar' | false - } - -} diff --git a/test-cli.sh b/test-cli.sh new file mode 100755 index 0000000000..f1f1f50142 --- /dev/null +++ b/test-cli.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# Test script for new plugin command system +set -e + +echo "๐Ÿงช Testing Nextflow CLI Plugin Command System" +echo "============================================" + +# Set up environment +export JAVA_HOME=${JAVA_HOME:-$(dirname $(dirname $(which java)))} +export NXF_HOME=$(pwd)/build/tmp/nxf-test +export NXF_PLUGINS_MODE=dev +export NXF_PLUGINS_DEV=$(pwd)/plugins + +# Clean and create test directory +rm -rf $NXF_HOME +mkdir -p $NXF_HOME + +# Copy the built nextflow jar +cp build/libs/nextflow-*-all.jar $NXF_HOME/nextflow.jar + +# Function to run nextflow with proper setup +run_nextflow() { + echo "$ nextflow $@" + java -cp "$NXF_HOME/nextflow.jar" nextflow.cli.Launcher "$@" || echo "Command failed (expected for some tests)" + echo +} + +echo "๐Ÿ“‹ 1. Testing main help output (should show plugin commands)" +run_nextflow --help + +echo "๐Ÿ“‹ 2. Testing Wave help specifically" +run_nextflow wave --help + +echo "๐Ÿ“‹ 3. Testing new Wave command syntax" +run_nextflow wave pack --help + +echo "๐Ÿ“‹ 4. Testing legacy plugin command syntax (backward compatibility)" +run_nextflow plugin nf-wave:pack --help + +echo "๐Ÿ“‹ 5. Testing command discovery without plugins" +export NXF_PLUGINS_MODE=off +run_nextflow --help + +echo "โœ… Testing complete!" \ No newline at end of file