// 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.Commands;
using Aspire.Cli.DotNet;
using Aspire.Cli.Exceptions;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Utils;
using System.Globalization;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
namespace Aspire.Cli.Templating;
/// <summary>
/// Handles NuGet.config creation and updates for template output directories,
/// and provides channel-aware template package resolution and installation.
/// </summary>
internal sealed class TemplateNuGetConfigService(
IInteractionService interactionService,
CliExecutionContext executionContext,
IPackagingService packagingService,
ITemplateVersionPrompter templateVersionPrompter,
ICliHostEnvironment hostEnvironment)
{
/// <summary>
/// The name of the NuGet package that ships the Aspire project templates.
/// </summary>
public const string TemplatesPackageName = "Aspire.ProjectTemplates";
/// <summary>
/// Applies NuGet.config create/update behavior for a resolved package channel.
/// </summary>
/// <param name="channel">The resolved package channel.</param>
/// <param name="outputPath">The output path where the project was created.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public async Task PromptToCreateOrUpdateNuGetConfigAsync(PackageChannel channel, string outputPath, CancellationToken cancellationToken)
{
// Implicit channels (and any explicit channel without feed mappings) resolve from the
// ambient NuGet config, so there's nothing to create or merge — return before touching
// the output directory (which may not exist yet during `aspire new`).
var mappings = channel.Mappings;
if (mappings is null || mappings.Length == 0)
{
return;
}
// If this channel shouldn't get a fresh project NuGet.config (e.g. stable → nuget.org),
// only update an *existing* config in the target directory to clean up stale feeds from a
// previous channel; never create a new one, because a <clear/>-based config would wipe the
// user's other feeds. If the output directory doesn't exist yet there can't be an existing
// config, so there's nothing to do. See: https://github.com/microsoft/aspire/issues/18124
if (!channel.ShouldCreateNuGetConfig())
{
var targetDir = new DirectoryInfo(outputPath);
if (!targetDir.Exists || !NuGetConfigMerger.TryFindNuGetConfigInDirectory(targetDir, out _))
{
return;
}
}
var workingDir = executionContext.WorkingDirectory;
var outputDir = new DirectoryInfo(outputPath);
var normalizedOutputPath = Path.GetFullPath(outputPath);
var normalizedWorkingPath = workingDir.FullName;
var isInPlaceCreation = string.Equals(normalizedOutputPath, normalizedWorkingPath, StringComparison.OrdinalIgnoreCase);
var nugetConfigPrompter = new NuGetConfigPrompter(interactionService);
if (!isInPlaceCreation)
{
await nugetConfigPrompter.CreateOrUpdateWithoutPromptAsync(outputDir, channel, cancellationToken);
return;
}
await nugetConfigPrompter.PromptToCreateOrUpdateAsync(workingDir, channel, cancellationToken);
}
/// <summary>
/// Applies NuGet.config create/update behavior for a channel name resolved from any of
/// the equivalent channel-name sources: <c>--channel</c>, per-project
/// <c>aspire.config.json#channel</c>, or the running CLI's
/// <see cref="CliExecutionContext.IdentityChannel"/>.
/// </summary>
/// <param name="channelName">
/// The channel name to look up in the packaging service. May be sourced from
/// <c>--channel</c>, per-project <c>aspire.config.json#channel</c>, or the running
/// CLI's <see cref="CliExecutionContext.IdentityChannel"/> — all are name-equivalent
/// lookup keys for this entrypoint.
/// </param>
/// <param name="outputPath">The output path where the project was created.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public async Task PromptToCreateOrUpdateNuGetConfigAsync(string? channelName, string outputPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(channelName))
{
return;
}
var channels = await packagingService.GetChannelsAsync(cancellationToken, channelName);
var matchingChannel = channels.FirstOrDefault(c =>
string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));
if (matchingChannel is null)
{
return;
}
await PromptToCreateOrUpdateNuGetConfigAsync(matchingChannel, outputPath, cancellationToken);
}
/// <summary>
/// Creates or updates NuGet.config for the given channel name without prompting the user
/// and without displaying a confirmation message containing "NuGet.config" (which can
/// trip up automation/tests that match on substrings). Suitable for non-interactive
/// code paths such as <c>aspire init</c> where the caller wants to display its own
/// message (or none). The channel name may come from any of the equivalent
/// channel-name sources: <c>--channel</c>, per-project
/// <c>aspire.config.json#channel</c>, or the running CLI's
/// <see cref="CliExecutionContext.IdentityChannel"/>.
/// </summary>
/// <param name="channelName">
/// The channel name to look up in the packaging service. May be sourced from
/// <c>--channel</c>, per-project <c>aspire.config.json#channel</c>, or the running
/// CLI's <see cref="CliExecutionContext.IdentityChannel"/> — all are name-equivalent
/// lookup keys for this entrypoint.
/// </param>
/// <param name="outputPath">The output path where the NuGet.config should be created or updated.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns><see langword="true"/> if a NuGet.config was created or updated; otherwise <see langword="false"/>.</returns>
public async Task<bool> CreateOrUpdateNuGetConfigWithoutPromptAsync(string? channelName, string outputPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(channelName))
{
return false;
}
var channels = await packagingService.GetChannelsAsync(cancellationToken, channelName);
var matchingChannel = channels.FirstOrDefault(c =>
string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));
if (matchingChannel is null)
{
return false;
}
// Implicit channels (and any explicit channel without feed mappings) resolve from the
// ambient NuGet config, so there's nothing to create or merge — return before touching
// the output directory (which may not exist yet).
var mappings = matchingChannel.Mappings;
if (mappings is null || mappings.Length == 0)
{
return false;
}
// If this channel shouldn't get a fresh project NuGet.config (e.g. stable → nuget.org),
// only update an *existing* config to clean up stale feeds from a previous channel; never
// create a new one — a <clear/>-based config would hide the ambient nuget.org feed and the
// user's other feeds. If the output directory doesn't exist yet there can't be an existing
// config, so there's nothing to do. See: https://github.com/microsoft/aspire/issues/18124
if (!matchingChannel.ShouldCreateNuGetConfig())
{
var targetDir = new DirectoryInfo(outputPath);
if (!targetDir.Exists || !NuGetConfigMerger.TryFindNuGetConfigInDirectory(targetDir, out _))
{
return false;
}
}
// Call the merger directly — bypass NuGetConfigPrompter so we don't emit a
// confirmation message containing the substring "NuGet.config", which the
// AspireInitAsync test helper false-matches as a user-facing Y/n prompt.
await NuGetConfigMerger.CreateOrUpdateAsync(new DirectoryInfo(outputPath), matchingChannel, cancellationToken: cancellationToken);
return true;
}
/// <summary>
/// Creates or updates a project NuGet.config that maps Aspire packages to an explicit package source override.
/// </summary>
public async Task<bool> CreateOrUpdateNuGetConfigForSourceOverrideAsync(
string? sourceOverride,
string? channelName,
string outputPath,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(sourceOverride))
{
return false;
}
PackageChannel? matchingChannel = null;
if (!string.IsNullOrWhiteSpace(channelName))
{
var channels = await packagingService.GetChannelsAsync(cancellationToken, channelName);
matchingChannel = channels.FirstOrDefault(c =>
string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));
}
return await CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, matchingChannel, outputPath, cancellationToken, executionContext.NuGetServiceIndexOverride);
}
/// <summary>
/// Creates or updates a project NuGet.config that maps Aspire packages to an explicit package source override.
/// </summary>
public static async Task<bool> CreateOrUpdateNuGetConfigForSourceOverrideAsync(
string? sourceOverride,
PackageChannel? channel,
string outputPath,
CancellationToken cancellationToken,
string? nugetServiceIndexOverride = null)
{
if (string.IsNullOrWhiteSpace(sourceOverride))
{
return false;
}
var mappings = PackageSourceOverrideMappings.Create(sourceOverride, channel, nugetServiceIndexOverride);
await NuGetConfigMerger.CreateOrUpdateAsync(
new DirectoryInfo(outputPath),
mappings,
channel?.ConfigureGlobalPackagesFolder ?? false,
cancellationToken: cancellationToken);
return true;
}
/// <summary>
/// Resolves the channel and template package version that should be used to install Aspire project templates.
/// </summary>
/// <param name="query">Inputs that control channel/version selection.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The selected template package and the channel it was resolved from.</returns>
/// <exception cref="ChannelNotFoundException">Thrown when <paramref name="query"/> specifies a channel name that does not match any configured channel.</exception>
/// <exception cref="EmptyChoicesException">Thrown when no template package versions are available across the considered channels.</exception>
public async Task<TemplatePackageSelection> ResolveTemplatePackageAsync(TemplatePackageQuery query, CancellationToken cancellationToken)
{
var allChannels = await packagingService.GetChannelsAsync(cancellationToken, query.RequestedChannel);
var isUnqualifiedLocalResolution =
query.IncludePrHives &&
string.Equals(executionContext.IdentityChannel, PackageChannelNames.Local, StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(query.RequestedChannel) &&
string.IsNullOrWhiteSpace(query.VersionOverride) &&
string.IsNullOrWhiteSpace(query.SourceOverride);
// Honor PR hives only when the caller opts in. Init suppresses this so a developer
// with stale ~/.aspire/hives/* doesn't get a different template than on a clean machine.
// PR dogfood installs can discover a matching local-build channel outside the default
// hives directory, so also treat an installed local-build source as a hive signal.
//
// An ASPIRE_CLI_PACKAGES / sidecar `packages` override is different from a stale hive: it
// is a deliberate, per-invocation instruction to resolve Aspire.* from a local directory
// (used to emulate a released/staging build entirely from locally built packages). Honor it
// unconditionally — even when PR-hive discovery is suppressed (e.g. `init`) and regardless of
// the emulated channel name (stable/daily/staging) — otherwise template resolution silently
// falls back to nuget.org instead of the local packages. See docs/specs/cli-identity-sidecar.md.
var hasLocalPackagesOverride = executionContext.IdentityPackagesDirectory is not null;
var hasPrHives = hasLocalPackagesOverride ||
(query.IncludePrHives &&
(executionContext.GetHiveCount() > 0 ||
allChannels.Any(static c => c.Type is PackageChannelType.Explicit && HasInstalledLocalBuildPackageSource(c))));
IEnumerable<PackageChannel> channels;
if (isUnqualifiedLocalResolution)
{
channels = allChannels.Where(c =>
c.IsBackedByLocalPackageDirectory &&
string.Equals(c.Name, executionContext.IdentityChannel, StringComparison.OrdinalIgnoreCase));
}
else if (!string.IsNullOrEmpty(query.RequestedChannel))
{
var matchingChannel = allChannels.FirstOrDefault(c =>
string.Equals(c.Name, query.RequestedChannel, StringComparison.OrdinalIgnoreCase))
?? throw new ChannelNotFoundException(
$"No channel found matching '{query.RequestedChannel}'. Valid options are: " +
$"{string.Join(", ", allChannels.Select(c => c.Name))}");
channels = [matchingChannel];
}
else if (!string.IsNullOrWhiteSpace(query.SourceOverride))
{
// Every channel would query the same explicit source, so querying PR/local channels as
// well would attach identical results to whichever channel finishes first. Keep the
// implicit channel as the deterministic owner unless the user requested a channel.
channels = allChannels.Where(c => c.Type is PackageChannelType.Implicit);
}
else
{
// If there are hives (PR build directories), include all channels.
// Otherwise, only use the implicit/default channel to avoid prompting.
channels = hasPrHives
? allChannels
: allChannels.Where(c => c.Type is PackageChannelType.Implicit);
}
var packagesFromChannels = await interactionService.ShowStatusAsync(Resources.TemplatingStrings.SearchingForAvailableTemplateVersions, async () =>
{
var results = new List<(NuGetPackage Package, PackageChannel Channel)>();
var resultsLock = new object();
await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
{
var templateSearchMappings = string.IsNullOrWhiteSpace(query.SourceOverride)
? channel.Mappings
: PackageSourceOverrideMappings.CreateForTemplateOperations(query.SourceOverride);
var templatePackages = await channel.GetTemplatePackagesAsync(
executionContext.WorkingDirectory,
templateSearchMappings,
// Init and explicit source/version overrides historically enumerate the source
// before this service selects a version. Keep pin filtering only for channel
// resolution in `aspire new`; unqualified local resolution selects the exact
// CLI identity version below from the complete candidate set.
filterLocalPackagesToPinnedVersion:
query.IncludePrHives &&
!isUnqualifiedLocalResolution &&
string.IsNullOrWhiteSpace(query.VersionOverride) &&
string.IsNullOrWhiteSpace(query.SourceOverride),
ct);
lock (resultsLock)
{
results.AddRange(templatePackages.Select(p => (p, channel)));
}
});
return results;
});
if (isUnqualifiedLocalResolution)
{
var localMatch = packagesFromChannels.FirstOrDefault(p =>
string.Equals(p.Package.Version, executionContext.IdentitySdkVersion, StringComparison.OrdinalIgnoreCase));
if (localMatch.Package is null)
{
throw new EmptyChoicesException(
string.Format(
CultureInfo.CurrentCulture,
Resources.TemplatingStrings.NoMatchingLocalTemplatePackage,
executionContext.IdentitySdkVersion));
}
return new TemplatePackageSelection(localMatch.Package, localMatch.Channel);
}
var orderedPackagesFromChannels = packagesFromChannels.OrderByDescending(p => Semver.SemVersion.Parse(p.Package.Version), Semver.SemVersion.PrecedenceComparer);
if (query.VersionOverride is { } version)
{
var explicitMatch = orderedPackagesFromChannels.FirstOrDefault(p =>
string.Equals(p.Package.Version, version, StringComparison.OrdinalIgnoreCase));
if (explicitMatch.Package is not null)
{
return new TemplatePackageSelection(explicitMatch.Package, explicitMatch.Channel);
}
throw new EmptyChoicesException(
string.Format(
CultureInfo.CurrentCulture,
Resources.TemplatingStrings.TemplateVersionNotFound,
version));
}
if (!packagesFromChannels.Any())
{
throw new EmptyChoicesException(Resources.TemplatingStrings.NoTemplateVersionsFound);
}
if (VersionHelper.TryGetCurrentCliVersionMatch(
orderedPackagesFromChannels,
p => p.Package.Version,
executionContext.IdentitySdkVersion,
out var cliVersionMatch,
channelName: query.RequestedChannel,
hasPrHives: hasPrHives))
{
return new TemplatePackageSelection(cliVersionMatch.Package, cliVersionMatch.Channel);
}
// If channel was specified via --channel option or per-project aspire.config.json
// (but no --version), automatically select the highest version from that channel
// without prompting.
if (!string.IsNullOrEmpty(query.RequestedChannel))
{
var first = orderedPackagesFromChannels.First();
return new TemplatePackageSelection(first.Package, first.Channel);
}
// In non-interactive mode, automatically select the highest version.
if (!hostEnvironment.SupportsInteractiveInput)
{
var first = orderedPackagesFromChannels.First();
return new TemplatePackageSelection(first.Package, first.Channel);
}
var prompted = await templateVersionPrompter.PromptForTemplatesVersionAsync(orderedPackagesFromChannels, cancellationToken);
return new TemplatePackageSelection(prompted.Package, prompted.Channel);
}
private static bool HasInstalledLocalBuildPackageSource(PackageChannel channel)
{
return VersionHelper.IsLocalBuildChannel(channel.Name) &&
channel.Mappings?.Any(static mapping => mapping.IsAspireDirectoryMapping) == true;
}
/// <summary>
/// Installs the resolved Aspire project templates package, generating a temporary NuGet.config from source-adjusted mappings when needed.
/// </summary>
/// <param name="selection">The template package + channel returned by <see cref="ResolveTemplatePackageAsync"/>.</param>
/// <param name="sourceOverride">Optional package source override applied to Aspire packages for installation.</param>
/// <param name="runner">The .NET CLI runner used to invoke <c>dotnet new install</c>. Passed in (rather than injected) because the runner has a transient DI lifetime.</param>
/// <param name="statusMessage">Status text shown while the install runs.</param>
/// <param name="statusEmoji">Optional emoji prefix shown next to the status message.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The install exit code, the parsed template version (if available), and the captured stdout/stderr lines.</returns>
public async Task<TemplateInstallOutcome> InstallTemplatePackageAsync(
TemplatePackageSelection selection,
string? sourceOverride,
IDotNetCliRunner runner,
string statusMessage,
KnownEmoji? statusEmoji,
CancellationToken cancellationToken)
{
var templateInstallMappings = string.IsNullOrWhiteSpace(sourceOverride)
? selection.Channel.Mappings
: PackageSourceOverrideMappings.CreateForTemplateOperations(sourceOverride);
// Whilst we install the templates - if source mappings are available we need
// to generate a temporary NuGet.config file to make sure we install the right package
// from the right feed. Without mappings we just use the ambient configuration
// (although we should still specify the source) because the user would have selected it.
//
// The temporary config is disposed when this method returns. That is intentional —
// only `dotnet new install` consumes the config; the subsequent `dotnet new <template>`
// call (in DotNetTemplateFactory and InitCommand) operates against the already-installed
// template hive and uses the ambient NuGet configuration.
using var temporaryConfig = templateInstallMappings is not null
? await TemporaryNuGetConfig.CreateAsync(templateInstallMappings)
: null;
var collector = new OutputCollector();
var (exitCode, templateVersion) = await interactionService.ShowStatusAsync<(int ExitCode, string? TemplateVersion)>(
statusMessage,
async () =>
{
var options = new ProcessInvocationOptions
{
StandardOutputCallback = collector.AppendOutput,
StandardErrorCallback = collector.AppendOutput,
};
return await runner.InstallTemplateAsync(
packageName: TemplatesPackageName,
version: selection.Package.Version,
nugetConfigFile: temporaryConfig?.ConfigFile,
nugetSource: string.IsNullOrWhiteSpace(sourceOverride) ? selection.Package.Source : sourceOverride,
force: true,
options: options,
cancellationToken: cancellationToken);
},
emoji: statusEmoji);
return new TemplateInstallOutcome(exitCode, templateVersion, collector.GetLines().ToArray());
}
}
/// <summary>
/// Inputs that control how <see cref="TemplateNuGetConfigService.ResolveTemplatePackageAsync"/> picks a channel and version.
/// </summary>
/// <param name="RequestedChannel">
/// The user/project-side channel request — either from <c>--channel</c>, per-project
/// <c>aspire.config.json#channel</c>, or (for <c>aspire init</c> only) the running CLI's
/// <see cref="CliExecutionContext.IdentityChannel"/>. When null, channel selection falls
/// back to PR-hive discovery or implicit-only depending on <paramref name="IncludePrHives"/>.
/// </param>
/// <param name="VersionOverride">Optional explicit template version (e.g. from <c>--version</c>).</param>
/// <param name="SourceOverride">
/// Optional package source override used exclusively for template discovery and installation. Without
/// <paramref name="RequestedChannel"/>, the implicit channel owns the result so installed hives cannot
/// assign an unrelated channel identity to a package discovered from this source.
/// </param>
/// <param name="IncludePrHives">When true (e.g. for <c>aspire new</c>), local PR hive directories under <c>~/.aspire/hives</c> participate in channel discovery; when false (e.g. for <c>aspire init</c>), they are ignored.</param>
internal sealed record TemplatePackageQuery(
string? RequestedChannel,
string? VersionOverride,
string? SourceOverride,
bool IncludePrHives);
/// <summary>
/// The template package and channel selected by <see cref="TemplateNuGetConfigService.ResolveTemplatePackageAsync"/>.
/// </summary>
/// <param name="Package">The selected template package (id, version, source).</param>
/// <param name="Channel">The channel that produced <paramref name="Package"/>.</param>
internal sealed record TemplatePackageSelection(NuGetPackage Package, PackageChannel Channel);
/// <summary>
/// Result of <see cref="TemplateNuGetConfigService.InstallTemplatePackageAsync"/>.
/// </summary>
/// <param name="ExitCode">Exit code from <c>dotnet new install</c>.</param>
/// <param name="TemplateVersion">Parsed template version (when the install reported one).</param>
/// <param name="OutputLines">Captured stdout/stderr lines from the install process for diagnostic display by the caller.</param>
internal sealed record TemplateInstallOutcome(
int ExitCode,
string? TemplateVersion,
IReadOnlyList<(Aspire.Cli.Utils.OutputLineStream Stream, string Line)> OutputLines);