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.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();
			}
			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();
			}
			return false;
		}
	}

	/**
	 * Execute an npx command and return the output.
	 */
	public static ExecutionResult executeNpx(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())) {
			// Use StreamGobbler to read streams concurrently and avoid deadlock
			outputGobbler.start();
			errorGobbler.start();

			boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
			if (!finished) {
				process.destroyForcibly();
				throw new IOException("Process timed out after " + TIMEOUT_SECONDS + " seconds"); //$NON-NLS-1$ //$NON-NLS-2$
			}

			outputGobbler.join(1000);
			errorGobbler.join(1000);

			return new ExecutionResult(process.exitValue(), outputGobbler.getOutput(), errorGobbler.getOutput());
		} finally {
			process.destroy();
		}
	}

	/**
	 * 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;
		}

		@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 run() was never called or didn't complete, close the stream explicitly
			if (!streamConsumed) {
				try {
					inputStream.close();
				} catch (IOException e) {
					// Ignore close errors - stream will be closed when process is destroyed anyway
				}
			}
		}

		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;
		}
	}
}