Skip to content

NodeTool.kt

src/jvmMain/kotlin/afield/media/image/util/NodeTool.kt · 134 lines

package afield.media.image.util

import java.io.File

Locates a node binary new enough to run the JavaScript this repository generates, for the tests and tools that shell out to Node.

Generated Kotlin/JS uses globalThis (Node 12+) and optional chaining (Node 14+). A shell that inherits several nvm bin directories can put an old Node first on PATH, and taking the first match then fails inside a temporary .js file with a SyntaxError that says nothing about the binary. NodeTool.resolveNode therefore scans PATH for a candidate that meets the version floor rather than the first one that exists.

Resolution order, first match wins:

  1. AFIELD_NODE / NODE, if set and executable. An explicit override is honoured even below the floor, so asking for a binary by path gets that binary, and a clear failure if it is too old.
  2. Every PATH entry, in order, whose node reports a major version at or above the floor (NodeTool.DEFAULT_MIN_MAJOR unless the caller passes one).
  3. The repository-local Node that Gradle installs under .gradle/nodejs, so JS-backed tests work under a minimal PATH.

Nothing qualifying gives null. Call sites decide whether that is a skip or a failure, and NodeTool.unavailableMessage renders the reason either way.

object NodeTool

Resolves and caches a node binary that meets a major-version floor; see the file overview for the resolution order.

object NodeTool {

const val DEFAULT_MIN_MAJOR: Int = 14

Node major version required to run this repository's generated JavaScript.

data class Candidate(val path: String, val major: Int?, val version: String?)

A node candidate found on disk, with whatever --version reported.

    private val cache = HashMap<Int, Resolution>()

    private data class Resolution(val chosen: String?, val rejected: List<Candidate>)

fun resolveNode(minMajor: Int = DEFAULT_MIN_MAJOR): String?

Absolute path to a usable node, or null if none meets minMajor.

Cached per floor: resolving shells out to node --version once per candidate, and the callers are tests that ask repeatedly.

    @Synchronized
    fun resolveNode(minMajor: Int = DEFAULT_MIN_MAJOR): String? = resolution(minMajor).chosen

fun unavailableMessage(minMajor: Int = DEFAULT_MIN_MAJOR): String

Why resolveNode came back null, phrased for a test's skip/failure message: what was found, what version it reported, and what was needed.

body — 57 lines, from line 57
    @Synchronized
    fun unavailableMessage(minMajor: Int = DEFAULT_MIN_MAJOR): String {
        val rejected = resolution(minMajor).rejected
        if (rejected.isEmpty()) return "no `node` binary found on PATH, AFIELD_NODE/NODE, or .gradle/nodejs"
        val found = rejected.joinToString("; ") { c ->
            "${c.path} reports ${c.version ?: "an unreadable version"}"
        }
        return "no `node` >= v$minMajor available (needed for globalThis and optional chaining in " +
            "generated Kotlin/JS); found $found"
    }

    private fun resolution(minMajor: Int): Resolution = cache.getOrPut(minMajor) { resolve(minMajor) }

    private fun resolve(minMajor: Int): Resolution {
        val rejected = mutableListOf<Candidate>()

        // 1. explicit override, honoured verbatim
        for (name in listOf("AFIELD_NODE", "NODE")) {
            val file = System.getenv(name)?.takeIf(String::isNotBlank)?.let(::File) ?: continue
            if (file.canExecute() && file.isFile) return Resolution(file.absolutePath, rejected)
        }

        // 2. PATH, first entry that MEETS the floor
        for (file in pathCandidates()) {
            val candidate = describe(file)
            if (candidate.major != null && candidate.major >= minMajor) {
                return Resolution(file.absolutePath, rejected)
            }
            rejected += candidate
        }

        // 3. repository-local Node installed by Gradle
        for (file in managedCandidates()) {
            val candidate = describe(file)
            if (candidate.major != null && candidate.major >= minMajor) {
                return Resolution(file.absolutePath, rejected)
            }
            rejected += candidate
        }

        return Resolution(null, rejected)
    }

    private fun executableName(): String =
        if (System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) "node.exe" else "node"

    private fun pathCandidates(): List<File> =
        (System.getenv("PATH") ?: "").split(File.pathSeparator)
            .filter(String::isNotBlank)
            .map { File(it, executableName()) }
            .filter { it.isFile && it.canExecute() }

    private fun managedCandidates(): List<File> =
        File(System.getProperty("user.dir"), ".gradle/nodejs").listFiles()
            .orEmpty()
            .map { File(it, "bin/${executableName()}") }
            .filter { it.isFile && it.canExecute() }

node --version for one candidate. Never throws: an unrunnable binary is just not a match.

    private fun describe(file: File): Candidate {
        val version = runCatching {
            val process = ProcessBuilder(file.absolutePath, "--version")
                .redirectErrorStream(true)
                .start()
            val text = process.inputStream.bufferedReader().readText().trim()
            if (!process.waitFor(10, java.util.concurrent.TimeUnit.SECONDS)) {
                process.destroyForcibly()
                null
            } else if (process.exitValue() == 0) text else null
        }.getOrNull()
        return Candidate(file.absolutePath, majorOf(version), version)
    }

internal fun majorOf(version: String?): Int?

Major version out of a v22.8.0-style string.

    internal fun majorOf(version: String?): Int? =
        version?.trim()?.removePrefix("v")?.substringBefore('.')?.toIntOrNull()
}