Skip to content
60 changes: 43 additions & 17 deletions src/Aspire.Cli/Commands/AddCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,6 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
}
}

var source = parseResult.GetValue(s_sourceOption);

// For non-.NET projects, read the channel from the local Aspire configuration if available.
// Unlike .NET projects which have a nuget.config, polyglot apphosts persist the channel
// in aspire.config.json (or the legacy settings.json during migration).
Expand Down Expand Up @@ -161,8 +159,6 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
throw new EmptyChoicesException(AddCommandStrings.NoIntegrationPackagesFound);
}

var version = parseResult.GetValue(s_versionOption);

var packagesWithShortName = packagesWithChannels.Select(GenerateFriendlyName).OrderBy(p => p.FriendlyName, new CommunityToolkitFirstComparer());

if (!packagesWithShortName.Any())
Expand All @@ -171,7 +167,8 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
return ExitCodeConstants.FailedToAddPackage;
}

var filteredPackagesWithShortName = packagesWithShortName.Where(p => p.FriendlyName == integrationName || p.Package.Id == integrationName);
var filteredPackagesWithShortName = packagesWithShortName
.Where(p => p.FriendlyName == integrationName || p.Package.Id == integrationName);

if (!filteredPackagesWithShortName.Any() && integrationName is not null)
{
Expand All @@ -193,19 +190,20 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
.ToList();
}

var version = parseResult.GetValue(s_versionOption);

// If we didn't match any, show a complete list. If we matched one, and its
// an exact match, then we still prompt, but it will only prompt for
// the version. If there is more than one match then we prompt.
var selectedNuGetPackage = filteredPackagesWithShortName.Count() switch
{
0 => await GetPackageByInteractiveFlowWithNoMatchesMessage(packagesWithShortName, integrationName, cancellationToken),
1 => filteredPackagesWithShortName.First().Package.Version == version
? filteredPackagesWithShortName.First()
: await GetPackageByInteractiveFlow(filteredPackagesWithShortName, null, cancellationToken),
> 1 => await GetPackageByInteractiveFlow(filteredPackagesWithShortName, version, cancellationToken),
_ => throw new InvalidOperationException(AddCommandStrings.UnexpectedNumberOfPackagesFound)
0 => await GetPackageByInteractiveFlowWithNoMatchesMessage(effectiveAppHostProjectFile.Directory!, packagesWithShortName, integrationName, cancellationToken),
1 when filteredPackagesWithShortName.First().Package.Version == version
=> filteredPackagesWithShortName.First(),
_ => await GetPackageByInteractiveFlow(effectiveAppHostProjectFile.Directory!, filteredPackagesWithShortName, version, cancellationToken)
};

var source = parseResult.GetValue(s_sourceOption);
// Add the package using the appropriate project handler
context = new AddPackageContext
{
Expand Down Expand Up @@ -279,7 +277,24 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
}
}

private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlow(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, string? preferredVersion, CancellationToken cancellationToken)
private static async Task<IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>> GetAllPackageVersions(DirectoryInfo workingDirectory, IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, CancellationToken cancellationToken)
{
var distinctPackageIds = possiblePackages.DistinctBy(package => package.Package.Id);
var channels = possiblePackages.Select(package => package.Channel);

var versions = new List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>();
foreach (var channel in channels)
{
foreach (var package in distinctPackageIds)
{
var packages = await channel.GetPackageVersionsAsync(package.Package.Id, workingDirectory, cancellationToken);
versions.AddRange(packages.Select(p => (FriendlyName: package.FriendlyName, Package: p, Channel: channel)));
}
}
return versions;
}

private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlow(DirectoryInfo workingDirectory, IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, string? preferredVersion, CancellationToken cancellationToken)
{
var distinctPackages = possiblePackages.DistinctBy(p => p.Package.Id);

Expand All @@ -297,10 +312,21 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>

// If any of the package versions are an exact match for the preferred version
// then we can skip the version prompt and just use that version.
if (packageVersions.Any(p => p.Package.Version == preferredVersion))
if (!string.IsNullOrEmpty(preferredVersion))
{
var preferredVersionPackage = packageVersions.First(p => p.Package.Version == preferredVersion);
return preferredVersionPackage;
if (packageVersions.Any(p => p.Package.Version == preferredVersion))
{
var preferredVersionPackage = packageVersions.First(p => p.Package.Version == preferredVersion);
return preferredVersionPackage;
}
else // search all versions of the selected package for a match
{
var allVersions = await GetAllPackageVersions(workingDirectory, possiblePackages, cancellationToken);
if (allVersions.Any(packageVersion => packageVersion.Package.Version == preferredVersion))
{
return allVersions.First(package => package.Package.Version == preferredVersion);
}
}
}

// In non-interactive mode, prefer the implicit/default channel first to keep
Expand All @@ -320,14 +346,14 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
return version;
}

private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlowWithNoMatchesMessage(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, string? searchTerm, CancellationToken cancellationToken)
private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlowWithNoMatchesMessage(DirectoryInfo workingDirectory, IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, string? searchTerm, CancellationToken cancellationToken)
{
if (searchTerm is not null)
{
InteractionService.DisplaySubtleMessage(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.NoPackagesMatchedSearchTerm, searchTerm));
}

return await GetPackageByInteractiveFlow(possiblePackages, null, cancellationToken);
return await GetPackageByInteractiveFlow(workingDirectory, possiblePackages, null, cancellationToken);
}

internal static (string FriendlyName, NuGetPackage Package, PackageChannel Channel) GenerateFriendlyName((NuGetPackage Package, PackageChannel Channel) packageWithChannel)
Expand Down
28 changes: 19 additions & 9 deletions src/Aspire.Cli/DotNet/DotNetCliRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ internal interface IDotNetCliRunner
Task<int> BuildAsync(FileInfo projectFilePath, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<int> AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<int> AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool exactMatch, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<(int ExitCode, IReadOnlyList<FileInfo> Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<int> AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, ProcessInvocationOptions options, CancellationToken cancellationToken);
Expand Down Expand Up @@ -393,7 +393,7 @@ private async Task StartBackchannelAsync(IProcessExecution? execution, string so
using var activity = telemetry.StartDiagnosticActivity();

var isSingleFileAppHost = projectFile.Name.Equals("apphost.cs", StringComparison.OrdinalIgnoreCase);

// If we are a single file app host then we use the build command instead of msbuild command.
var cliArgsList = new List<string> { isSingleFileAppHost ? "build" : "msbuild" };

Expand Down Expand Up @@ -903,7 +903,7 @@ public async Task<string> ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w
return result;
}

public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, ProcessInvocationOptions options, CancellationToken cancellationToken)
public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool exactMatch, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();

Expand All @@ -928,7 +928,7 @@ public async Task<string> ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w

// Build a cache key using the main discriminators, including CLI version.
var cliVersion = VersionHelper.GetDefaultTemplateVersion();
rawKey = $"query={query}|prerelease={prerelease}|take={take}|skip={skip}|nugetConfigHash={nugetConfigHash}|cliVersion={cliVersion}";
rawKey = $"query={query}|exactMatch={exactMatch}|prerelease={prerelease}|take={take}|skip={skip}|nugetConfigHash={nugetConfigHash}|cliVersion={cliVersion}";
var cached = await _diskCache.GetAsync(rawKey, cancellationToken).ConfigureAwait(false);
if (cached is not null)
{
Expand All @@ -955,14 +955,24 @@ public async Task<string> ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w
"package",
"search",
query,
"--take",
take.ToString(CultureInfo.InvariantCulture),
"--skip",
skip.ToString(CultureInfo.InvariantCulture),
"--format",
"json"
];

if (exactMatch) // search for all versions that match the query exactly
{
cliArgs.Add("--exact-match");
}
else // 'exaxt-match' flag causes the take and skip arguments to be ignored
{
cliArgs.AddRange([
"--take",
take.ToString(CultureInfo.InvariantCulture),
"--skip",
skip.ToString(CultureInfo.InvariantCulture),
]);
}

if (nugetConfigFile is not null)
{
cliArgs.Add("--configfile");
Expand Down Expand Up @@ -1150,7 +1160,7 @@ public async Task<string> ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w
// Parse output - skip header lines (Project(s) and ----------)
var projects = new List<FileInfo>();
var startParsing = false;

foreach (var line in stdoutLines)
{
if (string.IsNullOrWhiteSpace(line))
Expand Down
61 changes: 52 additions & 9 deletions src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ public async Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(
{
var packages = await SearchPackagesInternalAsync(
workingDirectory,
"Aspire.ProjectTemplates",
query: "Aspire.ProjectTemplates",
exactMatch: false,
prerelease,
nugetConfigFile,
cancellationToken).ConfigureAwait(false);
Expand All @@ -58,7 +59,8 @@ public async Task<IEnumerable<NuGetPackage>> GetIntegrationPackagesAsync(
{
var packages = await SearchPackagesInternalAsync(
workingDirectory,
"Aspire.Hosting",
query: "Aspire.Hosting",
exactMatch: false,
prerelease,
nugetConfigFile,
cancellationToken).ConfigureAwait(false);
Expand All @@ -74,7 +76,8 @@ public async Task<IEnumerable<NuGetPackage>> GetCliPackagesAsync(
{
var packages = await SearchPackagesInternalAsync(
workingDirectory,
"Aspire.Cli",
query: "Aspire.Cli",
exactMatch: false,
prerelease,
nugetConfigFile,
cancellationToken).ConfigureAwait(false);
Expand All @@ -93,17 +96,39 @@ public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(
{
var packages = await SearchPackagesInternalAsync(
workingDirectory,
packageId,
query: packageId,
exactMatch: false,
prerelease,
nugetConfigFile,
cancellationToken).ConfigureAwait(false);

return FilterPackages(packages, filter);
}

public async Task<IEnumerable<NuGetPackage>> GetPackageVersionsAsync(
DirectoryInfo workingDirectory,
string exactPackageId,
bool prerelease,
FileInfo? nugetConfigFile,
bool useCache,
CancellationToken cancellationToken)
{
var packages = await SearchPackagesInternalAsync(
workingDirectory,
query: exactPackageId,
exactMatch: true,
prerelease,
nugetConfigFile,
cancellationToken).ConfigureAwait(false);

bool FilterExactIdMatch(string? id) => string.Equals(id, exactPackageId, StringComparison.Ordinal);
return FilterPackages(packages, FilterExactIdMatch);
}

private async Task<IEnumerable<NuGetPackage>> SearchPackagesInternalAsync(
DirectoryInfo workingDirectory,
string query,
bool exactMatch,
bool prerelease,
FileInfo? nugetConfigFile,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -195,12 +220,30 @@ private async Task<IEnumerable<NuGetPackage>> SearchPackagesInternalAsync(
}

// Convert to NuGetPackage format
return result.Packages.Select(p => new NuGetPackage
if (!exactMatch)
{
return result.Packages.Select(p => new NuGetPackage
{
Id = p.Id,
Version = p.Version,
Source = p.Source ?? string.Empty
}).ToList();
}
else
{
Id = p.Id,
Version = p.Version,
Source = p.Source ?? string.Empty
}).ToList();
var exactMatchResultPackage = result.Packages
.FirstOrDefault(p => p.Id.Equals(query, StringComparison.Ordinal));
if (exactMatchResultPackage is null || exactMatchResultPackage.AllVersions is null)
{
return [];
}
return exactMatchResultPackage.AllVersions.Select(packageVersion => new NuGetPackage
{
Id = exactMatchResultPackage.Id,
Version = packageVersion,
Source = exactMatchResultPackage.Source ?? string.Empty
}).ToList();
}
}
catch (JsonException ex)
{
Expand Down
Loading
Loading