blob: dd58e153a79f6465188a6f5e208ff706b54183b0 [file] [log] [blame]
#!/usr/bin/env groovy
@Grab('org.yaml:snakeyaml:2.4')
import java.util.regex.Pattern
import java.util.zip.ZipException
import java.util.zip.ZipFile
import groovy.io.FileType
import groovy.json.JsonOutput
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import groovy.xml.XmlSlurper
import org.yaml.snakeyaml.DumperOptions
import org.yaml.snakeyaml.Yaml
/**
* Represents a software version with major, minor and optional patch and modifier.
*/
@CompileStatic
class Version implements Comparable<Version> {
String versionText
int major
int minor
int patch
VersionModifier modifier
// Matches: 1.2.3, 1.2, 1.2.3-RC1, 1.2.3-M1, 1.2.3.RC1, 1.2.3-SNAPSHOT etc.
private static final Pattern VERSION_PATTERN = ~/^(\d+)\.(\d+)(?:\.(\d+))?(?:[.-](.+))?$/
/**
* Builds a Version instance from a version string.
*
* @param versionText The version string to parse.
* @return A Version instance.
* @throws IllegalArgumentException if the version string is null or invalid.
*/
static Version build(String versionText) {
if (versionText == null) {
throw new IllegalArgumentException('Version string cannot be null')
}
def matcher = VERSION_PATTERN.matcher(versionText)
if (!matcher.matches()) {
throw new IllegalArgumentException("Invalid version format: $versionText")
}
def patchGroup = matcher.group(3) // allowed to be be null
def version = new Version(
versionText: versionText,
major: matcher.group(1).toInteger(),
minor: matcher.group(2).toInteger(),
patch: patchGroup ? matcher.group(3).toInteger() : 0
)
def modifierPart = matcher.group(4) as String
if (modifierPart && VersionModifier.isModifier(modifierPart)) {
version.modifier = new VersionModifier(modifierPart)
}
return version
}
boolean isSnapshot() {
// There are some strange versions out there that don't use standard SNAPSHOT modifier
modifier?.snapshot || versionText.containsIgnoreCase('SNAPSHOT')
}
boolean hasModifier() {
modifier != null
}
@Override
int compareTo(Version o) {
int cmp = this.major <=> o.major
if (cmp != 0) return cmp
cmp = this.minor <=> o.minor
if (cmp != 0) return cmp
cmp = this.patch <=> o.patch
if (cmp != 0) return cmp
// Same major.minor.patch → look at qualifier
if (this.hasModifier() && !o.hasModifier()) {
return -1
} else if (!this.hasModifier() && o.hasModifier()) {
return 1
} else if (this.hasModifier() && o.hasModifier()) {
return this.modifier <=> o.modifier
}
// Fallback to string comparison if we end up here
return this.versionText <=> o.versionText
}
@Override
String toString() {
versionText
}
@CompileStatic
static class VersionModifier implements Comparable<VersionModifier> {
private String modifierText
VersionModifier(String modifierText) {
this.modifierText = modifierText
}
static boolean isModifier(String s) {
if (!s) return false
s == 'SNAPSHOT' ||
s == 'BUILD-SNAPSHOT' ||
(s ==~ /M\d+/) ||
(s ==~ /RC\d+/) ||
(s ==~ /ALPHA\d+/) ||
(s ==~ /BETA\d+/)
}
int getReleaseCandidateVersion() {
modifierText.replace('RC', '').toInteger()
}
int getMilestoneVersion() {
modifierText.replace('M', '').toInteger()
}
int getAlphaVersion() {
modifierText.replace('ALPHA', '').toInteger()
}
int getBetaVersion() {
modifierText.replace('BETA', '').toInteger()
}
boolean isSnapshot() {
modifierText in ['BUILD-SNAPSHOT', 'SNAPSHOT']
}
boolean isReleaseCandidate() {
modifierText.toUpperCase().startsWith('RC')
}
boolean isMilestone() {
modifierText.toUpperCase().startsWith('M')
}
boolean isAlpha() {
modifierText.toUpperCase().startsWith('ALPHA')
}
boolean isBeta() {
modifierText.toUpperCase().startsWith('BETA')
}
/**
* Main type rank:
* 0 = generic snapshot (SNAPSHOT / BUILD-SNAPSHOT)
* 1 = milestone (M1, M2, ...)
* 2 = release candidate (RC1, RC2, ...)
* 3 = final (no qualifier) – handled in Version, not here
*/
int getTypeRank() {
if (snapshot) return 0
if (alpha) return 1
if (beta) return 2
if (milestone) return 3
if (releaseCandidate) return 4
return 0
}
@Override
int compareTo(VersionModifier o) {
int rankCmp = typeRank <=> o.typeRank
if (rankCmp != 0) {
return rankCmp
}
// Same type: compare numeric part if applicable
if (releaseCandidate && o.releaseCandidate) {
return releaseCandidateVersion <=> o.releaseCandidateVersion
}
if (milestone && o.milestone) {
return milestoneVersion <=> o.milestoneVersion
}
if (beta && o.beta) {
return betaVersion <=> o.betaVersion
}
if (alpha && o.alpha) {
return alphaVersion <=> o.alphaVersion
}
return 0
}
@Override
String toString() {
modifierText
}
}
}
@CompileStatic
class Utils {
static final String USER_AGENT = 'Grails Plugin Version Update Checker'
/**
* Opens an HttpURLConnection to the given URL with the specified request method,
* executes the provided action closure, and ensures the connection is closed afterwards.
*
* @param url The URL to connect to.
* @param requestMethod The HTTP request method (e.g., 'GET', 'HEAD').
* @param action A closure that takes the HttpURLConnection as delegate to perform actions.
* @return The result of the action closure.
*/
static <T> T doWithConnection(
String url,
String requestMethod,
@DelegatesTo(
strategy = Closure.DELEGATE_FIRST,
value = HttpURLConnection
)
Closure<T> action
) {
def conn = (new URL(url).openConnection() as HttpURLConnection).tap {
it.requestMethod = requestMethod
it.connectTimeout = 20000
it.readTimeout = 20000
it.setRequestProperty('User-Agent', USER_AGENT)
}
try {
conn.connect()
action.delegate = conn
action.resolveStrategy = Closure.DELEGATE_FIRST
return action.call()
} finally {
conn.disconnect()
}
}
/**
* Fetches the Last-Modified header of an artifact URL via HEAD request.
*
* Returns ISO-8601 date string or null if not found.
*/
static Date fetchArtifactLastModified(String url) {
doWithConnection(url, 'HEAD') {
long ts = lastModified // 0 if header missing
if (ts > 0L) {
return new Date(ts)
}
return null
}
}
/**
* Constructs the maven-metadata.xml URL from mavenRepo and coords.
*/
static String createMetadataUrl(String mavenRepo, String coords) {
// Ensure mavenRepo does not end with '/'
if (mavenRepo.endsWith('/')) {
mavenRepo = mavenRepo.substring(0, mavenRepo.length() - 1)
}
// Convert groupId to path
def artefactPath = coords.replaceAll(/[.:]/, '/')
"$mavenRepo/$artefactPath/maven-metadata.xml"
}
/**
* Constructs the artifact JAR URL from mavenRepo, coords, and version.
*/
static String createArtifactUrl(String mavenRepo, String coords, Version version) {
// Ensure mavenRepo does not end with '/'
if (mavenRepo.endsWith('/')) {
mavenRepo = mavenRepo.substring(0, mavenRepo.length() - 1)
}
// Convert groupId to path
def parts = coords.split(':')
def groupId = parts[0]
def artifactId = parts[1]
def groupPath = groupId.replaceAll(/[.:]/, '/')
"$mavenRepo/$groupPath/$artifactId/$version/$artifactId-${version}.jar"
}
/**
* Fetches artifact version info (e.g. last modified date) from Maven repo.
*
* Returns [ 'date': String ] or [:] on failure.
*/
static Map<String, Object> fetchArtifactVersionInfo(Map<String, Object> pluginInfo, Version version) {
def mavenRepo = pluginInfo['maven-repo'] as String
def coords = pluginInfo['coords'] as String
if (!mavenRepo || !coords || !version) {
return [:]
}
def artifactUrl = createArtifactUrl(mavenRepo, coords, version)
Date lastModified = null
String grailsCompatibility = null
def candidateUrls = [
artifactUrl,
artifactUrl.replaceFirst(/\.jar$/, '-plain.jar'),
artifactUrl.replaceFirst(/\.jar$/, '.zip') // Grails 2
]
// Fetch Last-Modified header
for (url in candidateUrls) {
try {
lastModified = fetchArtifactLastModified(url)
if (lastModified) {
break
}
} catch(FileNotFoundException ignore) {
// Try next
}
}
// Try the candidate urls in order
for (url in candidateUrls) {
try {
grailsCompatibility = extractGrailsVersionFromPluginJar(url)
if (grailsCompatibility) {
break
}
} catch(FileNotFoundException ignore) {
// Try next
}
}
if (!grailsCompatibility) {
System.err.println("WARNING: Could not find plugin artifact for $coords:$version")
}
def versionInfo = [:] as Map<String, Object>
if (lastModified) {
versionInfo.put('date', lastModified as Date)
}
if (grailsCompatibility) {
versionInfo.put('grailsVersion', grailsCompatibility)
}
return versionInfo
}
/**
* Extracts the grailsVersion compatibility string from a Grails plugin JAR.
*
* Looks for META-INF/grails-plugin.xml and reads either:
* - <plugin grailsVersion='...'>
* - or <grailsVersion>...</grailsVersion>
*
* @param jarFile the plugin JAR file
* @return the grailsVersion string (e.g. "6.0.0 > *"), or null if not found
*/
@CompileDynamic
static String extractGrailsVersionFromPluginJar(String artifactUrl) throws FileNotFoundException {
def file = File.createTempFile('plugin-', '.jar').tap {
it.deleteOnExit()
}
println("Downloading plugin artifact from $artifactUrl to extract grailsVersion...")
doWithConnection(artifactUrl, 'GET') {
inputStream.withCloseable { is ->
file.withOutputStream { os ->
os << is
}
}
}
if (!file || !file.exists()) {
return null
}
ZipFile zip = null
try {
zip = new ZipFile(file)
def candidates = [
'META-INF/grails-plugin.xml',
'plugin.xml' // Grails 2
]
def entry = candidates.findResult {
zip.getEntry(it)
}
if (!entry) {
return null
}
def xml = null
zip.getInputStream(entry).withCloseable { is ->
xml = new XmlSlurper().parse(is)
}
if (!xml) {
return null
}
// 1) Try attribute on <plugin ... grailsVersion='...'>
String attrVersion = xml.@grailsVersion?.toString()
if (attrVersion) {
return attrVersion
}
// 2) Fallback: <grailsVersion>...</grailsVersion>
String elemVersion = xml.grailsVersion?.text()
if (elemVersion) {
return elemVersion
}
return null
}
catch(ZipException ex) {
System.err.println("Invalid Zip file at $artifactUrl : ${ex.message}")
return null
}
finally {
if (zip != null) {
zip.close()
}
}
}
/**
* Fetches all available versions from maven-metadata.xml.
*
* Returns List<Version> or [] on failure.
*/
@CompileDynamic
static List<Version> fetchPluginVersions(Map<String, Object> pluginInfo) {
def mavenRepo = pluginInfo['maven-repo'] as String
def coords = pluginInfo['coords'] as String
if (!mavenRepo || !coords) {
return []
}
def metadataUrl = createMetadataUrl(mavenRepo, coords)
doWithConnection(metadataUrl, 'GET') {
int code = responseCode
if (code != 200) {
System.err.println("WARNING: $metadataUrl -> HTTP $code")
return []
}
def xml = new XmlSlurper().parse(inputStream)
(xml.versioning.versions.version*.text() as List<String>)
.collect {
try { Version.build(it) }
catch(IllegalArgumentException ex) {
System.err.println("INFO: Skipping invalid version '$it' for $coords: $ex.message")
return null
}
}
.findAll { it && !it.snapshot }
}
}
/**
* Creates a Yaml dumper with standard options.
*/
static Yaml createYamlDumper() {
def options = new DumperOptions().tap {
it.defaultFlowStyle = DumperOptions.FlowStyle.BLOCK
it.defaultScalarStyle = DumperOptions.ScalarStyle.PLAIN
it.indicatorIndent = 2
it.indentWithIndicator = true
it.width = 100
}
new Yaml(options)
}
static Map processPluginFile(File f) {
if (!(f.name.endsWith('.yml') || f.name.endsWith('.yaml'))) {
System.err.println("INFO: Skipping non-YAML file: $f.path")
return null
}
println("Processing plugin file: $f.path")
def yaml = new Yaml()
Map<String, Object> pluginInfo = null
f.withInputStream {
pluginInfo = yaml.load(it)
}
if (!pluginInfo) {
System.err.println("WARNING: File $f.path could not be parsed as YAML, skipping")
return null
}
// Expect at least 'coords'
def coords = pluginInfo.coords as String
if (!coords) {
System.err.println("WARNING: File $f.path has no 'coords', skipping")
return null
}
// Coords format: groupId:artifactId[:version]
def parts = coords.split(':')
if (parts.length < 2) {
System.err.println("WARNING: Invalid coords '$coords' in $f.path, skipping")
return null
}
def mavenRepo = pluginInfo['maven-repo'] as String
if (!mavenRepo) {
// No mavenRepo -> can't sync versions, but still include in index
System.err.println("INFO: No 'maven-repo' for $coords in $f.path, not syncing versions")
} else {
// Fetch and update latest version info
def availableVersions = fetchPluginVersions(pluginInfo)
def existingVersions = pluginInfo.get('versions', []) as List<Map>
availableVersions.each { version ->
def alreadyPresent = existingVersions.any { v -> v.version == version.versionText }
if (alreadyPresent) {
return
}
def versionInfo = fetchArtifactVersionInfo(pluginInfo, version)
if (!versionInfo) {
System.err.println("WARNING: Could not fetch info for version $version of $coords")
return
}
def entry = [
version: version.versionText,
date: versionInfo.date,
grailsVersion: versionInfo.grailsVersion
].findAll { k, v -> v != null }
existingVersions << entry
}
pluginInfo.versions = existingVersions.sort { a, b ->
def verA = Version.build(a.version as String)
def verB = Version.build(b.version as String)
return verB <=> verA
}
}
// Restore the date types (YAML parser converts to String)
(pluginInfo.versions as List<Map>).each { v ->
if (v.date && v.date instanceof String) {
v.date = Date.parse("yyyy-MM-dd'T'mm:HH:ss'Z'", v.date as String)
}
}
// Rewrite YAML file with updated info
f.text = createYamlDumper().dump(pluginInfo)
pluginInfo as Map
}
}
def fileArg = (args && args.length > 0) ? new File(args[0]) : 'grails-plugins' as File
if (fileArg.file) {
Utils.processPluginFile(fileArg)
println("Processed single file: $fileArg.path")
}
else {
if (!fileArg.exists() || !fileArg.directory) {
System.err.println("ERROR: Directory '$fileArg.path' not found in ${('.' as File).absolutePath}")
System.exit(1)
}
List<Map> indexEntries = []
fileArg.eachFileRecurse(FileType.FILES) { f ->
def pluginInfo = Utils.processPluginFile(f)
// Add to index (include everything from YAML)
if (pluginInfo) {
indexEntries << pluginInfo
}
}
// Write index JSON
def indexFile = 'grails-plugins-index.json' as File
indexFile.text = JsonOutput.toJson(indexEntries)
println "Wrote index with ${indexEntries.size()} entries to ${indexFile.path}"
}