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:
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.- Every
PATHentry, in order, whosenodereports a major version at or above the floor (NodeTool.DEFAULT_MIN_MAJOR unless the caller passes one). - The repository-local Node that Gradle installs under
.gradle/nodejs, so JS-backed tests work under a minimalPATH.
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()
}