Skip to content

Add plugin create command #6032

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 78 additions & 2 deletions modules/nextflow/src/main/groovy/nextflow/cli/CmdPlugin.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,18 @@

package nextflow.cli

import static nextflow.cli.PluginExecAware.CMD_SEP

import java.nio.file.Path

import com.beust.jcommander.DynamicParameter
import com.beust.jcommander.Parameter
import com.beust.jcommander.Parameters
import groovy.transform.CompileStatic
import nextflow.exception.AbortOperationException
import nextflow.plugin.Plugins
import static nextflow.cli.PluginExecAware.CMD_SEP

import nextflow.plugin.util.PluginRefactor
import org.eclipse.jgit.api.Git
/**
* Plugin manager command
*
Expand Down Expand Up @@ -59,6 +63,9 @@ class CmdPlugin extends CmdBase {
throw new AbortOperationException("Missing plugin install target - usage: nextflow plugin install <pluginId,..>")
Plugins.pull(args[1].tokenize(','))
}
else if( args[0] == 'create' ) {
createPlugin(args)
}
// plugin run command
else if( args[0].contains(CMD_SEP) ) {
final head = args.pop()
Expand Down Expand Up @@ -91,4 +98,73 @@ class CmdPlugin extends CmdBase {
}
}

static createPlugin(List<String> args) {
if( args != ['create'] && (args[0] != 'create' || !(args.size() in [3, 4])) )
throw new AbortOperationException("Invalid create parameters - usage: nextflow plugin create <Plugin name> <Organization name>")

final refactor = new PluginRefactor()
if( args.size()>1 ) {
refactor.withPluginName(args[1])
refactor.withOrgName(args[2])
refactor.withPluginDir(Path.of(args[3] ?: refactor.pluginName).toFile())
}
else {
// Prompt for plugin name
print "Enter plugin name: "
refactor.withPluginName(readLine())

// Prompt for maintainer organization
print "Enter organization: "

// Prompt for plugin path (default to the normalised plugin name)
refactor.withOrgName(readLine())
print "Enter project path [${refactor.pluginName}]: "
refactor.withPluginDir(Path.of(readLine() ?: refactor.pluginName).toFile())

// confirm and proceed
print "All good, are you OK to continue [y/N]? "
final confirm = readLine()
if( confirm!='y' )
return
}

// the final directory where the plugin is created
final File targetDir = refactor.getPluginDir()

// clone the template repo
clonePluginTemplate(targetDir)
// now refactor the template code
refactor.apply()
// remove git plat
cleanup(targetDir)
// done
println "Plugin created successfully at path: $targetDir"
}

static private String readLine() {
final console = System.console()
return console != null
? console.readLine()
: new BufferedReader(new InputStreamReader(System.in)).readLine()
}

static private void clonePluginTemplate(File targetDir) {
final templateUri = "https://github.com/nextflow-io/nf-plugin-template.git"
try {
Git.cloneRepository()
.setURI(templateUri)
.setDirectory(targetDir)
.setBranchesToClone(["refs/tags/v1.0.0"])
.setBranch("refs/tags/v1.0.0")
.call()
}
catch (Exception e) {
throw new AbortOperationException("Unable to clone pluging template repository - cause: ${e.message}")
}
}

static private void cleanup(File targetDir) {
new File(targetDir, '.git').deleteDir()
new File(targetDir, '.github').deleteDir()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright 2013-2025, 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.Files
import java.nio.file.Path

import nextflow.plugin.Plugins
import spock.lang.IgnoreIf
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <[email protected]>
*/
class CmdPluginCreateTest extends Specification {

def cleanup() {
Plugins.stop()
}

@IgnoreIf({System.getenv('NXF_SMOKE')})
def 'should clone and create a plugin project' () {
given:
def folder = Files.createTempDirectory('test')
and:
def args = [
'create',
'hello world plugin',
'foo',
folder.toAbsolutePath().toString() + '/hello']

when:
def cmd = new CmdPlugin(args: args)
and:
cmd.run()

then:
Files.exists(folder.resolve('hello'))
Files.exists(folder.resolve('hello/src/main/groovy/foo/plugin/HelloWorldPlugin.groovy'))
Files.exists(folder.resolve('hello/src/main/groovy/foo/plugin/HelloWorldObserver.groovy'))
Files.exists(folder.resolve('hello/src/main/groovy/foo/plugin/HelloWorldFactory.groovy'))
and:
Files.exists(folder.resolve('hello/src/test/groovy/foo/plugin/HelloWorldObserverTest.groovy'))
and:
Path.of(folder.resolve('hello/settings.gradle').toUri()).text.contains("rootProject.name = 'hello-world-plugin'")
Path.of(folder.resolve('hello/build.gradle').toUri()).text.contains("provider = 'foo'")
Path.of(folder.resolve('hello/build.gradle').toUri()).text.contains("className = 'foo.plugin.HelloWorldPlugin'")

cleanup:
folder?.deleteDir()
}

}
204 changes: 204 additions & 0 deletions modules/nf-commons/src/main/nextflow/plugin/util/PluginRefactor.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/*
* Copyright 2013-2025, 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.util

import java.util.regex.Matcher
import java.util.regex.Pattern

import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import nextflow.exception.AbortOperationException
import nextflow.extension.FilesEx

@Slf4j
@CompileStatic
class PluginRefactor {

private String pluginName

private String orgName

private File pluginDir

private String pluginClassPrefix

private File gradleSettingsFile

private File gradleBuildFile

private Map<String,String> tokenMapping = new HashMap<>()

String getPluginName() {
return pluginName
}

String getOrgName() {
return orgName
}

File getPluginDir() {
return pluginDir
}

PluginRefactor withPluginDir(File directory) {
this.pluginDir = directory.absoluteFile.canonicalFile
return this
}

PluginRefactor withPluginName(String name) {
this.pluginName = normalizeToKebabCase(name)
this.pluginClassPrefix = normalizeToClassName(name)
if( pluginName.toLowerCase()=='plugin' )
throw new IllegalStateException("Invalid plugin name: '$name'")
if( !pluginClassPrefix )
throw new IllegalStateException("Invalid plugin name: '$name'")
return this
}

PluginRefactor withOrgName(String name) {
this.orgName = normalizeToPackageNameSegment(name)
if( !orgName )
throw new AbortOperationException("Invalid organization name: '$name'")
return this
}

protected void init() {
if( !pluginName )
throw new IllegalStateException("Missing plugin name")
if( !orgName )
throw new IllegalStateException("Missing organization name")
// initial
this.gradleBuildFile = new File(pluginDir, 'build.gradle')
this.gradleSettingsFile = new File(pluginDir, 'settings.gradle')
if( !gradleBuildFile.exists() )
throw new AbortOperationException("Plugin file does not exist: $gradleBuildFile")
if( !gradleSettingsFile.exists() )
throw new AbortOperationException("Plugin file does not exist: $gradleSettingsFile")
if( !orgName )
throw new AbortOperationException("Plugin org name is missing")
// packages to be updates
tokenMapping.put('acme', orgName)
tokenMapping.put('nf-plugin-template', pluginName)
}

void apply() {
init()
replacePrefixInFiles(pluginDir, pluginClassPrefix)
renameDirectory(new File(pluginDir, "src/main/groovy/acme"), new File(pluginDir, "src/main/groovy/${orgName}"))
renameDirectory(new File(pluginDir, "src/test/groovy/acme"), new File(pluginDir, "src/test/groovy/${orgName}"))
updateClassNames(pluginDir)
}

protected void replacePrefixInFiles(File rootDir, String newPrefix) {
if (!rootDir.exists() || !rootDir.isDirectory()) {
throw new IllegalStateException("Invalid directory: $rootDir")
}

rootDir.eachFileRecurse { file ->
if (file.isFile() && file.name.startsWith('My') && FilesEx.getExtension(file) in ['groovy']) {
final newName = file.name.replaceFirst(/^My/, newPrefix)
final renamedFile = new File(file.parentFile, newName)
if (file.renameTo(renamedFile)) {
log.debug "Renamed: ${file.name} -> ${renamedFile.name}"
final source = FilesEx.getBaseName(file)
final target = FilesEx.getBaseName(renamedFile)
tokenMapping.put(source, target)
}
else {
throw new IllegalStateException("Failed to rename: ${file.name}")
}
}
}
}

protected void updateClassNames(File rootDir) {
rootDir.eachFileRecurse { file ->
if (file.isFile() && FilesEx.getExtension(file) in ['groovy','gradle']) {
replaceTokensInFile(file, tokenMapping)
}
}
}

protected void replaceTokensInFile(File inputFile, Map<String, String> replacements, File outputFile = inputFile) {
def content = inputFile.text

// Replace each key with its corresponding value
for( Map.Entry<String,String> entry : replacements ) {
content = content.replaceAll(Pattern.quote(entry.key), Matcher.quoteReplacement(entry.value))
}

outputFile.text = content
log.debug "Replacements done in: ${outputFile.path}"
}

protected void renameDirectory(File oldDir, File newDir) {
if (!oldDir.exists() || !oldDir.isDirectory()) {
throw new AbortOperationException("Plugin template directory to rename does not exist: $oldDir")
}

if( oldDir==newDir ) {
log.debug "Unneeded path rename: $oldDir -> $newDir"
}

if (newDir.exists()) {
throw new AbortOperationException("Plugin target directory already exists: $newDir")
}

if (oldDir.renameTo(newDir)) {
log.debug "Successfully renamed: $oldDir -> $newDir"
}
else {
throw new AbortOperationException("Unable to replace plugin template path: $oldDir -> $newDir")
}
}

static String normalizeToClassName(String input) {
// Replace non-alphanumeric characters with spaces (except underscores)
final cleaned = input.replaceAll(/[^a-zA-Z0-9_]/, ' ')
.replaceAll(/_/, ' ')
.trim()
// Split by whitespace, capitalize each word, join them
final parts = cleaned.split(/\s+/).collect { it.capitalize() }
return parts.join('').replace('Plugin','')
}

static String normalizeToKebabCase(String input) {
// Insert spaces before capital letters (handles CamelCase)
def spaced = input.replaceAll(/([a-z])([A-Z])/, '$1 $2')
.replaceAll(/([A-Z]+)([A-Z][a-z])/, '$1 $2')
// Replace non-alphanumeric characters and underscores with spaces
def cleaned = spaced.replaceAll(/[^a-zA-Z0-9]/, ' ')
.trim()
// Split, lowercase, and join with hyphens
def parts = cleaned.split(/\s+/).collect { it.toLowerCase() }
return parts.join('-')
}

static String normalizeToPackageNameSegment(String input) {
// Replace non-alphanumeric characters with spaces
def cleaned = input.replaceAll(/[^a-zA-Z0-9]/, ' ')
.trim()
// Split into lowercase words and join
def parts = cleaned.split(/\s+/).collect { it.toLowerCase() }
def name = parts.join('')

// Strip leading digits
name = name.replaceFirst(/^\d+/, '')
return name ?: null
}

}
Loading