Skip to content

Commit

Permalink
feat(node-install)!: add possibility to install local node via plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
manuandru committed Mar 17, 2024
1 parent 3c73942 commit dff4eda
Show file tree
Hide file tree
Showing 13 changed files with 350 additions and 50 deletions.
147 changes: 143 additions & 4 deletions src/main/kotlin/io/github/zuccherosintattico/gradle/CheckNodeTask.kt
Original file line number Diff line number Diff line change
@@ -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<Boolean>

/**
* True if Node should be installed. Default is false.
*/
@get:Input
@get:Optional
abstract val zipUrl: Property<String>

/**
* The version of Node to install. Ignored if [zipUrl] is specified.
*/
@get:Input
abstract val version: Property<String>

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
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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") }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@ import org.gradle.kotlin.dsl.register
*/
open class Typescript : Plugin<Project> {
override fun apply(project: Project) {
val extension = project.extensions.create<TypescriptExtension>("typescript")
val checkNodeTask = project.registerTask<CheckNodeTask>("checkNode")
val nodeExtension = project.extensions.create<NodeExtension>("node")
val typescriptExtension = project.extensions.create<TypescriptExtension>("typescript")
val checkNodeTask = project.registerTask<CheckNodeTask>("checkNode") {
shouldInstall.set(nodeExtension.shouldInstall)
zipUrl.set(nodeExtension.zipUrl)
version.set(nodeExtension.version)
}
val npmDependenciesTask = project.registerTask<NpmDependenciesTask>("npmDependencies") {
dependsOn(checkNodeTask)
}
project.registerTask<TypescriptTask>("compileTypescript") {
dependsOn(npmDependenciesTask)
entrypoint.set(extension.entrypoint)
buildDir.set(extension.outputDir)
entrypoint.set(typescriptExtension.entrypoint)
buildDir.set(typescriptExtension.outputDir)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <reified T> ObjectFactory.propertyWithDefault(value: T): Property<T> =
property<T>().convention(value)
}

/**
* The extension for configuring TypeScript plugin.
*/
Expand All @@ -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<Boolean> = objects.propertyWithDefault(false)

/**
* The path to the TypeScript output directory. If specified, the plugin will download Node from this URL.
*/
val zipUrl: Property<String> = objects.property()

private inline fun <reified T> ObjectFactory.propertyWithDefault(value: T): Property<T> =
property<T>().convention(value)
/**
* The version of Node to install. Ignored if [zipUrl] is specified.
*/
val version: Property<String> = objects.propertyWithDefault("v21.7.1")

companion object {
private const val serialVersionUID = 1L
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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") }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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].
Expand All @@ -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>): 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)
}
Loading

0 comments on commit dff4eda

Please sign in to comment.