Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
namespace UniGetUI.PackageEngine.Managers.NpmManager;

/// <summary>
/// Normalizes npm package ids that may represent aliases.
/// npm-aliased dependencies are reported by `npm outdated --json` and `npm list --json`
/// with ids shaped like "localName:targetName@range". Real npm package names cannot contain
/// a colon, so the presence of one identifies an alias.
/// </summary>
internal readonly record struct NpmPackageIdentifier(string LocalName, string TargetName, bool IsAlias)
{
/// <summary>
/// Parses an npm package id into local and registry-facing names.
/// For non-aliased packages, LocalName and TargetName are the same.
/// </summary>
public static NpmPackageIdentifier Parse(string id)
{
int colonIndex = id.IndexOf(':');
if (colonIndex <= 0)
{
return new NpmPackageIdentifier(id, id, false);
}

string aliasLocalName = id[..colonIndex];
string aliasTargetSpec = id[(colonIndex + 1)..];
int versionIndex = aliasTargetSpec.LastIndexOf('@');
string aliasTargetName =
versionIndex > 0 ? aliasTargetSpec[..versionIndex] : aliasTargetSpec;

return new NpmPackageIdentifier(aliasLocalName, aliasTargetName, true);
}

/// <summary>
/// Builds the npm install or update specifier for the parsed package id.
/// Aliases must be reconstructed as "localName@npm:targetName@version".
/// </summary>
public string GetInstallSpec(string version) =>
IsAlias ? $"{LocalName}@npm:{TargetName}@{version}" : $"{LocalName}@{version}";

/// <summary>
/// Returns the registry-facing package name used by npm show and package URLs.
/// </summary>
public string GetRegistryName() => TargetName;

/// <summary>
/// Returns the local on-disk package name used under node_modules.
/// </summary>
public string GetInstallLocationName() => LocalName;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ protected override void GetDetails_UnSafe(IPackageDetails details)
{
try
{
var identifier = NpmPackageIdentifier.Parse(details.Package.Id);
details.InstallerType = "Tarball";
details.ManifestUrl = new Uri(
$"https://www.npmjs.com/package/{details.Package.Id}"
$"https://www.npmjs.com/package/{identifier.GetRegistryName()}"
);
details.ReleaseNotesUrl = new Uri(
$"https://www.npmjs.com/package/{details.Package.Id}?activeTab=versions"
$"https://www.npmjs.com/package/{identifier.GetRegistryName()}?activeTab=versions"
);

using Process p = new();
Expand All @@ -34,7 +35,7 @@ protected override void GetDetails_UnSafe(IPackageDetails details)
Arguments =
Manager.Status.ExecutableCallArgs
+ " show "
+ details.Package.Id
+ identifier.GetRegistryName()
+ " --json",
UseShellExecute = false,
RedirectStandardOutput = true,
Expand Down Expand Up @@ -170,24 +171,26 @@ protected override IReadOnlyList<Uri> GetScreenshots_UnSafe(IPackage package)

protected override string? GetInstallLocation_UnSafe(IPackage package)
{
var identifier = NpmPackageIdentifier.Parse(package.Id);
if (package.OverridenOptions.Scope is PackageScope.Local)
return Path.Join(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"node_modules",
package.Id
identifier.GetInstallLocationName()
);
// ApplicationData already resolves to the Roaming folder; npm's default global prefix
// is %AppData%\npm, so global modules live under %AppData%\npm\node_modules.
return Path.Join(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"npm",
"node_modules",
package.Id
identifier.GetInstallLocationName()
);
}

protected override IReadOnlyList<string> GetInstallableVersions_UnSafe(IPackage package)
{
var identifier = NpmPackageIdentifier.Parse(package.Id);
using Process p = new()
{
StartInfo = new ProcessStartInfo
Expand All @@ -196,7 +199,7 @@ protected override IReadOnlyList<string> GetInstallableVersions_UnSafe(IPackage
Arguments =
Manager.Status.ExecutableCallArgs
+ " show "
+ package.Id
+ identifier.GetRegistryName()
+ " versions --json",
UseShellExecute = false,
RedirectStandardOutput = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,17 @@ OperationType operation
OperationType.Install =>
[
Manager.Properties.InstallVerb,
useShellQuotes
? $"'{package.Id}@{(options.Version == string.Empty ? package.VersionString : options.Version)}'"
: $"{package.Id}@{(options.Version == string.Empty ? package.VersionString : options.Version)}",
FormatSpec(
ResolveInstallSpec(package.Id, options.Version == string.Empty ? package.VersionString : options.Version),
useShellQuotes
),
],
OperationType.Update =>
[
Manager.Properties.UpdateVerb,
useShellQuotes
? $"'{package.Id}@{package.NewVersionString}'"
: $"{package.Id}@{package.NewVersionString}",
FormatSpec(ResolveInstallSpec(package.Id, package.NewVersionString), useShellQuotes),
],
OperationType.Uninstall => [Manager.Properties.UninstallVerb, package.Id],
OperationType.Uninstall => [Manager.Properties.UninstallVerb, ResolveLocalName(package.Id)],
_ => throw new InvalidDataException("Invalid package operation"),
};

Expand Down Expand Up @@ -66,6 +65,47 @@ package.OverridenOptions.Scope is null
return parameters;
}

private static string FormatSpec(string spec, bool useShellQuotes) =>
useShellQuotes ? $"'{spec}'" : spec;

/// <summary>
/// npm-aliased dependencies (package.json entries like "eslint-v9": "npm:eslint@^9.x")
/// are reported by `npm outdated --json` / `npm list --json` with a package id shaped
/// like "eslint-v9:eslint@^9.x" -- the local alias name, a literal colon, then the raw
/// alias target specifier (see Npm.ParseAvailableUpdatesOutput / ParseInstalledPackagesOutput,
/// which pass that id straight through as package.Id). Real npm package names can never
/// contain a colon, so its presence in package.Id unambiguously identifies an alias.
/// </summary>
private static bool TryParseAlias(string id, out string localName, out string targetName)
{
int colonIndex = id.IndexOf(':');
if (colonIndex <= 0)
{
localName = id;
targetName = "";
return false;
}

localName = id[..colonIndex];
string targetSpec = id[(colonIndex + 1)..];
int atIndex = targetSpec.LastIndexOf('@');
targetName = atIndex > 0 ? targetSpec[..atIndex] : targetSpec;
return true;
}

private static string ResolveLocalName(string id) =>
TryParseAlias(id, out string localName, out _) ? localName : id;

/// <summary>
/// Builds the npm install/update specifier for a package, preserving alias syntax
/// ("localName@npm:targetName@version") for aliased dependencies instead of treating
/// package.Id as a literal, directly-installable package name.
/// </summary>
private static string ResolveInstallSpec(string id, string version) =>
TryParseAlias(id, out string localName, out string targetName)
? $"{localName}@npm:{targetName}@{version}"
: $"{id}@{version}";

protected override OperationVeredict _getOperationResult(
IPackage package,
OperationType operation,
Expand Down
127 changes: 127 additions & 0 deletions src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,133 @@ public void OperationHelperLetsPackageScopeOverrideUpdateScope()
);
}

[Fact]
public void OperationHelperReconstructsAliasSyntaxForUpdate()
{
// `npm outdated --json` reports npm-aliased dependencies (package.json entries like
// "eslint-v9": "npm:eslint@^9.39.4") with this exact "localName:targetName@targetRange"
// shape as the package id -- see Npm.ParseAvailableUpdatesOutput.
var manager = new Npm();
var package = new PackageBuilder()
.WithManager(manager)
.WithId("eslint-v9:eslint@^9.39.4")
.WithVersion("9.39.4")
.WithNewVersion("10.6.0")
.Build();
var options = new InstallOptions();

var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Update);

Assert.Equal(
[
"install",
OperatingSystem.IsWindows()
? "'eslint-v9@npm:eslint@10.6.0'"
: "eslint-v9@npm:eslint@10.6.0",
],
parameters
);
}

[Fact]
public void OperationHelperReconstructsAliasSyntaxForScopedTarget()
{
// The alias target itself can be scoped (e.g. "npm:@babel/core@^7.20.0"), which contains
// its own '@'. Splitting on the *last* '@' must still separate the scoped target name
// from the version, not the scope prefix from the rest of the name.
var manager = new Npm();
var package = new PackageBuilder()
.WithManager(manager)
.WithId("babel-core-legacy:@babel/core@^7.20.0")
.WithVersion("7.20.0")
.WithNewVersion("7.28.0")
.Build();
var options = new InstallOptions();

var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Update);

Assert.Equal(
[
"install",
OperatingSystem.IsWindows()
? "'babel-core-legacy@npm:@babel/core@7.28.0'"
: "babel-core-legacy@npm:@babel/core@7.28.0",
],
parameters
);
}

[Fact]
public void OperationHelperUsesLocalNameForAliasUninstall()
{
var manager = new Npm();
var package = new PackageBuilder()
.WithManager(manager)
.WithId("eslint-v9:eslint@^9.39.4")
.WithVersion("9.39.4")
.Build();
var options = new InstallOptions();

var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Uninstall);

Assert.Equal(["uninstall", "eslint-v9"], parameters);
}

[Fact]
public void OperationHelperLeavesOrdinaryPackageIdsUntouched()
{
// Sanity check: ordinary (non-aliased) package ids never contain a colon, so the alias
// path must not be reachable for them.
var manager = new Npm();
var package = new PackageBuilder()
.WithManager(manager)
.WithId("contoso-tool")
.WithVersion("1.0.0")
.WithNewVersion("2.0.0")
.Build();
var options = new InstallOptions();

var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Update);

Assert.Equal(
["install", OperatingSystem.IsWindows() ? "'contoso-tool@2.0.0'" : "contoso-tool@2.0.0"],
parameters
);
}

[Fact]
public void DetailsHelperUsesAliasLocalNameForInstallLocation()
{
var manager = new Npm();
var package = new PackageBuilder()
.WithManager(manager)
.WithId("eslint-v9:eslint@^9.39.4")
.WithVersion("9.39.4")
.WithOptions(new OverridenInstallationOptions(PackageScope.Local))
.Build();
string expectedLocation = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"node_modules",
"eslint-v9"
);
bool existed = Directory.Exists(expectedLocation);
Directory.CreateDirectory(expectedLocation);

try
{
var location = manager.DetailsHelper.GetInstallLocation(package);

Assert.Equal(expectedLocation, location);
}
finally
{
if (!existed)
{
Directory.Delete(expectedLocation, recursive: true);
}
}
}

[Fact]
public void OperationHelperReturnsSuccessOnlyForZeroExitCode()
{
Expand Down
45 changes: 45 additions & 0 deletions src/UniGetUI.PackageEngine.Tests/NpmPackageIdentifierTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using UniGetUI.PackageEngine.Managers.NpmManager;

namespace UniGetUI.PackageEngine.Tests;

public sealed class NpmPackageIdentifierTests
{
[Fact]
public void ParseSeparatesAliasComponents()
{
var identifier = NpmPackageIdentifier.Parse("eslint-v9:eslint@^9.39.4");

Assert.True(identifier.IsAlias);
Assert.Equal("eslint-v9", identifier.LocalName);
Assert.Equal("eslint", identifier.TargetName);
Assert.Equal("eslint-v9@npm:eslint@10.6.0", identifier.GetInstallSpec("10.6.0"));
Assert.Equal("eslint", identifier.GetRegistryName());
Assert.Equal("eslint-v9", identifier.GetInstallLocationName());
}

[Fact]
public void ParseHandlesScopedAliasTargets()
{
var identifier = NpmPackageIdentifier.Parse("babel-core-legacy:@babel/core@^7.20.0");

Assert.True(identifier.IsAlias);
Assert.Equal("babel-core-legacy", identifier.LocalName);
Assert.Equal("@babel/core", identifier.TargetName);
Assert.Equal("babel-core-legacy@npm:@babel/core@7.28.0", identifier.GetInstallSpec("7.28.0"));
Assert.Equal("@babel/core", identifier.GetRegistryName());
Assert.Equal("babel-core-legacy", identifier.GetInstallLocationName());
}

[Fact]
public void ParseLeavesOrdinaryNamesUntouched()
{
var identifier = NpmPackageIdentifier.Parse("contoso-tool");

Assert.False(identifier.IsAlias);
Assert.Equal("contoso-tool", identifier.LocalName);
Assert.Equal("contoso-tool", identifier.TargetName);
Assert.Equal("contoso-tool@2.0.0", identifier.GetInstallSpec("2.0.0"));
Assert.Equal("contoso-tool", identifier.GetRegistryName());
Assert.Equal("contoso-tool", identifier.GetInstallLocationName());
}
}