// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Immutable;
using System.CommandLine;
using System.Globalization;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Semver;
using Spectre.Console;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
namespace Aspire.Cli.Commands;
internal sealed class AddCommand : BaseCommand
{
internal override HelpGroup HelpGroup => HelpGroup.AppCommands;
protected override bool UpdateNotificationsEnabled => true;
private readonly IProjectLocator _projectLocator;
private readonly IntegrationPackageSearchService _integrationPackageSearchService;
private readonly IAddCommandPrompter _prompter;
private readonly IDotNetSdkInstaller _sdkInstaller;
private readonly ICliHostEnvironment _hostEnvironment;
private readonly IAppHostProjectFactory _projectFactory;
private readonly ProfilingTelemetry _profilingTelemetry;
private readonly IFeatures _features;
private static readonly Argument<string> s_integrationArgument = new("integration")
{
Description = AddCommandStrings.IntegrationArgumentDescription,
Arity = ArgumentArity.ZeroOrOne
};
private static readonly OptionWithLegacy<FileInfo?> s_appHostOption = new("--apphost", "--project", AddCommandStrings.ProjectArgumentDescription);
private static readonly Option<string> s_versionOption = new("--version")
{
Description = AddCommandStrings.VersionArgumentDescription
};
private static readonly Option<string?> s_sourceOption = new("--source", "-s")
{
Description = AddCommandStrings.SourceArgumentDescription
};
private static readonly Option<bool> s_allOption = new("--all")
{
Description = AddCommandStrings.AllArgumentDescription
};
public AddCommand(IProjectLocator projectLocator, IntegrationPackageSearchService integrationPackageSearchService, IAddCommandPrompter prompter, IDotNetSdkInstaller sdkInstaller, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ProfilingTelemetry profilingTelemetry, CommonCommandServices services)
: base("add", AddCommandStrings.Description, services)
{
_projectLocator = projectLocator;
_integrationPackageSearchService = integrationPackageSearchService;
_prompter = prompter;
_sdkInstaller = sdkInstaller;
_hostEnvironment = hostEnvironment;
_projectFactory = projectFactory;
_profilingTelemetry = profilingTelemetry;
_features = services.Features;
Arguments.Add(s_integrationArgument);
Options.Add(s_appHostOption);
Options.Add(s_versionOption);
Options.Add(s_sourceOption);
Options.Add(s_allOption);
}
protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
using var activity = Telemetry.StartDiagnosticActivity(this.Name);
AddPackageContext? context = null;
ProfilingTelemetry.ActivityScope addActivity = default;
CommandResult AddCommandFailure(int exitCode, string? message = null)
{
addActivity.SetProcessExitCode(exitCode);
addActivity.SetError(message ?? $"Add command exited with code {exitCode}.");
return message is null
? CommandResult.Failure(exitCode)
: CommandResult.Failure(exitCode, message);
}
CommandResult AddCommandFromExitCode(int exitCode)
{
addActivity.SetProcessExitCode(exitCode);
if (exitCode != CliExitCodes.Success)
{
addActivity.SetError($"Add command exited with code {exitCode}.");
}
return CommandResult.FromExitCode(exitCode);
}
try
{
var integrationName = parseResult.GetValue(s_integrationArgument);
var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption);
var version = parseResult.GetValue(s_versionOption);
var source = parseResult.GetValue(s_sourceOption);
var includeAllIntegrations = parseResult.GetValue(s_allOption);
addActivity = _profilingTelemetry.StartAddCommand(integrationName, version, source, passedAppHostProjectFile);
AppHostProjectSearchResult searchResult;
using (var findAppHostActivity = _profilingTelemetry.StartAddFindAppHost(passedAppHostProjectFile))
{
searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, MultipleAppHostProjectsFoundBehavior.Prompt, createSettingsFile: true, cancellationToken);
findAppHostActivity.SetAppHostCandidateCount(searchResult.AllProjectFileCandidates.Count);
}
addActivity.SetAppHostCandidateCount(searchResult.AllProjectFileCandidates.Count);
var effectiveAppHostProjectFile = searchResult.SelectedProjectFile;
if (effectiveAppHostProjectFile is null)
{
return AddCommandFailure(CliExitCodes.FailedToFindProject);
}
// Get the appropriate project handler
var project = _projectFactory.GetProject(effectiveAppHostProjectFile);
addActivity.SetAppHostLanguage(project.LanguageId);
// Check if the .NET SDK is available (only needed for .NET projects)
if (project.LanguageId == KnownLanguageId.CSharp)
{
if (!await SdkInstallHelper.EnsureSdkInstalledAsync(_sdkInstaller, InteractionService, Telemetry, cancellationToken: cancellationToken))
{
return AddCommandFailure(CliExitCodes.SdkNotInstalled);
}
}
string? configuredChannel;
int? configuredChannelExitCode;
using (var configuredChannelActivity = _profilingTelemetry.StartAddGetConfiguredChannel())
{
(configuredChannel, configuredChannelExitCode) = _integrationPackageSearchService.GetConfiguredChannel(effectiveAppHostProjectFile, project);
configuredChannelActivity.SetAddConfiguredChannel(configuredChannel);
if (configuredChannelExitCode is { } channelExitCode)
{
configuredChannelActivity.SetProcessExitCode(channelExitCode);
if (channelExitCode != CliExitCodes.Success)
{
configuredChannelActivity.SetError($"Configured channel lookup exited with code {channelExitCode}.");
}
}
}
if (configuredChannelExitCode is { } exitCode)
{
return AddCommandFromExitCode(exitCode);
}
// For non-C# (polyglot) AppHosts, only integrations with ATS export coverage are usable: a
// TypeScript/Python/Go/Java/Rust AppHost gets a generated SDK only for packages carrying the
// `polyglot` NuGet tag. The tag is added by default to Aspire.Hosting integrations that run the
// export analyzer; authors opt out with <IsAspirePolyglotCompatible>false</IsAspirePolyglotCompatible>.
// Decide up front whether to hide the rest so the polyglot allow-list is resolved in the same
// discovery pass as the integration search rather than a second serial round. C# AppHosts consume
// the C# API directly and are never filtered; --all opts out of filtering entirely.
//
// The filter is opt-in and off by default: resolving the allow-list depends on the package source
// answering a `tags:polyglot` search, which no remote feed does usefully today (see the notes on
// PackageChannel.PolyglotTagSearchTerm). Because the filter fails closed, an unresolvable
// allow-list is indistinguishable from "nothing is compatible" and hides every integration.
// See https://github.com/microsoft/aspire/issues/19161.
var applyPolyglotFilter = _features.IsFeatureEnabled(KnownFeatures.PolyglotIntegrationFilterEnabled, false)
&& project.LanguageId != KnownLanguageId.CSharp
&& !includeAllIntegrations;
List<(NuGetPackage Package, PackageChannel Channel)> packagesWithChannels;
IReadOnlySet<string> polyglotCompatibleIds = ImmutableHashSet<string>.Empty;
using (var searchPackagesActivity = _profilingTelemetry.StartAddSearchPackages(configuredChannel))
{
if (applyPolyglotFilter)
{
var (discoveredPackages, discoveredPolyglotIds) = await InteractionService.ShowStatusAsync(
AddCommandStrings.SearchingForAspirePackages,
async () => await _integrationPackageSearchService.GetIntegrationPackagesWithPolyglotCompatibilityAsync(effectiveAppHostProjectFile.Directory!, configuredChannel, cancellationToken));
packagesWithChannels = discoveredPackages as List<(NuGetPackage Package, PackageChannel Channel)> ?? discoveredPackages.ToList();
polyglotCompatibleIds = discoveredPolyglotIds;
}
else
{
var discoveredPackages = await InteractionService.ShowStatusAsync(
AddCommandStrings.SearchingForAspirePackages,
async () => await _integrationPackageSearchService.GetIntegrationPackagesWithChannelsAsync(effectiveAppHostProjectFile.Directory!, configuredChannel, cancellationToken));
packagesWithChannels = discoveredPackages as List<(NuGetPackage Package, PackageChannel Channel)> ?? discoveredPackages.ToList();
}
var packageCount = packagesWithChannels.Count;
searchPackagesActivity.SetAddPackageSearchResultCount(packageCount);
addActivity.SetAddPackageSearchResultCount(packageCount);
}
if (packagesWithChannels.Count == 0)
{
throw new EmptyChoicesException(AddCommandStrings.NoIntegrationPackagesFound);
}
var packagesWithShortName = packagesWithChannels.Select(IntegrationPackageSearchService.GenerateFriendlyName).OrderBy(p => p.FriendlyName, new CommunityToolkitFirstComparer()).ToList();
if (packagesWithShortName.Count == 0)
{
return AddCommandFailure(CliExitCodes.FailedToAddPackage, AddCommandStrings.NoPackagesFound);
}
if (applyPolyglotFilter)
{
bool MatchesIntegrationName((string FriendlyName, NuGetPackage Package, PackageChannel Channel) p)
=> p.FriendlyName == integrationName || p.Package.Id == integrationName;
// If the user named a specific integration that exists but is not polyglot-compatible, give a
// precise, actionable error rather than silently dropping it and fuzzy-matching something else.
if (integrationName is not null
&& packagesWithShortName.Any(p => MatchesIntegrationName(p) && !polyglotCompatibleIds.Contains(p.Package.Id))
&& !packagesWithShortName.Any(p => MatchesIntegrationName(p) && polyglotCompatibleIds.Contains(p.Package.Id)))
{
var notCompatibleMessage = string.Format(CultureInfo.CurrentCulture, AddCommandStrings.IntegrationNotPolyglotCompatible, integrationName, project.LanguageId);
return AddCommandFailure(CliExitCodes.FailedToAddPackage, notCompatibleMessage);
}
var compatiblePackagesWithShortName = packagesWithShortName
.Where(p => polyglotCompatibleIds.Contains(p.Package.Id))
.ToList();
var hiddenIntegrationCount = packagesWithShortName.Count - compatiblePackagesWithShortName.Count;
packagesWithShortName = compatiblePackagesWithShortName;
if (packagesWithShortName.Count == 0)
{
return AddCommandFailure(CliExitCodes.FailedToAddPackage, AddCommandStrings.NoPolyglotCompatibleIntegrationsFound);
}
if (hiddenIntegrationCount > 0)
{
InteractionService.DisplaySubtleMessage(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.PolyglotIntegrationsHidden, hiddenIntegrationCount));
}
}
var filteredPackagesWithShortName = packagesWithShortName
.Where(p => p.FriendlyName == integrationName || p.Package.Id == integrationName)
.ToList();
var packageMatchKind = filteredPackagesWithShortName.Count > 0
? ProfilingTelemetry.Values.AddPackageMatchKindExact
: ProfilingTelemetry.Values.AddPackageMatchKindNone;
// Non-interactive mode never falls back to fuzzy search: in interactive mode the user picks
// from the fuzzy candidates, but a script/CI invocation would otherwise silently auto-select
// distinctPackages.First() in GetPackageByInteractiveFlow and install the wrong package
// (https://github.com/microsoft/aspire/issues/17724). Refusing with an actionable error
// forces the caller to supply an exact package id or friendly name.
if (filteredPackagesWithShortName.Count == 0 && integrationName is not null && !_hostEnvironment.SupportsInteractiveInput)
{
var message = version is not null
? string.Format(CultureInfo.CurrentCulture, AddCommandStrings.SpecifiedVersionRequiresExactPackageMatch, integrationName)
: string.Format(CultureInfo.CurrentCulture, AddCommandStrings.NonInteractiveRequiresExactPackageMatch, integrationName);
throw new EmptyChoicesException(message);
}
if (filteredPackagesWithShortName.Count == 0 && integrationName is not null)
{
// If we didn't get an exact match on the friendly name or the package ID
// then try a fuzzy search to create a broader filtered list.
// Materialize the query with ToList() to avoid multiple enumerations
// (which would recalculate fuzzy scores on each Count()/First() call).
filteredPackagesWithShortName = IntegrationPackageSearchService.GetIntegrationSearchMatches(packagesWithShortName, integrationName)
.Select(x => (x.FriendlyName, x.Package, x.Channel))
.ToList();
packageMatchKind = filteredPackagesWithShortName.Count > 0
? ProfilingTelemetry.Values.AddPackageMatchKindFuzzy
: ProfilingTelemetry.Values.AddPackageMatchKindNone;
}
// If the user supplied a partial/fuzzy search term, keep the package prompt even when
// the fallback only found one candidate; otherwise `aspire add kube` can silently add
// the lone fuzzy match without asking the interactive user to confirm it.
var promptForSingleFuzzyPackage = packageMatchKind == ProfilingTelemetry.Values.AddPackageMatchKindFuzzy;
// If we didn't match any, show a complete list. If we matched one, and it's
// 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.
(string FriendlyName, NuGetPackage Package, PackageChannel Channel) selectedNuGetPackage;
selectedNuGetPackage = filteredPackagesWithShortName.Count switch
{
0 => await GetPackageByInteractiveFlowWithNoMatchesMessage(
effectiveAppHostProjectFile.Directory!,
packagesWithShortName,
integrationName,
version,
configuredChannel,
cancellationToken,
promptForSinglePackage: integrationName is not null),
1 when packageMatchKind == ProfilingTelemetry.Values.AddPackageMatchKindExact
&& filteredPackagesWithShortName[0].Package.Version == version
=> filteredPackagesWithShortName[0],
_ => await GetPackageByInteractiveFlow(
effectiveAppHostProjectFile.Directory!,
filteredPackagesWithShortName,
version,
configuredChannel,
cancellationToken,
promptForSingleFuzzyPackage)
};
using (var selectPackageActivity = _profilingTelemetry.StartAddSelectPackage(integrationName, version))
{
selectPackageActivity.SetAddPackageMatch(filteredPackagesWithShortName.Count, packageMatchKind);
selectPackageActivity.SetAddSelectedPackage(selectedNuGetPackage.Package.Id, selectedNuGetPackage.Package.Version, selectedNuGetPackage.Channel.Name);
addActivity.SetAddSelectedPackage(selectedNuGetPackage.Package.Id, selectedNuGetPackage.Package.Version, selectedNuGetPackage.Channel.Name);
}
// When installing from a PR channel, ensure the project has access to
// the PR hive as a NuGet source so `dotnet add package` can resolve the
// PR-version package. We add the hive source to the project's nuget.config
// WITHOUT package source mapping restrictions, so that transitive deps
// (including RID-specific and stable-versioned packages) can still resolve
// from NuGet.org via the normal NuGet source hierarchy.
//
// IsBackedByLocalPackageDirectory (not just the local-build NAME) is required so this
// also fires when emulating a released build via ASPIRE_CLI_PACKAGES: there the channel
// is named stable/daily/staging but its Aspire.* packages live in a local directory, and
// without the local source in nuget.config the C# `dotnet add package` restore of the
// local-only version fails. See docs/specs/cli-identity-sidecar.md.
if (string.IsNullOrEmpty(source) &&
(VersionHelper.IsLocalBuildChannel(selectedNuGetPackage.Channel.Name) || selectedNuGetPackage.Channel.IsBackedByLocalPackageDirectory))
{
var mappings = selectedNuGetPackage.Channel.Mappings;
if (mappings is { Length: > 0 })
{
var hiveSources = mappings
.Select(m => m.Source)
.Where(s => !s.StartsWith("http", StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase);
var projectDir = effectiveAppHostProjectFile.Directory!;
var nugetConfigPath = Path.Combine(projectDir.FullName, "nuget.config");
if (!File.Exists(nugetConfigPath))
{
projectDir.Create(); // ensure directory exists
var configXml = new System.Xml.Linq.XDocument(
new System.Xml.Linq.XElement("configuration",
new System.Xml.Linq.XElement("packageSources",
hiveSources.Select(s =>
new System.Xml.Linq.XElement("add",
new System.Xml.Linq.XAttribute("key", s),
new System.Xml.Linq.XAttribute("value", s))))));
configXml.Save(nugetConfigPath);
InteractionService.DisplayMessage(KnownEmojis.Package, Aspire.Cli.Resources.TemplatingStrings.NuGetConfigCreatedOrUpdatedConfirmationMessage);
}
}
}
context = new AddPackageContext
{
AppHostFile = effectiveAppHostProjectFile,
PackageId = selectedNuGetPackage.Package.Id,
PackageVersion = selectedNuGetPackage.Package.Version,
Source = source
};
// Stop any running AppHost instance before adding the package.
// A running AppHost (especially in detach mode) locks project files,
// which prevents 'dotnet add package' from modifying the project.
RunningInstanceResult runningInstanceResult;
using (var stopRunningInstanceActivity = _profilingTelemetry.StartAddStopExistingInstance())
{
runningInstanceResult = await project.FindAndStopRunningInstanceAsync(
effectiveAppHostProjectFile,
ExecutionContext.HomeDirectory,
cancellationToken);
stopRunningInstanceActivity.SetAppHostRunningInstanceResult(runningInstanceResult);
}
if (runningInstanceResult == RunningInstanceResult.InstanceStopped)
{
InteractionService.DisplayMessage(KnownEmojis.Information, AddCommandStrings.StoppedRunningInstance);
}
else if (runningInstanceResult == RunningInstanceResult.StopFailed)
{
return AddCommandFailure(CliExitCodes.FailedToAddPackage, AddCommandStrings.UnableToStopRunningInstances);
}
bool success;
using (var addPackageActivity = _profilingTelemetry.StartAddPackage(context.PackageId, context.PackageVersion, context.Source))
{
success = await InteractionService.ShowStatusAsync(
AddCommandStrings.AddingAspireIntegration,
async () => await project.AddPackageAsync(context, cancellationToken)
);
addPackageActivity.SetAddPackageSuccess(success);
if (!success)
{
addPackageActivity.SetError("Package installation failed.");
}
}
if (!success)
{
if (context.OutputCollector is { } outputCollector)
{
InteractionService.DisplayLines(outputCollector.GetLines());
}
return AddCommandFailure(CliExitCodes.FailedToAddPackage, string.Format(CultureInfo.CurrentCulture, AddCommandStrings.PackageInstallationFailed, CliExitCodes.FailedToAddPackage));
}
InteractionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.PackageAddedSuccessfully, selectedNuGetPackage.Package.Id, selectedNuGetPackage.Package.Version));
addActivity.SetProcessExitCode(CliExitCodes.Success);
return CommandResult.Success();
}
catch (ProjectLocatorException ex)
{
addActivity.SetError(ex);
return HandleProjectLocatorException(ex, InteractionService, Telemetry);
}
catch (OperationCanceledException)
{
return CommandResult.Cancelled();
}
catch (EmptyChoicesException ex)
{
addActivity.SetProcessExitCode(CliExitCodes.FailedToAddPackage);
addActivity.SetError(ex.Message);
Telemetry.RecordError(ex.Message, ex);
return CommandResult.Failure(CliExitCodes.FailedToAddPackage, ex.Message);
}
catch (Exception ex)
{
if (context?.OutputCollector is { } outputCollector)
{
InteractionService.DisplayLines(outputCollector.GetLines());
}
var errorMessage = string.Format(CultureInfo.CurrentCulture, AddCommandStrings.ErrorOccurredWhileAddingPackage, ex.Message);
addActivity.SetProcessExitCode(CliExitCodes.FailedToAddPackage);
addActivity.SetError(ex);
Telemetry.RecordError(errorMessage, ex);
return CommandResult.Failure(CliExitCodes.FailedToAddPackage, errorMessage);
}
finally
{
addActivity.Dispose();
}
}
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).Distinct();
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,
string? configuredChannel,
CancellationToken cancellationToken,
bool promptForSinglePackage = false)
{
var distinctPackages = possiblePackages.DistinctBy(p => p.Package.Id).ToArray();
// Exact matches can skip the package prompt when one package remains. Fuzzy/no-match
// fallbacks opt into prompting so interactive users confirm the candidate first.
// In non-interactive mode, auto-select the first package.
var selectedPackage = distinctPackages.Length switch
{
1 when promptForSinglePackage && _hostEnvironment.SupportsInteractiveInput => await PromptForIntegrationAsync(distinctPackages, cancellationToken),
1 => distinctPackages.First(),
> 1 when !_hostEnvironment.SupportsInteractiveInput => distinctPackages.First(),
> 1 => await PromptForIntegrationAsync(distinctPackages, cancellationToken),
_ => throw new InvalidOperationException(AddCommandStrings.UnexpectedNumberOfPackagesFound)
};
var packageVersions = possiblePackages.Where(p => p.Package.Id == selectedPackage.Package.Id).ToArray();
// 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 (!string.IsNullOrEmpty(preferredVersion))
{
if (packageVersions.Any(p => p.Package.Version == preferredVersion))
{
var preferredVersionPackage = packageVersions.First(p => p.Package.Version == preferredVersion);
return preferredVersionPackage;
}
var allVersions = await InteractionService.ShowStatusAsync(
string.Format(CultureInfo.CurrentCulture, AddCommandStrings.SearchingForSpecifiedPackageVersion, selectedPackage.Package.Id, preferredVersion),
async () => await GetAllPackageVersions(workingDirectory, packageVersions, cancellationToken));
var matchedPreferredVersionPackage = allVersions.FirstOrDefault(packageVersion => packageVersion.Package.Version == preferredVersion);
if (matchedPreferredVersionPackage.Package is not null)
{
return matchedPreferredVersionPackage;
}
throw new EmptyChoicesException(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.SpecifiedVersionNotFoundForPackage, selectedPackage.Package.Id, preferredVersion));
}
// When PR hives are present, prefer the package that exactly matches the installed
// CLI/SDK version so template- and add-generated projects stay on the same build.
// IsBackedByLocalPackageDirectory also covers the ASPIRE_CLI_PACKAGES emulation case,
// where the local channel is named stable/daily/staging rather than a local-build name.
var prChannelPackageVersions = packageVersions
.Where(p => VersionHelper.IsLocalBuildChannel(p.Channel.Name) || p.Channel.IsBackedByLocalPackageDirectory)
.ToArray();
if (VersionHelper.TryGetCurrentCliVersionMatch(
prChannelPackageVersions,
p => p.Package.Version,
ExecutionContext.IdentitySdkVersion,
out var cliVersionPackage,
channelName: null,
hasPrHives: ExecutionContext.GetHiveCount() > 0 || ExecutionContext.IdentityPackagesDirectory is not null))
{
return cliVersionPackage;
}
// In non-interactive mode, prefer the channel the apphost has pinned (e.g. a polyglot
// apphost that persists `channel: daily` in aspire.config.json). When a channel is pinned,
// it reflects explicit user intent and must outrank the implicit/ambient channel — otherwise
// the implicit channel (which for polyglot apphosts just mirrors nuget.org) shadows the pinned
// feed and we auto-select a stable version the pinned feed can't restore, producing a hard
// restore failure. See https://github.com/microsoft/aspire/issues/18114 (the polyglot
// selection-path manifestation of the C#-only display bug https://github.com/microsoft/aspire/issues/17294).
// When no channel is pinned (the common C# case, where configuredChannel is null) we keep the prior behavior:
// implicit/default channel first, then latest version within the chosen channel.
var orderedPackageVersions = packageVersions
.OrderByDescending(p => MatchesConfiguredChannel(p.Channel, configuredChannel))
.ThenByDescending(p => p.Channel.Type is PackageChannelType.Implicit)
.ThenByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer);
if (!_hostEnvironment.SupportsInteractiveInput)
{
return orderedPackageVersions.First();
}
// ... otherwise we had better prompt.
var version = await PromptForIntegrationVersionAsync(orderedPackageVersions, configuredChannel, cancellationToken);
return version;
}
// A package "matches" the pinned channel when the apphost pinned an explicit channel name and the
// package was discovered from the explicit channel of that name (case-insensitive). The implicit
// channel never matches because it has no stable name to pin against.
private static bool MatchesConfiguredChannel(PackageChannel channel, string? configuredChannel)
{
return !string.IsNullOrEmpty(configuredChannel)
&& channel.Type is PackageChannelType.Explicit
&& string.Equals(channel.Name, configuredChannel, StringComparisons.ChannelName);
}
private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForIntegrationAsync(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> packages, CancellationToken cancellationToken)
{
using var promptActivity = _profilingTelemetry.StartAddSelectPackagePrompt();
return await _prompter.PromptForIntegrationAsync(packages, cancellationToken);
}
private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForIntegrationVersionAsync(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> packages, string? configuredChannel, CancellationToken cancellationToken)
{
using var promptActivity = _profilingTelemetry.StartAddSelectPackagePrompt();
return await _prompter.PromptForIntegrationVersionAsync(packages, configuredChannel, cancellationToken);
}
private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlowWithNoMatchesMessage(
DirectoryInfo workingDirectory,
IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages,
string? searchTerm,
string? preferredVersion,
string? configuredChannel,
CancellationToken cancellationToken,
bool promptForSinglePackage = false)
{
if (searchTerm is not null)
{
InteractionService.DisplaySubtleMessage(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.NoPackagesMatchedSearchTerm, searchTerm));
}
return await GetPackageByInteractiveFlow(workingDirectory, possiblePackages, preferredVersion, configuredChannel, cancellationToken, promptForSinglePackage);
}
}
internal interface IAddCommandPrompter
{
Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForIntegrationAsync(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> packages, CancellationToken cancellationToken);
Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForIntegrationVersionAsync(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> packages, string? configuredChannel, CancellationToken cancellationToken);
}
internal class AddCommandPrompter(IInteractionService interactionService) : IAddCommandPrompter
{
public virtual async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForIntegrationVersionAsync(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> packages, string? configuredChannel, CancellationToken cancellationToken)
{
var firstPackage = packages.First();
// Helper to keep labels consistently formatted: "Version (source)"
static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, PackageChannel Channel) item)
{
return $"{item.Package.Version.EscapeMarkup()} ({item.Channel.SourceDetails.EscapeMarkup()})";
}
async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForChannelPackagesAsync(
PackageChannel channel,
IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> items,
CancellationToken ct)
{
var choices = items
.Select(i => (
Label: FormatVersionLabel(i),
Result: i
))
.ToArray();
// Auto-select when there's only one version in the channel
if (choices.Length == 1)
{
return choices[0].Result;
}
var selection = await interactionService.PromptForSelectionAsync(
string.Format(CultureInfo.CurrentCulture, AddCommandStrings.SelectAVersionOfPackage, firstPackage.Package.Id),
choices,
c => c.Label,
cancellationToken: ct);
return selection.Result;
}
// Group the incoming package versions by channel and filter to highest version per channel
var byChannel = packages
.GroupBy(p => p.Channel)
.Select(g => new
{
Channel = g.Key,
// Keep only the highest version in each channel
HighestVersion = g.OrderByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer).First()
})
.ToArray();
var implicitGroup = byChannel.FirstOrDefault(g => g.Channel.Type is Packaging.PackageChannelType.Implicit);
var explicitGroups = byChannel
.Where(g => g.Channel.Type is Packaging.PackageChannelType.Explicit)
.ToArray();
// If there are no explicit channels, automatically select from the implicit channel
if (explicitGroups.Length == 0 && implicitGroup is not null)
{
return implicitGroup.HighestVersion;
}
// Build the root menu. When the apphost has pinned a channel (e.g. a polyglot apphost that
// persists `channel: daily` in aspire.config.json), surface that channel's package as the first
// option so it becomes the default selection — the pinned channel reflects explicit user intent
// and its feed is the one the project will actually restore from. Otherwise the implicit/ambient
// channel can shadow the pinned feed and offer a version the project can't restore. See
// https://github.com/microsoft/aspire/issues/18114.
var rootChoices = new List<(string Label, Func<CancellationToken, Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>> Action)>();
var configuredGroup = string.IsNullOrEmpty(configuredChannel)
? null
: explicitGroups.FirstOrDefault(g => string.Equals(g.Channel.Name, configuredChannel, StringComparisons.ChannelName));
if (configuredGroup is not null)
{
var channel = configuredGroup.Channel;
var item = configuredGroup.HighestVersion;
rootChoices.Add((
Label: channel.Name.EscapeMarkup(),
Action: ct => PromptForChannelPackagesAsync(channel, new[] { item }, ct)
));
}
if (implicitGroup is not null)
{
var captured = implicitGroup.HighestVersion;
rootChoices.Add((
Label: FormatVersionLabel(captured),
Action: ct => Task.FromResult(captured)
));
}
foreach (var channelGroup in explicitGroups)
{
// The pinned channel (if any) was already added above as the first/default choice.
if (ReferenceEquals(channelGroup, configuredGroup))
{
continue;
}
var channel = channelGroup.Channel;
var item = channelGroup.HighestVersion;
rootChoices.Add((
Label: channel.Name.EscapeMarkup(),
// For explicit channels, we still show submenu but with only the highest version
Action: ct => PromptForChannelPackagesAsync(channel, new[] { item }, ct)
));
}
// Fallback if no choices for some reason
if (rootChoices.Count == 0)
{
return firstPackage;
}
// Auto-select when there's only one option (e.g., single explicit channel)
if (rootChoices.Count == 1)
{
return await rootChoices[0].Action(cancellationToken);
}
var topSelection = await interactionService.PromptForSelectionAsync(
string.Format(CultureInfo.CurrentCulture, AddCommandStrings.SelectAVersionOfPackage, firstPackage.Package.Id),
rootChoices,
c => c.Label,
cancellationToken: cancellationToken);
return await topSelection.Action(cancellationToken);
}
public virtual async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForIntegrationAsync(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> packages, CancellationToken cancellationToken)
{
// Filter to show only the highest version for each package ID
var filteredPackages = packages
.GroupBy(p => p.Package.Id)
.Select(g => g.OrderByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer).First())
.ToArray();
var selectedIntegration = await interactionService.PromptForSelectionAsync(
AddCommandStrings.SelectAnIntegrationToAdd,
filteredPackages,
PackageNameWithFriendlyNameIfAvailable,
cancellationToken: cancellationToken);
return selectedIntegration;
}
private static string PackageNameWithFriendlyNameIfAvailable((string FriendlyName, NuGetPackage Package, PackageChannel Channel) packageWithFriendlyName)
{
if (packageWithFriendlyName.FriendlyName is { } friendlyName)
{
return $"[bold]{friendlyName.EscapeMarkup()}[/] ({packageWithFriendlyName.Package.Id.EscapeMarkup()})";
}
else
{
return packageWithFriendlyName.Package.Id.EscapeMarkup();
}
}
}
internal sealed class CommunityToolkitFirstComparer : IComparer<string>
{
public int Compare(string? x, string? y)
{
ArgumentNullException.ThrowIfNull(x);
ArgumentNullException.ThrowIfNull(y);
var prefix = "communitytoolkit-";
var xStarts = x.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
var yStarts = y.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
return (xStarts, yStarts) switch
{
(true, false) => 1,
(false, true) => -1,
_ => string.Compare(x, y, StringComparison.OrdinalIgnoreCase)
};
}
}