From 6669722529e49c39e1a2d266fe174e0543b856a0 Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Thu, 22 Oct 2026 11:15:54 -0400 Subject: [PATCH 1/2] Find npm installed by nvm when the app is launched from Finder A GUI-launched UniGetUI on macOS starts with launchd's minimal PATH. ExpandMacOSPath widens it with a login, non-interactive zsh, which reads .zprofile/.zlogin but skips .zshrc, where the nvm installer writes its setup. ~/.nvm/versions/node//bin therefore never reached the process PATH, and Npm.FindCandidateExecutableFiles, which only searched PATH, found no candidate, so npm was reported missing and could not be picked by hand either. On non-Windows, discovery now adds nvm installs after the PATH hits, following Cargo's fallback shape: $NVM_DIR (or ~/.nvm) versions are listed newest first, with nvm's alias/default (following alias chains and partial versions) moved to the front, filtered for the executable bit and de-duplicated. An npm already on PATH still wins. nvm's npm is a script with a "#!/usr/bin/env node" shebang, so finding it is not enough with the minimal PATH. When npm is loaded on non-Windows, its directory is appended to the process PATH if missing, before the version probe, so the probe and every operation inherit a PATH where node resolves. Appending rather than prepending keeps that directory from shadowing other managers' tools, and the append is idempotent across reloads. Windows behaviour is unchanged. Fixes #5553 Co-Authored-By: Claude Opus 5.5 --- .../Npm.cs | 167 +++++++++++++++++- .../NpmNodePathTests.cs | 36 ++++ .../NpmNvmDiscoveryTests.cs | 115 ++++++++++++ 3 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Tests/NpmNodePathTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/NpmNvmDiscoveryTests.cs diff --git a/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs b/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs index 2479a89551..f3572d8082 100644 --- a/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs +++ b/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs @@ -200,8 +200,160 @@ 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 (value.StartsWith('v') || char.IsAsciiDigit(value[0])) + return value; + alias = value; + } + + return null; + } + + 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 +373,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..a3bd257a5a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/NpmNvmDiscoveryTests.cs @@ -0,0 +1,115 @@ +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 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()); + }); + } +} From 0f6a3085c39d4742c1da248b5e306ed7f889e0c1 Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Thu, 22 Oct 2026 11:31:10 -0400 Subject: [PATCH 2/2] Follow custom nvm aliases whose names look like versions ResolveNvmAlias treated any alias value that started with 'v' or a digit as a version, so a chain such as default -> verified -> v22.11.0 stopped at "verified". MatchesNvmVersion could not parse that name, the installations stayed sorted newest-first, and with v24 also installed the newest Node was picked instead of the configured v22 default, exposing a different set of global packages. A value now counts as a version only when, after one optional leading 'v', it is one to three non-empty, all-digit, dot-separated components. Anything else is followed as an alias file, still capped at 8 hops, and node/stable keep resolving to no preference. The regression test DiscoveryFollowsACustomNvmAliasWhoseNameStartsWithV covers the chain. Co-Authored-By: Claude Opus 5.5 --- .../Npm.cs | 9 +++++++- .../NpmNvmDiscoveryTests.cs | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs b/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs index f3572d8082..157f2bbc50 100644 --- a/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs +++ b/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs @@ -293,7 +293,7 @@ string userProfileDirectory if (value.Length is 0 || value is "node" or "stable") return null; - if (value.StartsWith('v') || char.IsAsciiDigit(value[0])) + if (IsNvmVersionSpecification(value)) return value; alias = value; } @@ -301,6 +301,13 @@ string userProfileDirectory 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('.'); diff --git a/src/UniGetUI.PackageEngine.Tests/NpmNvmDiscoveryTests.cs b/src/UniGetUI.PackageEngine.Tests/NpmNvmDiscoveryTests.cs index a3bd257a5a..e61521020d 100644 --- a/src/UniGetUI.PackageEngine.Tests/NpmNvmDiscoveryTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/NpmNvmDiscoveryTests.cs @@ -97,6 +97,27 @@ public void DiscoveryMatchesAPartialNvmDefaultVersion() }); } + [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() {