From dff4eda746519968a42a1874b092ef6b264914c8 Mon Sep 17 00:00:00 2001 From: Manuel Andruccioli Date: Sun, 17 Mar 2024 11:20:46 +0100 Subject: [PATCH] feat(node-install)!: add possibility to install local node via plugin --- .../gradle/CheckNodeTask.kt | 147 +++++++++++++++++- .../gradle/NpmDependenciesTask.kt | 4 +- .../zuccherosintattico/gradle/Typescript.kt | 13 +- .../gradle/TypescriptExtension.kt | 35 ++++- .../gradle/TypescriptTask.kt | 6 +- .../utils/NodeCommandsExtension.kt | 34 +++- .../utils/NodePathBundle.kt | 89 +++++++++++ .../utils/NpmCommandsExtension.kt | 34 ---- .../gradle/TypescriptPluginTest.kt | 9 ++ .../download-node-env/build.gradle.kts | 8 + .../resources/download-node-env/package.json | 9 ++ .../src/main/typescript/index.ts | 4 + .../src/main/typescript/person.ts | 8 + 13 files changed, 350 insertions(+), 50 deletions(-) create mode 100644 src/main/kotlin/io/github/zuccherosintattico/utils/NodePathBundle.kt delete mode 100644 src/main/kotlin/io/github/zuccherosintattico/utils/NpmCommandsExtension.kt create mode 100644 src/test/resources/download-node-env/build.gradle.kts create mode 100644 src/test/resources/download-node-env/package.json create mode 100644 src/test/resources/download-node-env/src/main/typescript/index.ts create mode 100644 src/test/resources/download-node-env/src/main/typescript/person.ts diff --git a/src/main/kotlin/io/github/zuccherosintattico/gradle/CheckNodeTask.kt b/src/main/kotlin/io/github/zuccherosintattico/gradle/CheckNodeTask.kt index ede3ddc..eeb7144 100644 --- a/src/main/kotlin/io/github/zuccherosintattico/gradle/CheckNodeTask.kt +++ b/src/main/kotlin/io/github/zuccherosintattico/gradle/CheckNodeTask.kt @@ -1,26 +1,165 @@ package io.github.zuccherosintattico.gradle import com.lordcodes.turtle.shellRun +import io.github.zuccherosintattico.utils.ArchiveExtractor import io.github.zuccherosintattico.utils.NodeCommandsExtension.nodeVersion +import io.github.zuccherosintattico.utils.NodeDistribution +import io.github.zuccherosintattico.utils.NodePathBundle +import io.github.zuccherosintattico.utils.NodePathBundle.Companion.toNodePathBundle import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional import org.gradle.api.tasks.TaskAction +import org.gradle.kotlin.dsl.provideDelegate +import java.net.URL +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermission +import kotlin.io.path.createTempDirectory +import kotlin.io.path.extension +import kotlin.io.path.name /** * A task to check if Node is installed. */ -open class CheckNodeTask : DefaultTask() { +abstract class CheckNodeTask : DefaultTask() { + + companion object { + private const val TEMP_DIR_PREFIX = "temp" + private const val NODE_DIR = "node" + private fun Project.nodeBuildDir(): Directory = layout.buildDirectory.get().dir(NODE_DIR) + + /** + * The file containing the paths to the node, npm, and npx executables. + */ + const val NODE_BUNDLE_PATHS_FILE = "nodePaths.properties" + + /** + * The project's build directory. + */ + fun Project.nodeBundleFile(): Path = nodeBuildDir().asFile.resolve(NODE_BUNDLE_PATHS_FILE).toPath() + } + init { group = "Node" description = "Check if Node is installed" } + /** + * True if Node should be installed. Default is false. + */ + @get:Input + abstract val shouldInstall: Property + + /** + * True if Node should be installed. Default is false. + */ + @get:Input + @get:Optional + abstract val zipUrl: Property + + /** + * The version of Node to install. Ignored if [zipUrl] is specified. + */ + @get:Input + abstract val version: Property + + private val nodeBuildDir: Directory by lazy { + project.nodeBuildDir() + .also { logger.quiet("Node will be installed in $it") } + } + /** * The task action to check if Node is installed. */ @TaskAction - fun checkNode() { - runCatching { shellRun { nodeVersion() } } + fun installAndCheckNode() { + val nodePathBundle = if (shouldInstall.get()) { + downloadNode().also { + addPermissionsToNode(it) + } + } else { + NodePathBundle.defaultPathBundle + } + installNodeByBundle(nodePathBundle) + check() + } + + private fun installNodeByBundle(nodePathBundle: NodePathBundle) { + if (!Files.exists(nodeBuildDir.asFile.toPath())) { + Files.createDirectories(nodeBuildDir.asFile.toPath()) + } + nodePathBundle.saveToPropertiesFile(project.nodeBundleFile()) + } + + private fun addPermissionsToNode(nodePathBundle: NodePathBundle) { + System.getProperty("os.name").let { + if (it.contains(NodeDistribution.SupportedSystem.LINUX) || + it.contains(NodeDistribution.SupportedSystem.MAC) + ) { + nodePathBundle.toSet().forEach { executable -> + Files.setPosixFilePermissions( + executable, + setOf( + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ), + ) + } + } + } + } + + private fun check() { + runCatching { shellRun(project.projectDir) { nodeVersion(project) } } .onSuccess { logger.quiet("Node is installed at version $it") } - .onFailure { logger.error("Node is not installed") } + .onFailure { logger.error("Node not found: $it") } + } + + private fun downloadNode(): NodePathBundle { + val urlToDownload = zipUrl.orElse(NodeDistribution.endpointFromVersion(version.get())).get() + .also { logger.quiet("Download node from: $it") } + .let { URL(it) } + /* + This call has the side effect to + - download the Node distribution; + - extract it; + - move it to the build directory. + The structure will be e.g. build/node/node-v20.11.1-darwin-x64/... + */ + return downloadFromUrlAndMoveToBuildDirectory(urlToDownload).toNodePathBundle() + } + + private fun downloadFromUrlAndMoveToBuildDirectory(url: URL): Path { + return downloadFromUrl(url).let { + when { + it.name.endsWith(NodeDistribution.SupportedExtensions.TAR_GZ) -> + ArchiveExtractor.extractTarGz(it.toFile(), nodeBuildDir.asFile) + it.name.endsWith(NodeDistribution.SupportedExtensions.ZIP) -> + ArchiveExtractor.extractZip(it.toFile(), nodeBuildDir.asFile) + else -> throw GradleException("Unsupported archive format: ${it.extension}") + } + } + } + + /** + * Retrieve the name of the file, including the extension. + */ + private fun URL.name() = file.split("/").last() + + private fun downloadFromUrl(url: URL): Path { + val tempDir = createTempDirectory(TEMP_DIR_PREFIX).also { logger.quiet("Node will be downloaded in $it") } + val nodeArchive = tempDir.resolve(url.name()) + runCatching { + url.openStream().use { + Files.copy(it, nodeArchive) + } + }.onFailure { throw GradleException("Error while downloading Node: $it") } + return nodeArchive } } diff --git a/src/main/kotlin/io/github/zuccherosintattico/gradle/NpmDependenciesTask.kt b/src/main/kotlin/io/github/zuccherosintattico/gradle/NpmDependenciesTask.kt index a8c33cc..42b8bcc 100644 --- a/src/main/kotlin/io/github/zuccherosintattico/gradle/NpmDependenciesTask.kt +++ b/src/main/kotlin/io/github/zuccherosintattico/gradle/NpmDependenciesTask.kt @@ -1,7 +1,7 @@ package io.github.zuccherosintattico.gradle import com.lordcodes.turtle.shellRun -import io.github.zuccherosintattico.utils.NpmCommandsExtension.npmInstall +import io.github.zuccherosintattico.utils.NodeCommandsExtension.npmInstall import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.tasks.TaskAction @@ -31,7 +31,7 @@ open class NpmDependenciesTask : DefaultTask() { if (!project.file("package.json").exists()) { throw GradleException(PACKAGE_JSON_ERROR) } - runCatching { shellRun(project.projectDir) { npmInstall() } } + runCatching { shellRun(project.projectDir) { npmInstall(project) } } .onSuccess { logger.quiet("Installed NPM dependencies") } .onFailure { throw GradleException("Failed to install NPM dependencies: $it") } } diff --git a/src/main/kotlin/io/github/zuccherosintattico/gradle/Typescript.kt b/src/main/kotlin/io/github/zuccherosintattico/gradle/Typescript.kt index fad90e9..38dd1af 100644 --- a/src/main/kotlin/io/github/zuccherosintattico/gradle/Typescript.kt +++ b/src/main/kotlin/io/github/zuccherosintattico/gradle/Typescript.kt @@ -11,15 +11,20 @@ import org.gradle.kotlin.dsl.register */ open class Typescript : Plugin { override fun apply(project: Project) { - val extension = project.extensions.create("typescript") - val checkNodeTask = project.registerTask("checkNode") + val nodeExtension = project.extensions.create("node") + val typescriptExtension = project.extensions.create("typescript") + val checkNodeTask = project.registerTask("checkNode") { + shouldInstall.set(nodeExtension.shouldInstall) + zipUrl.set(nodeExtension.zipUrl) + version.set(nodeExtension.version) + } val npmDependenciesTask = project.registerTask("npmDependencies") { dependsOn(checkNodeTask) } project.registerTask("compileTypescript") { dependsOn(npmDependenciesTask) - entrypoint.set(extension.entrypoint) - buildDir.set(extension.outputDir) + entrypoint.set(typescriptExtension.entrypoint) + buildDir.set(typescriptExtension.outputDir) } } diff --git a/src/main/kotlin/io/github/zuccherosintattico/gradle/TypescriptExtension.kt b/src/main/kotlin/io/github/zuccherosintattico/gradle/TypescriptExtension.kt index 94cb81f..e5c4716 100644 --- a/src/main/kotlin/io/github/zuccherosintattico/gradle/TypescriptExtension.kt +++ b/src/main/kotlin/io/github/zuccherosintattico/gradle/TypescriptExtension.kt @@ -1,10 +1,19 @@ package io.github.zuccherosintattico.gradle +import io.github.zuccherosintattico.gradle.Utils.propertyWithDefault import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import org.gradle.kotlin.dsl.property import java.io.Serializable +internal object Utils { + /** + * Create a property with a default value. + */ + inline fun ObjectFactory.propertyWithDefault(value: T): Property = + property().convention(value) +} + /** * The extension for configuring TypeScript plugin. */ @@ -22,8 +31,30 @@ open class TypescriptExtension(objects: ObjectFactory) : Serializable { companion object { private const val serialVersionUID = 1L + } +} + +/** + * The extension for configuring TypeScript plugin. + */ +open class NodeExtension(objects: ObjectFactory) : Serializable { + + /** + * The path to the TypeScript source set. + */ + val shouldInstall: Property = objects.propertyWithDefault(false) + + /** + * The path to the TypeScript output directory. If specified, the plugin will download Node from this URL. + */ + val zipUrl: Property = objects.property() - private inline fun ObjectFactory.propertyWithDefault(value: T): Property = - property().convention(value) + /** + * The version of Node to install. Ignored if [zipUrl] is specified. + */ + val version: Property = objects.propertyWithDefault("v21.7.1") + + companion object { + private const val serialVersionUID = 1L } } diff --git a/src/main/kotlin/io/github/zuccherosintattico/gradle/TypescriptTask.kt b/src/main/kotlin/io/github/zuccherosintattico/gradle/TypescriptTask.kt index 5c980ba..2e0361c 100644 --- a/src/main/kotlin/io/github/zuccherosintattico/gradle/TypescriptTask.kt +++ b/src/main/kotlin/io/github/zuccherosintattico/gradle/TypescriptTask.kt @@ -1,7 +1,7 @@ package io.github.zuccherosintattico.gradle import com.lordcodes.turtle.shellRun -import io.github.zuccherosintattico.utils.NpmCommandsExtension.npxCommand +import io.github.zuccherosintattico.utils.NodeCommandsExtension.npxCommand import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.provider.Property @@ -31,7 +31,9 @@ abstract class TypescriptTask : DefaultTask() { */ @TaskAction fun compileTypescript() { - runCatching { shellRun(project.projectDir) { npxCommand("tsc", "--outDir", buildDir.get(), entrypoint.get()) } } + runCatching { + shellRun(project.projectDir) { npxCommand(project, "tsc", "--outDir", buildDir.get(), entrypoint.get()) } + } .onSuccess { logger.quiet("Compiled: $it") } .onFailure { throw GradleException("Failed to compile TypeScript files: $it") } } diff --git a/src/main/kotlin/io/github/zuccherosintattico/utils/NodeCommandsExtension.kt b/src/main/kotlin/io/github/zuccherosintattico/utils/NodeCommandsExtension.kt index bea360c..50945c3 100644 --- a/src/main/kotlin/io/github/zuccherosintattico/utils/NodeCommandsExtension.kt +++ b/src/main/kotlin/io/github/zuccherosintattico/utils/NodeCommandsExtension.kt @@ -1,6 +1,9 @@ package io.github.zuccherosintattico.utils import com.lordcodes.turtle.ShellScript +import io.github.zuccherosintattico.gradle.CheckNodeTask.Companion.nodeBundleFile +import org.gradle.api.Project +import java.nio.file.Path /** * The extension for Node commands for [ShellScript]. @@ -10,7 +13,34 @@ object NodeCommandsExtension { /** * Get the version of installed Node. */ - fun ShellScript.nodeVersion(): String = nodeCommand(listOf("--version")) + fun ShellScript.nodeVersion(project: Project): String = nodeCommand(project, "--version") - private fun ShellScript.nodeCommand(arguments: List): String = command("node", arguments) + /** + * Install the dependencies. + */ + fun ShellScript.npmInstall(project: Project): String = npmCommand(project, "install") + + private fun Project.loadNodeBundlePaths(): NodePathBundle = NodePathBundle.loadFromPropertiesFile(nodeBundleFile()) + + private fun ShellScript.runCommand( + project: Project, + withPath: NodePathBundle.() -> Path, + vararg arguments: String, + ): String = + command(project.loadNodeBundlePaths().withPath().toString(), arguments.toList()) + + private fun ShellScript.nodeCommand(project: Project, vararg arguments: String): String = + runCommand(project, NodePathBundle::node, *arguments) + + /** + * Run the NPM command. + */ + fun ShellScript.npmCommand(project: Project, vararg arguments: String): String = + runCommand(project, NodePathBundle::npm, *arguments) + + /** + * Run the NPX command. + */ + fun ShellScript.npxCommand(project: Project, vararg arguments: String): String = + runCommand(project, NodePathBundle::npx, *arguments) } diff --git a/src/main/kotlin/io/github/zuccherosintattico/utils/NodePathBundle.kt b/src/main/kotlin/io/github/zuccherosintattico/utils/NodePathBundle.kt new file mode 100644 index 0000000..189980e --- /dev/null +++ b/src/main/kotlin/io/github/zuccherosintattico/utils/NodePathBundle.kt @@ -0,0 +1,89 @@ +package io.github.zuccherosintattico.utils + +import io.github.zuccherosintattico.utils.NodeDistribution.SupportedSystem.MAC +import io.github.zuccherosintattico.utils.NodeDistribution.SupportedSystem.WINDOWS +import java.nio.file.Path +import kotlin.io.path.writeText + +/** + * A bundle of paths to the node, npm, and npx executables. + */ +internal data class NodePathBundle( + val node: Path, + val npm: Path, + val npx: Path, +) { + companion object { + + /** + * Append the node, npm, and npx paths to the given path. + */ + fun Path.toNodePathBundle(): NodePathBundle = System.getProperty("os.name").let { + when { + it.contains(WINDOWS) -> NodePathBundle( + resolve("node.exe"), + resolve("npm.cmd"), + resolve("npx.cmd"), + ) + it.contains(MAC) or it.contains(NodeDistribution.SupportedSystem.LINUX) -> NodePathBundle( + resolve("bin/node"), + resolve("bin/npm"), + resolve("bin/npx"), + ) + else -> throw PlatformError("Unsupported OS: $it") + } + } + + /** + * The default node, npm, and npx paths for platforms. + */ + val defaultPathBundle: NodePathBundle = System.getProperty("os.name").let { + when { + it.contains(WINDOWS) -> NodePathBundle( + Path.of("node.exe"), + Path.of("npm.cmd"), + Path.of("npx.cmd"), + ) + it.contains(MAC) or it.contains(NodeDistribution.SupportedSystem.LINUX) -> NodePathBundle( + Path.of("node"), + Path.of("npm"), + Path.of("npx"), + ) + else -> throw PlatformError("Unsupported OS: $it") + } + } + + /** + * Load the node, npm, and npx paths from the given properties file. + */ + fun loadFromPropertiesFile(propertiesFile: Path): NodePathBundle { + val properties = propertiesFile.toFile().inputStream().use { input -> + java.util.Properties().apply { load(input) } + } + return NodePathBundle( + Path.of(properties.getProperty("node")), + Path.of(properties.getProperty("npm")), + Path.of(properties.getProperty("npx")), + ) + } + } + + /** + * Save the [NodePathBundle] paths to the given properties file. + */ + fun saveToPropertiesFile(propertiesFile: Path) { + val nodePaths = mapOf( + "node" to node.toString(), + "npm" to npm.toString(), + "npx" to npx.toString(), + ) + propertiesFile.writeText(nodePaths.entries.joinToString("\n") { (k, v) -> "$k=$v" }) + } + + /** + * Convert the [NodePathBundle] to a set. + */ + fun toSet() = setOf(node, npm, npx) +} + +internal data class PlatformError(override val message: String) : Error(message) diff --git a/src/main/kotlin/io/github/zuccherosintattico/utils/NpmCommandsExtension.kt b/src/main/kotlin/io/github/zuccherosintattico/utils/NpmCommandsExtension.kt deleted file mode 100644 index f27b44d..0000000 --- a/src/main/kotlin/io/github/zuccherosintattico/utils/NpmCommandsExtension.kt +++ /dev/null @@ -1,34 +0,0 @@ -package io.github.zuccherosintattico.utils - -import com.lordcodes.turtle.ShellScript - -/** - * The extension for NPM commands for [ShellScript]. - */ -object NpmCommandsExtension { - - private fun String.cmdIfOnWindows(): String = System.getProperty("os.name").let { os -> - when { - os.contains("Windows") -> "$this.cmd" - else -> this - } - } - - private val npmCommand: String = "npm".cmdIfOnWindows() - private val npxCommand: String = "npx".cmdIfOnWindows() - - /** - * Install the dependencies. - */ - fun ShellScript.npmInstall(): String = npmCommand("install") - - /** - * Run the NPM command. - */ - fun ShellScript.npmCommand(vararg arguments: String): String = command(npmCommand, arguments.toList()) - - /** - * Run the NPX command. - */ - fun ShellScript.npxCommand(vararg arguments: String): String = command(npxCommand, arguments.toList()) -} diff --git a/src/test/kotlin/io/github/zuccherosintattico/gradle/TypescriptPluginTest.kt b/src/test/kotlin/io/github/zuccherosintattico/gradle/TypescriptPluginTest.kt index 1b4cad0..0f0fabb 100644 --- a/src/test/kotlin/io/github/zuccherosintattico/gradle/TypescriptPluginTest.kt +++ b/src/test/kotlin/io/github/zuccherosintattico/gradle/TypescriptPluginTest.kt @@ -72,4 +72,13 @@ class TypescriptPluginTest : AnnotationSpec() { testFolder.executeGradleTask("compileTypescript") }.message shouldContain NpmDependenciesTask.PACKAGE_JSON_ERROR } + + @Test + fun `test node download`() { + val testFolder = getTempDirectoryWithResources("src/test/resources/download-node-env") + + testFolder + .executeGradleTask("checkNode", "checkNode") + .output shouldContain "Node is installed at version v20.7.0" // version set in tested env + } } diff --git a/src/test/resources/download-node-env/build.gradle.kts b/src/test/resources/download-node-env/build.gradle.kts new file mode 100644 index 0000000..c73c21d --- /dev/null +++ b/src/test/resources/download-node-env/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("io.github.zucchero-sintattico.typescript-gradle-plugin") +} + +node { + shouldInstall.set(true) + version.set("20.7.0") +} diff --git a/src/test/resources/download-node-env/package.json b/src/test/resources/download-node-env/package.json new file mode 100644 index 0000000..eed7b45 --- /dev/null +++ b/src/test/resources/download-node-env/package.json @@ -0,0 +1,9 @@ +{ + "name": "plugin-base-env", + "version": "1.0.0", + "description": "", + "main": "index.js", + "devDependencies": { + "typescript": "^5.4.2" + } +} diff --git a/src/test/resources/download-node-env/src/main/typescript/index.ts b/src/test/resources/download-node-env/src/main/typescript/index.ts new file mode 100644 index 0000000..69c446f --- /dev/null +++ b/src/test/resources/download-node-env/src/main/typescript/index.ts @@ -0,0 +1,4 @@ +import { Person } from "./person"; + +let p = new Person("John", 42); +console.log(p.toString()); diff --git a/src/test/resources/download-node-env/src/main/typescript/person.ts b/src/test/resources/download-node-env/src/main/typescript/person.ts new file mode 100644 index 0000000..eb8f650 --- /dev/null +++ b/src/test/resources/download-node-env/src/main/typescript/person.ts @@ -0,0 +1,8 @@ +class Person { + constructor(public name: string, public age: number) { + } + toString() { + return `${this.name} is ${this.age} years old`; + } +} +export { Person }; \ No newline at end of file