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 BuildWindowsCandidates(string command, string normalized) { var candidates = new List(); 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; } }