Skip to content

Commit 68865f7

Browse files
authored
Add plugin create command (#6032) [ci fast]
Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
1 parent f99bcd3 commit 68865f7

File tree

4 files changed

+431
-2
lines changed

4 files changed

+431
-2
lines changed

modules/nextflow/src/main/groovy/nextflow/cli/CmdPlugin.groovy

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,18 @@
1717

1818
package nextflow.cli
1919

20+
import static nextflow.cli.PluginExecAware.CMD_SEP
21+
22+
import java.nio.file.Path
23+
2024
import com.beust.jcommander.DynamicParameter
2125
import com.beust.jcommander.Parameter
2226
import com.beust.jcommander.Parameters
2327
import groovy.transform.CompileStatic
2428
import nextflow.exception.AbortOperationException
2529
import nextflow.plugin.Plugins
26-
import static nextflow.cli.PluginExecAware.CMD_SEP
27-
30+
import nextflow.plugin.util.PluginRefactor
31+
import org.eclipse.jgit.api.Git
2832
/**
2933
* Plugin manager command
3034
*
@@ -59,6 +63,9 @@ class CmdPlugin extends CmdBase {
5963
throw new AbortOperationException("Missing plugin install target - usage: nextflow plugin install <pluginId,..>")
6064
Plugins.pull(args[1].tokenize(','))
6165
}
66+
else if( args[0] == 'create' ) {
67+
createPlugin(args)
68+
}
6269
// plugin run command
6370
else if( args[0].contains(CMD_SEP) ) {
6471
final head = args.pop()
@@ -91,4 +98,73 @@ class CmdPlugin extends CmdBase {
9198
}
9299
}
93100

101+
static createPlugin(List<String> args) {
102+
if( args != ['create'] && (args[0] != 'create' || !(args.size() in [3, 4])) )
103+
throw new AbortOperationException("Invalid create parameters - usage: nextflow plugin create <Plugin name> <Organization name>")
104+
105+
final refactor = new PluginRefactor()
106+
if( args.size()>1 ) {
107+
refactor.withPluginName(args[1])
108+
refactor.withOrgName(args[2])
109+
refactor.withPluginDir(Path.of(args[3] ?: refactor.pluginName).toFile())
110+
}
111+
else {
112+
// Prompt for plugin name
113+
print "Enter plugin name: "
114+
refactor.withPluginName(readLine())
115+
116+
// Prompt for maintainer organization
117+
print "Enter organization: "
118+
119+
// Prompt for plugin path (default to the normalised plugin name)
120+
refactor.withOrgName(readLine())
121+
print "Enter project path [${refactor.pluginName}]: "
122+
refactor.withPluginDir(Path.of(readLine() ?: refactor.pluginName).toFile())
123+
124+
// confirm and proceed
125+
print "All good, are you OK to continue [y/N]? "
126+
final confirm = readLine()
127+
if( confirm!='y' )
128+
return
129+
}
130+
131+
// the final directory where the plugin is created
132+
final File targetDir = refactor.getPluginDir()
133+
134+
// clone the template repo
135+
clonePluginTemplate(targetDir)
136+
// now refactor the template code
137+
refactor.apply()
138+
// remove git plat
139+
cleanup(targetDir)
140+
// done
141+
println "Plugin created successfully at path: $targetDir"
142+
}
143+
144+
static private String readLine() {
145+
final console = System.console()
146+
return console != null
147+
? console.readLine()
148+
: new BufferedReader(new InputStreamReader(System.in)).readLine()
149+
}
150+
151+
static private void clonePluginTemplate(File targetDir) {
152+
final templateUri = "https://github.com/nextflow-io/nf-plugin-template.git"
153+
try {
154+
Git.cloneRepository()
155+
.setURI(templateUri)
156+
.setDirectory(targetDir)
157+
.setBranchesToClone(["refs/tags/v1.0.0"])
158+
.setBranch("refs/tags/v1.0.0")
159+
.call()
160+
}
161+
catch (Exception e) {
162+
throw new AbortOperationException("Unable to clone pluging template repository - cause: ${e.message}")
163+
}
164+
}
165+
166+
static private void cleanup(File targetDir) {
167+
new File(targetDir, '.git').deleteDir()
168+
new File(targetDir, '.github').deleteDir()
169+
}
94170
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright 2013-2025, Seqera Labs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package nextflow.cli
18+
19+
import java.nio.file.Files
20+
import java.nio.file.Path
21+
22+
import nextflow.plugin.Plugins
23+
import spock.lang.IgnoreIf
24+
import spock.lang.Specification
25+
/**
26+
*
27+
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
28+
*/
29+
class CmdPluginCreateTest extends Specification {
30+
31+
def cleanup() {
32+
Plugins.stop()
33+
}
34+
35+
@IgnoreIf({System.getenv('NXF_SMOKE')})
36+
def 'should clone and create a plugin project' () {
37+
given:
38+
def folder = Files.createTempDirectory('test')
39+
and:
40+
def args = [
41+
'create',
42+
'hello world plugin',
43+
'foo',
44+
folder.toAbsolutePath().toString() + '/hello']
45+
46+
when:
47+
def cmd = new CmdPlugin(args: args)
48+
and:
49+
cmd.run()
50+
51+
then:
52+
Files.exists(folder.resolve('hello'))
53+
Files.exists(folder.resolve('hello/src/main/groovy/foo/plugin/HelloWorldPlugin.groovy'))
54+
Files.exists(folder.resolve('hello/src/main/groovy/foo/plugin/HelloWorldObserver.groovy'))
55+
Files.exists(folder.resolve('hello/src/main/groovy/foo/plugin/HelloWorldFactory.groovy'))
56+
and:
57+
Files.exists(folder.resolve('hello/src/test/groovy/foo/plugin/HelloWorldObserverTest.groovy'))
58+
and:
59+
Path.of(folder.resolve('hello/settings.gradle').toUri()).text.contains("rootProject.name = 'hello-world-plugin'")
60+
Path.of(folder.resolve('hello/build.gradle').toUri()).text.contains("provider = 'foo'")
61+
Path.of(folder.resolve('hello/build.gradle').toUri()).text.contains("className = 'foo.plugin.HelloWorldPlugin'")
62+
63+
cleanup:
64+
folder?.deleteDir()
65+
}
66+
67+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/*
2+
* Copyright 2013-2025, Seqera Labs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package nextflow.plugin.util
18+
19+
import java.util.regex.Matcher
20+
import java.util.regex.Pattern
21+
22+
import groovy.transform.CompileStatic
23+
import groovy.util.logging.Slf4j
24+
import nextflow.exception.AbortOperationException
25+
import nextflow.extension.FilesEx
26+
27+
@Slf4j
28+
@CompileStatic
29+
class PluginRefactor {
30+
31+
private String pluginName
32+
33+
private String orgName
34+
35+
private File pluginDir
36+
37+
private String pluginClassPrefix
38+
39+
private File gradleSettingsFile
40+
41+
private File gradleBuildFile
42+
43+
private Map<String,String> tokenMapping = new HashMap<>()
44+
45+
String getPluginName() {
46+
return pluginName
47+
}
48+
49+
String getOrgName() {
50+
return orgName
51+
}
52+
53+
File getPluginDir() {
54+
return pluginDir
55+
}
56+
57+
PluginRefactor withPluginDir(File directory) {
58+
this.pluginDir = directory.absoluteFile.canonicalFile
59+
return this
60+
}
61+
62+
PluginRefactor withPluginName(String name) {
63+
this.pluginName = normalizeToKebabCase(name)
64+
this.pluginClassPrefix = normalizeToClassName(name)
65+
if( pluginName.toLowerCase()=='plugin' )
66+
throw new IllegalStateException("Invalid plugin name: '$name'")
67+
if( !pluginClassPrefix )
68+
throw new IllegalStateException("Invalid plugin name: '$name'")
69+
return this
70+
}
71+
72+
PluginRefactor withOrgName(String name) {
73+
this.orgName = normalizeToPackageNameSegment(name)
74+
if( !orgName )
75+
throw new AbortOperationException("Invalid organization name: '$name'")
76+
return this
77+
}
78+
79+
protected void init() {
80+
if( !pluginName )
81+
throw new IllegalStateException("Missing plugin name")
82+
if( !orgName )
83+
throw new IllegalStateException("Missing organization name")
84+
// initial
85+
this.gradleBuildFile = new File(pluginDir, 'build.gradle')
86+
this.gradleSettingsFile = new File(pluginDir, 'settings.gradle')
87+
if( !gradleBuildFile.exists() )
88+
throw new AbortOperationException("Plugin file does not exist: $gradleBuildFile")
89+
if( !gradleSettingsFile.exists() )
90+
throw new AbortOperationException("Plugin file does not exist: $gradleSettingsFile")
91+
if( !orgName )
92+
throw new AbortOperationException("Plugin org name is missing")
93+
// packages to be updates
94+
tokenMapping.put('acme', orgName)
95+
tokenMapping.put('nf-plugin-template', pluginName)
96+
}
97+
98+
void apply() {
99+
init()
100+
replacePrefixInFiles(pluginDir, pluginClassPrefix)
101+
renameDirectory(new File(pluginDir, "src/main/groovy/acme"), new File(pluginDir, "src/main/groovy/${orgName}"))
102+
renameDirectory(new File(pluginDir, "src/test/groovy/acme"), new File(pluginDir, "src/test/groovy/${orgName}"))
103+
updateClassNames(pluginDir)
104+
}
105+
106+
protected void replacePrefixInFiles(File rootDir, String newPrefix) {
107+
if (!rootDir.exists() || !rootDir.isDirectory()) {
108+
throw new IllegalStateException("Invalid directory: $rootDir")
109+
}
110+
111+
rootDir.eachFileRecurse { file ->
112+
if (file.isFile() && file.name.startsWith('My') && FilesEx.getExtension(file) in ['groovy']) {
113+
final newName = file.name.replaceFirst(/^My/, newPrefix)
114+
final renamedFile = new File(file.parentFile, newName)
115+
if (file.renameTo(renamedFile)) {
116+
log.debug "Renamed: ${file.name} -> ${renamedFile.name}"
117+
final source = FilesEx.getBaseName(file)
118+
final target = FilesEx.getBaseName(renamedFile)
119+
tokenMapping.put(source, target)
120+
}
121+
else {
122+
throw new IllegalStateException("Failed to rename: ${file.name}")
123+
}
124+
}
125+
}
126+
}
127+
128+
protected void updateClassNames(File rootDir) {
129+
rootDir.eachFileRecurse { file ->
130+
if (file.isFile() && FilesEx.getExtension(file) in ['groovy','gradle']) {
131+
replaceTokensInFile(file, tokenMapping)
132+
}
133+
}
134+
}
135+
136+
protected void replaceTokensInFile(File inputFile, Map<String, String> replacements, File outputFile = inputFile) {
137+
def content = inputFile.text
138+
139+
// Replace each key with its corresponding value
140+
for( Map.Entry<String,String> entry : replacements ) {
141+
content = content.replaceAll(Pattern.quote(entry.key), Matcher.quoteReplacement(entry.value))
142+
}
143+
144+
outputFile.text = content
145+
log.debug "Replacements done in: ${outputFile.path}"
146+
}
147+
148+
protected void renameDirectory(File oldDir, File newDir) {
149+
if (!oldDir.exists() || !oldDir.isDirectory()) {
150+
throw new AbortOperationException("Plugin template directory to rename does not exist: $oldDir")
151+
}
152+
153+
if( oldDir==newDir ) {
154+
log.debug "Unneeded path rename: $oldDir -> $newDir"
155+
}
156+
157+
if (newDir.exists()) {
158+
throw new AbortOperationException("Plugin target directory already exists: $newDir")
159+
}
160+
161+
if (oldDir.renameTo(newDir)) {
162+
log.debug "Successfully renamed: $oldDir -> $newDir"
163+
}
164+
else {
165+
throw new AbortOperationException("Unable to replace plugin template path: $oldDir -> $newDir")
166+
}
167+
}
168+
169+
static String normalizeToClassName(String input) {
170+
// Replace non-alphanumeric characters with spaces (except underscores)
171+
final cleaned = input.replaceAll(/[^a-zA-Z0-9_]/, ' ')
172+
.replaceAll(/_/, ' ')
173+
.trim()
174+
// Split by whitespace, capitalize each word, join them
175+
final parts = cleaned.split(/\s+/).collect { it.capitalize() }
176+
return parts.join('').replace('Plugin','')
177+
}
178+
179+
static String normalizeToKebabCase(String input) {
180+
// Insert spaces before capital letters (handles CamelCase)
181+
def spaced = input.replaceAll(/([a-z])([A-Z])/, '$1 $2')
182+
.replaceAll(/([A-Z]+)([A-Z][a-z])/, '$1 $2')
183+
// Replace non-alphanumeric characters and underscores with spaces
184+
def cleaned = spaced.replaceAll(/[^a-zA-Z0-9]/, ' ')
185+
.trim()
186+
// Split, lowercase, and join with hyphens
187+
def parts = cleaned.split(/\s+/).collect { it.toLowerCase() }
188+
return parts.join('-')
189+
}
190+
191+
static String normalizeToPackageNameSegment(String input) {
192+
// Replace non-alphanumeric characters with spaces
193+
def cleaned = input.replaceAll(/[^a-zA-Z0-9]/, ' ')
194+
.trim()
195+
// Split into lowercase words and join
196+
def parts = cleaned.split(/\s+/).collect { it.toLowerCase() }
197+
def name = parts.join('')
198+
199+
// Strip leading digits
200+
name = name.replaceFirst(/^\d+/, '')
201+
return name ?: null
202+
}
203+
204+
}

0 commit comments

Comments
 (0)