diff --git a/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs b/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs index 2479a89551..157f2bbc50 100644 --- a/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs +++ b/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs @@ -200,8 +200,167 @@ private string BuildSearchArguments(string query) => + CoreTools.EscapePowerShellSingleQuoted(query) + " --json"; - public override IReadOnlyList FindCandidateExecutableFiles() => - CoreTools.WhichMultiple(OperatingSystem.IsWindows() ? "npm.cmd" : "npm"); + public override IReadOnlyList FindCandidateExecutableFiles() + { + if (OperatingSystem.IsWindows()) + return CoreTools.WhichMultiple("npm.cmd"); + + return CoreTools.WhichMultiple("npm") + .Concat( + GetNvmBinDirectories( + Environment.GetEnvironmentVariable, + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + ) + .Select(directory => Path.Join(directory, "npm")) + .Where(IsExecutableFile) + ) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + internal static IReadOnlyList GetNvmBinDirectories( + Func readEnvironmentVariable, + string userProfileDirectory + ) + { + string nvmDirectory; + if (readEnvironmentVariable("NVM_DIR")?.Trim() is { Length: > 0 } configured) + nvmDirectory = configured; + else if (userProfileDirectory.Trim() is { Length: > 0 } userProfile) + nvmDirectory = Path.Join(userProfile, ".nvm"); + else + return []; + + List<(Version Version, string Directory)> installed = []; + try + { + string versionsDirectory = Path.Join(nvmDirectory, "versions", "node"); + if (!Directory.Exists(versionsDirectory)) + return []; + + foreach (string directory in Directory.EnumerateDirectories(versionsDirectory)) + { + string name = Path.GetFileName(directory); + if (name.StartsWith('v') && Version.TryParse(name[1..], out Version? version)) + installed.Add((version, directory)); + } + } + catch (IOException) + { + return []; + } + catch (UnauthorizedAccessException) + { + return []; + } + + installed.Sort((a, b) => b.Version.CompareTo(a.Version)); + + if (ResolveNvmAlias(nvmDirectory, "default") is { } defaultVersion) + { + int index = installed.FindIndex(entry => MatchesNvmVersion(entry.Version, defaultVersion)); + if (index > 0) + { + var entry = installed[index]; + installed.RemoveAt(index); + installed.Insert(0, entry); + } + } + + return installed.Select(entry => Path.Join(entry.Directory, "bin")).ToArray(); + } + + private static string? ResolveNvmAlias(string nvmDirectory, string alias) + { + for (int hop = 0; hop < 8; hop++) + { + string value; + try + { + string aliasFile = Path.Join(nvmDirectory, "alias", alias); + if (!File.Exists(aliasFile)) + return null; + value = File.ReadAllText(aliasFile).Trim(); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + + if (value.Length is 0 || value is "node" or "stable") + return null; + if (IsNvmVersionSpecification(value)) + return value; + alias = value; + } + + return null; + } + + private static bool IsNvmVersionSpecification(string value) + { + string[] parts = (value.StartsWith('v') ? value[1..] : value).Split('.'); + return parts.Length is >= 1 and <= 3 + && parts.All(part => part.Length > 0 && part.All(char.IsAsciiDigit)); + } + + private static bool MatchesNvmVersion(Version version, string specification) + { + string[] parts = specification.TrimStart('v').Split('.'); + int[] components = [version.Major, version.Minor, version.Build]; + if (parts.Length > components.Length) + return false; + + for (int i = 0; i < parts.Length; i++) + { + if (!int.TryParse(parts[i], out int value) || value != components[i]) + return false; + } + + return true; + } + + private static bool IsExecutableFile(string path) + { + if (!File.Exists(path)) + return false; + + if (OperatingSystem.IsWindows()) + return true; + + const UnixFileMode ExecutableBits = + UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + + try + { + return (File.GetUnixFileMode(path) & ExecutableBits) is not 0; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + internal static string AppendDirectoryToPath(string? currentPath, string directory) + { + if (string.IsNullOrEmpty(currentPath)) + return directory; + + string trimmedDirectory = Path.TrimEndingDirectorySeparator(directory); + bool alreadyPresent = currentPath + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Any(entry => Path.TrimEndingDirectorySeparator(entry) == trimmedDirectory); + + return alreadyPresent ? currentPath : currentPath + Path.PathSeparator + directory; + } protected override void _loadManagerExecutableFile( out bool found, @@ -221,6 +380,17 @@ out string callArguments return; } + if (found && Path.GetDirectoryName(_executable) is { Length: > 0 } executableDirectory) + { + string? currentPath = Environment.GetEnvironmentVariable("PATH"); + string updatedPath = AppendDirectoryToPath(currentPath, executableDirectory); + if (updatedPath != currentPath) + { + Environment.SetEnvironmentVariable("PATH", updatedPath); + Logger.Info($"Added {executableDirectory} to the PATH so npm can reach its node"); + } + } + path = _executable; callArguments = ""; } diff --git a/src/UniGetUI.PackageEngine.Tests/NpmNodePathTests.cs b/src/UniGetUI.PackageEngine.Tests/NpmNodePathTests.cs new file mode 100644 index 0000000000..22ffd3ff19 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/NpmNodePathTests.cs @@ -0,0 +1,36 @@ +using UniGetUI.PackageEngine.Managers.NpmManager; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class NpmNodePathTests +{ + private static readonly string Separator = Path.PathSeparator.ToString(); + + [Fact] + public void AppendDirectoryToPathAddsAMissingDirectoryAtTheEnd() + { + string nvmBin = Path.Join("Users", "tester", ".nvm", "versions", "node", "v24.21.0", "bin"); + string path = string.Join(Separator, "usr-bin", "bin"); + + Assert.Equal( + string.Join(Separator, "usr-bin", "bin", nvmBin), + Npm.AppendDirectoryToPath(path, nvmBin) + ); + } + + [Fact] + public void AppendDirectoryToPathKeepsAPathThatAlreadyHasTheDirectory() + { + string nvmBin = Path.Join("Users", "tester", ".nvm", "versions", "node", "v24.21.0", "bin"); + string path = string.Join(Separator, "usr-bin", nvmBin + Path.DirectorySeparatorChar, "bin"); + + Assert.Equal(path, Npm.AppendDirectoryToPath(path, nvmBin)); + } + + [Fact] + public void AppendDirectoryToPathStartsAnEmptyPath() + { + Assert.Equal("bin", Npm.AppendDirectoryToPath(null, "bin")); + Assert.Equal("bin", Npm.AppendDirectoryToPath("", "bin")); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/NpmNvmDiscoveryTests.cs b/src/UniGetUI.PackageEngine.Tests/NpmNvmDiscoveryTests.cs new file mode 100644 index 0000000000..e61521020d --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/NpmNvmDiscoveryTests.cs @@ -0,0 +1,136 @@ +using UniGetUI.PackageEngine.Managers.NpmManager; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class NpmNvmDiscoveryTests +{ + private static string CreateNvmNode(string nvmDirectory, string version) + { + string binDirectory = Path.Join(nvmDirectory, "versions", "node", version, "bin"); + Directory.CreateDirectory(binDirectory); + string npmPath = Path.Join(binDirectory, "npm"); + File.WriteAllText(npmPath, "#!/usr/bin/env node\n"); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + npmPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + ); + } + return npmPath; + } + + private static void RunWithNvmDirectory(Action test) + { + string nvmDirectory = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + string? previous = Environment.GetEnvironmentVariable("NVM_DIR"); + + try + { + Environment.SetEnvironmentVariable("NVM_DIR", nvmDirectory); + test(nvmDirectory); + } + finally + { + Environment.SetEnvironmentVariable("NVM_DIR", previous); + if (Directory.Exists(nvmDirectory)) + Directory.Delete(nvmDirectory, true); + } + } + + [Fact] + public void DiscoveryFindsNpmInstalledByNvmOutsideThePath() + { + if (OperatingSystem.IsWindows()) + return; + + RunWithNvmDirectory(nvmDirectory => + { + string npmPath = CreateNvmNode(nvmDirectory, "v24.21.0"); + + Assert.Contains(npmPath, new Npm().FindCandidateExecutableFiles()); + }); + } + + [Fact] + public void DiscoveryPrefersTheNvmDefaultAliasThenTheNewestVersion() + { + if (OperatingSystem.IsWindows()) + return; + + RunWithNvmDirectory(nvmDirectory => + { + string oldest = CreateNvmNode(nvmDirectory, "v18.20.4"); + string lts = CreateNvmNode(nvmDirectory, "v22.11.0"); + string newest = CreateNvmNode(nvmDirectory, "v24.21.0"); + Directory.CreateDirectory(Path.Join(nvmDirectory, "alias", "lts")); + File.WriteAllText(Path.Join(nvmDirectory, "alias", "default"), "lts/*\n"); + File.WriteAllText(Path.Join(nvmDirectory, "alias", "lts", "*"), "lts/jod\n"); + File.WriteAllText(Path.Join(nvmDirectory, "alias", "lts", "jod"), "v22.11.0\n"); + + var candidates = new Npm().FindCandidateExecutableFiles().ToList(); + + Assert.True(candidates.IndexOf(lts) >= 0); + Assert.True(candidates.IndexOf(lts) < candidates.IndexOf(newest)); + Assert.True(candidates.IndexOf(newest) < candidates.IndexOf(oldest)); + }); + } + + [Fact] + public void DiscoveryMatchesAPartialNvmDefaultVersion() + { + if (OperatingSystem.IsWindows()) + return; + + RunWithNvmDirectory(nvmDirectory => + { + string older = CreateNvmNode(nvmDirectory, "v20.18.0"); + string newer = CreateNvmNode(nvmDirectory, "v24.21.0"); + Directory.CreateDirectory(Path.Join(nvmDirectory, "alias")); + File.WriteAllText(Path.Join(nvmDirectory, "alias", "default"), "20\n"); + + var candidates = new Npm().FindCandidateExecutableFiles().ToList(); + + Assert.True(candidates.IndexOf(older) >= 0); + Assert.True(candidates.IndexOf(older) < candidates.IndexOf(newer)); + }); + } + + [Fact] + public void DiscoveryFollowsACustomNvmAliasWhoseNameStartsWithV() + { + if (OperatingSystem.IsWindows()) + return; + + RunWithNvmDirectory(nvmDirectory => + { + string configured = CreateNvmNode(nvmDirectory, "v22.11.0"); + string newest = CreateNvmNode(nvmDirectory, "v24.21.0"); + Directory.CreateDirectory(Path.Join(nvmDirectory, "alias")); + File.WriteAllText(Path.Join(nvmDirectory, "alias", "default"), "verified\n"); + File.WriteAllText(Path.Join(nvmDirectory, "alias", "verified"), "v22.11.0\n"); + + var candidates = new Npm().FindCandidateExecutableFiles().ToList(); + + Assert.True(candidates.IndexOf(configured) >= 0); + Assert.True(candidates.IndexOf(configured) < candidates.IndexOf(newest)); + }); + } + + [Fact] + public void DiscoveryIgnoresNvmNpmWithoutExecutablePermissions() + { + if (OperatingSystem.IsWindows()) + return; + + RunWithNvmDirectory(nvmDirectory => + { + string npmPath = CreateNvmNode(nvmDirectory, "v24.21.0"); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(npmPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + + Assert.DoesNotContain(npmPath, new Npm().FindCandidateExecutableFiles()); + }); + } +}