Files
ft/Client/Packages/io.github.hatayama.uloopmcp/Editor/Utils/NodeEnvironmentResolver.cs

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