Skip to content

Commit 25b063b

Browse files
authored
Find npm installed by nvm when the app is launched from Finder (#5576)
1 parent 9a69980 commit 25b063b

3 files changed

Lines changed: 344 additions & 2 deletions

File tree

‎src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs‎

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,167 @@ private string BuildSearchArguments(string query) =>
200200
+ CoreTools.EscapePowerShellSingleQuoted(query)
201201
+ " --json";
202202

203-
public override IReadOnlyList<string> FindCandidateExecutableFiles() =>
204-
CoreTools.WhichMultiple(OperatingSystem.IsWindows() ? "npm.cmd" : "npm");
203+
public override IReadOnlyList<string> FindCandidateExecutableFiles()
204+
{
205+
if (OperatingSystem.IsWindows())
206+
return CoreTools.WhichMultiple("npm.cmd");
207+
208+
return CoreTools.WhichMultiple("npm")
209+
.Concat(
210+
GetNvmBinDirectories(
211+
Environment.GetEnvironmentVariable,
212+
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)
213+
)
214+
.Select(directory => Path.Join(directory, "npm"))
215+
.Where(IsExecutableFile)
216+
)
217+
.Distinct(StringComparer.Ordinal)
218+
.ToArray();
219+
}
220+
221+
internal static IReadOnlyList<string> GetNvmBinDirectories(
222+
Func<string, string?> readEnvironmentVariable,
223+
string userProfileDirectory
224+
)
225+
{
226+
string nvmDirectory;
227+
if (readEnvironmentVariable("NVM_DIR")?.Trim() is { Length: > 0 } configured)
228+
nvmDirectory = configured;
229+
else if (userProfileDirectory.Trim() is { Length: > 0 } userProfile)
230+
nvmDirectory = Path.Join(userProfile, ".nvm");
231+
else
232+
return [];
233+
234+
List<(Version Version, string Directory)> installed = [];
235+
try
236+
{
237+
string versionsDirectory = Path.Join(nvmDirectory, "versions", "node");
238+
if (!Directory.Exists(versionsDirectory))
239+
return [];
240+
241+
foreach (string directory in Directory.EnumerateDirectories(versionsDirectory))
242+
{
243+
string name = Path.GetFileName(directory);
244+
if (name.StartsWith('v') && Version.TryParse(name[1..], out Version? version))
245+
installed.Add((version, directory));
246+
}
247+
}
248+
catch (IOException)
249+
{
250+
return [];
251+
}
252+
catch (UnauthorizedAccessException)
253+
{
254+
return [];
255+
}
256+
257+
installed.Sort((a, b) => b.Version.CompareTo(a.Version));
258+
259+
if (ResolveNvmAlias(nvmDirectory, "default") is { } defaultVersion)
260+
{
261+
int index = installed.FindIndex(entry => MatchesNvmVersion(entry.Version, defaultVersion));
262+
if (index > 0)
263+
{
264+
var entry = installed[index];
265+
installed.RemoveAt(index);
266+
installed.Insert(0, entry);
267+
}
268+
}
269+
270+
return installed.Select(entry => Path.Join(entry.Directory, "bin")).ToArray();
271+
}
272+
273+
private static string? ResolveNvmAlias(string nvmDirectory, string alias)
274+
{
275+
for (int hop = 0; hop < 8; hop++)
276+
{
277+
string value;
278+
try
279+
{
280+
string aliasFile = Path.Join(nvmDirectory, "alias", alias);
281+
if (!File.Exists(aliasFile))
282+
return null;
283+
value = File.ReadAllText(aliasFile).Trim();
284+
}
285+
catch (IOException)
286+
{
287+
return null;
288+
}
289+
catch (UnauthorizedAccessException)
290+
{
291+
return null;
292+
}
293+
294+
if (value.Length is 0 || value is "node" or "stable")
295+
return null;
296+
if (IsNvmVersionSpecification(value))
297+
return value;
298+
alias = value;
299+
}
300+
301+
return null;
302+
}
303+
304+
private static bool IsNvmVersionSpecification(string value)
305+
{
306+
string[] parts = (value.StartsWith('v') ? value[1..] : value).Split('.');
307+
return parts.Length is >= 1 and <= 3
308+
&& parts.All(part => part.Length > 0 && part.All(char.IsAsciiDigit));
309+
}
310+
311+
private static bool MatchesNvmVersion(Version version, string specification)
312+
{
313+
string[] parts = specification.TrimStart('v').Split('.');
314+
int[] components = [version.Major, version.Minor, version.Build];
315+
if (parts.Length > components.Length)
316+
return false;
317+
318+
for (int i = 0; i < parts.Length; i++)
319+
{
320+
if (!int.TryParse(parts[i], out int value) || value != components[i])
321+
return false;
322+
}
323+
324+
return true;
325+
}
326+
327+
private static bool IsExecutableFile(string path)
328+
{
329+
if (!File.Exists(path))
330+
return false;
331+
332+
if (OperatingSystem.IsWindows())
333+
return true;
334+
335+
const UnixFileMode ExecutableBits =
336+
UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
337+
338+
try
339+
{
340+
return (File.GetUnixFileMode(path) & ExecutableBits) is not 0;
341+
}
342+
catch (IOException)
343+
{
344+
return false;
345+
}
346+
catch (UnauthorizedAccessException)
347+
{
348+
return false;
349+
}
350+
}
351+
352+
internal static string AppendDirectoryToPath(string? currentPath, string directory)
353+
{
354+
if (string.IsNullOrEmpty(currentPath))
355+
return directory;
356+
357+
string trimmedDirectory = Path.TrimEndingDirectorySeparator(directory);
358+
bool alreadyPresent = currentPath
359+
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
360+
.Any(entry => Path.TrimEndingDirectorySeparator(entry) == trimmedDirectory);
361+
362+
return alreadyPresent ? currentPath : currentPath + Path.PathSeparator + directory;
363+
}
205364

206365
protected override void _loadManagerExecutableFile(
207366
out bool found,
@@ -221,6 +380,17 @@ out string callArguments
221380
return;
222381
}
223382

383+
if (found && Path.GetDirectoryName(_executable) is { Length: > 0 } executableDirectory)
384+
{
385+
string? currentPath = Environment.GetEnvironmentVariable("PATH");
386+
string updatedPath = AppendDirectoryToPath(currentPath, executableDirectory);
387+
if (updatedPath != currentPath)
388+
{
389+
Environment.SetEnvironmentVariable("PATH", updatedPath);
390+
Logger.Info($"Added {executableDirectory} to the PATH so npm can reach its node");
391+
}
392+
}
393+
224394
path = _executable;
225395
callArguments = "";
226396
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using UniGetUI.PackageEngine.Managers.NpmManager;
2+
3+
namespace UniGetUI.PackageEngine.Tests;
4+
5+
public sealed class NpmNodePathTests
6+
{
7+
private static readonly string Separator = Path.PathSeparator.ToString();
8+
9+
[Fact]
10+
public void AppendDirectoryToPathAddsAMissingDirectoryAtTheEnd()
11+
{
12+
string nvmBin = Path.Join("Users", "tester", ".nvm", "versions", "node", "v24.21.0", "bin");
13+
string path = string.Join(Separator, "usr-bin", "bin");
14+
15+
Assert.Equal(
16+
string.Join(Separator, "usr-bin", "bin", nvmBin),
17+
Npm.AppendDirectoryToPath(path, nvmBin)
18+
);
19+
}
20+
21+
[Fact]
22+
public void AppendDirectoryToPathKeepsAPathThatAlreadyHasTheDirectory()
23+
{
24+
string nvmBin = Path.Join("Users", "tester", ".nvm", "versions", "node", "v24.21.0", "bin");
25+
string path = string.Join(Separator, "usr-bin", nvmBin + Path.DirectorySeparatorChar, "bin");
26+
27+
Assert.Equal(path, Npm.AppendDirectoryToPath(path, nvmBin));
28+
}
29+
30+
[Fact]
31+
public void AppendDirectoryToPathStartsAnEmptyPath()
32+
{
33+
Assert.Equal("bin", Npm.AppendDirectoryToPath(null, "bin"));
34+
Assert.Equal("bin", Npm.AppendDirectoryToPath("", "bin"));
35+
}
36+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using UniGetUI.PackageEngine.Managers.NpmManager;
2+
3+
namespace UniGetUI.PackageEngine.Tests;
4+
5+
[Collection(nameof(OperationOrchestrationTestCollection))]
6+
public sealed class NpmNvmDiscoveryTests
7+
{
8+
private static string CreateNvmNode(string nvmDirectory, string version)
9+
{
10+
string binDirectory = Path.Join(nvmDirectory, "versions", "node", version, "bin");
11+
Directory.CreateDirectory(binDirectory);
12+
string npmPath = Path.Join(binDirectory, "npm");
13+
File.WriteAllText(npmPath, "#!/usr/bin/env node\n");
14+
if (!OperatingSystem.IsWindows())
15+
{
16+
File.SetUnixFileMode(
17+
npmPath,
18+
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute
19+
);
20+
}
21+
return npmPath;
22+
}
23+
24+
private static void RunWithNvmDirectory(Action<string> test)
25+
{
26+
string nvmDirectory = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
27+
string? previous = Environment.GetEnvironmentVariable("NVM_DIR");
28+
29+
try
30+
{
31+
Environment.SetEnvironmentVariable("NVM_DIR", nvmDirectory);
32+
test(nvmDirectory);
33+
}
34+
finally
35+
{
36+
Environment.SetEnvironmentVariable("NVM_DIR", previous);
37+
if (Directory.Exists(nvmDirectory))
38+
Directory.Delete(nvmDirectory, true);
39+
}
40+
}
41+
42+
[Fact]
43+
public void DiscoveryFindsNpmInstalledByNvmOutsideThePath()
44+
{
45+
if (OperatingSystem.IsWindows())
46+
return;
47+
48+
RunWithNvmDirectory(nvmDirectory =>
49+
{
50+
string npmPath = CreateNvmNode(nvmDirectory, "v24.21.0");
51+
52+
Assert.Contains(npmPath, new Npm().FindCandidateExecutableFiles());
53+
});
54+
}
55+
56+
[Fact]
57+
public void DiscoveryPrefersTheNvmDefaultAliasThenTheNewestVersion()
58+
{
59+
if (OperatingSystem.IsWindows())
60+
return;
61+
62+
RunWithNvmDirectory(nvmDirectory =>
63+
{
64+
string oldest = CreateNvmNode(nvmDirectory, "v18.20.4");
65+
string lts = CreateNvmNode(nvmDirectory, "v22.11.0");
66+
string newest = CreateNvmNode(nvmDirectory, "v24.21.0");
67+
Directory.CreateDirectory(Path.Join(nvmDirectory, "alias", "lts"));
68+
File.WriteAllText(Path.Join(nvmDirectory, "alias", "default"), "lts/*\n");
69+
File.WriteAllText(Path.Join(nvmDirectory, "alias", "lts", "*"), "lts/jod\n");
70+
File.WriteAllText(Path.Join(nvmDirectory, "alias", "lts", "jod"), "v22.11.0\n");
71+
72+
var candidates = new Npm().FindCandidateExecutableFiles().ToList();
73+
74+
Assert.True(candidates.IndexOf(lts) >= 0);
75+
Assert.True(candidates.IndexOf(lts) < candidates.IndexOf(newest));
76+
Assert.True(candidates.IndexOf(newest) < candidates.IndexOf(oldest));
77+
});
78+
}
79+
80+
[Fact]
81+
public void DiscoveryMatchesAPartialNvmDefaultVersion()
82+
{
83+
if (OperatingSystem.IsWindows())
84+
return;
85+
86+
RunWithNvmDirectory(nvmDirectory =>
87+
{
88+
string older = CreateNvmNode(nvmDirectory, "v20.18.0");
89+
string newer = CreateNvmNode(nvmDirectory, "v24.21.0");
90+
Directory.CreateDirectory(Path.Join(nvmDirectory, "alias"));
91+
File.WriteAllText(Path.Join(nvmDirectory, "alias", "default"), "20\n");
92+
93+
var candidates = new Npm().FindCandidateExecutableFiles().ToList();
94+
95+
Assert.True(candidates.IndexOf(older) >= 0);
96+
Assert.True(candidates.IndexOf(older) < candidates.IndexOf(newer));
97+
});
98+
}
99+
100+
[Fact]
101+
public void DiscoveryFollowsACustomNvmAliasWhoseNameStartsWithV()
102+
{
103+
if (OperatingSystem.IsWindows())
104+
return;
105+
106+
RunWithNvmDirectory(nvmDirectory =>
107+
{
108+
string configured = CreateNvmNode(nvmDirectory, "v22.11.0");
109+
string newest = CreateNvmNode(nvmDirectory, "v24.21.0");
110+
Directory.CreateDirectory(Path.Join(nvmDirectory, "alias"));
111+
File.WriteAllText(Path.Join(nvmDirectory, "alias", "default"), "verified\n");
112+
File.WriteAllText(Path.Join(nvmDirectory, "alias", "verified"), "v22.11.0\n");
113+
114+
var candidates = new Npm().FindCandidateExecutableFiles().ToList();
115+
116+
Assert.True(candidates.IndexOf(configured) >= 0);
117+
Assert.True(candidates.IndexOf(configured) < candidates.IndexOf(newest));
118+
});
119+
}
120+
121+
[Fact]
122+
public void DiscoveryIgnoresNvmNpmWithoutExecutablePermissions()
123+
{
124+
if (OperatingSystem.IsWindows())
125+
return;
126+
127+
RunWithNvmDirectory(nvmDirectory =>
128+
{
129+
string npmPath = CreateNvmNode(nvmDirectory, "v24.21.0");
130+
if (!OperatingSystem.IsWindows())
131+
File.SetUnixFileMode(npmPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
132+
133+
Assert.DoesNotContain(npmPath, new Npm().FindCandidateExecutableFiles());
134+
});
135+
}
136+
}

0 commit comments

Comments
 (0)