From a17c697def907ef85de1deaf828de8ece0d88e68 Mon Sep 17 00:00:00 2001
From: 7ranceaddic7 <4062616+7ranceaddic7@users.noreply.github.com>
Date: Fri, 3 Jul 2026 14:15:26 -0700
Subject: [PATCH 1/2] Fix npm alias packages (e.g. "npm:eslint@^9.x") failing
every operation
npm outdated --json / npm list --json report npm-aliased dependencies
(package.json entries like "eslint-v9": "npm:eslint@^9.39.4") with a
package id shaped like "eslint-v9:eslint@^9.39.4" -- the local alias
name, a literal colon, then the raw alias target specifier with the
"npm:" prefix stripped. NpmPkgOperationHelper was using that id
verbatim as the npm package specifier, producing commands like
"npm install 'eslint-v9:eslint@^9.39.4@10.6.0'", which npm rejects
outright with EINVALIDPACKAGENAME (colons aren't valid in npm package
names) -- so any aliased package could never be installed, updated,
or uninstalled through UniGetUI.
Real npm package names can never contain a colon, so its presence in
package.Id unambiguously identifies an alias. Added alias-aware
resolution in NpmPkgOperationHelper: reconstructs the proper
"localName@npm:targetName@version" syntax for install/update, and
uses just the local name for uninstall (npm doesn't need or accept
the target spec to remove a package).
Verified against a real project with this exact scenario: the
generated update command changed from the invalid
"eslint-v9:eslint@^9.39.4@10.6.0" to the valid
"eslint-v9@npm:eslint@10.6.0", which npm accepted and executed
successfully (confirmed via a temporary, uncommitted test calling the
production GetParameters API directly, then running the exact
returned command for real -- not committed to this diff).
Added 4 regression tests to NpmManagerTests.cs covering: alias
resolution on update, a scoped alias target (target names can contain
their own '@', e.g. "npm:@babel/core@^7.x"), local-name-only
uninstall, and that ordinary non-aliased package ids are left
untouched.
---
.../Helpers/NpmPkgOperationHelper.cs | 54 +++++++++--
.../NpmManagerTests.cs | 94 +++++++++++++++++++
2 files changed, 141 insertions(+), 7 deletions(-)
diff --git a/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPkgOperationHelper.cs
index aff3c126d1..8cbe06e47c 100644
--- a/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPkgOperationHelper.cs
+++ b/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPkgOperationHelper.cs
@@ -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"),
};
@@ -66,6 +65,47 @@ package.OverridenOptions.Scope is null
return parameters;
}
+ private static string FormatSpec(string spec, bool useShellQuotes) =>
+ useShellQuotes ? $"'{spec}'" : spec;
+
+ ///
+ /// 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.
+ ///
+ 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;
+
+ ///
+ /// 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.
+ ///
+ 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,
diff --git a/src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs
index a2b79b4bfd..e33bcd6bb0 100644
--- a/src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs
+++ b/src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs
@@ -151,6 +151,100 @@ 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 OperationHelperReturnsSuccessOnlyForZeroExitCode()
{
From 45ec0069d0d9b2af29318645d17e6830441917a6 Mon Sep 17 00:00:00 2001
From: 7ranceaddic7 <4062616+7ranceaddic7@users.noreply.github.com>
Date: Fri, 3 Jul 2026 18:04:31 -0700
Subject: [PATCH 2/2] Fix npm alias package operations
---
.../Helpers/NpmPackageIdentifier.cs | 48 +++++++++++++++++++
.../Helpers/NpmPkgDetailsHelper.cs | 15 +++---
.../NpmManagerTests.cs | 33 +++++++++++++
.../NpmPackageIdentifierTests.cs | 45 +++++++++++++++++
4 files changed, 135 insertions(+), 6 deletions(-)
create mode 100644 src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPackageIdentifier.cs
create mode 100644 src/UniGetUI.PackageEngine.Tests/NpmPackageIdentifierTests.cs
diff --git a/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPackageIdentifier.cs b/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPackageIdentifier.cs
new file mode 100644
index 0000000000..16a4303172
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPackageIdentifier.cs
@@ -0,0 +1,48 @@
+namespace UniGetUI.PackageEngine.Managers.NpmManager;
+
+///
+/// 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.
+///
+internal readonly record struct NpmPackageIdentifier(string LocalName, string TargetName, bool IsAlias)
+{
+ ///
+ /// Parses an npm package id into local and registry-facing names.
+ /// For non-aliased packages, LocalName and TargetName are the same.
+ ///
+ 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);
+ }
+
+ ///
+ /// Builds the npm install or update specifier for the parsed package id.
+ /// Aliases must be reconstructed as "localName@npm:targetName@version".
+ ///
+ public string GetInstallSpec(string version) =>
+ IsAlias ? $"{LocalName}@npm:{TargetName}@{version}" : $"{LocalName}@{version}";
+
+ ///
+ /// Returns the registry-facing package name used by npm show and package URLs.
+ ///
+ public string GetRegistryName() => TargetName;
+
+ ///
+ /// Returns the local on-disk package name used under node_modules.
+ ///
+ public string GetInstallLocationName() => LocalName;
+}
diff --git a/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPkgDetailsHelper.cs
index b831d00e70..fbae4d9589 100644
--- a/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPkgDetailsHelper.cs
+++ b/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPkgDetailsHelper.cs
@@ -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();
@@ -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,
@@ -170,11 +171,12 @@ protected override IReadOnlyList 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.
@@ -182,12 +184,13 @@ protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package)
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"npm",
"node_modules",
- package.Id
+ identifier.GetInstallLocationName()
);
}
protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package)
{
+ var identifier = NpmPackageIdentifier.Parse(package.Id);
using Process p = new()
{
StartInfo = new ProcessStartInfo
@@ -196,7 +199,7 @@ protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage
Arguments =
Manager.Status.ExecutableCallArgs
+ " show "
- + package.Id
+ + identifier.GetRegistryName()
+ " versions --json",
UseShellExecute = false,
RedirectStandardOutput = true,
diff --git a/src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs
index e33bcd6bb0..f47e37d73a 100644
--- a/src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs
+++ b/src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs
@@ -245,6 +245,39 @@ public void OperationHelperLeavesOrdinaryPackageIdsUntouched()
);
}
+ [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()
{
diff --git a/src/UniGetUI.PackageEngine.Tests/NpmPackageIdentifierTests.cs b/src/UniGetUI.PackageEngine.Tests/NpmPackageIdentifierTests.cs
new file mode 100644
index 0000000000..c9db8ab078
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/NpmPackageIdentifierTests.cs
@@ -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());
+ }
+}