372 lines
13 KiB
C#
372 lines
13 KiB
C#
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using UnityEngine;
|
|
|
|
namespace io.github.hatayama.uLoopMCP
|
|
{
|
|
/// <summary>
|
|
/// Utility class for detecting Node.js and npm executables.
|
|
/// Uses login shell to resolve PATH, matching the user's terminal environment.
|
|
/// </summary>
|
|
public static class NodeEnvironmentResolver
|
|
{
|
|
private const int PROCESS_TIMEOUT_MS = 5000;
|
|
|
|
public static string FindNodePath()
|
|
{
|
|
return FindNodePathAtPlatform(Application.platform);
|
|
}
|
|
|
|
public static IEnumerable<string> FindAllNodePaths()
|
|
{
|
|
return FindAllNodePathsAtPlatform(Application.platform);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds an executable path using platform-appropriate resolution.
|
|
/// On Windows, resolves .cmd shims via 'where' command.
|
|
/// On Unix, resolves via login shell 'which' command.
|
|
/// </summary>
|
|
public static string FindExecutablePath(string executableName)
|
|
{
|
|
return FindExecutablePathAtPlatform(executableName, Application.platform);
|
|
}
|
|
|
|
public static string FindNpmPath()
|
|
{
|
|
return FindExecutablePathAtPlatform("npm", Application.platform);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets up the PATH environment variable for a process.
|
|
/// On Unix, retrieves PATH from login shell to match the user's terminal environment.
|
|
/// </summary>
|
|
public static void SetupEnvironmentPath(ProcessStartInfo startInfo, string executablePath)
|
|
{
|
|
SetupEnvironmentPathAtPlatform(startInfo, executablePath, Application.platform);
|
|
}
|
|
|
|
internal static string FindNodePathAtPlatform(RuntimePlatform platform)
|
|
{
|
|
return FindExecutablePathAtPlatform("node", platform);
|
|
}
|
|
|
|
internal static IEnumerable<string> FindAllNodePathsAtPlatform(RuntimePlatform platform)
|
|
{
|
|
if (IsWindowsEditor(platform))
|
|
{
|
|
return FindAllExecutablePathsWindows("node");
|
|
}
|
|
|
|
return FindAllExecutablePathsUnix("node");
|
|
}
|
|
|
|
internal static string FindExecutablePathAtPlatform(string executableName, RuntimePlatform platform)
|
|
{
|
|
if (IsWindowsEditor(platform))
|
|
{
|
|
return FindExecutableWindows(executableName);
|
|
}
|
|
|
|
return FindExecutableUnix(executableName);
|
|
}
|
|
|
|
internal static void SetupEnvironmentPathAtPlatform(
|
|
ProcessStartInfo startInfo,
|
|
string executablePath,
|
|
RuntimePlatform platform)
|
|
{
|
|
if (string.IsNullOrEmpty(executablePath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string executableDir = Path.GetDirectoryName(executablePath);
|
|
string loginShellPath = GetLoginShellPathAtPlatform(platform);
|
|
string basePath = !string.IsNullOrEmpty(loginShellPath)
|
|
? loginShellPath
|
|
: (System.Environment.GetEnvironmentVariable("PATH") ?? "");
|
|
|
|
string separator = IsWindowsEditor(platform) ? ";" : ":";
|
|
|
|
if (!string.IsNullOrEmpty(executableDir))
|
|
{
|
|
startInfo.EnvironmentVariables["PATH"] = executableDir + separator + basePath;
|
|
}
|
|
else
|
|
{
|
|
startInfo.EnvironmentVariables["PATH"] = basePath;
|
|
}
|
|
}
|
|
|
|
private static string FindExecutableUnix(string executableName)
|
|
{
|
|
return TryWhichCommand(executableName);
|
|
}
|
|
|
|
private static string FindExecutableWindows(string executableName)
|
|
{
|
|
return TryWhereCommand(executableName);
|
|
}
|
|
|
|
// Only returns the login shell's which result — no hardcoded fallback paths.
|
|
// Scanning version-manager directories directly caused false positives (e.g. detecting
|
|
// an uninstalled CLI version), which was the original bug this PR fixes.
|
|
private static IEnumerable<string> FindAllExecutablePathsUnix(string executableName)
|
|
{
|
|
string whichPath = TryWhichCommand(executableName);
|
|
if (!string.IsNullOrEmpty(whichPath))
|
|
{
|
|
yield return whichPath;
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<string> FindAllExecutablePathsWindows(string executableName)
|
|
{
|
|
string[] wherePaths = TryWhereCommandAll(executableName);
|
|
if (wherePaths != null)
|
|
{
|
|
foreach (string path in wherePaths)
|
|
{
|
|
if (!string.IsNullOrEmpty(path))
|
|
{
|
|
yield return path;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Interactive login shell (-l -i) loads .zprofile and .zshrc/.bashrc, matching the user's terminal
|
|
// Markers isolate which output from shell startup banners; ExtractAbsolutePathLine filters alias text
|
|
// executableName is not shell-escaped because all callers pass hardcoded constants (YAGNI)
|
|
private static string TryWhichCommand(string executableName)
|
|
{
|
|
string shell = GetUserShell();
|
|
ProcessStartInfo startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = shell,
|
|
Arguments = "-l -i -c \"echo " + WHICH_START_MARKER + "; which " + executableName + "; echo " + WHICH_END_MARKER + "\"",
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true
|
|
};
|
|
|
|
string output = ExecuteAndGetOutput(startInfo);
|
|
string block = ExtractBetweenMarkers(output, WHICH_START_MARKER, WHICH_END_MARKER);
|
|
return ExtractAbsolutePathLine(block);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the first executable path for the given name using the Windows 'where' command.
|
|
/// Prioritizes .cmd/.exe over extensionless entries because npm generates both a bash shim
|
|
/// (extensionless) and a Windows cmd shim (.cmd); the bash shim cannot be launched via Process.Start.
|
|
/// </summary>
|
|
private static string TryWhereCommand(string executableName)
|
|
{
|
|
string[] paths = TryWhereCommandAll(executableName);
|
|
if (paths == null || paths.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
foreach (string path in paths)
|
|
{
|
|
string extension = Path.GetExtension(path);
|
|
if (string.Equals(extension, ".cmd", System.StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(extension, ".exe", System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return path;
|
|
}
|
|
}
|
|
|
|
return paths[0];
|
|
}
|
|
|
|
private static string[] TryWhereCommandAll(string executableName)
|
|
{
|
|
ProcessStartInfo startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = "cmd.exe",
|
|
Arguments = $"/c where {executableName}",
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true
|
|
};
|
|
|
|
string output = ExecuteAndGetOutput(startInfo);
|
|
if (!string.IsNullOrEmpty(output))
|
|
{
|
|
string[] lines = output.Split('\n');
|
|
List<string> result = new List<string>();
|
|
foreach (string line in lines)
|
|
{
|
|
string trimmed = line.Trim();
|
|
if (!string.IsNullOrEmpty(trimmed))
|
|
{
|
|
result.Add(trimmed);
|
|
}
|
|
}
|
|
return result.Count > 0 ? result.ToArray() : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string ExecuteAndGetOutput(ProcessStartInfo startInfo)
|
|
{
|
|
UnityEngine.Debug.Assert(startInfo != null, "startInfo must not be null");
|
|
UnityEngine.Debug.Assert(startInfo.RedirectStandardOutput, "RedirectStandardOutput must be true");
|
|
UnityEngine.Debug.Assert(startInfo.RedirectStandardError, "RedirectStandardError must be true");
|
|
|
|
using (Process process = Process.Start(startInfo))
|
|
{
|
|
if (process == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
System.Text.StringBuilder stdoutBuilder = new System.Text.StringBuilder();
|
|
|
|
process.OutputDataReceived += (sender, e) =>
|
|
{
|
|
if (e.Data != null)
|
|
{
|
|
stdoutBuilder.AppendLine(e.Data);
|
|
}
|
|
};
|
|
process.ErrorDataReceived += (sender, e) => { };
|
|
|
|
process.BeginOutputReadLine();
|
|
process.BeginErrorReadLine();
|
|
|
|
if (!process.WaitForExit(PROCESS_TIMEOUT_MS))
|
|
{
|
|
// Process may exit between WaitForExit(timeout) and Kill() (TOCTOU race)
|
|
try
|
|
{
|
|
process.Kill();
|
|
process.WaitForExit(1000);
|
|
}
|
|
catch (System.InvalidOperationException)
|
|
{
|
|
UnityEngine.Debug.Log("Process already exited before Kill() was called");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Parameterless WaitForExit flushes async output buffers
|
|
process.WaitForExit();
|
|
|
|
string output = stdoutBuilder.ToString().Trim();
|
|
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output))
|
|
{
|
|
return output;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private const string PATH_START_MARKER = "__PATH_START__";
|
|
private const string PATH_END_MARKER = "__PATH_END__";
|
|
private const string WHICH_START_MARKER = "__WHICH_START__";
|
|
private const string WHICH_END_MARKER = "__WHICH_END__";
|
|
|
|
// Uses markers to extract PATH value, ignoring any banner/echo output from shell startup files
|
|
private static string GetLoginShellPath()
|
|
{
|
|
return GetLoginShellPathAtPlatform(Application.platform);
|
|
}
|
|
|
|
private static string GetLoginShellPathAtPlatform(RuntimePlatform platform)
|
|
{
|
|
if (IsWindowsEditor(platform))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
string shell = GetUserShell();
|
|
ProcessStartInfo startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = shell,
|
|
Arguments = "-l -i -c \"echo " + PATH_START_MARKER + "; printenv PATH; echo " + PATH_END_MARKER + "\"",
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true
|
|
};
|
|
|
|
string output = ExecuteAndGetOutput(startInfo);
|
|
return ExtractBetweenMarkers(output, PATH_START_MARKER, PATH_END_MARKER);
|
|
}
|
|
|
|
internal static string ExtractBetweenMarkers(string output, string startMarker, string endMarker)
|
|
{
|
|
if (string.IsNullOrEmpty(output))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
int startIndex = output.IndexOf(startMarker, System.StringComparison.Ordinal);
|
|
if (startIndex < 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
int searchFrom = startIndex + startMarker.Length;
|
|
int endIndex = output.IndexOf(endMarker, searchFrom, System.StringComparison.Ordinal);
|
|
if (endIndex < 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return output.Substring(startIndex + startMarker.Length, endIndex - startIndex - startMarker.Length).Trim();
|
|
}
|
|
|
|
internal static string ExtractAbsolutePathLine(string block)
|
|
{
|
|
if (string.IsNullOrEmpty(block))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
string[] lines = block.Split('\n');
|
|
foreach (string rawLine in lines)
|
|
{
|
|
string line = rawLine.Trim();
|
|
if (!string.IsNullOrEmpty(line) && Path.IsPathRooted(line))
|
|
{
|
|
return line;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Falls back to /bin/sh when $SHELL is unset or invalid. /bin/sh won't load
|
|
// .zshrc/.bashrc, so version-manager paths may be missed — but $SHELL being unset
|
|
// is extremely rare on macOS/Linux and there is no reliable way to detect the user's
|
|
// preferred shell without it.
|
|
private static string GetUserShell()
|
|
{
|
|
string shell = System.Environment.GetEnvironmentVariable("SHELL");
|
|
// $SHELL is external input — validate the path exists to avoid Process.Start exceptions
|
|
if (!string.IsNullOrEmpty(shell) && File.Exists(shell))
|
|
{
|
|
return shell;
|
|
}
|
|
|
|
return "/bin/sh";
|
|
}
|
|
|
|
private static bool IsWindowsEditor(RuntimePlatform platform)
|
|
{
|
|
return platform == RuntimePlatform.WindowsEditor;
|
|
}
|
|
}
|
|
}
|