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()); + } +}