174 lines
6.2 KiB
C#
174 lines
6.2 KiB
C#
namespace Sdt.Core;
|
|
|
|
public enum CommandResolutionSource
|
|
{
|
|
Exact,
|
|
Path,
|
|
Shim,
|
|
NodeAdjacentShim,
|
|
ConfiguredOverride,
|
|
Fallback,
|
|
}
|
|
|
|
public sealed record CommandResolutionResult(
|
|
string Requested,
|
|
string Resolved,
|
|
CommandResolutionSource Source);
|
|
|
|
public static class CommandResolver
|
|
{
|
|
public static CommandResolutionResult ResolveWithTrace(string command, Config.DevToolConfig? config = null, string? tool = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(command))
|
|
return new CommandResolutionResult(command, command, CommandResolutionSource.Exact);
|
|
|
|
if (!OperatingSystem.IsWindows())
|
|
return new CommandResolutionResult(command, command, CommandResolutionSource.Exact);
|
|
|
|
if (command.Contains(Path.DirectorySeparatorChar) || command.Contains(Path.AltDirectorySeparatorChar))
|
|
return new CommandResolutionResult(command, command, CommandResolutionSource.Exact);
|
|
|
|
var normalized = command.ToLowerInvariant();
|
|
if (Path.HasExtension(command))
|
|
{
|
|
var extensionResolved = ResolveFromPath(command);
|
|
return extensionResolved is null
|
|
? new CommandResolutionResult(command, command, CommandResolutionSource.Fallback)
|
|
: new CommandResolutionResult(command, extensionResolved, CommandResolutionSource.Path);
|
|
}
|
|
|
|
var overrideTool = string.IsNullOrWhiteSpace(tool) ? normalized : tool.ToLowerInvariant();
|
|
var configuredCandidates = config?.Tooling?.Tools
|
|
.FirstOrDefault(t => string.Equals(t.Tool, overrideTool, StringComparison.OrdinalIgnoreCase))
|
|
?.Executables
|
|
?.Where(x => !string.IsNullOrWhiteSpace(x))
|
|
.ToList();
|
|
|
|
if (configuredCandidates is not null)
|
|
{
|
|
foreach (var configured in configuredCandidates)
|
|
{
|
|
var resolvedConfigured = ResolveFromPath(configured!) ?? configured!;
|
|
if (IsUsableExecutable(resolvedConfigured))
|
|
return new CommandResolutionResult(command, resolvedConfigured, CommandResolutionSource.ConfiguredOverride);
|
|
}
|
|
}
|
|
|
|
foreach (var candidate in BuildWindowsCandidates(command, normalized))
|
|
{
|
|
var resolved = ResolveFromPath(candidate);
|
|
if (!string.IsNullOrWhiteSpace(resolved))
|
|
{
|
|
var source = candidate.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) ||
|
|
candidate.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ||
|
|
candidate.EndsWith(".bat", StringComparison.OrdinalIgnoreCase)
|
|
? CommandResolutionSource.Shim
|
|
: CommandResolutionSource.Path;
|
|
return new CommandResolutionResult(command, resolved!, source);
|
|
}
|
|
}
|
|
|
|
if (normalized is "npm" or "npx" or "pnpm" or "yarn")
|
|
{
|
|
var nodePath = ResolveFromPath("node.exe") ?? ResolveFromPath("node");
|
|
if (!string.IsNullOrWhiteSpace(nodePath))
|
|
{
|
|
var nodeDir = Path.GetDirectoryName(nodePath);
|
|
if (!string.IsNullOrWhiteSpace(nodeDir))
|
|
{
|
|
var shim = Path.Combine(nodeDir, normalized + ".cmd");
|
|
if (File.Exists(shim))
|
|
return new CommandResolutionResult(command, shim, CommandResolutionSource.NodeAdjacentShim);
|
|
}
|
|
}
|
|
}
|
|
|
|
var fallback = BuildWindowsCandidates(command, normalized).LastOrDefault() ?? command;
|
|
return new CommandResolutionResult(command, fallback, CommandResolutionSource.Fallback);
|
|
}
|
|
|
|
public static string Resolve(string command)
|
|
{
|
|
return ResolveWithTrace(command).Resolved;
|
|
}
|
|
|
|
private static string? ResolveFromPath(string executable)
|
|
{
|
|
var pathValue = Environment.GetEnvironmentVariable("PATH");
|
|
if (string.IsNullOrWhiteSpace(pathValue))
|
|
return null;
|
|
|
|
foreach (var segment in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
try
|
|
{
|
|
var expandedSegment = ExpandWindowsPathTokens(segment.Trim());
|
|
var candidate = Path.Combine(expandedSegment, executable);
|
|
if (File.Exists(candidate))
|
|
{
|
|
// If PATH lookup hit extensionless npm but npm.cmd exists beside it, prefer npm.cmd.
|
|
var fileName = Path.GetFileName(candidate).ToLowerInvariant();
|
|
if (fileName is "npm" or "npx" or "pnpm" or "yarn" or "tauri")
|
|
{
|
|
var shim = candidate + ".cmd";
|
|
if (File.Exists(shim))
|
|
return shim;
|
|
}
|
|
return candidate;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore malformed PATH segments.
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string ExpandWindowsPathTokens(string segment)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(segment) || !OperatingSystem.IsWindows())
|
|
return segment;
|
|
|
|
var expanded = segment;
|
|
for (var i = 0; i < 4; i++)
|
|
{
|
|
var next = Environment.ExpandEnvironmentVariables(expanded);
|
|
if (string.Equals(next, expanded, StringComparison.Ordinal))
|
|
break;
|
|
expanded = next;
|
|
}
|
|
return expanded;
|
|
}
|
|
|
|
private static List<string> BuildWindowsCandidates(string command, string normalized)
|
|
{
|
|
var candidates = new List<string>();
|
|
if (normalized is "npm" or "npx" or "pnpm" or "yarn" or "tauri")
|
|
{
|
|
candidates.Add(command + ".cmd");
|
|
candidates.Add(command + ".exe");
|
|
candidates.Add(command + ".bat");
|
|
candidates.Add(command);
|
|
}
|
|
else
|
|
{
|
|
candidates.Add(command);
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
private static bool IsUsableExecutable(string resolved)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(resolved))
|
|
return false;
|
|
|
|
if (Path.IsPathRooted(resolved))
|
|
return File.Exists(resolved);
|
|
|
|
return ResolveFromPath(resolved) is not null;
|
|
}
|
|
}
|