NodeEnvironment.java
package org.egothor.methodatlas.discovery.typescript;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Detects the Node.js runtime and records its version for audit logging.
*
* <p>
* At most one detection attempt is made per JVM lifetime (the result is
* cached). If {@code node --version} exits with a non-zero code or is not
* found on the {@code PATH}, {@link #isAvailable()} returns {@code false} and
* the TypeScript plugin is disabled gracefully.
* </p>
*
* <h2>Minimum version</h2>
*
* <p>
* Node.js 18 or later is required; versions below 18 lack the stable
* {@code --experimental-permission} flag needed for filesystem sandboxing and
* may have incompatible ESM / module-resolution behaviour. When an older
* version is found the plugin logs a {@code WARNING} and disables itself.
* </p>
*
* <h2>Audit trail</h2>
*
* <p>
* The detected Node.js version string is included in every worker-start log
* line so that audit teams can trace exactly which runtime executed the
* scanner bundle during any given MethodAtlas run.
* </p>
*/
final class NodeEnvironment {
private static final Logger LOG = Logger.getLogger(NodeEnvironment.class.getName());
/** Minimum major version of Node.js required by this plugin. */
/* default */ static final int MINIMUM_MAJOR_VERSION = 18;
/**
* Major version at which the Node.js permission model becomes available.
* Workers are sandboxed with {@code --experimental-permission} when running
* on this version or above.
*/
/* default */ static final int PERMISSION_MODEL_VERSION = 20;
/**
* Major version at which the permission model was promoted to stable and
* the flag was renamed from {@code --experimental-permission} to
* {@code --permission}.
*/
/* default */ static final int PERMISSION_STABLE_VERSION = 22;
private final boolean available;
private final String versionString;
private final int majorVersion;
private final boolean permissionModelSupported;
/**
* Detects the Node.js runtime by running {@code node --version}.
*
* <p>
* The detection result is logged once at {@code INFO} level when Node.js is
* found, or at {@code WARNING} level when it is absent or too old.
* </p>
*/
/* default */ NodeEnvironment() {
String detected = detectVersion();
if (detected == null) {
this.available = false;
this.versionString = "unavailable";
this.majorVersion = 0;
this.permissionModelSupported = false;
if (LOG.isLoggable(Level.WARNING)) {
LOG.warning("Node.js not found on PATH — TypeScript discovery plugin is disabled. "
+ "Install Node.js " + MINIMUM_MAJOR_VERSION + " or later to enable TypeScript scanning.");
}
} else {
this.versionString = detected;
this.majorVersion = parseMajorVersion(detected);
if (majorVersion < MINIMUM_MAJOR_VERSION) {
this.available = false;
this.permissionModelSupported = false;
if (LOG.isLoggable(Level.WARNING)) {
LOG.warning("Node.js " + detected + " is below the minimum required version "
+ MINIMUM_MAJOR_VERSION + " — TypeScript discovery plugin is disabled.");
}
} else {
this.available = true;
this.permissionModelSupported = majorVersion >= PERMISSION_MODEL_VERSION;
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Node.js detected: {0} (major={1}, permission-model={2})",
new Object[] { versionString, majorVersion, permissionModelSupported });
}
}
}
}
/**
* Returns {@code true} when Node.js of a supported version is on the PATH.
*
* @return {@code true} when the TypeScript plugin can be used
*/
/* default */ boolean isAvailable() {
return available;
}
/**
* Returns the Node.js version string reported by {@code node --version}
* (e.g. {@code "v20.11.0"}), or {@code "unavailable"} when Node.js is not
* found.
*
* @return version string; never {@code null}
*/
/* default */ String versionString() {
return versionString;
}
/**
* Returns {@code true} when Node.js supports the permission model
* (Node.js 20 or later).
*
* <p>
* When this returns {@code true}, workers are started with a permission
* flag and {@code --allow-fs-read} arguments to restrict the worker
* process to reading only the directories being scanned. The exact flag
* name is determined by {@link #permissionFlagName()}.
* </p>
*
* @return {@code true} when file-system sandboxing is available
*/
/* default */ boolean isPermissionModelSupported() {
return permissionModelSupported;
}
/**
* Returns the correct Node.js permission flag for the detected version.
*
* <p>
* The flag was renamed from {@code --experimental-permission} (Node.js
* 20–21) to {@code --permission} (Node.js 22 and later) when the
* permission model was promoted to stable. Using the wrong flag name
* causes Node.js to exit with an unrecognised-option error.
* </p>
*
* @return {@code "--permission"} for Node.js 22 or later;
* {@code "--experimental-permission"} for Node.js 20–21
*/
/* default */ String permissionFlagName() {
return majorVersion >= PERMISSION_STABLE_VERSION
? "--permission"
: "--experimental-permission";
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Runs {@code node --version} and returns the trimmed output, or
* {@code null} when the command fails or is not found.
*/
@SuppressWarnings("PMD.DoNotUseThreads")
private static String detectVersion() {
try {
ProcessBuilder pb = new ProcessBuilder("node", "--version");
pb.redirectErrorStream(true);
Process proc = pb.start();
String output;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
output = reader.readLine();
}
int exitCode = proc.waitFor();
if (exitCode != 0 || output == null || output.isBlank()) {
return null;
}
return output.trim();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
if (LOG.isLoggable(Level.FINE)) {
LOG.log(Level.FINE, "Could not execute 'node --version'", e);
}
return null;
} catch (IOException e) {
if (LOG.isLoggable(Level.FINE)) {
LOG.log(Level.FINE, "Could not execute 'node --version'", e);
}
return null;
}
}
/**
* Parses the major version number from a Node.js version string such as
* {@code "v20.11.0"}. Returns {@code 0} when the string does not conform
* to the expected format.
*
* @param version Node.js version string
* @return parsed major version, or {@code 0} on parse failure
*/
/* default */ static int parseMajorVersion(String version) {
if (version == null || version.isEmpty()) {
return 0;
}
// Strip leading 'v' then take everything up to the first dot.
String stripped = version.startsWith("v") ? version.substring(1) : version;
int dot = stripped.indexOf('.');
String majorStr = dot >= 0 ? stripped.substring(0, dot) : stripped;
try {
return Integer.parseInt(majorStr);
} catch (NumberFormatException e) {
return 0;
}
}
}