From aae82168d90243beeef74e641d1579e3175110e4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 30 Nov 2025 22:30:58 +0000
Subject: [PATCH 1/6] Initial plan
From 3242db04a6a786a2f015296f1ad135c8f75fe236 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 30 Nov 2025 22:47:09 +0000
Subject: [PATCH 2/6] Add IPackageMigration interface, PackageMigration
implementation, and tests
Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com>
---
src/Aspire.Cli/Packaging/PackageMigration.cs | 133 +++++
src/Aspire.Cli/Program.cs | 1 +
.../Packaging/PackageMigrationTests.cs | 477 ++++++++++++++++++
tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 7 +
4 files changed, 618 insertions(+)
create mode 100644 src/Aspire.Cli/Packaging/PackageMigration.cs
create mode 100644 tests/Aspire.Cli.Tests/Packaging/PackageMigrationTests.cs
diff --git a/src/Aspire.Cli/Packaging/PackageMigration.cs b/src/Aspire.Cli/Packaging/PackageMigration.cs
new file mode 100644
index 00000000000..79214798a4e
--- /dev/null
+++ b/src/Aspire.Cli/Packaging/PackageMigration.cs
@@ -0,0 +1,133 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Logging;
+using Semver;
+
+namespace Aspire.Cli.Packaging;
+
+///
+/// Provides package migration information for upgrading or downgrading packages
+/// when switching between different Aspire versions.
+///
+internal interface IPackageMigration
+{
+ ///
+ /// Gets the replacement package ID for a package when migrating to a specific target Aspire hosting version.
+ ///
+ /// The target Aspire hosting SDK version being migrated to.
+ /// The package ID to check for migration.
+ ///
+ /// The replacement package ID if a migration rule exists for the given package and version;
+ /// null if no migration is needed for this package.
+ ///
+ /// Thrown when multiple migration rules match for the same package.
+ string? GetMigration(SemVersion targetHostingVersion, string packageId);
+}
+
+///
+/// Implementation of that provides package migration rules
+/// for migrating packages between different Aspire versions.
+///
+internal sealed class PackageMigration : IPackageMigration
+{
+ private readonly ILogger _logger;
+ private readonly List<(Func VersionPredicate, string FromPackageId, string ToPackageId)> _migrationRules;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger instance.
+ public PackageMigration(ILogger logger)
+ {
+ _logger = logger;
+ _migrationRules = CreateMigrationRules();
+ }
+
+ ///
+ /// Creates the list of migration rules.
+ /// Each rule contains a version predicate, the source package ID, and the target package ID.
+ /// Rules are evaluated based on the target hosting version.
+ ///
+ private static List<(Func VersionPredicate, string FromPackageId, string ToPackageId)> CreateMigrationRules()
+ {
+ // Migration rules are defined here. Each rule specifies:
+ // - A predicate that determines if the rule applies based on the target version
+ // - The package ID being migrated from
+ // - The package ID being migrated to
+ //
+ // Both upwards (stable -> daily) and downwards (daily -> stable) migrations should be defined
+ // to support users switching between channels.
+ //
+ // Example migration rules (placeholder - replace with actual migrations):
+ // - When upgrading to version >= 10.0.0, migrate Aspire.Hosting.OldPackage to Aspire.Hosting.NewPackage
+ // - When downgrading to version < 10.0.0, migrate Aspire.Hosting.NewPackage to Aspire.Hosting.OldPackage
+ return
+ [
+ // Add migration rules here as packages are renamed/replaced
+ // Example:
+ // (v => v >= SemVersion.Parse("10.0.0", SemVersionStyles.Strict), "Aspire.Hosting.OldPackage", "Aspire.Hosting.NewPackage"),
+ // (v => v < SemVersion.Parse("10.0.0", SemVersionStyles.Strict), "Aspire.Hosting.NewPackage", "Aspire.Hosting.OldPackage"),
+ ];
+ }
+
+ ///
+ public string? GetMigration(SemVersion targetHostingVersion, string packageId)
+ {
+ _logger.LogDebug("Checking migration rules for package '{PackageId}' targeting version '{TargetVersion}'", packageId, targetHostingVersion);
+
+ // Find all matching rules for the given package ID and version
+ var matchingRules = _migrationRules
+ .Where(rule => string.Equals(rule.FromPackageId, packageId, StringComparison.OrdinalIgnoreCase)
+ && rule.VersionPredicate(targetHostingVersion))
+ .ToList();
+
+ if (matchingRules.Count == 0)
+ {
+ _logger.LogDebug("No migration rules found for package '{PackageId}'", packageId);
+ return null;
+ }
+
+ if (matchingRules.Count > 1)
+ {
+ var toPackages = string.Join(", ", matchingRules.Select(r => r.ToPackageId));
+ _logger.LogError(
+ "Multiple migration rules found for package '{PackageId}' at version '{TargetVersion}'. Target packages: {ToPackages}",
+ packageId, targetHostingVersion, toPackages);
+
+ throw new PackageMigrationException(
+ $"Multiple migration rules match for package '{packageId}' at version '{targetHostingVersion}'. " +
+ $"This is a configuration error. Matching target packages: {toPackages}");
+ }
+
+ var matchingRule = matchingRules[0];
+ _logger.LogInformation(
+ "Migration rule found: '{FromPackageId}' -> '{ToPackageId}' for target version '{TargetVersion}'",
+ matchingRule.FromPackageId, matchingRule.ToPackageId, targetHostingVersion);
+
+ return matchingRule.ToPackageId;
+ }
+}
+
+///
+/// Exception thrown when there is an error in package migration processing.
+///
+internal sealed class PackageMigrationException : Exception
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message.
+ public PackageMigrationException(string message) : base(message)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message.
+ /// The inner exception.
+ public PackageMigrationException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs
index bd763009c46..b32b2852221 100644
--- a/src/Aspire.Cli/Program.cs
+++ b/src/Aspire.Cli/Program.cs
@@ -167,6 +167,7 @@ private static async Task BuildApplicationAsync(string[] args)
builder.Services.AddHostedService(sp => sp.GetRequiredService());
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddMemoryCache();
diff --git a/tests/Aspire.Cli.Tests/Packaging/PackageMigrationTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackageMigrationTests.cs
new file mode 100644
index 00000000000..cdbfb4b2d01
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/Packaging/PackageMigrationTests.cs
@@ -0,0 +1,477 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Cli.Packaging;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Semver;
+
+namespace Aspire.Cli.Tests.Packaging;
+
+public class PackageMigrationTests
+{
+ private static ILogger CreateTestLogger()
+ {
+ return NullLogger.Instance;
+ }
+
+ ///
+ /// Helper method to create a version predicate for versions >= threshold
+ ///
+ private static Func VersionAtLeast(string threshold)
+ {
+ var thresholdVersion = SemVersion.Parse(threshold, SemVersionStyles.Strict);
+ return v => SemVersion.ComparePrecedence(v, thresholdVersion) >= 0;
+ }
+
+ ///
+ /// Helper method to create a version predicate for versions < threshold
+ ///
+ private static Func VersionBelow(string threshold)
+ {
+ var thresholdVersion = SemVersion.Parse(threshold, SemVersionStyles.Strict);
+ return v => SemVersion.ComparePrecedence(v, thresholdVersion) < 0;
+ }
+
+ ///
+ /// Helper method to create a version predicate for versions in a range [min, max)
+ ///
+ private static Func VersionInRange(string minInclusive, string maxExclusive)
+ {
+ var minVersion = SemVersion.Parse(minInclusive, SemVersionStyles.Strict);
+ var maxVersion = SemVersion.Parse(maxExclusive, SemVersionStyles.Strict);
+ return v => SemVersion.ComparePrecedence(v, minVersion) >= 0
+ && SemVersion.ComparePrecedence(v, maxVersion) < 0;
+ }
+
+ #region GetMigration - Basic functionality tests
+
+ [Fact]
+ public void GetMigration_WhenNoMatchingRule_ReturnsNull()
+ {
+ // Arrange
+ var migration = new PackageMigration(CreateTestLogger());
+ var targetVersion = SemVersion.Parse("9.5.0", SemVersionStyles.Strict);
+
+ // Act
+ var result = migration.GetMigration(targetVersion, "Aspire.Hosting.NonExistentPackage");
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetMigration_WhenPackageIdDoesNotExist_ReturnsNull()
+ {
+ // Arrange
+ var migration = new PackageMigration(CreateTestLogger());
+ var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict);
+
+ // Act
+ var result = migration.GetMigration(targetVersion, "SomeOtherPackage.NotAspire");
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetMigration_WithEmptyPackageId_ReturnsNull()
+ {
+ // Arrange
+ var migration = new PackageMigration(CreateTestLogger());
+ var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict);
+
+ // Act
+ var result = migration.GetMigration(targetVersion, string.Empty);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ #endregion
+
+ #region GetMigration - Version matching tests
+
+ [Fact]
+ public void GetMigration_WithPrereleaseVersion_EvaluatesPredicateCorrectly()
+ {
+ // Arrange
+ var migration = new PackageMigration(CreateTestLogger());
+ var prereleaseVersion = SemVersion.Parse("10.0.0-preview.1", SemVersionStyles.Strict);
+
+ // Act - testing that prerelease versions are handled without throwing
+ var result = migration.GetMigration(prereleaseVersion, "Aspire.Hosting.SomePackage");
+
+ // Assert
+ Assert.Null(result); // No migration rules defined, so should be null
+ }
+
+ [Fact]
+ public void GetMigration_WithDailyBuildVersion_EvaluatesPredicateCorrectly()
+ {
+ // Arrange
+ var migration = new PackageMigration(CreateTestLogger());
+ var dailyVersion = SemVersion.Parse("10.0.0-daily.12345.1", SemVersionStyles.Strict);
+
+ // Act
+ var result = migration.GetMigration(dailyVersion, "Aspire.Hosting.SomePackage");
+
+ // Assert
+ Assert.Null(result); // No migration rules defined, so should be null
+ }
+
+ #endregion
+
+ #region GetMigration - Case insensitivity tests
+
+ [Fact]
+ public void GetMigration_PackageIdComparison_IsCaseInsensitive()
+ {
+ // Arrange - Using a testable migration service with predefined rules
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ (VersionAtLeast("10.0.0"), "Aspire.Hosting.OldPackage", "Aspire.Hosting.NewPackage")
+ ]);
+ var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict);
+
+ // Act
+ var lowerCaseResult = migration.GetMigration(targetVersion, "aspire.hosting.oldpackage");
+ var upperCaseResult = migration.GetMigration(targetVersion, "ASPIRE.HOSTING.OLDPACKAGE");
+ var mixedCaseResult = migration.GetMigration(targetVersion, "Aspire.Hosting.OldPackage");
+
+ // Assert
+ Assert.Equal("Aspire.Hosting.NewPackage", lowerCaseResult);
+ Assert.Equal("Aspire.Hosting.NewPackage", upperCaseResult);
+ Assert.Equal("Aspire.Hosting.NewPackage", mixedCaseResult);
+ }
+
+ #endregion
+
+ #region Upward migration tests (stable -> daily/preview)
+
+ [Fact]
+ public void GetMigration_UpwardMigration_WhenTargetVersionMeetsThreshold_ReturnsMigratedPackage()
+ {
+ // Arrange - Simulating an upgrade scenario
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ (VersionAtLeast("10.0.0"), "Aspire.Hosting.LegacyPackage", "Aspire.Hosting.ModernPackage")
+ ]);
+ var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict);
+
+ // Act
+ var result = migration.GetMigration(targetVersion, "Aspire.Hosting.LegacyPackage");
+
+ // Assert
+ Assert.Equal("Aspire.Hosting.ModernPackage", result);
+ }
+
+ [Fact]
+ public void GetMigration_UpwardMigration_WhenTargetVersionExceedsThreshold_ReturnsMigratedPackage()
+ {
+ // Arrange - Simulating an upgrade scenario with version beyond threshold
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ (VersionAtLeast("10.0.0"), "Aspire.Hosting.LegacyPackage", "Aspire.Hosting.ModernPackage")
+ ]);
+ var targetVersion = SemVersion.Parse("10.5.0", SemVersionStyles.Strict);
+
+ // Act
+ var result = migration.GetMigration(targetVersion, "Aspire.Hosting.LegacyPackage");
+
+ // Assert
+ Assert.Equal("Aspire.Hosting.ModernPackage", result);
+ }
+
+ [Fact]
+ public void GetMigration_UpwardMigration_WhenTargetVersionBelowThreshold_ReturnsNull()
+ {
+ // Arrange - Simulating scenario where version doesn't meet threshold
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ (VersionAtLeast("10.0.0"), "Aspire.Hosting.LegacyPackage", "Aspire.Hosting.ModernPackage")
+ ]);
+ var targetVersion = SemVersion.Parse("9.5.0", SemVersionStyles.Strict);
+
+ // Act
+ var result = migration.GetMigration(targetVersion, "Aspire.Hosting.LegacyPackage");
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ #endregion
+
+ #region Downward migration tests (daily/preview -> stable)
+
+ [Fact]
+ public void GetMigration_DownwardMigration_WhenTargetVersionBelowThreshold_ReturnsMigratedPackage()
+ {
+ // Arrange - Simulating a downgrade scenario (daily -> stable)
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ (VersionBelow("10.0.0"), "Aspire.Hosting.NewPackage", "Aspire.Hosting.OldPackage")
+ ]);
+ var targetVersion = SemVersion.Parse("9.5.0", SemVersionStyles.Strict);
+
+ // Act
+ var result = migration.GetMigration(targetVersion, "Aspire.Hosting.NewPackage");
+
+ // Assert
+ Assert.Equal("Aspire.Hosting.OldPackage", result);
+ }
+
+ [Fact]
+ public void GetMigration_DownwardMigration_WhenTargetVersionMeetsThreshold_ReturnsNull()
+ {
+ // Arrange - Simulating scenario where version doesn't require downgrade migration
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ (VersionBelow("10.0.0"), "Aspire.Hosting.NewPackage", "Aspire.Hosting.OldPackage")
+ ]);
+ var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict);
+
+ // Act
+ var result = migration.GetMigration(targetVersion, "Aspire.Hosting.NewPackage");
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ #endregion
+
+ #region Bidirectional migration tests
+
+ [Fact]
+ public void GetMigration_BidirectionalMigrations_UpwardMigrationWorks()
+ {
+ // Arrange - Setup bidirectional migration rules
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ // Upward migration: when upgrading to 10.0.0+, migrate OldPackage -> NewPackage
+ (VersionAtLeast("10.0.0"), "Aspire.Hosting.OldPackage", "Aspire.Hosting.NewPackage"),
+ // Downward migration: when downgrading to < 10.0.0, migrate NewPackage -> OldPackage
+ (VersionBelow("10.0.0"), "Aspire.Hosting.NewPackage", "Aspire.Hosting.OldPackage")
+ ]);
+
+ // Act - Test upward migration
+ var upgradeResult = migration.GetMigration(
+ SemVersion.Parse("10.0.0", SemVersionStyles.Strict),
+ "Aspire.Hosting.OldPackage");
+
+ // Assert
+ Assert.Equal("Aspire.Hosting.NewPackage", upgradeResult);
+ }
+
+ [Fact]
+ public void GetMigration_BidirectionalMigrations_DownwardMigrationWorks()
+ {
+ // Arrange - Setup bidirectional migration rules
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ // Upward migration
+ (VersionAtLeast("10.0.0"), "Aspire.Hosting.OldPackage", "Aspire.Hosting.NewPackage"),
+ // Downward migration
+ (VersionBelow("10.0.0"), "Aspire.Hosting.NewPackage", "Aspire.Hosting.OldPackage")
+ ]);
+
+ // Act - Test downward migration
+ var downgradeResult = migration.GetMigration(
+ SemVersion.Parse("9.5.0", SemVersionStyles.Strict),
+ "Aspire.Hosting.NewPackage");
+
+ // Assert
+ Assert.Equal("Aspire.Hosting.OldPackage", downgradeResult);
+ }
+
+ [Fact]
+ public void GetMigration_BidirectionalMigrations_SamePackageNoMigrationNeeded()
+ {
+ // Arrange - Setup bidirectional migration rules
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ // Upward migration
+ (VersionAtLeast("10.0.0"), "Aspire.Hosting.OldPackage", "Aspire.Hosting.NewPackage"),
+ // Downward migration
+ (VersionBelow("10.0.0"), "Aspire.Hosting.NewPackage", "Aspire.Hosting.OldPackage")
+ ]);
+
+ // Act - Old package at old version - no migration needed
+ var oldPackageAtOldVersion = migration.GetMigration(
+ SemVersion.Parse("9.5.0", SemVersionStyles.Strict),
+ "Aspire.Hosting.OldPackage");
+
+ // Act - New package at new version - no migration needed
+ var newPackageAtNewVersion = migration.GetMigration(
+ SemVersion.Parse("10.0.0", SemVersionStyles.Strict),
+ "Aspire.Hosting.NewPackage");
+
+ // Assert
+ Assert.Null(oldPackageAtOldVersion);
+ Assert.Null(newPackageAtNewVersion);
+ }
+
+ #endregion
+
+ #region Multiple rule match error tests
+
+ [Fact]
+ public void GetMigration_WhenMultipleRulesMatch_ThrowsPackageMigrationException()
+ {
+ // Arrange - Setup conflicting rules
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ (VersionAtLeast("10.0.0"), "Aspire.Hosting.SomePackage", "Aspire.Hosting.TargetA"),
+ (VersionAtLeast("9.5.0"), "Aspire.Hosting.SomePackage", "Aspire.Hosting.TargetB")
+ ]);
+ var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict);
+
+ // Act & Assert
+ var exception = Assert.Throws(() =>
+ migration.GetMigration(targetVersion, "Aspire.Hosting.SomePackage"));
+
+ Assert.Contains("Multiple migration rules match", exception.Message);
+ Assert.Contains("Aspire.Hosting.SomePackage", exception.Message);
+ Assert.Contains("10.0.0", exception.Message);
+ }
+
+ [Fact]
+ public void GetMigration_WhenMultipleRulesMatch_ExceptionIncludesTargetPackages()
+ {
+ // Arrange - Setup conflicting rules
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ (_ => true, "Aspire.Hosting.ConflictingPackage", "Aspire.Hosting.Target1"),
+ (_ => true, "Aspire.Hosting.ConflictingPackage", "Aspire.Hosting.Target2")
+ ]);
+ var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict);
+
+ // Act & Assert
+ var exception = Assert.Throws(() =>
+ migration.GetMigration(targetVersion, "Aspire.Hosting.ConflictingPackage"));
+
+ Assert.Contains("Target1", exception.Message);
+ Assert.Contains("Target2", exception.Message);
+ }
+
+ #endregion
+
+ #region Complex version predicate tests
+
+ [Fact]
+ public void GetMigration_WithVersionRangePredicate_MatchesCorrectly()
+ {
+ // Arrange - Setup rule that only applies to specific version range [10.0.0, 11.0.0)
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ (VersionInRange("10.0.0", "11.0.0"), "Aspire.Hosting.RangePackage", "Aspire.Hosting.RangeTarget")
+ ]);
+
+ // Act
+ var belowRange = migration.GetMigration(SemVersion.Parse("9.5.0", SemVersionStyles.Strict), "Aspire.Hosting.RangePackage");
+ var inRange = migration.GetMigration(SemVersion.Parse("10.5.0", SemVersionStyles.Strict), "Aspire.Hosting.RangePackage");
+ var aboveRange = migration.GetMigration(SemVersion.Parse("11.0.0", SemVersionStyles.Strict), "Aspire.Hosting.RangePackage");
+
+ // Assert
+ Assert.Null(belowRange);
+ Assert.Equal("Aspire.Hosting.RangeTarget", inRange);
+ Assert.Null(aboveRange);
+ }
+
+ [Fact]
+ public void GetMigration_WithPrereleaseVersionPredicate_MatchesCorrectly()
+ {
+ // Arrange - Setup rule that includes prerelease versions
+ var previewThreshold = SemVersion.Parse("10.0.0-preview.1", SemVersionStyles.Strict);
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ (v => SemVersion.ComparePrecedence(v, previewThreshold) >= 0,
+ "Aspire.Hosting.PreviewPackage", "Aspire.Hosting.PreviewTarget")
+ ]);
+
+ // Act
+ var stableBelow = migration.GetMigration(SemVersion.Parse("9.5.0", SemVersionStyles.Strict), "Aspire.Hosting.PreviewPackage");
+ var previewMatch = migration.GetMigration(SemVersion.Parse("10.0.0-preview.1", SemVersionStyles.Strict), "Aspire.Hosting.PreviewPackage");
+ var previewAbove = migration.GetMigration(SemVersion.Parse("10.0.0-preview.5", SemVersionStyles.Strict), "Aspire.Hosting.PreviewPackage");
+ var stableAbove = migration.GetMigration(SemVersion.Parse("10.0.0", SemVersionStyles.Strict), "Aspire.Hosting.PreviewPackage");
+
+ // Assert
+ Assert.Null(stableBelow);
+ Assert.Equal("Aspire.Hosting.PreviewTarget", previewMatch);
+ Assert.Equal("Aspire.Hosting.PreviewTarget", previewAbove);
+ Assert.Equal("Aspire.Hosting.PreviewTarget", stableAbove);
+ }
+
+ #endregion
+
+ #region Multiple packages migration tests
+
+ [Fact]
+ public void GetMigration_WithMultiplePackageMigrations_EachPackageResolvesCorrectly()
+ {
+ // Arrange - Setup rules for multiple packages
+ var migration = new TestablePackageMigration(CreateTestLogger(), [
+ (VersionAtLeast("10.0.0"), "Aspire.Hosting.PackageA", "Aspire.Hosting.NewPackageA"),
+ (VersionAtLeast("10.0.0"), "Aspire.Hosting.PackageB", "Aspire.Hosting.NewPackageB"),
+ (VersionAtLeast("10.0.0"), "Aspire.Hosting.PackageC", "Aspire.Hosting.NewPackageC")
+ ]);
+ var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict);
+
+ // Act
+ var resultA = migration.GetMigration(targetVersion, "Aspire.Hosting.PackageA");
+ var resultB = migration.GetMigration(targetVersion, "Aspire.Hosting.PackageB");
+ var resultC = migration.GetMigration(targetVersion, "Aspire.Hosting.PackageC");
+ var resultUnknown = migration.GetMigration(targetVersion, "Aspire.Hosting.PackageD");
+
+ // Assert
+ Assert.Equal("Aspire.Hosting.NewPackageA", resultA);
+ Assert.Equal("Aspire.Hosting.NewPackageB", resultB);
+ Assert.Equal("Aspire.Hosting.NewPackageC", resultC);
+ Assert.Null(resultUnknown);
+ }
+
+ #endregion
+
+ #region Helper class for testing with custom rules
+
+ ///
+ /// A testable version of PackageMigration that allows injecting custom migration rules for testing.
+ ///
+ private sealed class TestablePackageMigration : IPackageMigration
+ {
+ private readonly ILogger _logger;
+ private readonly List<(Func VersionPredicate, string FromPackageId, string ToPackageId)> _migrationRules;
+
+ public TestablePackageMigration(
+ ILogger logger,
+ List<(Func VersionPredicate, string FromPackageId, string ToPackageId)> migrationRules)
+ {
+ _logger = logger;
+ _migrationRules = migrationRules;
+ }
+
+ public string? GetMigration(SemVersion targetHostingVersion, string packageId)
+ {
+ _logger.LogDebug("Checking migration rules for package '{PackageId}' targeting version '{TargetVersion}'", packageId, targetHostingVersion);
+
+ var matchingRules = _migrationRules
+ .Where(rule => string.Equals(rule.FromPackageId, packageId, StringComparison.OrdinalIgnoreCase)
+ && rule.VersionPredicate(targetHostingVersion))
+ .ToList();
+
+ if (matchingRules.Count == 0)
+ {
+ _logger.LogDebug("No migration rules found for package '{PackageId}'", packageId);
+ return null;
+ }
+
+ if (matchingRules.Count > 1)
+ {
+ var toPackages = string.Join(", ", matchingRules.Select(r => r.ToPackageId));
+ _logger.LogError(
+ "Multiple migration rules found for package '{PackageId}' at version '{TargetVersion}'. Target packages: {ToPackages}",
+ packageId, targetHostingVersion, toPackages);
+
+ throw new PackageMigrationException(
+ $"Multiple migration rules match for package '{packageId}' at version '{targetHostingVersion}'. " +
+ $"This is a configuration error. Matching target packages: {toPackages}");
+ }
+
+ var matchingRule = matchingRules[0];
+ _logger.LogInformation(
+ "Migration rule found: '{FromPackageId}' -> '{ToPackageId}' for target version '{TargetVersion}'",
+ matchingRule.FromPackageId, matchingRule.ToPackageId, targetHostingVersion);
+
+ return matchingRule.ToPackageId;
+ }
+ }
+
+ #endregion
+}
diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
index 554d9b59a58..51efb112188 100644
--- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
+++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
@@ -89,6 +89,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work
services.AddSingleton(options.CliUpdateNotifierFactory);
services.AddSingleton(options.DotNetSdkInstallerFactory);
services.AddSingleton(options.PackagingServiceFactory);
+ services.AddSingleton(options.PackageMigrationFactory);
services.AddSingleton(options.CliExecutionContextFactory);
services.AddSingleton(options.DiskCacheFactory);
services.AddSingleton(options.CliHostEnvironmentFactory);
@@ -340,6 +341,12 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser
return new PackagingService(executionContext, nuGetPackageCache, features, configuration);
};
+ public Func PackageMigrationFactory { get; set; } = (IServiceProvider serviceProvider) =>
+ {
+ var logger = serviceProvider.GetRequiredService>();
+ return new PackageMigration(logger);
+ };
+
public Func DiskCacheFactory { get; set; } = (IServiceProvider serviceProvider) => new NullDiskCache();
public Func CliDownloaderFactory { get; set; } = (IServiceProvider serviceProvider) =>
From 1021c829f096527fe365a71583048bf40cf923a2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 30 Nov 2025 22:57:06 +0000
Subject: [PATCH 3/6] Integrate IPackageMigration into ProjectUpdater with
migration support
Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com>
---
src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 48 +++++
src/Aspire.Cli/Projects/ProjectUpdater.cs | 180 +++++++++++++++++-
.../UpdateCommandStrings.Designer.cs | 2 +
.../Resources/UpdateCommandStrings.resx | 6 +
.../Resources/xlf/UpdateCommandStrings.cs.xlf | 10 +
.../Resources/xlf/UpdateCommandStrings.de.xlf | 10 +
.../Resources/xlf/UpdateCommandStrings.es.xlf | 10 +
.../Resources/xlf/UpdateCommandStrings.fr.xlf | 10 +
.../Resources/xlf/UpdateCommandStrings.it.xlf | 10 +
.../Resources/xlf/UpdateCommandStrings.ja.xlf | 10 +
.../Resources/xlf/UpdateCommandStrings.ko.xlf | 10 +
.../Resources/xlf/UpdateCommandStrings.pl.xlf | 10 +
.../xlf/UpdateCommandStrings.pt-BR.xlf | 10 +
.../Resources/xlf/UpdateCommandStrings.ru.xlf | 10 +
.../Resources/xlf/UpdateCommandStrings.tr.xlf | 10 +
.../xlf/UpdateCommandStrings.zh-Hans.xlf | 10 +
.../xlf/UpdateCommandStrings.zh-Hant.xlf | 10 +
.../Projects/ProjectUpdaterTests.cs | 14 +-
.../Templating/DotNetTemplateFactoryTests.cs | 3 +
.../TestServices/TestDotNetCliRunner.cs | 8 +
tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 3 +-
21 files changed, 385 insertions(+), 9 deletions(-)
diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs
index 9cad830834c..0aa3b02fb95 100644
--- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs
+++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs
@@ -36,6 +36,7 @@ internal interface IDotNetCliRunner
Task NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
+ Task RemovePackageAsync(FileInfo projectFilePath, string packageName, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
@@ -831,6 +832,53 @@ public async Task AddPackageAsync(FileInfo projectFilePath, string packageN
return result;
}
+ public async Task RemovePackageAsync(FileInfo projectFilePath, string packageName, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
+ {
+ using var activity = telemetry.ActivitySource.StartActivity();
+
+ var cliArgsList = new List
+ {
+ "remove"
+ };
+
+ // For single-file AppHost (apphost.cs), use --file switch instead of positional argument
+ var isSingleFileAppHost = projectFilePath.Name.Equals("apphost.cs", StringComparison.OrdinalIgnoreCase);
+ if (isSingleFileAppHost)
+ {
+ cliArgsList.AddRange(["package", "--file", projectFilePath.FullName]);
+ cliArgsList.Add(packageName);
+ }
+ else
+ {
+ cliArgsList.AddRange([projectFilePath.FullName, "package"]);
+ cliArgsList.Add(packageName);
+ }
+
+ string[] cliArgs = [.. cliArgsList];
+
+ logger.LogInformation("Removing package {PackageName} from project {ProjectFilePath}", packageName, projectFilePath.FullName);
+
+ var result = await ExecuteAsync(
+ args: cliArgs,
+ env: null,
+ projectFile: projectFilePath,
+ workingDirectory: projectFilePath.Directory!,
+ backchannelCompletionSource: null,
+ options: options,
+ cancellationToken: cancellationToken);
+
+ if (result != 0)
+ {
+ logger.LogError("Failed to remove package {PackageName} from project {ProjectFilePath}. See debug logs for more details.", packageName, projectFilePath.FullName);
+ }
+ else
+ {
+ logger.LogInformation("Package {PackageName} removed from project {ProjectFilePath}", packageName, projectFilePath.FullName);
+ }
+
+ return result;
+ }
+
public async Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.ActivitySource.StartActivity();
diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs
index 12622e530fe..69fd4704e87 100644
--- a/src/Aspire.Cli/Projects/ProjectUpdater.cs
+++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs
@@ -23,7 +23,7 @@ internal interface IProjectUpdater
Task UpdateProjectAsync(FileInfo projectFile, PackageChannel channel, CancellationToken cancellationToken = default);
}
-internal sealed partial class ProjectUpdater(ILogger logger, IDotNetCliRunner runner, IInteractionService interactionService, IMemoryCache cache, CliExecutionContext executionContext, FallbackProjectParser fallbackParser) : IProjectUpdater
+internal sealed partial class ProjectUpdater(ILogger logger, IDotNetCliRunner runner, IInteractionService interactionService, IMemoryCache cache, CliExecutionContext executionContext, FallbackProjectParser fallbackParser, IPackageMigration packageMigration) : IProjectUpdater
{
public async Task UpdateProjectAsync(FileInfo projectFile, PackageChannel channel, CancellationToken cancellationToken = default)
{
@@ -258,6 +258,13 @@ private async Task AnalyzeAppHostSdkAsync(UpdateContext context, CancellationTok
var latestSdkPackage = await GetLatestVersionOfPackageAsync(context, "Aspire.AppHost.Sdk", cancellationToken);
+ // Set the target hosting version in the context for package migration decisions
+ if (latestSdkPackage is not null && SemVersion.TryParse(latestSdkPackage.Version, SemVersionStyles.Strict, out var targetVersion))
+ {
+ context.TargetHostingVersion = targetVersion;
+ logger.LogDebug("Target hosting version set to: {TargetVersion}", targetVersion);
+ }
+
// Treat unparseable versions (including range expressions) like wildcards - always update them
// Only skip if the version is a valid semantic version that matches the latest
if (!string.IsNullOrEmpty(sdkVersion) && IsValidSemanticVersion(sdkVersion) && sdkVersion == latestSdkPackage?.Version)
@@ -461,6 +468,22 @@ private static CentralPackageManagementInfo DetectCentralPackageManagement(FileI
private async Task AnalyzePackageForTraditionalManagementAsync(string packageId, string packageVersion, FileInfo projectFile, UpdateContext context, CancellationToken cancellationToken)
{
+ // Check if this package needs migration to a different package
+ if (context.TargetHostingVersion is not null)
+ {
+ var migrationTarget = packageMigration.GetMigration(context.TargetHostingVersion, packageId);
+ if (migrationTarget is not null)
+ {
+ logger.LogInformation("Migration detected: '{FromPackage}' -> '{ToPackage}' for target version '{TargetVersion}'",
+ packageId, migrationTarget, context.TargetHostingVersion);
+
+ // Add steps to remove old package and add new package
+ await AddMigrationStepsForTraditionalManagementAsync(
+ packageId, packageVersion, migrationTarget, projectFile, context, cancellationToken);
+ return;
+ }
+ }
+
var latestPackage = await GetLatestVersionOfPackageAsync(context, packageId, cancellationToken);
// Treat unparseable versions (including range expressions) like wildcards - always update them
@@ -481,6 +504,32 @@ private async Task AnalyzePackageForTraditionalManagementAsync(string packageId,
context.UpdateSteps.Enqueue(updateStep);
}
+ private async Task AddMigrationStepsForTraditionalManagementAsync(
+ string fromPackageId, string fromVersion, string toPackageId,
+ FileInfo projectFile, UpdateContext context, CancellationToken cancellationToken)
+ {
+ // Get the latest version of the target package
+ var latestTargetPackage = await GetLatestVersionOfPackageAsync(context, toPackageId, cancellationToken);
+
+ // Step 1: Remove the old package
+ var removeStep = new PackageMigrationRemoveStep(
+ string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.RemovePackageFormat, fromPackageId),
+ () => RemovePackageFromProject(projectFile, fromPackageId, cancellationToken),
+ fromPackageId,
+ fromVersion,
+ projectFile);
+ context.UpdateSteps.Enqueue(removeStep);
+
+ // Step 2: Add the new package
+ var addStep = new PackageMigrationAddStep(
+ string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.AddPackageFormat, toPackageId, latestTargetPackage!.Version),
+ () => UpdatePackageReferenceInProject(projectFile, latestTargetPackage, cancellationToken),
+ toPackageId,
+ latestTargetPackage!.Version,
+ projectFile);
+ context.UpdateSteps.Enqueue(addStep);
+ }
+
private async Task AnalyzePackageForCentralPackageManagementAsync(string packageId, FileInfo projectFile, FileInfo directoryPackagesPropsFile, UpdateContext context, CancellationToken cancellationToken)
{
var currentVersion = await GetPackageVersionFromDirectoryPackagesPropsAsync(packageId, directoryPackagesPropsFile, projectFile, cancellationToken);
@@ -491,6 +540,22 @@ private async Task AnalyzePackageForCentralPackageManagementAsync(string package
return;
}
+ // Check if this package needs migration to a different package
+ if (context.TargetHostingVersion is not null)
+ {
+ var migrationTarget = packageMigration.GetMigration(context.TargetHostingVersion, packageId);
+ if (migrationTarget is not null)
+ {
+ logger.LogInformation("Migration detected: '{FromPackage}' -> '{ToPackage}' for target version '{TargetVersion}'",
+ packageId, migrationTarget, context.TargetHostingVersion);
+
+ // Add steps to remove old package and add new package
+ await AddMigrationStepsForCentralPackageManagementAsync(
+ packageId, currentVersion, migrationTarget, projectFile, directoryPackagesPropsFile, context, cancellationToken);
+ return;
+ }
+ }
+
var latestPackage = await GetLatestVersionOfPackageAsync(context, packageId, cancellationToken);
// Treat unparseable versions (including range expressions) like wildcards - always update them
@@ -511,6 +576,82 @@ private async Task AnalyzePackageForCentralPackageManagementAsync(string package
context.UpdateSteps.Enqueue(updateStep);
}
+ private async Task AddMigrationStepsForCentralPackageManagementAsync(
+ string fromPackageId, string fromVersion, string toPackageId,
+ FileInfo projectFile, FileInfo directoryPackagesPropsFile, UpdateContext context, CancellationToken cancellationToken)
+ {
+ // Get the latest version of the target package
+ var latestTargetPackage = await GetLatestVersionOfPackageAsync(context, toPackageId, cancellationToken);
+
+ // Step 1: Remove the old package from Directory.Packages.props
+ var removeStep = new PackageMigrationRemoveStep(
+ string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.RemovePackageFormat, fromPackageId),
+ () => RemovePackageFromDirectoryPackagesProps(fromPackageId, directoryPackagesPropsFile),
+ fromPackageId,
+ fromVersion,
+ projectFile);
+ context.UpdateSteps.Enqueue(removeStep);
+
+ // Step 2: Add the new package to Directory.Packages.props
+ var addStep = new PackageMigrationAddStep(
+ string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.AddPackageFormat, toPackageId, latestTargetPackage!.Version),
+ () => AddPackageToDirectoryPackagesProps(toPackageId, latestTargetPackage!.Version, directoryPackagesPropsFile),
+ toPackageId,
+ latestTargetPackage!.Version,
+ projectFile);
+ context.UpdateSteps.Enqueue(addStep);
+ }
+
+ private async Task RemovePackageFromProject(FileInfo projectFile, string packageId, CancellationToken cancellationToken)
+ {
+ return await runner.RemovePackageAsync(projectFile, packageId, new(), cancellationToken);
+ }
+
+ private static Task RemovePackageFromDirectoryPackagesProps(string packageId, FileInfo directoryPackagesPropsFile)
+ {
+ var doc = new XmlDocument { PreserveWhitespace = true };
+ doc.Load(directoryPackagesPropsFile.FullName);
+
+ var packageVersionNode = doc.SelectSingleNode($"/Project/ItemGroup/PackageVersion[@Include='{packageId}']");
+ if (packageVersionNode?.ParentNode is not null)
+ {
+ packageVersionNode.ParentNode.RemoveChild(packageVersionNode);
+ doc.Save(directoryPackagesPropsFile.FullName);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static Task AddPackageToDirectoryPackagesProps(string packageId, string version, FileInfo directoryPackagesPropsFile)
+ {
+ var doc = new XmlDocument { PreserveWhitespace = true };
+ doc.Load(directoryPackagesPropsFile.FullName);
+
+ // Find the ItemGroup that contains PackageVersion elements
+ var itemGroup = doc.SelectSingleNode("/Project/ItemGroup[PackageVersion]");
+ if (itemGroup is null)
+ {
+ // Create a new ItemGroup if one doesn't exist
+ var project = doc.SelectSingleNode("/Project");
+ if (project is null)
+ {
+ return Task.CompletedTask;
+ }
+
+ itemGroup = doc.CreateElement("ItemGroup");
+ project.AppendChild(itemGroup);
+ }
+
+ // Create the new PackageVersion element
+ var packageVersionElement = doc.CreateElement("PackageVersion");
+ packageVersionElement.SetAttribute("Include", packageId);
+ packageVersionElement.SetAttribute("Version", version);
+ itemGroup.AppendChild(packageVersionElement);
+
+ doc.Save(directoryPackagesPropsFile.FullName);
+ return Task.CompletedTask;
+ }
+
private async Task GetPackageVersionFromDirectoryPackagesPropsAsync(string packageId, FileInfo directoryPackagesPropsFile, FileInfo projectFile, CancellationToken cancellationToken)
{
try
@@ -884,6 +1025,11 @@ internal sealed class UpdateContext(FileInfo appHostProjectFile, PackageChannel
public ConcurrentQueue AnalyzeSteps { get; } = new();
public HashSet VisitedProjects { get; } = new();
public bool FallbackParsing { get; set; }
+ ///
+ /// The target Aspire Hosting SDK version being updated to.
+ /// This is set during SDK analysis and used for package migration decisions.
+ ///
+ public SemVersion? TargetHostingVersion { get; set; }
}
internal abstract record UpdateStep(string Description, Func Callback)
@@ -911,6 +1057,38 @@ public override string GetFormattedDisplayText()
}
}
+///
+/// Represents a migration step to remove an old package during package migration.
+///
+internal record PackageMigrationRemoveStep(
+ string Description,
+ Func Callback,
+ string PackageId,
+ string Version,
+ FileInfo ProjectFile) : UpdateStep(Description, Callback)
+{
+ public override string GetFormattedDisplayText()
+ {
+ return $"[bold red]Remove[/] [bold yellow]{PackageId}[/] [bold green]{Version.EscapeMarkup()}[/]";
+ }
+}
+
+///
+/// Represents a migration step to add a new package during package migration.
+///
+internal record PackageMigrationAddStep(
+ string Description,
+ Func Callback,
+ string PackageId,
+ string Version,
+ FileInfo ProjectFile) : UpdateStep(Description, Callback)
+{
+ public override string GetFormattedDisplayText()
+ {
+ return $"[bold green]Add[/] [bold yellow]{PackageId}[/] [bold green]{Version.EscapeMarkup()}[/]";
+ }
+}
+
internal record AnalyzeStep(string Description, Func Callback);
internal sealed class ProjectUpdaterException : System.Exception
diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs
index 11324520d48..846e052a8f9 100644
--- a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs
+++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs
@@ -106,5 +106,7 @@ internal static string ProjectArgumentDescription {
internal static string QualityOptionDescription => ResourceManager.GetString("QualityOptionDescription", resourceCulture);
internal static string QualityOptionDescriptionWithStaging => ResourceManager.GetString("QualityOptionDescriptionWithStaging", resourceCulture);
internal static string DotNetToolSelfUpdateMessage => ResourceManager.GetString("DotNetToolSelfUpdateMessage", resourceCulture);
+ internal static string RemovePackageFormat => ResourceManager.GetString("RemovePackageFormat", resourceCulture);
+ internal static string AddPackageFormat => ResourceManager.GetString("AddPackageFormat", resourceCulture);
}
}
diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx
index 2b40fb44d18..e149a9a9760 100644
--- a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx
+++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx
@@ -135,4 +135,10 @@
To update the Aspire CLI when installed as a .NET tool, run:
+
+ Remove package {0}
+
+
+ Add package {0} version {1}
+
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf
index 98a9616bf3e..7523d82da56 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
Přidáno: {0}
@@ -167,6 +172,11 @@
Úroveň kvality, na kterou se má aktualizovat (stabilní, příprava, denní)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
Odebráno: {0}
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf
index 2908cd3090b..dde9fec97c4 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
Hinzugefügt: {0}
@@ -167,6 +172,11 @@
Qualitätsstufe, auf die aktualisiert werden soll (stable, staging, daily)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
Entfernt: {0}
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf
index 4412cf5c975..b79a560b8c3 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
Agregado: {0}
@@ -167,6 +172,11 @@
Nivel de calidad al que se va a actualizar (estable, de ensayo, diario)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
Quitado: {0}
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf
index 333130a4982..46f66f7d4b8 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
Ajouté : {0}
@@ -167,6 +172,11 @@
Niveau de qualité vers lequel effectuer une mise à jour (stable, gestion intermédiaire, quotidien)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
Supprimé : {0}
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf
index ce822b23102..8a275d53bfd 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
Aggiunto: {0}
@@ -167,6 +172,11 @@
Livello di qualità da aggiornare a (stabile, staging, giornaliero)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
Rimosso: {0}
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf
index fad84c2aeca..b48f8a2fa55 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
追加済み: {0}
@@ -167,6 +172,11 @@
更新先の品質レベル (安定、ステージング、毎日)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
削除済み: {0}
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf
index f13f9893e70..bd4800032e0 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
추가됨: {0}
@@ -167,6 +172,11 @@
업데이트할 품질 수준(안정, 스테이징, 데일리)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
제거됨: {0}
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf
index 62293be966c..05ec2b8aa48 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
Dodano: {0}
@@ -167,6 +172,11 @@
Poziom jakości, do którego należy zaktualizować (stabilny, przejściowy, dzienny)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
Usunięto: {0}
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf
index bfc7e7358b7..0c636efb5fb 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
Adicionado: {0}
@@ -167,6 +172,11 @@
Nível de qualidade para o qual atualizar (estável, preparo, diariamente)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
Removido: {0}
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf
index 0b96df9baee..9c59127fcf9 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
Добавлено: {0}
@@ -167,6 +172,11 @@
Уровень качества для обновления (стабильный, промежуточный, ежедневный)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
Удалено: {0}
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf
index 6c9851d5974..ab16c446812 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
Eklendi: {0}
@@ -167,6 +172,11 @@
Güncelleştirilecek kalite seviyesi (kararlı, hazırlama, günlük)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
Kaldırıldı: {0}
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf
index b29ce7b9284..e6c4082fe3a 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
已添加: {0}
@@ -167,6 +172,11 @@
要更新到的质量级别(稳定、暂存、每日)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
已移除: {0}
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf
index 2be7eaa110a..8d5faa95493 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf
@@ -2,6 +2,11 @@
+
+ Add package {0} version {1}
+ Add package {0} version {1}
+
+
Added: {0}
已新增: {0}
@@ -167,6 +172,11 @@
要更新的品質等級 (穩定、暫存、每日)
+
+ Remove package {0}
+ Remove package {0}
+
+
Removed: {0}
已移除: {0}
diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs
index 3e40fb823ee..3596aeb6987 100644
--- a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs
+++ b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs
@@ -1476,7 +1476,7 @@ await File.WriteAllTextAsync(
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
- var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
+ var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance));
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
// Should not throw ProjectUpdaterException; should produce update steps including AppHost SDK
@@ -1578,7 +1578,7 @@ await File.WriteAllTextAsync(
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
- var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
+ var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance));
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
// Should discover package reference (version may be absent) and not crash
@@ -1657,7 +1657,7 @@ await File.WriteAllTextAsync(
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
- var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
+ var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance));
// Should throw ProjectUpdaterException due to invalid XML
await Assert.ThrowsAsync(() =>
@@ -1739,7 +1739,7 @@ await File.WriteAllTextAsync(
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
- var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
+ var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance));
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
// Normal path unaffected - no updates needed since version is already current
@@ -1818,7 +1818,7 @@ await File.WriteAllTextAsync(
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
- var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
+ var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance));
var updateResult = await projectUpdater.UpdateProjectAsync(appHostFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
Assert.True(updateResult.UpdatedApplied);
@@ -1901,7 +1901,7 @@ await File.WriteAllTextAsync(
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
- var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
+ var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance));
var updateResult = await projectUpdater.UpdateProjectAsync(appHostFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
Assert.True(updateResult.UpdatedApplied);
@@ -1989,7 +1989,7 @@ await File.WriteAllTextAsync(
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
- var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
+ var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance));
// This should not throw and should handle the * version gracefully
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs
index f8a2cfe33f1..77469fc387e 100644
--- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs
+++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs
@@ -414,6 +414,9 @@ public Task BuildAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptio
public Task AddPackageAsync(FileInfo projectFile, string packageName, string version, string? packageSourceUrl, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
=> throw new NotImplementedException();
+ public Task RemovePackageAsync(FileInfo projectFile, string packageName, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
+ => throw new NotImplementedException();
+
public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
=> throw new NotImplementedException();
diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs
index 0d4a63f112a..b68f8ee07ef 100644
--- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs
+++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs
@@ -12,6 +12,7 @@ namespace Aspire.Cli.Tests.TestServices;
internal sealed class TestDotNetCliRunner : IDotNetCliRunner
{
public Func? AddPackageAsyncCallback { get; set; }
+ public Func? RemovePackageAsyncCallback { get; set; }
public Func? AddProjectToSolutionAsyncCallback { get; set; }
public Func? BuildAsyncCallback { get; set; }
public Func? CheckHttpCertificateAsyncCallback { get; set; }
@@ -33,6 +34,13 @@ public Task AddPackageAsync(FileInfo projectFilePath, string packageName, s
: throw new NotImplementedException();
}
+ public Task RemovePackageAsync(FileInfo projectFilePath, string packageName, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
+ {
+ return RemovePackageAsyncCallback != null
+ ? Task.FromResult(RemovePackageAsyncCallback(projectFilePath, packageName, options, cancellationToken))
+ : Task.FromResult(0); // If not overridden, just return success.
+ }
+
public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
{
return AddProjectToSolutionAsyncCallback != null
diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
index 51efb112188..85013933096 100644
--- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
+++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
@@ -240,7 +240,8 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser
var cache = serviceProvider.GetRequiredService();
var executionContext = serviceProvider.GetRequiredService();
var fallbackParser = serviceProvider.GetRequiredService();
- return new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
+ var packageMigration = serviceProvider.GetRequiredService();
+ return new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, packageMigration);
};
public Func CliHostEnvironmentFactory { get; set; } = (IServiceProvider serviceProvider) =>
From 96ad8c52370802e4ad83757e6117ee61a3e7bfa1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 30 Nov 2025 22:59:37 +0000
Subject: [PATCH 4/6] Address code review feedback: add warning for missing
Project element
Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com>
---
src/Aspire.Cli/Projects/ProjectUpdater.cs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs
index 69fd4704e87..423489d55ab 100644
--- a/src/Aspire.Cli/Projects/ProjectUpdater.cs
+++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs
@@ -622,7 +622,7 @@ private static Task RemovePackageFromDirectoryPackagesProps(string packageId, Fi
return Task.CompletedTask;
}
- private static Task AddPackageToDirectoryPackagesProps(string packageId, string version, FileInfo directoryPackagesPropsFile)
+ private Task AddPackageToDirectoryPackagesProps(string packageId, string version, FileInfo directoryPackagesPropsFile)
{
var doc = new XmlDocument { PreserveWhitespace = true };
doc.Load(directoryPackagesPropsFile.FullName);
@@ -635,6 +635,8 @@ private static Task AddPackageToDirectoryPackagesProps(string packageId, string
var project = doc.SelectSingleNode("/Project");
if (project is null)
{
+ logger.LogWarning("Could not find element in {DirectoryPackagesPropsFile}. Package '{PackageId}' will not be added.",
+ directoryPackagesPropsFile.FullName, packageId);
return Task.CompletedTask;
}
From 7cd4277e6ef814bbe9037aa0f90c0309c8c146a5 Mon Sep 17 00:00:00 2001
From: Mitch Denny
Date: Mon, 1 Dec 2025 11:07:01 +1100
Subject: [PATCH 5/6] WIP.
---
src/Aspire.Cli/Packaging/PackageMigration.cs | 10 ++-
src/Aspire.Cli/Projects/ProjectUpdater.cs | 84 ++++++++-----------
.../UpdateCommandStrings.Designer.cs | 7 +-
.../Resources/UpdateCommandStrings.resx | 3 +
.../Resources/xlf/UpdateCommandStrings.cs.xlf | 5 ++
.../Resources/xlf/UpdateCommandStrings.de.xlf | 5 ++
.../Resources/xlf/UpdateCommandStrings.es.xlf | 5 ++
.../Resources/xlf/UpdateCommandStrings.fr.xlf | 5 ++
.../Resources/xlf/UpdateCommandStrings.it.xlf | 5 ++
.../Resources/xlf/UpdateCommandStrings.ja.xlf | 5 ++
.../Resources/xlf/UpdateCommandStrings.ko.xlf | 5 ++
.../Resources/xlf/UpdateCommandStrings.pl.xlf | 5 ++
.../xlf/UpdateCommandStrings.pt-BR.xlf | 5 ++
.../Resources/xlf/UpdateCommandStrings.ru.xlf | 5 ++
.../Resources/xlf/UpdateCommandStrings.tr.xlf | 5 ++
.../xlf/UpdateCommandStrings.zh-Hans.xlf | 5 ++
.../xlf/UpdateCommandStrings.zh-Hant.xlf | 5 ++
17 files changed, 114 insertions(+), 55 deletions(-)
diff --git a/src/Aspire.Cli/Packaging/PackageMigration.cs b/src/Aspire.Cli/Packaging/PackageMigration.cs
index 79214798a4e..c6eddedd433 100644
--- a/src/Aspire.Cli/Packaging/PackageMigration.cs
+++ b/src/Aspire.Cli/Packaging/PackageMigration.cs
@@ -62,12 +62,14 @@ public PackageMigration(ILogger logger)
// Example migration rules (placeholder - replace with actual migrations):
// - When upgrading to version >= 10.0.0, migrate Aspire.Hosting.OldPackage to Aspire.Hosting.NewPackage
// - When downgrading to version < 10.0.0, migrate Aspire.Hosting.NewPackage to Aspire.Hosting.OldPackage
+
+ var version13 = SemVersion.Parse("13.0.0", SemVersionStyles.Strict);
+
return
[
- // Add migration rules here as packages are renamed/replaced
- // Example:
- // (v => v >= SemVersion.Parse("10.0.0", SemVersionStyles.Strict), "Aspire.Hosting.OldPackage", "Aspire.Hosting.NewPackage"),
- // (v => v < SemVersion.Parse("10.0.0", SemVersionStyles.Strict), "Aspire.Hosting.NewPackage", "Aspire.Hosting.OldPackage"),
+ // Aspire.Hosting.NodeJs was renamed to Aspire.Hosting.JavaScript in 13.0.0
+ (v => v.ComparePrecedenceTo(version13) >= 0, "Aspire.Hosting.NodeJs", "Aspire.Hosting.JavaScript"),
+ (v => v.ComparePrecedenceTo(version13) < 0, "Aspire.Hosting.JavaScript", "Aspire.Hosting.NodeJs"),
];
}
diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs
index 423489d55ab..749aeb0a97a 100644
--- a/src/Aspire.Cli/Projects/ProjectUpdater.cs
+++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs
@@ -42,7 +42,7 @@ public async Task UpdateProjectAsync(FileInfo projectFile,
// Group update steps by project for better visual organization
var updateStepsByProject = updateSteps
- .OfType()
+ .OfType()
.GroupBy(step => step.ProjectFile.FullName)
.ToList();
@@ -511,23 +511,20 @@ private async Task AddMigrationStepsForTraditionalManagementAsync(
// Get the latest version of the target package
var latestTargetPackage = await GetLatestVersionOfPackageAsync(context, toPackageId, cancellationToken);
- // Step 1: Remove the old package
- var removeStep = new PackageMigrationRemoveStep(
- string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.RemovePackageFormat, fromPackageId),
- () => RemovePackageFromProject(projectFile, fromPackageId, cancellationToken),
+ // Create a single migration step that removes the old package and adds the new one
+ var migrationStep = new PackageMigrationStep(
+ string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.MigratePackageFormat, fromPackageId, toPackageId),
+ async () =>
+ {
+ await RemovePackageFromProject(projectFile, fromPackageId, cancellationToken);
+ await UpdatePackageReferenceInProject(projectFile, latestTargetPackage!, cancellationToken);
+ },
fromPackageId,
fromVersion,
- projectFile);
- context.UpdateSteps.Enqueue(removeStep);
-
- // Step 2: Add the new package
- var addStep = new PackageMigrationAddStep(
- string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.AddPackageFormat, toPackageId, latestTargetPackage!.Version),
- () => UpdatePackageReferenceInProject(projectFile, latestTargetPackage, cancellationToken),
toPackageId,
latestTargetPackage!.Version,
projectFile);
- context.UpdateSteps.Enqueue(addStep);
+ context.UpdateSteps.Enqueue(migrationStep);
}
private async Task AnalyzePackageForCentralPackageManagementAsync(string packageId, FileInfo projectFile, FileInfo directoryPackagesPropsFile, UpdateContext context, CancellationToken cancellationToken)
@@ -583,23 +580,20 @@ private async Task AddMigrationStepsForCentralPackageManagementAsync(
// Get the latest version of the target package
var latestTargetPackage = await GetLatestVersionOfPackageAsync(context, toPackageId, cancellationToken);
- // Step 1: Remove the old package from Directory.Packages.props
- var removeStep = new PackageMigrationRemoveStep(
- string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.RemovePackageFormat, fromPackageId),
- () => RemovePackageFromDirectoryPackagesProps(fromPackageId, directoryPackagesPropsFile),
+ // Create a single migration step that removes the old package and adds the new one
+ var migrationStep = new PackageMigrationStep(
+ string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.MigratePackageFormat, fromPackageId, toPackageId),
+ async () =>
+ {
+ await RemovePackageFromDirectoryPackagesProps(fromPackageId, directoryPackagesPropsFile);
+ await AddPackageToDirectoryPackagesProps(toPackageId, latestTargetPackage!.Version, directoryPackagesPropsFile);
+ },
fromPackageId,
fromVersion,
- projectFile);
- context.UpdateSteps.Enqueue(removeStep);
-
- // Step 2: Add the new package to Directory.Packages.props
- var addStep = new PackageMigrationAddStep(
- string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.AddPackageFormat, toPackageId, latestTargetPackage!.Version),
- () => AddPackageToDirectoryPackagesProps(toPackageId, latestTargetPackage!.Version, directoryPackagesPropsFile),
toPackageId,
latestTargetPackage!.Version,
projectFile);
- context.UpdateSteps.Enqueue(addStep);
+ context.UpdateSteps.Enqueue(migrationStep);
}
private async Task RemovePackageFromProject(FileInfo projectFile, string packageId, CancellationToken cancellationToken)
@@ -1043,51 +1037,45 @@ internal abstract record UpdateStep(string Description, Func Callback)
}
///
-/// Represents an update step for a package reference, containing package and project information.
+/// Represents an update step associated with a specific project file.
///
-internal record PackageUpdateStep(
+internal abstract record ProjectUpdateStep(
string Description,
Func Callback,
- string PackageId,
- string CurrentVersion,
- string NewVersion,
- FileInfo ProjectFile) : UpdateStep(Description, Callback)
-{
- public override string GetFormattedDisplayText()
- {
- return $"[bold yellow]{PackageId}[/] [bold green]{CurrentVersion.EscapeMarkup()}[/] to [bold green]{NewVersion.EscapeMarkup()}[/]";
- }
-}
+ FileInfo ProjectFile) : UpdateStep(Description, Callback);
///
-/// Represents a migration step to remove an old package during package migration.
+/// Represents an update step for a package reference, containing package and project information.
///
-internal record PackageMigrationRemoveStep(
+internal record PackageUpdateStep(
string Description,
Func Callback,
string PackageId,
- string Version,
- FileInfo ProjectFile) : UpdateStep(Description, Callback)
+ string CurrentVersion,
+ string NewVersion,
+ FileInfo ProjectFile) : ProjectUpdateStep(Description, Callback, ProjectFile)
{
public override string GetFormattedDisplayText()
{
- return $"[bold red]Remove[/] [bold yellow]{PackageId}[/] [bold green]{Version.EscapeMarkup()}[/]";
+ return $"[bold yellow]{PackageId}[/] [bold green]{CurrentVersion.EscapeMarkup()}[/] to [bold green]{NewVersion.EscapeMarkup()}[/]";
}
}
///
-/// Represents a migration step to add a new package during package migration.
+/// Represents a migration step that replaces one package with another during package migration.
///
-internal record PackageMigrationAddStep(
+internal record PackageMigrationStep(
string Description,
Func Callback,
- string PackageId,
- string Version,
- FileInfo ProjectFile) : UpdateStep(Description, Callback)
+ string FromPackageId,
+ string FromVersion,
+ string ToPackageId,
+ string ToVersion,
+ FileInfo ProjectFile) : ProjectUpdateStep(Description, Callback, ProjectFile)
{
public override string GetFormattedDisplayText()
{
- return $"[bold green]Add[/] [bold yellow]{PackageId}[/] [bold green]{Version.EscapeMarkup()}[/]";
+ return $"[bold yellow]{FromPackageId}[/] [bold green]{FromVersion.EscapeMarkup()}[/] to [bold yellow]{ToPackageId}[/] [bold green]{ToVersion.EscapeMarkup()}[/]";
}
}
diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs
index 846e052a8f9..c0fb9742c87 100644
--- a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs
+++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs
@@ -105,8 +105,9 @@ internal static string ProjectArgumentDescription {
internal static string ChannelOptionDescriptionWithStaging => ResourceManager.GetString("ChannelOptionDescriptionWithStaging", resourceCulture);
internal static string QualityOptionDescription => ResourceManager.GetString("QualityOptionDescription", resourceCulture);
internal static string QualityOptionDescriptionWithStaging => ResourceManager.GetString("QualityOptionDescriptionWithStaging", resourceCulture);
- internal static string DotNetToolSelfUpdateMessage => ResourceManager.GetString("DotNetToolSelfUpdateMessage", resourceCulture);
- internal static string RemovePackageFormat => ResourceManager.GetString("RemovePackageFormat", resourceCulture);
- internal static string AddPackageFormat => ResourceManager.GetString("AddPackageFormat", resourceCulture);
+ internal static string DotNetToolSelfUpdateMessage => ResourceManager.GetString("DotNetToolSelfUpdateMessage", resourceCulture)!;
+ internal static string RemovePackageFormat => ResourceManager.GetString("RemovePackageFormat", resourceCulture)!;
+ internal static string AddPackageFormat => ResourceManager.GetString("AddPackageFormat", resourceCulture)!;
+ internal static string MigratePackageFormat => ResourceManager.GetString("MigratePackageFormat", resourceCulture)!;
}
}
diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx
index e149a9a9760..541ddb549c0 100644
--- a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx
+++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx
@@ -141,4 +141,7 @@
Add package {0} version {1}
+
+ Migrate package {0} to {1}
+
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf
index 7523d82da56..d50573d81b1 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf
@@ -117,6 +117,11 @@
Mapování: {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
Nenašel se žádný projekt Aspire AppHost. Chcete místo toho aktualizovat rozhraní CLI Aspire?
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf
index dde9fec97c4..3a6e9d62409 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf
@@ -117,6 +117,11 @@
Zuordnung: {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
Es wurde kein Aspire-AppHost-Projekt gefunden. Möchten Sie stattdessen die Aspire-CLI aktualisieren?
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf
index b79a560b8c3..52916e92e19 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf
@@ -117,6 +117,11 @@
Asignación: {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
No se encontró ningún proyecto de AppHost. ¿Quiere actualizar la CLI de Aspire en su lugar?
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf
index 46f66f7d4b8..b125dee1c77 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf
@@ -117,6 +117,11 @@
Mappage : {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
Aucun projet Aspire AppHost trouvé. Voulez-vous plutôt mettre à jour l’interface CLI Aspire ?
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf
index 8a275d53bfd..3949881fbec 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf
@@ -117,6 +117,11 @@
Mapping: {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
Nessun progetto Aspire AppHost trovato. Aggiornare invece l'interfaccia della riga di comando Aspire?
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf
index b48f8a2fa55..8c4ee6dfde8 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf
@@ -117,6 +117,11 @@
マッピング: {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
Aspire AppHost プロジェクトが見つかりませんでした。代わりに Aspire CLI を更新しますか?
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf
index bd4800032e0..9f051698439 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf
@@ -117,6 +117,11 @@
매핑: {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
Azure AppHost 프로젝트를 찾을 수 없습니다. 대신 Aspire CLI를 업데이트하시겠어요?
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf
index 05ec2b8aa48..05b929ff6c0 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf
@@ -117,6 +117,11 @@
Mapowanie: {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
Nie znaleziono projektu Aspire AppHost. Czy chcesz zamiast tego zaktualizować narzędzie Aspire CLI?
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf
index 0c636efb5fb..d42833d8ef6 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf
@@ -117,6 +117,11 @@
Mapeamento: {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
Não foram encontrados projetos AppHost do Aspire. Você quer atualizar a CLI do Aspire em vez disso?
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf
index 9c59127fcf9..8394b9b3f82 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf
@@ -117,6 +117,11 @@
Сопоставление: {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
Проект Aspire AppHost не найден. Хотите вместо этого обновить Aspire CLI?
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf
index ab16c446812..d67d8d6f60d 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf
@@ -117,6 +117,11 @@
Eşleme: {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
Aspire AppHost projesi bulunamadı. Bunun yerine Aspire CLI'yı güncelleştirmek ister misiniz?
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf
index e6c4082fe3a..ca900fdb01a 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf
@@ -117,6 +117,11 @@
映射: {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
找不到 Aspire AppHost 项目。是否改为更新 Aspire CLI?
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf
index 8d5faa95493..466d3e616f0 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf
@@ -117,6 +117,11 @@
對應: {0}
+
+ Migrate package {0} to {1}
+ Migrate package {0} to {1}
+
+
No Aspire AppHost project found. Would you like to update the Aspire CLI instead?
找不到任何 Aspire AppHost 專案。您是否要改為更新 Aspire CLI?
From fb4c4141298c114f70512e2c3b9c2ce7771b65ae Mon Sep 17 00:00:00 2001
From: Mitch Denny
Date: Mon, 1 Dec 2025 12:06:56 +1100
Subject: [PATCH 6/6] WIP
---
src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 31 +++++++++++--------
src/Aspire.Cli/Packaging/PackageMigration.cs | 2 ++
src/Aspire.Cli/Projects/ProjectUpdater.cs | 25 ++++++++++++++-
.../Projects/ProjectUpdaterTests.cs | 8 ++---
.../Templating/DotNetTemplateFactoryTests.cs | 2 +-
.../TestServices/TestDotNetCliRunner.cs | 4 +--
6 files changed, 51 insertions(+), 21 deletions(-)
diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs
index 0aa3b02fb95..4989454366c 100644
--- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs
+++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs
@@ -35,7 +35,7 @@ internal interface IDotNetCliRunner
Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
- Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
+ Task AddPackageAsync(FileInfo projectFilePath, string packageName, string? packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task RemovePackageAsync(FileInfo projectFilePath, string packageName, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
@@ -771,7 +771,7 @@ public async Task BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvoc
options: options,
cancellationToken: cancellationToken);
}
- public async Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
+ public async Task AddPackageAsync(FileInfo projectFilePath, string packageName, string? packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.ActivitySource.StartActivity();
@@ -785,16 +785,23 @@ public async Task AddPackageAsync(FileInfo projectFilePath, string packageN
if (isSingleFileAppHost)
{
cliArgsList.AddRange(["package", "--file", projectFilePath.FullName]);
- // For single-file AppHost, use packageName@version format
+ // For single-file AppHost, use packageName@version format (version required)
+ if (string.IsNullOrEmpty(packageVersion))
+ {
+ throw new ArgumentException("Package version is required for single-file AppHost projects.", nameof(packageVersion));
+ }
cliArgsList.Add($"{packageName}@{packageVersion}");
}
else
{
cliArgsList.AddRange([projectFilePath.FullName, "package"]);
- // For non single-file scenarios, use separate --version flag
cliArgsList.Add(packageName);
- cliArgsList.Add("--version");
- cliArgsList.Add(packageVersion);
+ // Only add --version if a version is specified (for CPM, version comes from Directory.Packages.props)
+ if (!string.IsNullOrEmpty(packageVersion))
+ {
+ cliArgsList.Add("--version");
+ cliArgsList.Add(packageVersion);
+ }
}
if (string.IsNullOrEmpty(nugetSource))
@@ -836,21 +843,19 @@ public async Task RemovePackageAsync(FileInfo projectFilePath, string packa
{
using var activity = telemetry.ActivitySource.StartActivity();
- var cliArgsList = new List
- {
- "remove"
- };
+ var cliArgsList = new List();
- // For single-file AppHost (apphost.cs), use --file switch instead of positional argument
+ // For single-file AppHost (apphost.cs), use "dotnet package remove --file "
var isSingleFileAppHost = projectFilePath.Name.Equals("apphost.cs", StringComparison.OrdinalIgnoreCase);
if (isSingleFileAppHost)
{
- cliArgsList.AddRange(["package", "--file", projectFilePath.FullName]);
+ cliArgsList.AddRange(["package", "remove", "--file", projectFilePath.FullName]);
cliArgsList.Add(packageName);
}
else
{
- cliArgsList.AddRange([projectFilePath.FullName, "package"]);
+ // For regular projects, use "dotnet remove package "
+ cliArgsList.AddRange(["remove", projectFilePath.FullName, "package"]);
cliArgsList.Add(packageName);
}
diff --git a/src/Aspire.Cli/Packaging/PackageMigration.cs b/src/Aspire.Cli/Packaging/PackageMigration.cs
index c6eddedd433..5ea6fcaec92 100644
--- a/src/Aspire.Cli/Packaging/PackageMigration.cs
+++ b/src/Aspire.Cli/Packaging/PackageMigration.cs
@@ -69,7 +69,9 @@ public PackageMigration(ILogger logger)
[
// Aspire.Hosting.NodeJs was renamed to Aspire.Hosting.JavaScript in 13.0.0
(v => v.ComparePrecedenceTo(version13) >= 0, "Aspire.Hosting.NodeJs", "Aspire.Hosting.JavaScript"),
+ (v => v.ComparePrecedenceTo(version13) >= 0, "CommunityToolkit.Aspire.Hosting.NodeJs.Extensions", "CommunityToolkit.Aspire.Hosting.JavaScript.Extensions"),
(v => v.ComparePrecedenceTo(version13) < 0, "Aspire.Hosting.JavaScript", "Aspire.Hosting.NodeJs"),
+ (v => v.ComparePrecedenceTo(version13) < 0, "CommunityToolkit.Aspire.Hosting.JavaScript.Extensions", "CommunityToolkit.Aspire.Hosting.NodeJs.Extensions")
];
}
diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs
index 749aeb0a97a..55347bd5c82 100644
--- a/src/Aspire.Cli/Projects/ProjectUpdater.cs
+++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs
@@ -448,7 +448,7 @@ private async Task AnalyzeProjectAsync(FileInfo projectFile, UpdateContext conte
private static bool IsUpdatablePackage(string packageId)
{
- return packageId.StartsWith("Aspire.");
+ return packageId.StartsWith("Aspire.") || packageId.StartsWith("CommunityToolkit.Aspire.");
}
private static CentralPackageManagementInfo DetectCentralPackageManagement(FileInfo projectFile)
@@ -581,12 +581,18 @@ private async Task AddMigrationStepsForCentralPackageManagementAsync(
var latestTargetPackage = await GetLatestVersionOfPackageAsync(context, toPackageId, cancellationToken);
// Create a single migration step that removes the old package and adds the new one
+ // For CPM, we need to update both Directory.Packages.props AND the project file's PackageReference
var migrationStep = new PackageMigrationStep(
string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.MigratePackageFormat, fromPackageId, toPackageId),
async () =>
{
+ // Update Directory.Packages.props
await RemovePackageFromDirectoryPackagesProps(fromPackageId, directoryPackagesPropsFile);
await AddPackageToDirectoryPackagesProps(toPackageId, latestTargetPackage!.Version, directoryPackagesPropsFile);
+
+ // Update the project file's PackageReference
+ await RemovePackageFromProject(projectFile, fromPackageId, cancellationToken);
+ await AddPackageReferenceToProject(projectFile, toPackageId, cancellationToken);
},
fromPackageId,
fromVersion,
@@ -601,6 +607,23 @@ private async Task RemovePackageFromProject(FileInfo projectFile, string pa
return await runner.RemovePackageAsync(projectFile, packageId, new(), cancellationToken);
}
+ private async Task AddPackageReferenceToProject(FileInfo projectFile, string packageId, CancellationToken cancellationToken)
+ {
+ // For CPM projects, we add a PackageReference without a version (version comes from Directory.Packages.props)
+ var exitCode = await runner.AddPackageAsync(
+ projectFilePath: projectFile,
+ packageName: packageId,
+ packageVersion: null,
+ nugetSource: null,
+ options: new(),
+ cancellationToken: cancellationToken);
+
+ if (exitCode != 0)
+ {
+ throw new ProjectUpdaterException(string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.FailedUpdatePackageReferenceFormat, packageId, projectFile.FullName));
+ }
+ }
+
private static Task RemovePackageFromDirectoryPackagesProps(string packageId, FileInfo directoryPackagesPropsFile)
{
var doc = new XmlDocument { PreserveWhitespace = true };
diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs
index 3596aeb6987..62dd01b2ed0 100644
--- a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs
+++ b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs
@@ -152,7 +152,7 @@ await File.WriteAllTextAsync(
""");
- var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>();
+ var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string? PackageVersion, string? PackageSource)>();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
@@ -289,7 +289,7 @@ await File.WriteAllTextAsync(
""");
- var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>();
+ var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string? PackageVersion, string? PackageSource)>();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
@@ -448,7 +448,7 @@ await File.WriteAllTextAsync(
""");
- var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>();
+ var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string? PackageVersion, string? PackageSource)>();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
@@ -2302,7 +2302,7 @@ await File.WriteAllTextAsync(
""");
- var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>();
+ var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string? PackageVersion, string? PackageSource)>();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs
index 77469fc387e..5fc84e657ed 100644
--- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs
+++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs
@@ -411,7 +411,7 @@ public Task NewProjectAsync(string templateName, string projectName, string
public Task BuildAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
=> throw new NotImplementedException();
- public Task AddPackageAsync(FileInfo projectFile, string packageName, string version, string? packageSourceUrl, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
+ public Task AddPackageAsync(FileInfo projectFile, string packageName, string? version, string? packageSourceUrl, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public Task RemovePackageAsync(FileInfo projectFile, string packageName, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs
index b68f8ee07ef..c644a06faa4 100644
--- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs
+++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs
@@ -11,7 +11,7 @@ namespace Aspire.Cli.Tests.TestServices;
internal sealed class TestDotNetCliRunner : IDotNetCliRunner
{
- public Func? AddPackageAsyncCallback { get; set; }
+ public Func? AddPackageAsyncCallback { get; set; }
public Func? RemovePackageAsyncCallback { get; set; }
public Func? AddProjectToSolutionAsyncCallback { get; set; }
public Func? BuildAsyncCallback { get; set; }
@@ -27,7 +27,7 @@ internal sealed class TestDotNetCliRunner : IDotNetCliRunner
public Func Projects)>? GetSolutionProjectsAsyncCallback { get; set; }
public Func? AddProjectReferenceAsyncCallback { get; set; }
- public Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
+ public Task AddPackageAsync(FileInfo projectFilePath, string packageName, string? packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
{
return AddPackageAsyncCallback != null
? Task.FromResult(AddPackageAsyncCallback(projectFilePath, packageName, packageVersion, nugetSource, options, cancellationToken))