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
60 changes: 43 additions & 17 deletions src/Aspire.Cli/Commands/AddCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,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 @@ -162,8 +160,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 @@ -172,7 +168,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 @@ -194,19 +191,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 @@ -280,7 +278,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 @@ -298,10 +313,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 @@ -321,14 +347,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 @@ -35,7 +35,7 @@ internal interface IDotNetCliRunner
Task<int> BuildAsync(FileInfo projectFilePath, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task<int> AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, 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, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool exactMatch, 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);
Task<(int ExitCode, IReadOnlyList<FileInfo> Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Task<int> AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
Expand Down Expand Up @@ -316,7 +316,7 @@ private async Task StartBackchannelAsync(IDotNetCliExecution? execution, string
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 @@ -826,7 +826,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, DotNetCliRunnerInvocationOptions 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, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();

Expand All @@ -851,7 +851,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 @@ -878,14 +878,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 @@ -1073,7 +1083,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
18 changes: 18 additions & 0 deletions src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,24 @@ public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(
return FilterPackages(packages, filter);
}

public async Task<IEnumerable<NuGetPackage>> GetPackageVersionsAsync(
DirectoryInfo workingDirectory,
string exactPackageId,
bool prerelease,
FileInfo? nugetConfigFile,
bool useCache,
CancellationToken cancellationToken)
{
return await GetPackagesAsync(
workingDirectory,
exactPackageId,
filter: id => string.Equals(id, exactPackageId, StringComparison.OrdinalIgnoreCase),
prerelease,
nugetConfigFile,
useCache,
cancellationToken);
}

private async Task<IEnumerable<NuGetPackage>> SearchPackagesInternalAsync(
DirectoryInfo workingDirectory,
string query,
Expand Down
54 changes: 50 additions & 4 deletions src/Aspire.Cli/NuGet/NuGetPackageCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ internal interface INuGetPackageCache
Task<IEnumerable<NuGetPackage>> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken);
Task<IEnumerable<NuGetPackage>> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken);
Task<IEnumerable<NuGetPackage>> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func<string, bool>? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken);
Task<IEnumerable<NuGetPackage>> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken);
}

internal sealed class NuGetPackageCache(IDotNetCliRunner cliRunner, IMemoryCache memoryCache, AspireCliTelemetry telemetry, IFeatures features) : INuGetPackageCache
{
private const int SearchPageSize = 1000;

// List of deprecated packages that should be filtered by default
private static readonly HashSet<string> s_deprecatedPackages = new(StringComparer.OrdinalIgnoreCase)
{
Expand Down Expand Up @@ -87,6 +88,7 @@ public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(DirectoryInfo work
var result = await cliRunner.SearchPackagesAsync(
workingDirectory,
query,
exactMatch: false,
prerelease,
SearchPageSize,
skip,
Expand Down Expand Up @@ -121,15 +123,15 @@ public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(DirectoryInfo work

// If no specific filter is specified we use the fallback filter which is useful in most circumstances
// other that aspire update which really needs to see all the packages to work effectively.
var effectiveFilter = (NuGetPackage p) =>
var effectiveFilter = (NuGetPackage p) =>
{
if (filter is not null)
{
return filter(p.Id);
}

var isOfficialPackage = IsOfficialOrCommunityToolkitPackage(p.Id);

// Apply deprecated package filter unless the user wants to show deprecated packages
if (isOfficialPackage && !features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false))
{
Expand All @@ -138,7 +140,7 @@ public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(DirectoryInfo work

return isOfficialPackage;
};

return collectedPackages.Where(effectiveFilter);

static bool IsOfficialOrCommunityToolkitPackage(string packageName)
Expand All @@ -157,6 +159,50 @@ static bool IsOfficialOrCommunityToolkitPackage(string packageName)
return isHostingOrCommunityToolkitNamespaced && !isExcluded;
}
}

public async Task<IEnumerable<NuGetPackage>> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();

var collectedPackages = new List<NuGetPackage>();

var result = await cliRunner.SearchPackagesAsync(
workingDirectory,
exactPackageId,
exactMatch: true,
prerelease,
take: 0,
skip: 0, // skip and take parameters are ignored when exactMatch is true
nugetConfigFile,
useCache, // Pass through the useCache parameter
new DotNetCliRunnerInvocationOptions { SuppressLogging = true },
cancellationToken
);

if (result.ExitCode != 0)
{
throw new NuGetPackageCacheException(string.Format(CultureInfo.CurrentCulture, ErrorStrings.FailedToSearchForPackages, result.ExitCode));
}

if (result.Packages?.Length > 0)
{
collectedPackages.AddRange(result.Packages);
}

// If no specific filter is specified we use the fallback filter which is useful in most circumstances
// other that aspire update which really needs to see all the packages to work effectively.
var effectiveFilter = (NuGetPackage p) =>
{
// Apply deprecated package filter unless the user wants to show deprecated packages
if (!features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false))
{
return !s_deprecatedPackages.Contains(p.Id);
}
return true;
};

return collectedPackages.Where(effectiveFilter);
}
}

internal sealed class NuGetPackageCacheException(string message) : Exception(message)
Expand Down
Loading