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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 59 additions & 6 deletions src/Aspire.Cli/DotNet/DotNetCliRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ internal interface IDotNetCliRunner
Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task<int> NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task<int> BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task<int> AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task<int> AddPackageAsync(FileInfo projectFilePath, string packageName, string? packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task<int> RemovePackageAsync(FileInfo projectFilePath, string packageName, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task<int> 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);
Expand Down Expand Up @@ -770,7 +771,7 @@ public async Task<int> BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvoc
options: options,
cancellationToken: cancellationToken);
}
public async Task<int> AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
public async Task<int> AddPackageAsync(FileInfo projectFilePath, string packageName, string? packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.ActivitySource.StartActivity();

Expand All @@ -784,16 +785,23 @@ public async Task<int> 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))
Expand Down Expand Up @@ -831,6 +839,51 @@ public async Task<int> AddPackageAsync(FileInfo projectFilePath, string packageN
return result;
}

public async Task<int> RemovePackageAsync(FileInfo projectFilePath, string packageName, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.ActivitySource.StartActivity();

var cliArgsList = new List<string>();

// For single-file AppHost (apphost.cs), use "dotnet package remove --file <file> <packageName>"
var isSingleFileAppHost = projectFilePath.Name.Equals("apphost.cs", StringComparison.OrdinalIgnoreCase);
if (isSingleFileAppHost)
{
cliArgsList.AddRange(["package", "remove", "--file", projectFilePath.FullName]);
cliArgsList.Add(packageName);
}
else
{
// For regular projects, use "dotnet remove <project> package <packageName>"
cliArgsList.AddRange(["remove", 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<int> AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.ActivitySource.StartActivity();
Expand Down
137 changes: 137 additions & 0 deletions src/Aspire.Cli/Packaging/PackageMigration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// 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;

/// <summary>
/// Provides package migration information for upgrading or downgrading packages
/// when switching between different Aspire versions.
/// </summary>
internal interface IPackageMigration
{
/// <summary>
/// Gets the replacement package ID for a package when migrating to a specific target Aspire hosting version.
/// </summary>
/// <param name="targetHostingVersion">The target Aspire hosting SDK version being migrated to.</param>
/// <param name="packageId">The package ID to check for migration.</param>
/// <returns>
/// The replacement package ID if a migration rule exists for the given package and version;
/// <c>null</c> if no migration is needed for this package.
/// </returns>
/// <exception cref="PackageMigrationException">Thrown when multiple migration rules match for the same package.</exception>
string? GetMigration(SemVersion targetHostingVersion, string packageId);
}

/// <summary>
/// Implementation of <see cref="IPackageMigration"/> that provides package migration rules
/// for migrating packages between different Aspire versions.
/// </summary>
internal sealed class PackageMigration : IPackageMigration
{
private readonly ILogger<PackageMigration> _logger;
private readonly List<(Func<SemVersion, bool> VersionPredicate, string FromPackageId, string ToPackageId)> _migrationRules;

/// <summary>
/// Initializes a new instance of the <see cref="PackageMigration"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
public PackageMigration(ILogger<PackageMigration> logger)
{
_logger = logger;
_migrationRules = CreateMigrationRules();
}

/// <summary>
/// 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.
/// </summary>
private static List<(Func<SemVersion, bool> 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

var version13 = SemVersion.Parse("13.0.0", SemVersionStyles.Strict);

return
[
// 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")
];
}

/// <inheritdoc />
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;
}
}

/// <summary>
/// Exception thrown when there is an error in package migration processing.
/// </summary>
internal sealed class PackageMigrationException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="PackageMigrationException"/> class.
/// </summary>
/// <param name="message">The error message.</param>
public PackageMigrationException(string message) : base(message)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="PackageMigrationException"/> class.
/// </summary>
/// <param name="message">The error message.</param>
/// <param name="innerException">The inner exception.</param>
public PackageMigrationException(string message, Exception innerException) : base(message, innerException)
{
}
}
1 change: 1 addition & 0 deletions src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ private static async Task<IHost> BuildApplicationAsync(string[] args)
builder.Services.AddHostedService(sp => sp.GetRequiredService<AuxiliaryBackchannelMonitor>());
builder.Services.AddSingleton<ICliUpdateNotifier, CliUpdateNotifier>();
builder.Services.AddSingleton<IPackagingService, PackagingService>();
builder.Services.AddSingleton<IPackageMigration, PackageMigration>();
builder.Services.AddSingleton<ICliDownloader, CliDownloader>();
builder.Services.AddMemoryCache();

Expand Down
Loading
Loading