diff --git a/FORK_CHANGELOG.md b/FORK_CHANGELOG.md index 182613387..6ffdc7152 100644 --- a/FORK_CHANGELOG.md +++ b/FORK_CHANGELOG.md @@ -6,4 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -[unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.1...HEAD +### Added + +- [DCA11Y-1145]: Automatic version detection of the Node version from `.tool-versions`, `.node-version`, and `.nvmrc` files +- [DCA11Y-1145]: The configuration property `nodeVersionFile` to specify a file that can be read in `install-node-and-npm`, `install-node-and-pnpm`, and `install-node-and-yarn` + +### Changed + +- [DCA11Y-1145]: Now tolerant of `v` missing or present at the start of a Node version + +[DCA11Y-1145]: https://hello.jira.atlassian.cloud/browse/DCA11Y-1145 +[unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.15.0...HEAD diff --git a/README.md b/README.md index f7d1bf2a5..23d09c26f 100644 --- a/README.md +++ b/README.md @@ -52,4 +52,24 @@ Only Atlassians may release a new version, [follow this guide](https://hello.atl ## Usage guidance +### Format of the Node version + +It shouldn't matter if the `v` prefix is present, e.g. `14.8.0` and `v14.8.0`. + +Old, non-standard, and codename versions are also supported if they're [available](https://nodejs.org/dist), e.g. `latest-v12.x`. + +### Using Node version files + +The plugin should automatically detect the version from files like: `.node-version`, `.nvmrc`, and `.tool-versions`. Comments in the files should be ignored. If the file is not in the working directory, nor any of the parent directories, it can be manually set in the configuration like so: + +```xml + + com.github.eirslett + frontend-maven-plugin + + ${project.basedir}/dotfiles/.nvmrc + + +``` + [![Cheers from Atlassian](https://raw.githubusercontent.com/atlassian-internal/oss-assets/master/banner-cheers-light.png)](https://www.atlassian.com) diff --git a/frontend-maven-plugin/.nvmrc b/frontend-maven-plugin/.nvmrc new file mode 100644 index 000000000..b771ee2db --- /dev/null +++ b/frontend-maven-plugin/.nvmrc @@ -0,0 +1,4 @@ + # comment + + v22.5.1 ! comment + // comment diff --git a/frontend-maven-plugin/pom.xml b/frontend-maven-plugin/pom.xml index a3d75bbce..210d9c0d6 100644 --- a/frontend-maven-plugin/pom.xml +++ b/frontend-maven-plugin/pom.xml @@ -130,6 +130,12 @@ run verify + + + TESTDIR + TESTPROFILE + + diff --git a/frontend-maven-plugin/src/it/mise-config-file/.mise.toml b/frontend-maven-plugin/src/it/mise-config-file/.mise.toml new file mode 100644 index 000000000..1cd6234fa --- /dev/null +++ b/frontend-maven-plugin/src/it/mise-config-file/.mise.toml @@ -0,0 +1,10 @@ +[tools] +java = "temurin-21" +maven = "3.9" +pre-commit = "latest" +ktlint = "latest" +python = "3.12" +# node 22.5.1 +node = "22.5.1" +# node 22.5.1 +yarn = "1.22.22" diff --git a/frontend-maven-plugin/src/it/mise-config-file/package-lock.json b/frontend-maven-plugin/src/it/mise-config-file/package-lock.json new file mode 100644 index 000000000..aa1c583f3 --- /dev/null +++ b/frontend-maven-plugin/src/it/mise-config-file/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "example", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "example", + "version": "0.0.1" + } + } +} diff --git a/frontend-maven-plugin/src/it/mise-config-file/package.json b/frontend-maven-plugin/src/it/mise-config-file/package.json new file mode 100644 index 000000000..30551a8fe --- /dev/null +++ b/frontend-maven-plugin/src/it/mise-config-file/package.json @@ -0,0 +1,4 @@ +{ + "name": "example", + "version": "0.0.1" +} diff --git a/frontend-maven-plugin/src/it/mise-config-file/pom.xml b/frontend-maven-plugin/src/it/mise-config-file/pom.xml new file mode 100644 index 000000000..2dd31616a --- /dev/null +++ b/frontend-maven-plugin/src/it/mise-config-file/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + com.github.eirslett + example + 0 + pom + + + + + com.github.eirslett + frontend-maven-plugin + + @project.version@ + + + target + + + + + + install node and npm + + install-node-and-npm + + + + + + + diff --git a/frontend-maven-plugin/src/it/mise-config-file/verify.groovy b/frontend-maven-plugin/src/it/mise-config-file/verify.groovy new file mode 100644 index 000000000..b7a37416c --- /dev/null +++ b/frontend-maven-plugin/src/it/mise-config-file/verify.groovy @@ -0,0 +1,5 @@ +assert new File(basedir, 'target/node').exists() : "Node was not installed in the custom install directory"; + +String buildLog = new File(basedir, 'build.log').text +assert buildLog.contains('.mise.toml') : 'The wrong file was used' +assert buildLog.contains('Installing node version v22.5.1') : 'The correct node version was not detected' diff --git a/frontend-maven-plugin/src/it/mise-env-config-file/TESTDIR/mise.TESTPROFILE.toml b/frontend-maven-plugin/src/it/mise-env-config-file/TESTDIR/mise.TESTPROFILE.toml new file mode 100644 index 000000000..1cd6234fa --- /dev/null +++ b/frontend-maven-plugin/src/it/mise-env-config-file/TESTDIR/mise.TESTPROFILE.toml @@ -0,0 +1,10 @@ +[tools] +java = "temurin-21" +maven = "3.9" +pre-commit = "latest" +ktlint = "latest" +python = "3.12" +# node 22.5.1 +node = "22.5.1" +# node 22.5.1 +yarn = "1.22.22" diff --git a/frontend-maven-plugin/src/it/mise-env-config-file/package-lock.json b/frontend-maven-plugin/src/it/mise-env-config-file/package-lock.json new file mode 100644 index 000000000..aa1c583f3 --- /dev/null +++ b/frontend-maven-plugin/src/it/mise-env-config-file/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "example", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "example", + "version": "0.0.1" + } + } +} diff --git a/frontend-maven-plugin/src/it/mise-env-config-file/package.json b/frontend-maven-plugin/src/it/mise-env-config-file/package.json new file mode 100644 index 000000000..30551a8fe --- /dev/null +++ b/frontend-maven-plugin/src/it/mise-env-config-file/package.json @@ -0,0 +1,4 @@ +{ + "name": "example", + "version": "0.0.1" +} diff --git a/frontend-maven-plugin/src/it/mise-env-config-file/pom.xml b/frontend-maven-plugin/src/it/mise-env-config-file/pom.xml new file mode 100644 index 000000000..2dd31616a --- /dev/null +++ b/frontend-maven-plugin/src/it/mise-env-config-file/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + com.github.eirslett + example + 0 + pom + + + + + com.github.eirslett + frontend-maven-plugin + + @project.version@ + + + target + + + + + + install node and npm + + install-node-and-npm + + + + + + + diff --git a/frontend-maven-plugin/src/it/mise-env-config-file/verify.groovy b/frontend-maven-plugin/src/it/mise-env-config-file/verify.groovy new file mode 100644 index 000000000..445e9c53f --- /dev/null +++ b/frontend-maven-plugin/src/it/mise-env-config-file/verify.groovy @@ -0,0 +1,5 @@ +assert new File(basedir, 'target/node').exists() : "Node was not installed in the custom install directory"; + +String buildLog = new File(basedir, 'build.log').text +assert buildLog.contains(['TESTDIR','mise.TESTPROFILE.toml'].join(File.separator)) : 'The wrong file was used' +assert buildLog.contains('Installing node version v22.5.1') : 'The correct node version was not detected' diff --git a/frontend-maven-plugin/src/it/nested-project-nvmrc-file/package-lock.json b/frontend-maven-plugin/src/it/nested-project-nvmrc-file/package-lock.json new file mode 100644 index 000000000..aa1c583f3 --- /dev/null +++ b/frontend-maven-plugin/src/it/nested-project-nvmrc-file/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "example", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "example", + "version": "0.0.1" + } + } +} diff --git a/frontend-maven-plugin/src/it/nested-project-nvmrc-file/package.json b/frontend-maven-plugin/src/it/nested-project-nvmrc-file/package.json new file mode 100644 index 000000000..30551a8fe --- /dev/null +++ b/frontend-maven-plugin/src/it/nested-project-nvmrc-file/package.json @@ -0,0 +1,4 @@ +{ + "name": "example", + "version": "0.0.1" +} diff --git a/frontend-maven-plugin/src/it/nested-project-nvmrc-file/pom.xml b/frontend-maven-plugin/src/it/nested-project-nvmrc-file/pom.xml new file mode 100644 index 000000000..2dd31616a --- /dev/null +++ b/frontend-maven-plugin/src/it/nested-project-nvmrc-file/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + com.github.eirslett + example + 0 + pom + + + + + com.github.eirslett + frontend-maven-plugin + + @project.version@ + + + target + + + + + + install node and npm + + install-node-and-npm + + + + + + + diff --git a/frontend-maven-plugin/src/it/nested-project-nvmrc-file/verify.groovy b/frontend-maven-plugin/src/it/nested-project-nvmrc-file/verify.groovy new file mode 100644 index 000000000..17eda1f1b --- /dev/null +++ b/frontend-maven-plugin/src/it/nested-project-nvmrc-file/verify.groovy @@ -0,0 +1,5 @@ +assert new File(basedir, 'target/node').exists() : "Node was not installed in the custom install directory"; + +String buildLog = new File(basedir, 'build.log').text +assert buildLog.contains(['frontend-maven-plugin','.nvmrc'].join(File.separator)) : 'The wrong file was used' +assert buildLog.contains('Installing node version v22.5.1') : 'The correct node version was not detected' diff --git a/frontend-maven-plugin/src/it/specified-node-version-file/dotfiles/.nvmrc b/frontend-maven-plugin/src/it/specified-node-version-file/dotfiles/.nvmrc new file mode 100644 index 000000000..3d3475a7e --- /dev/null +++ b/frontend-maven-plugin/src/it/specified-node-version-file/dotfiles/.nvmrc @@ -0,0 +1 @@ +v22.5.1 diff --git a/frontend-maven-plugin/src/it/specified-node-version-file/package-lock.json b/frontend-maven-plugin/src/it/specified-node-version-file/package-lock.json new file mode 100644 index 000000000..aa1c583f3 --- /dev/null +++ b/frontend-maven-plugin/src/it/specified-node-version-file/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "example", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "example", + "version": "0.0.1" + } + } +} diff --git a/frontend-maven-plugin/src/it/specified-node-version-file/package.json b/frontend-maven-plugin/src/it/specified-node-version-file/package.json new file mode 100644 index 000000000..30551a8fe --- /dev/null +++ b/frontend-maven-plugin/src/it/specified-node-version-file/package.json @@ -0,0 +1,4 @@ +{ + "name": "example", + "version": "0.0.1" +} diff --git a/frontend-maven-plugin/src/it/specified-node-version-file/pom.xml b/frontend-maven-plugin/src/it/specified-node-version-file/pom.xml new file mode 100644 index 000000000..7500afe23 --- /dev/null +++ b/frontend-maven-plugin/src/it/specified-node-version-file/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + com.github.eirslett + example + 0 + pom + + + + + com.github.eirslett + frontend-maven-plugin + + @project.version@ + + + ${project.basedir}/dotfiles/.nvmrc + target + + + + + + install node and npm + + install-node-and-npm + + + + + + + diff --git a/frontend-maven-plugin/src/it/specified-node-version-file/verify.groovy b/frontend-maven-plugin/src/it/specified-node-version-file/verify.groovy new file mode 100644 index 000000000..5f4c4254f --- /dev/null +++ b/frontend-maven-plugin/src/it/specified-node-version-file/verify.groovy @@ -0,0 +1,5 @@ +assert new File(basedir, 'target/node').exists() : "Node was not installed in the custom install directory"; + +String buildLog = new File(basedir, 'build.log').text +assert buildLog.contains(['specified-node-version-file', 'dotfiles', '.nvmrc'].join(File.separator)) : 'The wrong file was used' +assert buildLog.contains('Installing node version v22.5.1') : 'The correct node version was not detected' diff --git a/frontend-maven-plugin/src/it/tool-versions-file/.tool-versions b/frontend-maven-plugin/src/it/tool-versions-file/.tool-versions new file mode 100644 index 000000000..dc60ae4bf --- /dev/null +++ b/frontend-maven-plugin/src/it/tool-versions-file/.tool-versions @@ -0,0 +1 @@ +node v22.5.1 diff --git a/frontend-maven-plugin/src/it/tool-versions-file/package-lock.json b/frontend-maven-plugin/src/it/tool-versions-file/package-lock.json new file mode 100644 index 000000000..aa1c583f3 --- /dev/null +++ b/frontend-maven-plugin/src/it/tool-versions-file/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "example", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "example", + "version": "0.0.1" + } + } +} diff --git a/frontend-maven-plugin/src/it/tool-versions-file/package.json b/frontend-maven-plugin/src/it/tool-versions-file/package.json new file mode 100644 index 000000000..30551a8fe --- /dev/null +++ b/frontend-maven-plugin/src/it/tool-versions-file/package.json @@ -0,0 +1,4 @@ +{ + "name": "example", + "version": "0.0.1" +} diff --git a/frontend-maven-plugin/src/it/tool-versions-file/pom.xml b/frontend-maven-plugin/src/it/tool-versions-file/pom.xml new file mode 100644 index 000000000..2dd31616a --- /dev/null +++ b/frontend-maven-plugin/src/it/tool-versions-file/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + com.github.eirslett + example + 0 + pom + + + + + com.github.eirslett + frontend-maven-plugin + + @project.version@ + + + target + + + + + + install node and npm + + install-node-and-npm + + + + + + + diff --git a/frontend-maven-plugin/src/it/tool-versions-file/verify.groovy b/frontend-maven-plugin/src/it/tool-versions-file/verify.groovy new file mode 100644 index 000000000..97fcc8e6d --- /dev/null +++ b/frontend-maven-plugin/src/it/tool-versions-file/verify.groovy @@ -0,0 +1,5 @@ +assert new File(basedir, 'target/node').exists() : "Node was not installed in the custom install directory"; + +String buildLog = new File(basedir, 'build.log').text +assert buildLog.contains('.tool-versions') : 'The wrong file was used' +assert buildLog.contains('Installing node version v22.5.1') : 'The correct node version was not detected' diff --git a/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/AbstractFrontendMojo.java b/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/AbstractFrontendMojo.java index 2644835bf..4e48a6bd4 100644 --- a/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/AbstractFrontendMojo.java +++ b/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/AbstractFrontendMojo.java @@ -3,6 +3,7 @@ import java.io.File; import java.util.Map; +import org.apache.maven.lifecycle.LifecycleExecutionException; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.plugin.MojoFailureException; @@ -54,7 +55,7 @@ public abstract class AbstractFrontendMojo extends AbstractMojo { protected Map environmentVariables; @Parameter(defaultValue = "${project}", readonly = true) - private MavenProject project; + protected MavenProject project; @Parameter(defaultValue = "${repositorySystemSession}", readonly = true) private RepositorySystemSession repositorySystemSession; @@ -74,7 +75,7 @@ private boolean isTestingPhase() { return "test".equals(phase) || "integration-test".equals(phase); } - protected abstract void execute(FrontendPluginFactory factory) throws FrontendException; + protected abstract void execute(FrontendPluginFactory factory) throws Exception; /** * Implemented by children to determine if this execution should be skipped. @@ -99,7 +100,7 @@ public void execute() throws MojoFailureException { } else { throw new MojoFailureException("Failed to run task", e); } - } catch (FrontendException e) { + } catch (Exception e) { throw MojoUtils.toMojoFailureException(e); } } else { diff --git a/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/InstallNodeAndNpmMojo.java b/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/InstallNodeAndNpmMojo.java index c80b3c0a5..1b9511a0b 100644 --- a/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/InstallNodeAndNpmMojo.java +++ b/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/InstallNodeAndNpmMojo.java @@ -1,16 +1,21 @@ package com.github.eirslett.maven.plugins.frontend.mojo; import com.github.eirslett.maven.plugins.frontend.lib.FrontendPluginFactory; -import com.github.eirslett.maven.plugins.frontend.lib.InstallationException; import com.github.eirslett.maven.plugins.frontend.lib.NPMInstaller; +import com.github.eirslett.maven.plugins.frontend.lib.NodeVersionDetector; +import com.github.eirslett.maven.plugins.frontend.lib.NodeVersionHelper; import com.github.eirslett.maven.plugins.frontend.lib.ProxyConfig; import org.apache.maven.execution.MavenSession; +import org.apache.maven.lifecycle.LifecycleExecutionException; import org.apache.maven.plugins.annotations.Component; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; -import org.apache.maven.settings.crypto.SettingsDecrypter; import org.apache.maven.settings.Server; +import org.apache.maven.settings.crypto.SettingsDecrypter; + +import static com.github.eirslett.maven.plugins.frontend.lib.NodeVersionHelper.getDownloadableVersion; +import static java.util.Objects.isNull; @Mojo(name="install-node-and-npm", defaultPhase = LifecyclePhase.GENERATE_RESOURCES, threadSafe = true) public final class InstallNodeAndNpmMojo extends AbstractFrontendMojo { @@ -39,9 +44,15 @@ public final class InstallNodeAndNpmMojo extends AbstractFrontendMojo { /** * The version of Node.js to install. IMPORTANT! Most Node.js version names start with 'v', for example 'v0.10.18' */ - @Parameter(property="nodeVersion", required = true) + @Parameter(property = "nodeVersion", defaultValue = "", required = false) private String nodeVersion; + /** + * The path to the file that contains the Node version to use + */ + @Parameter(property = "nodeVersionFile", defaultValue = "", required = false) + private String nodeVersionFile; + /** * The version of NPM to install. */ @@ -72,21 +83,34 @@ protected boolean skipExecution() { } @Override - public void execute(FrontendPluginFactory factory) throws InstallationException { + public void execute(FrontendPluginFactory factory) throws Exception { ProxyConfig proxyConfig = MojoUtils.getProxyConfig(session, decrypter); String nodeDownloadRoot = getNodeDownloadRoot(); String npmDownloadRoot = getNpmDownloadRoot(); Server server = MojoUtils.decryptServer(serverId, session, decrypter); + + String nodeVersion = NodeVersionDetector.getNodeVersion(workingDirectory, this.nodeVersion, this.nodeVersionFile); + + if (isNull(nodeVersion)) { + throw new LifecycleExecutionException("Node version could not be detected from a file and was not set"); + } + + if (!NodeVersionHelper.validateVersion(nodeVersion)) { + throw new LifecycleExecutionException("Node version (" + nodeVersion + ") is not valid. If you think it actually is, raise an issue"); + } + + String validNodeVersion = getDownloadableVersion(nodeVersion); + if (null != server) { factory.getNodeInstaller(proxyConfig) - .setNodeVersion(nodeVersion) + .setNodeVersion(validNodeVersion) .setNodeDownloadRoot(nodeDownloadRoot) .setNpmVersion(npmVersion) .setUserName(server.getUsername()) .setPassword(server.getPassword()) .install(); factory.getNPMInstaller(proxyConfig) - .setNodeVersion(nodeVersion) + .setNodeVersion(validNodeVersion) .setNpmVersion(npmVersion) .setNpmDownloadRoot(npmDownloadRoot) .setUserName(server.getUsername()) @@ -94,12 +118,12 @@ public void execute(FrontendPluginFactory factory) throws InstallationException .install(); } else { factory.getNodeInstaller(proxyConfig) - .setNodeVersion(nodeVersion) + .setNodeVersion(validNodeVersion) .setNodeDownloadRoot(nodeDownloadRoot) .setNpmVersion(npmVersion) .install(); factory.getNPMInstaller(proxyConfig) - .setNodeVersion(this.nodeVersion) + .setNodeVersion(validNodeVersion) .setNpmVersion(this.npmVersion) .setNpmDownloadRoot(npmDownloadRoot) .install(); diff --git a/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/InstallNodeAndPnpmMojo.java b/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/InstallNodeAndPnpmMojo.java index 80f48e16e..bbeb1f098 100644 --- a/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/InstallNodeAndPnpmMojo.java +++ b/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/InstallNodeAndPnpmMojo.java @@ -1,16 +1,21 @@ package com.github.eirslett.maven.plugins.frontend.mojo; import com.github.eirslett.maven.plugins.frontend.lib.FrontendPluginFactory; -import com.github.eirslett.maven.plugins.frontend.lib.InstallationException; +import com.github.eirslett.maven.plugins.frontend.lib.NodeVersionDetector; +import com.github.eirslett.maven.plugins.frontend.lib.NodeVersionHelper; import com.github.eirslett.maven.plugins.frontend.lib.PnpmInstaller; import com.github.eirslett.maven.plugins.frontend.lib.ProxyConfig; import org.apache.maven.execution.MavenSession; +import org.apache.maven.lifecycle.LifecycleExecutionException; import org.apache.maven.plugins.annotations.Component; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; -import org.apache.maven.settings.crypto.SettingsDecrypter; import org.apache.maven.settings.Server; +import org.apache.maven.settings.crypto.SettingsDecrypter; + +import static com.github.eirslett.maven.plugins.frontend.lib.NodeVersionHelper.getDownloadableVersion; +import static java.util.Objects.isNull; @Mojo(name="install-node-and-pnpm", defaultPhase = LifecyclePhase.GENERATE_RESOURCES, threadSafe = true) public final class InstallNodeAndPnpmMojo extends AbstractFrontendMojo { @@ -39,9 +44,15 @@ public final class InstallNodeAndPnpmMojo extends AbstractFrontendMojo { /** * The version of Node.js to install. IMPORTANT! Most Node.js version names start with 'v', for example 'v0.10.18' */ - @Parameter(property="nodeVersion", required = true) + @Parameter(property = "nodeVersion", defaultValue = "", required = false) private String nodeVersion; + /** + * The path to the file that contains the Node version to use + */ + @Parameter(property = "nodeVersionFile", defaultValue = "", required = false) + private String nodeVersionFile; + /** * The version of pnpm to install. Note that the version string can optionally be prefixed with * 'v' (i.e., both 'v1.2.3' and '1.2.3' are valid). @@ -73,7 +84,7 @@ protected boolean skipExecution() { } @Override - public void execute(FrontendPluginFactory factory) throws InstallationException { + public void execute(FrontendPluginFactory factory) throws Exception { ProxyConfig proxyConfig = MojoUtils.getProxyConfig(session, decrypter); // Use different names to avoid confusion with fields `nodeDownloadRoot` and // `pnpmDownloadRoot`. @@ -83,9 +94,22 @@ public void execute(FrontendPluginFactory factory) throws InstallationException String resolvedNodeDownloadRoot = getNodeDownloadRoot(); String resolvedPnpmDownloadRoot = getPnpmDownloadRoot(); Server server = MojoUtils.decryptServer(serverId, session, decrypter); + + String nodeVersion = NodeVersionDetector.getNodeVersion(workingDirectory, this.nodeVersion, this.nodeVersionFile); + + if (isNull(nodeVersion)) { + throw new LifecycleExecutionException("Node version could not be detected from a file and was not set"); + } + + if (!NodeVersionHelper.validateVersion(nodeVersion)) { + throw new LifecycleExecutionException("Node version (" + nodeVersion + ") is not valid. If you think it actually is, raise an issue"); + } + + String validNodeVersion = getDownloadableVersion(nodeVersion); + if (null != server) { factory.getNodeInstaller(proxyConfig) - .setNodeVersion(nodeVersion) + .setNodeVersion(validNodeVersion) .setNodeDownloadRoot(resolvedNodeDownloadRoot) .setUserName(server.getUsername()) .setPassword(server.getPassword()) @@ -98,7 +122,7 @@ public void execute(FrontendPluginFactory factory) throws InstallationException .install(); } else { factory.getNodeInstaller(proxyConfig) - .setNodeVersion(nodeVersion) + .setNodeVersion(validNodeVersion) .setNodeDownloadRoot(resolvedNodeDownloadRoot) .install(); factory.getPnpmInstaller(proxyConfig) diff --git a/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/InstallNodeAndYarnMojo.java b/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/InstallNodeAndYarnMojo.java index d8a7a75c2..7284562ce 100644 --- a/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/InstallNodeAndYarnMojo.java +++ b/frontend-maven-plugin/src/main/java/com/github/eirslett/maven/plugins/frontend/mojo/InstallNodeAndYarnMojo.java @@ -1,10 +1,12 @@ package com.github.eirslett.maven.plugins.frontend.mojo; -import static com.github.eirslett.maven.plugins.frontend.mojo.YarnUtils.isYarnrcYamlFilePresent; - -import java.io.File; -import java.util.stream.Stream; +import com.github.eirslett.maven.plugins.frontend.lib.FrontendPluginFactory; +import com.github.eirslett.maven.plugins.frontend.lib.NodeVersionDetector; +import com.github.eirslett.maven.plugins.frontend.lib.NodeVersionHelper; +import com.github.eirslett.maven.plugins.frontend.lib.ProxyConfig; +import com.github.eirslett.maven.plugins.frontend.lib.YarnInstaller; import org.apache.maven.execution.MavenSession; +import org.apache.maven.lifecycle.LifecycleExecutionException; import org.apache.maven.plugins.annotations.Component; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; @@ -12,10 +14,9 @@ import org.apache.maven.settings.Server; import org.apache.maven.settings.crypto.SettingsDecrypter; -import com.github.eirslett.maven.plugins.frontend.lib.FrontendPluginFactory; -import com.github.eirslett.maven.plugins.frontend.lib.InstallationException; -import com.github.eirslett.maven.plugins.frontend.lib.ProxyConfig; -import com.github.eirslett.maven.plugins.frontend.lib.YarnInstaller; +import static com.github.eirslett.maven.plugins.frontend.lib.NodeVersionHelper.getDownloadableVersion; +import static com.github.eirslett.maven.plugins.frontend.mojo.YarnUtils.isYarnrcYamlFilePresent; +import static java.util.Objects.isNull; @Mojo(name = "install-node-and-yarn", defaultPhase = LifecyclePhase.GENERATE_RESOURCES, threadSafe = true) public final class InstallNodeAndYarnMojo extends AbstractFrontendMojo { @@ -39,9 +40,15 @@ public final class InstallNodeAndYarnMojo extends AbstractFrontendMojo { * The version of Node.js to install. IMPORTANT! Most Node.js version names start with 'v', for example * 'v0.10.18' */ - @Parameter(property = "nodeVersion", required = true) + @Parameter(property = "nodeVersion", defaultValue = "", required = false) private String nodeVersion; + /** + * The path to the file that contains the Node version to use + */ + @Parameter(property = "nodeVersionFile", defaultValue = "", required = false) + private String nodeVersionFile; + /** * The version of Yarn to install. IMPORTANT! Most Yarn names start with 'v', for example 'v0.15.0'. */ @@ -72,22 +79,34 @@ protected boolean skipExecution() { } @Override - public void execute(FrontendPluginFactory factory) throws InstallationException { + public void execute(FrontendPluginFactory factory) throws Exception { ProxyConfig proxyConfig = MojoUtils.getProxyConfig(this.session, this.decrypter); Server server = MojoUtils.decryptServer(this.serverId, this.session, this.decrypter); + String nodeVersion = NodeVersionDetector.getNodeVersion(workingDirectory, this.nodeVersion, this.nodeVersionFile); + + if (isNull(nodeVersion)) { + throw new LifecycleExecutionException("Node version could not be detected from a file and was not set"); + } + + if (!NodeVersionHelper.validateVersion(nodeVersion)) { + throw new LifecycleExecutionException("Node version (" + nodeVersion + ") is not valid. If you think it actually is, raise an issue"); + } + + String validNodeVersion = getDownloadableVersion(nodeVersion); + boolean isYarnYamlFilePresent = isYarnrcYamlFilePresent(this.session, this.workingDirectory); if (null != server) { factory.getNodeInstaller(proxyConfig).setNodeDownloadRoot(this.nodeDownloadRoot) - .setNodeVersion(this.nodeVersion).setPassword(server.getPassword()) + .setNodeVersion(validNodeVersion).setPassword(server.getPassword()) .setUserName(server.getUsername()).install(); factory.getYarnInstaller(proxyConfig).setYarnDownloadRoot(this.yarnDownloadRoot) .setYarnVersion(this.yarnVersion).setUserName(server.getUsername()) .setPassword(server.getPassword()).setIsYarnBerry(isYarnYamlFilePresent).install(); } else { factory.getNodeInstaller(proxyConfig).setNodeDownloadRoot(this.nodeDownloadRoot) - .setNodeVersion(this.nodeVersion).install(); + .setNodeVersion(validNodeVersion).install(); factory.getYarnInstaller(proxyConfig).setYarnDownloadRoot(this.yarnDownloadRoot) .setYarnVersion(this.yarnVersion).setIsYarnBerry(isYarnYamlFilePresent).install(); } diff --git a/frontend-plugin-core/pom.xml b/frontend-plugin-core/pom.xml index 5f5677e83..44acee636 100644 --- a/frontend-plugin-core/pom.xml +++ b/frontend-plugin-core/pom.xml @@ -71,6 +71,18 @@ 2.2 test + + com.google.guava + guava + 33.1.0-jre + provided + + + com.github.stefanbirkner + system-lambda + 1.2.1 + test + diff --git a/frontend-plugin-core/src/main/java/com/github/eirslett/maven/plugins/frontend/lib/NodeVersionDetector.java b/frontend-plugin-core/src/main/java/com/github/eirslett/maven/plugins/frontend/lib/NodeVersionDetector.java new file mode 100644 index 000000000..0462620c7 --- /dev/null +++ b/frontend-plugin-core/src/main/java/com/github/eirslett/maven/plugins/frontend/lib/NodeVersionDetector.java @@ -0,0 +1,260 @@ +package com.github.eirslett.maven.plugins.frontend.lib; + +import com.google.common.annotations.VisibleForTesting; +import org.slf4j.Logger; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static java.lang.String.format; +import static java.util.Objects.isNull; +import static java.util.Optional.empty; +import static org.slf4j.LoggerFactory.getLogger; + +public class NodeVersionDetector { + + private static final String TOOL_VERSIONS_FILENAME = ".tool-versions"; + + public static String getNodeVersion(File workingDir, String providedNodeVersion, String genericNodeVersionFile) throws Exception { + Logger logger = getLogger(NodeVersionDetector.class); + + if (!isNull(providedNodeVersion) && !providedNodeVersion.trim().isEmpty()) { + logger.debug("Looks like a node version was set so using that: " + providedNodeVersion); + return providedNodeVersion; + } + + if (!isNull(genericNodeVersionFile) && !genericNodeVersionFile.trim().isEmpty()) { + File genericNodeVersionFileFile = new File(genericNodeVersionFile); + if (!genericNodeVersionFileFile.exists()) { + throw new Exception("The Node version file doesn't seem to exist: " + genericNodeVersionFileFile); + } + + if (genericNodeVersionFile.endsWith(".toml") && genericNodeVersionFile.contains("mise")) { + return readMiseConfigTomlFile(genericNodeVersionFileFile, genericNodeVersionFileFile.toPath(), logger); + } else if (genericNodeVersionFile.endsWith(TOOL_VERSIONS_FILENAME)) { + return readToolVersionsFile(genericNodeVersionFileFile, genericNodeVersionFileFile.toPath(), logger); + } else { + return readNvmrcFile(genericNodeVersionFileFile, genericNodeVersionFileFile.toPath(), logger); + } + } + + try { + return recursivelyFindVersion(workingDir); + } catch (Throwable throwable) { + logger.debug("Going to use the configuration node version, failed to find a file with the version because", + throwable); + return providedNodeVersion; + } + } + + /** + * Mise has way too many options, see: + * https://mise.jdx.dev/profiles.html + * https://mise.jdx.dev/configuration.html#mise-toml + */ + public static List listMiseConfigFilenames() { + final String miseConfigDir = System.getenv("MISE_CONFIG_DIR"); + final String miseEnv = System.getenv("MISE_ENV"); + + // The order is important and should respect mises' ordering + final List allMiseConfigFilenames = new ArrayList<>(); + + allMiseConfigFilenames.add(format("%s/config.%s.toml", miseConfigDir, miseEnv)); + allMiseConfigFilenames.add(format("%s/mise.%s.toml", miseConfigDir, miseEnv)); + + allMiseConfigFilenames.add(".config/mise/config.toml"); + allMiseConfigFilenames.add("mise/config.toml"); + allMiseConfigFilenames.add("mise.toml"); + allMiseConfigFilenames.add(".mise/config.toml"); + allMiseConfigFilenames.add(".mise.toml"); + allMiseConfigFilenames.add(".config/mise/config.local.toml"); + allMiseConfigFilenames.add("mise/config.local.toml"); + allMiseConfigFilenames.add("mise.local.toml"); + allMiseConfigFilenames.add(".mise/config.local.toml"); + allMiseConfigFilenames.add(".mise.local.toml"); + + allMiseConfigFilenames.add(format(".config/mise/config.%s.toml", miseEnv)); + allMiseConfigFilenames.add(format("mise/config.%s.toml", miseEnv)); + allMiseConfigFilenames.add(format("mise.%s.toml", miseEnv)); + allMiseConfigFilenames.add(format(".mise/config.%s.toml", miseEnv)); + allMiseConfigFilenames.add(format(".mise.%s.toml", miseEnv)); + allMiseConfigFilenames.add(format(".config/mise/config.%s.local.toml", miseEnv)); + allMiseConfigFilenames.add(format("mise/config.%s.local.toml", miseEnv)); + allMiseConfigFilenames.add(format(".mise/config.%s.local.toml", miseEnv)); + allMiseConfigFilenames.add(format(".mise.%s.local.toml", miseEnv)); + + return allMiseConfigFilenames; + } + + /** + * Ordering this hierarchy of reading the files isn't just the most idiomatic, it's also probably the best + * for performance. + */ + public static String recursivelyFindVersion(File directory) throws Exception { + Logger logger = getLogger(NodeVersionDetector.class); + + if (!directory.canRead()) { + throw new Exception("Tried to find a Node version file but giving up because it's not possible to read " + + directory.getPath()); + } + + String directoryPath = directory.getPath(); + + Path nodeVersionFilePath = Paths.get(directoryPath, ".node-version"); + File nodeVersionFile = nodeVersionFilePath.toFile(); + if (nodeVersionFile.exists()) { + String trimmedLine = readNvmrcFile(nodeVersionFile, nodeVersionFilePath, logger); + if (trimmedLine != null) return trimmedLine; + } + + Path nvmrcFilePath = Paths.get(directoryPath, ".nvmrc"); + File nvmrcFile = nvmrcFilePath.toFile(); + if (nvmrcFile.exists()) { + String trimmedLine = readNvmrcFile(nvmrcFile, nvmrcFilePath, logger); + if (trimmedLine != null) return trimmedLine; + } + + Path toolVersionsFilePath = Paths.get(directoryPath, TOOL_VERSIONS_FILENAME); + File toolVersionsFile = toolVersionsFilePath.toFile(); + if (toolVersionsFile.exists()) { + String trimmedLine = readToolVersionsFile(toolVersionsFile, toolVersionsFilePath, logger); + if (trimmedLine != null) return trimmedLine; + } + + for (String miseConfigFilename: listMiseConfigFilenames()) { + // We don't know if MISE_CONFIG_DIR can result in absolute or relative file paths, try to do our best + String[] splitMiseConfigFilename = miseConfigFilename.split("/"); + Path potentiallyAbsoluteFilepath = Paths.get("", splitMiseConfigFilename); + Path miseConfigFilePath = potentiallyAbsoluteFilepath.isAbsolute() ? + potentiallyAbsoluteFilepath : Paths.get(directoryPath, splitMiseConfigFilename); + + File miseConfigFile = miseConfigFilePath.toFile(); + if (miseConfigFile.exists()) { + String trimmedVersion = readMiseConfigTomlFile(miseConfigFile, miseConfigFilePath, logger); + if (trimmedVersion != null) return trimmedVersion; + } + } + + File parent = directory.getParentFile(); + if (isNull(parent) || directory.equals(parent)) { + throw new Exception("Reach root-level without finding a suitable file"); + } + + return recursivelyFindVersion(parent); + } + + private static String readNvmrcFile(File nvmrcFile, Path nvmrcFilePath, Logger logger) throws Exception { + assertNodeVersionFileIsReadable(nvmrcFile); + + List lines = Files.readAllLines(nvmrcFilePath); + Optional version = readNvmrcFileLines(lines); + if (version.isPresent()) { + logger.info("Found the version of Node in: " + nvmrcFilePath.normalize()); + } + return version.orElse(null); + } + + /** + * We skip over a lot of comments. If there's no documentation in the POMs then we need it somewhere. Also, FNM, + * NVS, and NVM have varying levels of comment acceptance, so we have to be the most forgiving. + */ + @VisibleForTesting + static Optional readNvmrcFileLines(List lines) { + for (String line: lines) { + if (!isNull(line)) { + String trimmedLine = line.trim(); + + if (trimmedLine.isEmpty()) { + continue; + } + + if (trimmedLine.startsWith("#") || trimmedLine.startsWith("/") || trimmedLine.startsWith("!")) { + continue; + } + + trimmedLine = trimmedLine.replaceFirst( + "(" + // we only want what's part of the comment, we assume everything at the start is the + // version + "\\s*" + // Okay, fine we also remove any whitespace too, this isn't part of the version + "[#!/]" + // these characters will probably not be part of the version, but they look like the + // start of a comment + ".*)", // everything else to the end of the line + ""); + + return Optional.of(trimmedLine); + } + } + return empty(); + } + + /** + * If this gets any more complicated we'll add a reader, not sure how strict mise is with the spec, we want to be + * at least as loose. + */ + @VisibleForTesting + static String readMiseConfigTomlFile(File miseTomlFile, Path miseTomlFilePath, Logger logger) throws Exception { + assertNodeVersionFileIsReadable(miseTomlFile); + + List lines = Files.readAllLines(miseTomlFilePath); + for (String line: lines) { + if (!isNull(line)) { + String trimmedLine = line.trim(); + + if (trimmedLine.isEmpty()) { + continue; + } + + if (!trimmedLine.startsWith("node")) { // naturally skips over comments + continue; + } + + logger.info("Found the version of Node in: " + miseTomlFilePath.normalize()); + + if (trimmedLine.contains("[")) { + throw new Exception("mise file support is limited to a single version"); + } + + return trimmedLine + .replaceAll("node(js)?\\s*=\\s*", "") + .replaceAll("\"", "") // destringify the version -- there's no " in Node versions + .replaceAll("#.*$", "") // remove comments -- there's no '#' in Node versions + .trim(); + } + } + return null; + } + + private static String readToolVersionsFile(File toolVersionsFile, Path toolVersionsFilePath, Logger logger) throws Exception { + assertNodeVersionFileIsReadable(toolVersionsFile); + + List lines = Files.readAllLines(toolVersionsFilePath); + for (String line: lines) { + if (!isNull(line)) { + String trimmedLine = line.trim(); + + if (trimmedLine.isEmpty()) { + continue; + } + + if (!trimmedLine.startsWith("node")) { + continue; + } + + logger.info("Found the version of Node in: " + toolVersionsFilePath.normalize()); + return trimmedLine.replaceAll("node(js)?\\s*", ""); + } + } + return null; + } + + private static void assertNodeVersionFileIsReadable(File file) throws Exception { + if (!file.canRead()) { + throw new Exception("Tried to read the node version from the file, but giving up because it's not possible to read" + file.getPath()); + } + } +} diff --git a/frontend-plugin-core/src/main/java/com/github/eirslett/maven/plugins/frontend/lib/NodeVersionHelper.java b/frontend-plugin-core/src/main/java/com/github/eirslett/maven/plugins/frontend/lib/NodeVersionHelper.java new file mode 100644 index 000000000..b16ddc859 --- /dev/null +++ b/frontend-plugin-core/src/main/java/com/github/eirslett/maven/plugins/frontend/lib/NodeVersionHelper.java @@ -0,0 +1,306 @@ +package com.github.eirslett.maven.plugins.frontend.lib; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; +import static java.lang.String.format; +import static java.util.Arrays.asList; +import static java.util.Optional.empty; +import static java.util.stream.Collectors.toSet; + +public class NodeVersionHelper { + + private NodeVersionHelper() { + throw new UnsupportedOperationException("helper classes should not be instantiated"); + } + + /** + * Why contain the whole list? So this can work offline, it's a PITA when something doesn't work offline or on a + * flaky network. + */ + @VisibleForTesting + static final Set UNUSUAL_VALID_VERSIONS = Stream.of( + "latest", + "latest-argon", + "latest-boron", + "latest-carbon", + "latest-dubnium", + "latest-erbium", + "latest-fermium", + "latest-gallium", + "latest-hydrogen", + "latest-iron", + + // future releases + "latest-jod", + "latest-krypton", + "latest-lithium", + "latest-magnesium", + "latest-neon", + "latest-oxygen", + "latest-platinum", + + "latest-v0.10.x", + "latest-v0.12.x", + "latest-v10.x", + "latest-v11.x", + "latest-v12.x", + "latest-v13.x", + "latest-v14.x", + "latest-v15.x", + "latest-v16.x", + "latest-v17.x", + "latest-v18.x", + "latest-v19.x", + "latest-v20.x", + "latest-v21.x", + "latest-v22.x", + "latest-v23.x", + "latest-v24.x", + "latest-v25.x", + "latest-v26.x", + "latest-v27.x", + "latest-v28.x", + "latest-v4.x", + "latest-v5.x", + "latest-v6.x", + "latest-v7.x", + "latest-v8.x", + "latest-v9.x", + "v0.10.16-isaacs-manual", + "node-0.0.1", + "node-0.0.2", + "node-0.0.3", + "node-0.0.4", + "node-0.0.5", + "node-0.0.6", + "node-0.1.0", + "node-0.1.1", + "node-0.1.10", + "node-0.1.11", + "node-0.1.12", + "node-0.1.13", + "node-0.1.2", + "node-0.1.3", + "node-0.1.4", + "node-0.1.5", + "node-0.1.6", + "node-0.1.7", + "node-0.1.8", + "node-0.1.9", + "node-latest", + "node-v0.1.100", + "node-v0.1.101", + "node-v0.1.102", + "node-v0.1.103", + "node-v0.1.104", + "node-v0.1.14", + "node-v0.1.15", + "node-v0.1.16", + "node-v0.1.17", + "node-v0.1.18", + "node-v0.1.19", + "node-v0.1.20", + "node-v0.1.21", + "node-v0.1.22", + "node-v0.1.23", + "node-v0.1.24", + "node-v0.1.25", + "node-v0.1.26", + "node-v0.1.27", + "node-v0.1.28", + "node-v0.1.29", + "node-v0.1.30", + "node-v0.1.31", + "node-v0.1.32", + "node-v0.1.33", + "node-v0.1.90", + "node-v0.1.91", + "node-v0.1.92", + "node-v0.1.93", + "node-v0.1.94", + "node-v0.1.95", + "node-v0.1.96", + "node-v0.1.97", + "node-v0.1.98", + "node-v0.1.99", + "node-v0.10.14", + "node-v0.2.0", + "node-v0.2.1", + "node-v0.2.2", + "node-v0.2.3", + "node-v0.2.4", + "node-v0.2.5", + "node-v0.2.6", + "node-v0.3.0", + "node-v0.3.1", + "node-v0.3.2", + "node-v0.3.3", + "node-v0.3.4", + "node-v0.3.5", + "node-v0.3.6", + "node-v0.3.7", + "node-v0.3.8", + "node-v0.4.0", + "node-v0.4.1", + "node-v0.4.10", + "node-v0.4.11", + "node-v0.4.12", + "node-v0.4.2", + "node-v0.4.3", + "node-v0.4.4", + "node-v0.4.5", + "node-v0.4.6", + "node-v0.4.7", + "node-v0.4.8", + "node-v0.4.9", + "node-v0.4", + "node-v0.5.0", + "node-v0.6.1", + "node-v0.6.10", + "node-v0.6.11", + "node-v0.6.12", + "node-v0.6.13", + "node-v0.6.2", + "node-v0.6.3", + "node-v0.6.4", + "node-v0.6.5", + "node-v0.6.6", + "node-v0.6.7", + "node-v0.6.8", + "node-v0.6.9" + ).collect(toSet()); + + @VisibleForTesting + static final Pattern VALID_VERSION_PATTERN = Pattern.compile("^v?\\d*\\.\\d*\\.\\d*$"); + + private static final String LATEST_OF_GIVEN_MAJOR_FORMAT = "latest-v%s.x"; + private static final Pattern MAJOR_VERSION_ONLY_PATTERN = Pattern.compile("^\\d+$"); + + /** + * We cache this to prevent multiple network requests in a multi-module Maven build, it's very unlikely a version + * someone is released suddenly halfway through the build and that a consumer has actually asked for that + */ + @VisibleForTesting + static final AtomicReference>> nodeVersions = new AtomicReference<>(empty()); + + public static boolean validateVersion(String version) { + if (UNUSUAL_VALID_VERSIONS.contains(version)) { + return true; + } + + version = version.replaceFirst("v", ""); // we're about to add it back + if (MAJOR_VERSION_ONLY_PATTERN.matcher(version).find()) { + String latestOfMajorVersion = format(LATEST_OF_GIVEN_MAJOR_FORMAT, version).toLowerCase(); + return UNUSUAL_VALID_VERSIONS.contains(latestOfMajorVersion); + } + + Matcher matcher = VALID_VERSION_PATTERN.matcher(version); + return matcher.find(); + } + + public static String getDownloadableVersion(String version) { + version = version.toLowerCase(); // all the versions seem to be lower case + + if (UNUSUAL_VALID_VERSIONS.contains(version)) { + return version; + } + + version = version.replaceFirst("v", ""); // we're about to add it back + + return findMatchingReleasedVersion(version) + .orElse("v" + version); + } + + public static Optional findMatchingReleasedVersion(String requestedVersionLowercaseWithoutLeadingV) { + if (!nodeVersions.get().isPresent()) { + synchronized (NodeVersionHelper.class) { // avoiding racing sending multiple requests + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpGet getNodeVersionIndex = new HttpGet("https://nodejs.org/dist/index.json"); + try (CloseableHttpResponse response = httpClient.execute(getNodeVersionIndex)) { + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != 200) { + throw new RuntimeException(format("Response status was not 200, was: %s and body was:\n%s", + statusCode, response.getEntity().toString())); + } + + String contentType = response.getEntity().getContentType().getValue(); + if (!"application/json".equals(contentType)) { + throw new RuntimeException("Response content type was not JSON, was: " + contentType); + } + + ObjectMapper relaxedObjectMapper = + new ObjectMapper().configure(FAIL_ON_UNKNOWN_PROPERTIES, false); + nodeVersions.set(Optional.of(relaxedObjectMapper + .readValue(response.getEntity().getContent(), new TypeReference>() {}))); + } + } catch (Exception e) { + throw new RuntimeException("Failed to fetch the list of released node versions to take a " + + "loosely-defined version and turn it into something downloadable", e); + } + } + } + + String versionToLookFor = "v" + requestedVersionLowercaseWithoutLeadingV; + return nodeVersions.get().get().stream() + .map(NodeVersion::getVersion) + .sorted(new NodeVersionComparator() + .reversed()) // we want the newest version to appear first + .filter(listedVersion -> listedVersion.startsWith(versionToLookFor)) + .findFirst(); + } + + /** + * When I grow up, I want to be a Java record class! + */ + private static class NodeVersion { + public String version; + + public NodeVersion() {} + + public String getVersion() { + return version; + } + } + + @VisibleForTesting + static class NodeVersionComparator implements Comparator { + @Override + public int compare(String firstVersion, String secondVersion) { + firstVersion = firstVersion.replaceFirst("v", ""); + secondVersion = secondVersion.replaceFirst("v", ""); + + List firstVersionParts = asList(firstVersion.split("\\.")); + List secondVersionParts = asList(secondVersion.split("\\.")); + + for (int partsIndex = 0; partsIndex < firstVersionParts.size(); partsIndex++) { + int delta = Integer.parseInt(firstVersionParts.get(partsIndex)) + - Integer.parseInt(secondVersionParts.get(partsIndex)); + + // handle the same version appearing twice + if (delta == 0 && partsIndex != firstVersionParts.size() -1) { + continue; + } + + return delta; + } + + throw new RuntimeException("Unexpectedly couldn't sort released node versions. Raise a bug report"); + } + } +} diff --git a/frontend-plugin-core/src/test/java/com/github/eirslett/maven/plugins/frontend/lib/NodeVersionDetectorTest.java b/frontend-plugin-core/src/test/java/com/github/eirslett/maven/plugins/frontend/lib/NodeVersionDetectorTest.java new file mode 100644 index 000000000..ab2d298cf --- /dev/null +++ b/frontend-plugin-core/src/test/java/com/github/eirslett/maven/plugins/frontend/lib/NodeVersionDetectorTest.java @@ -0,0 +1,112 @@ +package com.github.eirslett.maven.plugins.frontend.lib; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.Logger; + +import java.io.File; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static com.github.eirslett.maven.plugins.frontend.lib.NodeVersionDetector.readMiseConfigTomlFile; +import static com.github.eirslett.maven.plugins.frontend.lib.NodeVersionDetector.readNvmrcFileLines; +import static com.github.eirslett.maven.plugins.frontend.lib.NodeVersionDetector.recursivelyFindVersion; +import static com.github.stefanbirkner.systemlambda.SystemLambda.withEnvironmentVariable; +import static java.lang.String.format; +import static java.nio.charset.Charset.defaultCharset; +import static java.nio.file.StandardOpenOption.WRITE; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.slf4j.LoggerFactory.getLogger; + +public class NodeVersionDetectorTest { + + Logger log = getLogger(NodeVersionDetectorTest.class); + + @Test + public void testNvmrcFileParsing_shouldWorkWithACommentWithWhiteSpaceOnTheSameLineAsTheVersion() { + assertEquals("v1.0.0", readNvmrcFileLines(singletonList("v1.0.0\t //\t comment")).get()); + } + + @Test + public void testNvmrcFileParsing_shouldIgnoreCommentOnlyLines() { + assertEquals("v1.0.0", readNvmrcFileLines(asList( + "#comment", + " ! comment", + "\t/\tcomment", + "v1.0.0", + "#comment", + " ! comment", + "\t/\tcomment" + )).get()); + } + + @Test + public void testNvmrcFileParsing_shouldIgnoreEmptyLines() { + assertEquals("v1.0.0", readNvmrcFileLines(asList( + "\t", + "\t \r", + "", + "v1.0.0", + "\t", + "\t \r", + "" + )).get()); + } + + @Test + public void testMiseConfigFileParsing_shouldNotAllowVersionArrays() throws URISyntaxException { + URL miseConfigFileUrl = + Thread.currentThread().getContextClassLoader().getResource("miseConfig-nodeVersionArray.toml"); + Path miseConfigFilePath = Paths.get(miseConfigFileUrl.toURI()); + assertTrue(Files.exists(miseConfigFilePath)); // required for a valid test + + assertThrows(Exception.class, + () -> readMiseConfigTomlFile(miseConfigFilePath.toFile(), miseConfigFilePath, log)); + } + + @Test + public void testMiseConfigFileParsing_shouldReadValidFiles() throws Exception { + URL miseConfigFileUrl = + Thread.currentThread().getContextClassLoader().getResource("miseConfig-difficultButParseable.toml"); + Path miseConfigFilePath = Paths.get(miseConfigFileUrl.toURI()); + assertTrue(Files.exists(miseConfigFilePath)); // required for a valid test + + assertEquals("20.0.0", readMiseConfigTomlFile(miseConfigFilePath.toFile(), miseConfigFilePath, log)); + } + + @Test + public void testAbsoluteMiseConfigFilePath( + @TempDir File tempMiseConfigDir, + @TempDir File tempUnrelatedDir + ) throws Exception { + // setup + String expectedVersion = "9.8.7"; + String miseProfile = "testabsolute"; + + String tempMiseConfigDirAbsolutePath = tempMiseConfigDir.getAbsolutePath(); + String tempMiseConfigFilename = format("mise.%s.toml", miseProfile); + Path tempMiseConfigFilePath = Paths.get(tempMiseConfigDirAbsolutePath, tempMiseConfigFilename); + Path tempMiseConfigFile = Files.createFile(tempMiseConfigFilePath); + + // given + withEnvironmentVariable("MISE_CONFIG_DIR", tempMiseConfigDirAbsolutePath) + .and("MISE_ENV", miseProfile) + .execute(() -> { + String miseConfigFileContents = format("node = \"%s\"", expectedVersion); + Files.write(tempMiseConfigFile, singletonList(miseConfigFileContents), defaultCharset(), WRITE); + + // when + String readVersion = recursivelyFindVersion(tempUnrelatedDir); + + // then + assertEquals(expectedVersion, readVersion, "versions didn't match"); + }); + } +} diff --git a/frontend-plugin-core/src/test/java/com/github/eirslett/maven/plugins/frontend/lib/NodeVersionHelperTest.java b/frontend-plugin-core/src/test/java/com/github/eirslett/maven/plugins/frontend/lib/NodeVersionHelperTest.java new file mode 100644 index 000000000..ce0c748c2 --- /dev/null +++ b/frontend-plugin-core/src/test/java/com/github/eirslett/maven/plugins/frontend/lib/NodeVersionHelperTest.java @@ -0,0 +1,60 @@ +package com.github.eirslett.maven.plugins.frontend.lib; + +import com.github.eirslett.maven.plugins.frontend.lib.NodeVersionHelper.NodeVersionComparator; +import org.junit.jupiter.api.Test; + +import static com.github.eirslett.maven.plugins.frontend.lib.NodeVersionHelper.UNUSUAL_VALID_VERSIONS; +import static com.github.eirslett.maven.plugins.frontend.lib.NodeVersionHelper.VALID_VERSION_PATTERN; +import static com.github.eirslett.maven.plugins.frontend.lib.NodeVersionHelper.getDownloadableVersion; +import static com.github.eirslett.maven.plugins.frontend.lib.NodeVersionHelper.validateVersion; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class NodeVersionHelperTest { + + @Test + public void testUnusualPatterns_shouldNotMatchThePattern_toKeepTheListSmall() { + UNUSUAL_VALID_VERSIONS.forEach(version -> { + assertFalse(VALID_VERSION_PATTERN.matcher(version).find()); + }); + } + + @Test + public void testUnusualPreviousVersions_shouldBeTreatedAsValid() { + UNUSUAL_VALID_VERSIONS.forEach(version -> { + assertTrue(validateVersion(version)); + }); + } + + @Test + public void testVersionsMissingV_shouldBeFixed() { + assertEquals("v1.0.0", getDownloadableVersion("1.0.0")); + } + + @Test + public void testInvalidCase_shouldBeFixed() { + assertEquals("v1.0.0", getDownloadableVersion("V1.0.0")); + } + + @Test + public void testLooselyDefinedMajorVersions_shouldBeValid() { + assertTrue(validateVersion("12")); + } + + @Test + public void testGetDownloadableVersion_shouldGiveUsTheLatestDownloadableVersion_forAGivenLooselyDefinedMajorVersion() { + // Using Node 12 since there shouldn't be anymore releases + assertEquals("v12.22.12", getDownloadableVersion("12")); + } + + @Test + public void testNodeVersionComparator_shouldCompareByNumbers() { + assertEquals(-1, new NodeVersionComparator().compare("v1.1.9", "v1.1.10")); + } + + @Test + public void testNodeVersionComparator_shouldHandleEqualVersions() { + assertEquals(0, new NodeVersionComparator().compare("v1.1.1", "v1.1.1")); + } +} diff --git a/frontend-plugin-core/src/test/resources/miseConfig-difficultButParseable.toml b/frontend-plugin-core/src/test/resources/miseConfig-difficultButParseable.toml new file mode 100644 index 000000000..9cf27c4c3 --- /dev/null +++ b/frontend-plugin-core/src/test/resources/miseConfig-difficultButParseable.toml @@ -0,0 +1,5 @@ + #starting comment + [tools] + #node=18 + node = " 20.0.0 " # comment # hehe nested comment + #node=22 diff --git a/frontend-plugin-core/src/test/resources/miseConfig-nodeVersionArray.toml b/frontend-plugin-core/src/test/resources/miseConfig-nodeVersionArray.toml new file mode 100644 index 000000000..921a3e1f9 --- /dev/null +++ b/frontend-plugin-core/src/test/resources/miseConfig-nodeVersionArray.toml @@ -0,0 +1,2 @@ +[tools] +node = ["20.0.0"]