File: Templating\TemplateNuGetConfigService.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// 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);