Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 172 additions & 2 deletions src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,167 @@ private string BuildSearchArguments(string query) =>
+ CoreTools.EscapePowerShellSingleQuoted(query)
+ " --json";

public override IReadOnlyList<string> FindCandidateExecutableFiles() =>
CoreTools.WhichMultiple(OperatingSystem.IsWindows() ? "npm.cmd" : "npm");
public override IReadOnlyList<string> 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<string> GetNvmBinDirectories(
Func<string, string?> 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,
Expand All @@ -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 = "";
}
Expand Down
36 changes: 36 additions & 0 deletions src/UniGetUI.PackageEngine.Tests/NpmNodePathTests.cs
Original file line number Diff line number Diff line change
@@ -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"));
}
}
136 changes: 136 additions & 0 deletions src/UniGetUI.PackageEngine.Tests/NpmNvmDiscoveryTests.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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());
});
}
}
Loading