NodeExecutor.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Carsten Hammer
*******************************************************************************/
package org.sandbox.jdt.internal.css.core;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.eclipse.core.runtime.ILog;
import org.eclipse.core.runtime.Platform;
/**
* Utility for executing Node.js/npm commands.
*/
public class NodeExecutor {
private static final int TIMEOUT_SECONDS = 30;
private static final ILog LOG = Platform.getLog(NodeExecutor.class);
/**
* Check if Node.js is available on the system.
*/
public static boolean isNodeAvailable() {
Process process = null;
try {
ProcessBuilder pb = new ProcessBuilder("node", "--version"); //$NON-NLS-1$ //$NON-NLS-2$
process = pb.start();
boolean finished = process.waitFor(5, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
return false;
}
return process.exitValue() == 0;
} catch (IOException | InterruptedException e) {
if (process != null) {
process.destroyForcibly();
}
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
return false;
}
}
/**
* Check if npx is available.
*/
public static boolean isNpxAvailable() {
Process process = null;
try {
ProcessBuilder pb = new ProcessBuilder("npx", "--version"); //$NON-NLS-1$ //$NON-NLS-2$
process = pb.start();
boolean finished = process.waitFor(5, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
return false;
}
return process.exitValue() == 0;
} catch (IOException | InterruptedException e) {
if (process != null) {
process.destroyForcibly();
}
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
return false;
}
}
/** Execute an npx command and return stdout/stderr without providing stdin. */
public static ExecutionResult executeNpx(String... args) throws IOException, InterruptedException {
return executeNpxInternal(null, args);
}
/** Execute an npx command with UTF-8 input supplied on stdin. */
public static ExecutionResult executeNpxWithInput(String input, String... args)
throws IOException, InterruptedException {
return executeNpxInternal(input == null ? "" : input, args); //$NON-NLS-1$
}
private static ExecutionResult executeNpxInternal(String input, String... args)
throws IOException, InterruptedException {
String[] command = new String[args.length + 1];
command[0] = "npx"; //$NON-NLS-1$
System.arraycopy(args, 0, command, 1, args.length);
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(false);
Process process = pb.start();
try (StreamGobbler outputGobbler = new StreamGobbler(process.getInputStream());
StreamGobbler errorGobbler = new StreamGobbler(process.getErrorStream())) {
outputGobbler.start();
errorGobbler.start();
try (OutputStream processInput = process.getOutputStream()) {
if (input != null) {
processInput.write(input.getBytes(StandardCharsets.UTF_8));
}
}
boolean finished;
try {
finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
process.destroyForcibly();
Thread.currentThread().interrupt();
throw e;
}
if (!finished) {
process.destroyForcibly();
throw new IOException("Process timed out after " + TIMEOUT_SECONDS + " seconds"); //$NON-NLS-1$ //$NON-NLS-2$
}
awaitCompleteOutput(outputGobbler, errorGobbler);
return new ExecutionResult(process.exitValue(), outputGobbler.getOutput(), errorGobbler.getOutput());
} finally {
process.destroy();
}
}
private static void awaitCompleteOutput(StreamGobbler outputGobbler, StreamGobbler errorGobbler)
throws InterruptedException {
try {
// The process has exited, so both streams will reach EOF. Do not return
// partially consumed JSON merely because a fixed join timeout elapsed.
outputGobbler.join();
errorGobbler.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw e;
}
}
/**
* Helper class to read stream output in a separate thread to avoid deadlock.
* Implements AutoCloseable to ensure the input stream is closed even if the thread doesn't run.
*/
private static class StreamGobbler extends Thread implements AutoCloseable {
private final InputStream inputStream;
private final StringBuilder output = new StringBuilder();
private volatile boolean streamConsumed = false;
StreamGobbler(InputStream inputStream) {
this.inputStream = inputStream;
setDaemon(true);
}
@Override
public void run() {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
streamConsumed = true;
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n"); //$NON-NLS-1$
}
} catch (IOException e) {
LOG.log(new org.eclipse.core.runtime.Status(org.eclipse.core.runtime.IStatus.WARNING,
"sandbox_css_cleanup", "Error reading stream", e)); //$NON-NLS-1$ //$NON-NLS-2$
}
}
@Override
public void close() {
if (!streamConsumed) {
try {
inputStream.close();
} catch (IOException e) {
// Process destruction also closes the stream.
}
}
}
String getOutput() {
return output.toString();
}
}
/** Result of a command execution. */
public static class ExecutionResult {
public final int exitCode;
public final String stdout;
public final String stderr;
public ExecutionResult(int exitCode, String stdout, String stderr) {
this.exitCode = exitCode;
this.stdout = stdout;
this.stderr = stderr;
}
public boolean isSuccess() {
return exitCode == 0;
}
}
}