File: Commands\NewCommand.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 System.CommandLine;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.RegularExpressions;
using Aspire.Cli.Bundles;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Templating;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Configuration;
using Spectre.Console;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
 
namespace Aspire.Cli.Commands;
 
internal sealed class NewCommand : BaseCommand
{
    internal override HelpGroup HelpGroup => HelpGroup.AppCommands;
 
    protected override bool UpdateNotificationsEnabled => true;
 
    internal override bool PrefetchesTemplatePackageMetadata => true;
 
    private readonly INewCommandPrompter _prompter;
    private readonly ITemplateProvider _templateProvider;
    private readonly ITemplate[] _templates;
    private readonly IBundleService _bundleService;
    private readonly IPackagingService _packagingService;
    private readonly AgentInitCommand _agentInitCommand;
    private readonly ICliHostEnvironment _hostEnvironment;
 
    internal static readonly Option<string?> s_nameOption = new("--name", "-n")
    {
        Description = NewCommandStrings.NameArgumentDescription,
        Recursive = true
    };
    internal static readonly Option<string?> s_outputOption = new("--output", "-o")
    {
        Description = NewCommandStrings.OutputArgumentDescription,
        Recursive = true
    };
    internal static readonly Option<string?> s_sourceOption = new("--source", "-s")
    {
        Description = NewCommandStrings.SourceArgumentDescription,
        Recursive = true
    };
    private static readonly Option<string?> s_versionOption = new("--version")
    {
        Description = NewCommandStrings.VersionArgumentDescription,
        Recursive = true
    };
 
    internal static readonly Option<bool?> s_suppressAgentInitOption = new("--suppress-agent-init")
    {
        Description = SharedCommandStrings.AgentInitOptionDescription,
        Recursive = true
    };
 
    private readonly Option<string?> _channelOption;
    private readonly Option<string?> _languageOption;
 
    public NewCommand(
        INewCommandPrompter prompter,
        ITemplateProvider templateProvider,
        IBundleService bundleService,
        IPackagingService packagingService,
        AgentInitCommand agentInitCommand,
        ICliHostEnvironment hostEnvironment,
        IConfiguration configuration,
        CommonCommandServices services)
        : base("new", NewCommandStrings.Description, services)
    {
        _prompter = prompter;
        _templateProvider = templateProvider;
        _bundleService = bundleService;
        _packagingService = packagingService;
        _agentInitCommand = agentInitCommand;
        _hostEnvironment = hostEnvironment;
 
        Options.Add(s_nameOption);
        Options.Add(s_outputOption);
        Options.Add(s_sourceOption);
        Options.Add(s_versionOption);
        Options.Add(s_suppressAgentInitOption);
        Options.Add(AgentInitCommand.s_skillLocationsOption);
        Options.Add(AgentInitCommand.s_skillsOption);
 
        // Customize description based on whether staging channel is enabled
        var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(services.Features, configuration)
            || string.Equals(ExecutionContext.IdentityChannel, PackageChannelNames.Staging, StringComparisons.ChannelName);
        _channelOption = new Option<string?>("--channel")
        {
            Description = isStagingEnabled
                ? NewCommandStrings.ChannelOptionDescriptionWithStaging
                : NewCommandStrings.ChannelOptionDescription,
            Recursive = true
        };
        Options.Add(_channelOption);
 
        _languageOption = new Option<string?>("--language")
        {
            Description = NewCommandStrings.LanguageOptionDescription,
            Recursive = true
        };
        Options.Add(_languageOption);
 
        // Register template definitions as subcommands synchronously.
        // This uses GetTemplates() which returns template definitions without
        // performing any async I/O (e.g. SDK availability checks). Runtime
        // availability is checked in ExecuteAsync via GetTemplatesAsync().
        _templates = templateProvider.GetTemplates().ToArray();
 
        foreach (var template in _templates)
        {
            var templateCommand = new TemplateCommand(template, ExecuteAsync, services);
            Subcommands.Add(templateCommand);
        }
    }
 
    private string? ParseExplicitLanguageId(ParseResult parseResult)
    {
        var explicitLanguageId = parseResult.GetValue(_languageOption);
        return string.IsNullOrWhiteSpace(explicitLanguageId) ? null : NormalizeLanguageId(explicitLanguageId);
    }
 
    internal override void PrepareForExecution(ParseResult parseResult)
    {
        if (!string.IsNullOrWhiteSpace(parseResult.GetValue(s_sourceOption)))
        {
            // The foreground template lookup applies --source. Background prefetch does not know
            // about invocation options, so letting it run would still contact fallback feeds.
            DisableTemplatePackageMetadataPrefetchingForInvocation();
        }
    }
 
    private static string NormalizeLanguageId(string languageId)
    {
        return languageId.Equals(KnownLanguageId.TypeScriptAlias, StringComparison.OrdinalIgnoreCase)
            ? KnownLanguageId.TypeScript
            : languageId;
    }
 
    private static string GetLanguageDisplayName(string languageId)
    {
        return NormalizeLanguageId(languageId) switch
        {
            KnownLanguageId.CSharp => KnownLanguageId.CSharpDisplayName,
            KnownLanguageId.TypeScript => "TypeScript (Node.js)",
            KnownLanguageId.Python => KnownLanguageId.PythonDisplayName,
            KnownLanguageId.Go => KnownLanguageId.GoDisplayName,
            KnownLanguageId.Java => KnownLanguageId.JavaDisplayName,
            KnownLanguageId.Rust => KnownLanguageId.RustDisplayName,
            _ => languageId
        };
    }
 
    private async Task<string> PromptForAppHostLanguageAsync(IReadOnlyList<string> selectableLanguages, CancellationToken cancellationToken)
    {
        var choices = selectableLanguages
            .Select(NormalizeLanguageId)
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .Select(static languageId => (LanguageId: languageId, DisplayName: GetLanguageDisplayName(languageId)))
            .ToArray();
 
        var selected = await InteractionService.PromptForSelectionAsync(
            "Which language would you like to use?",
            choices,
            choice => choice.DisplayName.EscapeMarkup(),
            cancellationToken: cancellationToken);
 
        return selected.LanguageId;
    }
 
    private static NuGetPackage? TryGetCurrentCliTemplateVersionPackage(PackageChannel selectedChannel, NuGetPackage[] packages, bool hasPrHives, string cliSdkVersion)
    {
        if (VersionHelper.TryGetCurrentCliVersionMatch(
            packages,
            p => p.Version,
            cliSdkVersion,
            out var cliVersionPackage,
            channelName: selectedChannel.Name,
            hasPrHives: hasPrHives))
        {
            return cliVersionPackage;
        }
 
        if (packages.Length > 0 &&
            selectedChannel.Type is PackageChannelType.Explicit &&
            !string.Equals(selectedChannel.Name, PackageChannelNames.Stable, StringComparisons.ChannelName) &&
            !VersionHelper.IsLocalBuildChannel(selectedChannel.Name))
        {
            // Prerelease channels (daily, staging) filter the shipped stable package out of channel
            // search even when the channel's feed mappings can still restore it (they fall back to
            // nuget.org). For those channels, pinning to the running CLI's SDK version keeps the
            // bundled server and the restored Aspire packages in lock-step. Without this, `aspire new
            // --channel daily` on a shipped 13.4 CLI floats templates to a 13.5 daily preview, which
            // then breaks the bundled 13.4 AppHost server with `Aspire.TypeSystem, Version=13.5.0.0`
            // assembly load errors followed by `No language support found for: typescript/nodejs`.
            //
            // The stable channel is excluded here on purpose: it does not apply that filter, so a
            // "no exact match" outcome means the CLI version is genuinely not published on the stable
            // feed (the CLI is daily-shape, staging-shape, or PR-shape `13.4.0-pr.X.gY`). Forcing
            // it through would either contradict the user's explicit `--channel stable` request or
            // write an unpublishable version into `apphost.cs` that NuGet restore cannot satisfy.
            // Fall through to the OrderByDescending picker so the user gets the highest shipped
            // stable package they actually asked for.
            return new NuGetPackage
            {
                Id = TemplateNuGetConfigService.TemplatesPackageName,
                Version = cliSdkVersion,
                Source = selectedChannel.SourceDetails
            };
        }
 
        return null;
    }
 
    private async Task<(bool Success, string? LanguageId)> ResolveSelectedLanguageAsync(ITemplate template, ParseResult parseResult, CancellationToken cancellationToken)
    {
        var explicitLanguageId = ParseExplicitLanguageId(parseResult);
 
        if (template.SelectableAppHostLanguages.Count == 0)
        {
            if (!string.IsNullOrWhiteSpace(explicitLanguageId) && !template.SupportsLanguage(explicitLanguageId))
            {
                InteractionService.DisplayError($"Template '{template.Name}' does not support language '{explicitLanguageId}'.");
                return (false, null);
            }
 
            return (true, explicitLanguageId ?? template.LanguageId);
        }
 
        if (!string.IsNullOrWhiteSpace(explicitLanguageId))
        {
            var normalizedExplicitLanguageId = NormalizeLanguageId(explicitLanguageId);
            if (!template.SelectableAppHostLanguages.Any(l => l.Equals(normalizedExplicitLanguageId, StringComparison.OrdinalIgnoreCase)))
            {
                InteractionService.DisplayError($"Template '{template.Name}' does not support language '{explicitLanguageId}'.");
                return (false, null);
            }
 
            return (true, normalizedExplicitLanguageId);
        }
 
        if (!_hostEnvironment.SupportsInteractiveInput)
        {
            return (true, NormalizeLanguageId(template.SelectableAppHostLanguages[0]));
        }
 
        var selectedLanguageId = await PromptForAppHostLanguageAsync(template.SelectableAppHostLanguages, cancellationToken);
        return (true, selectedLanguageId);
    }
 
    private ITemplate[] GetTemplatesForTemplateArgument(ITemplate[] availableTemplates, ParseResult parseResult)
    {
        var explicitLanguageId = ParseExplicitLanguageId(parseResult);
        var templates = availableTemplates.ToList();
 
        if (!string.IsNullOrWhiteSpace(explicitLanguageId))
        {
            templates = templates
                .Where(t => t.SupportsLanguage(explicitLanguageId))
                .ToList();
        }
 
        // Sort templates alphabetically by description, keeping empty templates at the end
        templates.Sort((a, b) =>
        {
            var aIsEmpty = a.IsEmpty;
            var bIsEmpty = b.IsEmpty;
 
            if (aIsEmpty != bIsEmpty)
            {
                return aIsEmpty ? 1 : -1;
            }
 
            return string.Compare(a.Description, b.Description, StringComparison.OrdinalIgnoreCase);
        });
 
        return templates.ToArray();
    }
 
    private ITemplate[] GetTemplatesForPrompt(ITemplate[] availableTemplates, ParseResult parseResult)
    {
        return GetTemplatesForTemplateArgument(availableTemplates, parseResult)
            .Where(static t => t.ShowInPrompt)
            .ToArray();
    }
 
    private async Task<ITemplate?> GetProjectTemplateAsync(ITemplate[] availableTemplates, ParseResult parseResult, CancellationToken cancellationToken)
    {
        // If a subcommand was matched (e.g., aspire new aspire-starter), find the template by command name
        if (parseResult.CommandResult.Command != this)
        {
            var subcommandTemplate = availableTemplates.SingleOrDefault(t => t.Name.Equals(parseResult.CommandResult.Command.Name, StringComparison.OrdinalIgnoreCase));
            if (subcommandTemplate is not null)
            {
                return subcommandTemplate;
            }
 
            // The template subcommand was parsed successfully but the template is
            // not available at runtime (e.g. .NET SDK is not installed).
            InteractionService.DisplayError($"Template '{parseResult.CommandResult.Command.Name}' is not available. Ensure the required runtime is installed.");
            return null;
        }
 
        var templatesForTemplateArgument = GetTemplatesForTemplateArgument(availableTemplates, parseResult);
        if (templatesForTemplateArgument.Length == 0)
        {
            InteractionService.DisplayError("No templates are available for the current environment.");
            return null;
        }
 
        if (!_hostEnvironment.SupportsInteractiveInput)
        {
            InteractionService.DisplayError(NewCommandStrings.NonInteractiveTemplateRequired);
            var templateNames = string.Join(", ", templatesForTemplateArgument.Select(t => t.Name));
            InteractionService.DisplaySubtleMessage(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NonInteractiveAvailableValues, templateNames));
            throw new NonInteractiveException("template");
        }
 
        var templatesForPrompt = GetTemplatesForPrompt(availableTemplates, parseResult);
        if (templatesForPrompt.Length == 0)
        {
            InteractionService.DisplayError("No templates are available for the current environment.");
            return null;
        }
 
        var result = await _prompter.PromptForTemplateAsync(templatesForPrompt, cancellationToken);
 
        return result;
    }
 
    private sealed class ResolveTemplateVersionResult
    {
        public string? Version { get; init; }
 
        public string? ChannelName { get; init; }
 
        [MemberNotNullWhen(true, nameof(Version))]
        [MemberNotNullWhen(false, nameof(ErrorMessage))]
        public bool Success => Version is not null;
 
        public string? ErrorMessage { get; init; }
    }
 
    private async Task<ResolveTemplateVersionResult> ResolveCliTemplateVersionAsync(ParseResult parseResult, string? source, CancellationToken cancellationToken)
    {
        return await InteractionService.ShowStatusAsync(
            NewCommandStrings.ResolvingTemplateVersion,
            async () =>
            {
                var configuredChannelName = parseResult.GetValue(_channelOption);
                var channels = await _packagingService.GetChannelsAsync(cancellationToken, configuredChannelName);
                var isUnqualifiedLocalResolution =
                    string.IsNullOrWhiteSpace(configuredChannelName) &&
                    string.IsNullOrWhiteSpace(source) &&
                    string.Equals(ExecutionContext.IdentityChannel, PackageChannelNames.Local, StringComparisons.ChannelName);
 
                // When no --channel was passed, prefer the channel whose name matches the running
                // CLI's identity (CliExecutionContext.IdentityChannel — stable, staging, daily,
                // local, or pr-<N>) over the Implicit (nuget.org) channel. This keeps the
                // resolved template package and the channel pinned into aspire.config.json
                // mutually satisfiable: a daily CLI scaffolds a daily-channel project whose
                // prerelease SDK version is reachable through the daily channel's Package Source
                // Mapping (Aspire.* → dnceng), a stable CLI scaffolds a stable project whose
                // stable SDK version is reachable through nuget.org, and so on. The opposite
                // outcome — resolving against Implicit while pinning channel to the identity —
                // makes restore reject the prerelease/stable mismatch with "Unable to find a
                // stable package Aspire.Hosting with version (>= …)".
                //
                // Falls back to the Implicit channel when the identity doesn't match any
                // registered channel (e.g. typoed override, future identity name) so the
                // command stays useful while surfacing a deterministic version.
                PackageChannel? identityChannelMatch = null;
                if (string.IsNullOrWhiteSpace(configuredChannelName) &&
                    string.IsNullOrWhiteSpace(source) &&
                    !string.IsNullOrWhiteSpace(ExecutionContext.IdentityChannel))
                {
                    identityChannelMatch = channels.FirstOrDefault(c =>
                        string.Equals(c.Name, ExecutionContext.IdentityChannel, StringComparisons.ChannelName));
                }
 
                var selectedChannel = isUnqualifiedLocalResolution
                    ? channels.FirstOrDefault(c =>
                        c.IsBackedByLocalPackageDirectory &&
                        string.Equals(c.Name, ExecutionContext.IdentityChannel, StringComparisons.ChannelName))
                    : string.IsNullOrWhiteSpace(configuredChannelName)
                        ? identityChannelMatch
                            ?? channels.FirstOrDefault(c => c.Type is PackageChannelType.Implicit)
                            ?? channels.FirstOrDefault()
                        : channels.FirstOrDefault(c => string.Equals(c.Name, configuredChannelName, StringComparisons.ChannelName));
 
                if (selectedChannel is null)
                {
                    string errorMessage;
                    if (isUnqualifiedLocalResolution)
                    {
                        errorMessage = GetMissingLocalTemplatePackageMessage();
                    }
                    else if (string.IsNullOrWhiteSpace(configuredChannelName))
                    {
                        errorMessage = NewCommandStrings.NoPackageChannelsAvailable;
                    }
                    else if (string.Equals(configuredChannelName, PackageChannelNames.Staging, StringComparisons.ChannelName)
                        && _packagingService.GetStagingChannelUnavailableReason() is { } stagingReason)
                    {
                        // Surface the actionable packaging-service reason (e.g. "daily CLI cannot
                        // synthesize a staging channel; set overrideStagingFeed") instead of the
                        // generic channel list, mirroring UpdateCommand's behavior.
                        // See https://github.com/microsoft/aspire/issues/16652.
                        errorMessage = stagingReason;
                    }
                    else
                    {
                        errorMessage = string.Format(
                            CultureInfo.CurrentCulture,
                            NewCommandStrings.NoChannelFoundMatching,
                            configuredChannelName,
                            string.Join(", ", channels.Select(c => c.Name)));
                    }
 
                    return new ResolveTemplateVersionResult { ErrorMessage = errorMessage };
                }
 
                // An explicit source without an explicit channel resolves through the implicit channel above,
                // so the identity channel cannot constrain package discovery or be persisted into the project.
                // When both options are explicit, the channel still owns version policy and persistence.
                var templateDiscoveryMappings = string.IsNullOrWhiteSpace(source)
                    ? selectedChannel.Mappings
                    : PackageSourceOverrideMappings.CreateForTemplateOperations(source);
 
                try
                {
                    var packages = (await selectedChannel.GetTemplatePackagesAsync(
                        ExecutionContext.WorkingDirectory,
                        templateDiscoveryMappings,
                        filterLocalPackagesToPinnedVersion: !isUnqualifiedLocalResolution,
                        cancellationToken))
                        .Where(p => Semver.SemVersion.TryParse(p.Version, Semver.SemVersionStyles.Strict, out _))
                        .ToArray();
                    var hasPrHives = ExecutionContext.GetHiveCount() > 0;
 
                    var package = isUnqualifiedLocalResolution
                        ? packages.FirstOrDefault(p => string.Equals(p.Version, ExecutionContext.IdentitySdkVersion, StringComparison.OrdinalIgnoreCase))
                        : TryGetCurrentCliTemplateVersionPackage(selectedChannel, packages, hasPrHives, ExecutionContext.IdentitySdkVersion);
 
                    if (!isUnqualifiedLocalResolution)
                    {
                        package ??= packages
                            .OrderByDescending(p => Semver.SemVersion.Parse(p.Version, Semver.SemVersionStyles.Strict), Semver.SemVersion.PrecedenceComparer)
                            .FirstOrDefault();
                    }
 
                    if (package is null)
                    {
                        var errorMessage = isUnqualifiedLocalResolution
                            ? GetMissingLocalTemplatePackageMessage()
                            : string.Format(
                                CultureInfo.CurrentCulture,
                                NewCommandStrings.NoTemplateVersionsFoundInChannel,
                                selectedChannel.Name);
                        return new ResolveTemplateVersionResult { ErrorMessage = errorMessage };
                    }
 
                    // Only persist channel names that should be pinned (e.g. local, daily,
                    // staging, pr-<N>). The `stable` channel is excluded (ShouldPersistChannelName)
                    // — its packages are on nuget.org, so leaving the project unpinned lets
                    // `aspire add`/`aspire restore` use the ambient NuGet config.
                    var channelName = selectedChannel.ShouldPersistChannelName() ? selectedChannel.Name : null;
 
                    return new ResolveTemplateVersionResult { Version = package.Version, ChannelName = channelName };
                }
                catch (NuGetPackageCacheException ex)
                {
                    return new ResolveTemplateVersionResult { ErrorMessage = ex.Message };
                }
            });
    }
 
    private string GetMissingLocalTemplatePackageMessage() =>
        string.Format(
            CultureInfo.CurrentCulture,
            NewCommandStrings.NoMatchingLocalTemplatePackage,
            ExecutionContext.IdentitySdkVersion);
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        using var activity = Telemetry.StartDiagnosticActivity(this.Name);
 
        var source = parseResult.GetValue(s_sourceOption);
        if (!string.IsNullOrWhiteSpace(source) && PackageSourceOverrideMappings.HasCredentialMaterial(source))
        {
            InteractionService.DisplayError(NewCommandStrings.SourceWithCredentialsCannotBePersisted);
            return CommandResult.Failure(CliExitCodes.InvalidCommand);
        }
        if (!string.IsNullOrWhiteSpace(source))
        {
            source = PackageSourceOverrideMappings.ResolveForWorkingDirectory(source, ExecutionContext.WorkingDirectory);
            if (PackageSourceOverrideMappings.GetMissingLocalDirectory(source) is { } missingDirectory)
            {
                InteractionService.DisplayError(string.Format(
                    CultureInfo.CurrentCulture,
                    NewCommandStrings.SourceDirectoryNotFound,
                    missingDirectory));
                return CommandResult.Failure(CliExitCodes.InvalidCommand);
            }
        }
 
        // Resolve which templates are actually available at runtime (performs
        // async checks like SDK availability). This may be a subset of the
        // templates registered as subcommands.
        var availableTemplates = (await _templateProvider.GetTemplatesAsync(cancellationToken)).ToArray();
 
        var template = await GetProjectTemplateAsync(availableTemplates, parseResult, cancellationToken);
        if (template is null)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand);
        }
 
        var (languageResolutionSuccess, selectedLanguageId) = await ResolveSelectedLanguageAsync(template, parseResult, cancellationToken);
        if (!languageResolutionSuccess)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand);
        }
 
        var version = parseResult.GetValue(s_versionOption);
        var hasExplicitVersion = !string.IsNullOrWhiteSpace(version);
        var requestedChannelName = parseResult.GetValue(_channelOption);
        // Precedence for the channel written into TemplateInputs.Channel:
        //   1. Explicit --channel argument (user override always wins).
        //   2. Channel returned by ResolveCliTemplateVersionAsync (CLI-runtime templates).
        //   3. The running CLI's IdentityChannel, when it matches a registered Explicit
        //      channel — needed for TemplateRuntime.DotNet starters (aspire-starter,
        //      aspire-starter-csharp-typescript) which otherwise resolve
        //      Aspire.ProjectTemplates from the Implicit (nuget.org) channel regardless
        //      of CLI identity, and also for CLI-runtime templates invoked with --version
        //      which short-circuits the resolver below.
        string? resolvedChannelName = null;
        if (ShouldResolveCliTemplateVersion(template) &&
            string.IsNullOrWhiteSpace(version))
        {
            var resolveResult = await ResolveCliTemplateVersionAsync(parseResult, source, cancellationToken);
            if (!resolveResult.Success)
            {
                return CommandResult.Failure(CliExitCodes.InvalidCommand, resolveResult.ErrorMessage);
            }
 
            version = resolveResult.Version;
            resolvedChannelName = resolveResult.ChannelName;
        }
 
        // An explicit source owns package resolution and must not inherit the CLI identity channel.
        // Unqualified local DotNet templates and explicit versions similarly defer local channel
        // selection. Forwarding "local" would persist mappings to a directory that may not contain
        // the requested version; callers that need those mappings can pass `--channel local`.
        var hasLocalIdentity = string.Equals(
            ExecutionContext.IdentityChannel,
            PackageChannelNames.Local,
            StringComparisons.ChannelName);
        var deferIdentityChannelResolution =
            string.IsNullOrWhiteSpace(requestedChannelName) &&
            (!string.IsNullOrWhiteSpace(source) ||
                hasLocalIdentity &&
                (template.Runtime is TemplateRuntime.DotNet || hasExplicitVersion));
 
        resolvedChannelName = requestedChannelName ?? resolvedChannelName;
        if (resolvedChannelName is null && !deferIdentityChannelResolution)
        {
            resolvedChannelName = await ResolveIdentityChannelNameAsync(cancellationToken);
        }
 
        var inputs = new TemplateInputs
        {
            Name = parseResult.GetValue(s_nameOption),
            Output = parseResult.GetValue(s_outputOption),
            Source = source,
            Version = version,
            Channel = resolvedChannelName,
            Language = selectedLanguageId
        };
        var templateResult = await template.ApplyTemplateAsync(inputs, parseResult, cancellationToken);
 
        // Generated AppHosts can be run directly by dotnet, which cannot trigger lazy bundle
        // extraction. Ensure the bundle is ready instead of relying on best-effort prefetching.
        if (templateResult.ExitCode == CliExitCodes.Success)
        {
            await _bundleService.EnsureExtractedAsync(cancellationToken);
        }
 
        var workspaceRoot = new DirectoryInfo(templateResult.OutputPath ?? ExecutionContext.WorkingDirectory.FullName);
        var agentInitBinding = PromptBinding.CreateInvertedBoolConfirm(parseResult, s_suppressAgentInitOption, defaultValue: true);
        var skillLocationsBinding = PromptBinding.Create(parseResult, AgentInitCommand.s_skillLocationsOption);
        var skillsBinding = PromptBinding.Create(parseResult, AgentInitCommand.s_skillsOption);
        // The template already produced the AppHost, so don't pre-select the one-time aspireify
        // wiring skill — users can still opt into it from the prompt.
        var agentInitResult = await _agentInitCommand.PromptAndChainAsync(InteractionService, templateResult.ExitCode, workspaceRoot, agentInitBinding, skillLocationsBinding, skillsBinding, AgentInitCommand.ExcludeOneTimeSetupSkillsFromDefaults, cancellationToken);
 
        if (templateResult.OutputPath is not null && ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _))
        {
            extensionInteractionService.OpenEditor(templateResult.OutputPath);
        }
 
        return CommandResult.FromExitCode(agentInitResult.ExitCode);
    }
 
    private static bool ShouldResolveCliTemplateVersion(ITemplate template)
    {
        return template.Runtime is TemplateRuntime.Cli;
    }
 
    /// <summary>
    /// Resolves <see cref="CliExecutionContext.IdentityChannel"/> to a registered channel name
    /// from the packaging service. Returns the channel name when an Explicit channel matches the
    /// identity (e.g. <c>daily</c>, <c>staging</c>, <c>stable</c>, <c>pr-&lt;N&gt;</c>); returns
    /// <see langword="null"/> when there is no identity, when no Explicit channel matches, or
    /// when only the Implicit (nuget.org) channel is registered. A <see langword="null"/> result
    /// intentionally lets the downstream template path consult the Implicit channel and avoids
    /// writing a per-project channel pin into the new project's NuGet configuration.
    /// </summary>
    private async Task<string?> ResolveIdentityChannelNameAsync(CancellationToken cancellationToken)
    {
        var identity = ExecutionContext.IdentityChannel;
        if (string.IsNullOrWhiteSpace(identity))
        {
            return null;
        }
 
        var channels = await _packagingService.GetChannelsAsync(cancellationToken, identity);
        var match = channels.FirstOrDefault(c =>
            string.Equals(c.Name, identity, StringComparisons.ChannelName));
 
        // Only forward channel names that should be pinned (daily, staging, pr-<N>, local).
        // `stable` is excluded (ShouldPersistChannelName): a stable CLI's packages are on
        // nuget.org, so returning null lets DotNetTemplateFactory resolve the Implicit
        // (nuget.org) channel and leaves the scaffolded project with no channel pin and no
        // NuGet.config — `aspire add`/`aspire restore` then use the ambient NuGet config.
        // Forwarding is only needed for non-stable identities (daily/pr/staging), whose
        // packages live on a custom feed the Implicit channel would otherwise miss. Mirrors
        // the same rule applied at the end of ResolveCliTemplateVersionAsync.
        return match?.ShouldPersistChannelName() is true ? match.Name : null;
    }
}
 
internal interface INewCommandPrompter
{
    Task<ITemplate> PromptForTemplateAsync(ITemplate[] validTemplates, CancellationToken cancellationToken);
    Task<string> PromptForProjectNameAsync(string defaultName, ParseResult parseResult, CancellationToken cancellationToken);
    Task<string> PromptForOutputPath(string v, ParseResult parseResult, Func<string, ValidationResult>? validator = null, Func<string, string>? outputPathResolver = null, CancellationToken cancellationToken = default);
}
 
internal interface ITemplateVersionPrompter
{
    /// <summary>
    /// Prompts the user to select a templates package version.
    /// </summary>
    /// <param name="candidatePackages">The available templates package candidates grouped across channels.</param>
    /// <param name="cancellationToken">A cancellation token.</param>
    /// <returns>The selected templates package and channel.</returns>
    Task<(NuGetPackage Package, PackageChannel Channel)> PromptForTemplatesVersionAsync(IEnumerable<(NuGetPackage Package, PackageChannel Channel)> candidatePackages, CancellationToken cancellationToken);
}
 
internal class NewCommandPrompter(IInteractionService interactionService) : INewCommandPrompter, ITemplateVersionPrompter
{
    public virtual async Task<(NuGetPackage Package, PackageChannel Channel)> PromptForTemplatesVersionAsync(IEnumerable<(NuGetPackage Package, PackageChannel Channel)> candidatePackages, CancellationToken cancellationToken)
    {
        // Check if we should skip the channel selection prompt
        // Skip prompt if there are no explicit channels (only the implicit/default channel)
        var byChannel = candidatePackages
            .GroupBy(cp => cp.Channel)
            .ToArray();
 
        var implicitGroup = byChannel.FirstOrDefault(g => g.Key.Type is Packaging.PackageChannelType.Implicit);
        var explicitGroups = byChannel
            .Where(g => g.Key.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 the highest version from the implicit channel
            return implicitGroup.OrderByDescending(p => Semver.SemVersion.Parse(p.Package.Version), Semver.SemVersion.PrecedenceComparer).First();
        }
 
        // Create a hierarchical selection experience:
        // - Top-level: all packages from the implicit channel (if any)
        // - Then: one entry per remaining channel that opens a sub-menu with that channel's packages
 
        // Local helpers
        static string FormatPackageLabel((NuGetPackage Package, PackageChannel Channel) item)
        {
            // Keep it concise: "Version (source)"
            return $"{item.Package.Version.EscapeMarkup()} ({item.Channel.SourceDetails.EscapeMarkup()})";
        }
 
        async Task<(NuGetPackage Package, PackageChannel Channel)> PromptForChannelPackagesAsync(
            PackageChannel channel,
            IEnumerable<(NuGetPackage Package, PackageChannel Channel)> items,
            CancellationToken ct)
        {
            // Show a sub-menu for this channel's packages
            var packageChoices = items
                .Select(i => (
                    Label: FormatPackageLabel(i),
                    Result: i
                ))
                .ToArray();
 
            var selection = await interactionService.PromptForSelectionAsync(
                NewCommandStrings.SelectATemplateVersion,
                packageChoices,
                c => c.Label,
                cancellationToken: ct);
 
            return selection.Result;
        }
 
        // Build the root menu as tuples of (label, action)
        var rootChoices = new List<(string Label, Func<CancellationToken, Task<(NuGetPackage, PackageChannel)>> Action)>();
 
        if (implicitGroup is not null)
        {
            // Add each implicit package directly to the root
            foreach (var item in implicitGroup)
            {
                var captured = item; // avoid modified-closure issues
                rootChoices.Add((
                    Label: FormatPackageLabel((captured.Package, captured.Channel)),
                    Action: ct => Task.FromResult((captured.Package, captured.Channel))
                ));
            }
        }
 
        // Add a submenu entry for each explicit channel
        foreach (var channelGroup in explicitGroups)
        {
            var channel = channelGroup.Key;
            var items = channelGroup.ToArray();
 
            rootChoices.Add((
                Label: channel.Name.EscapeMarkup(),
                Action: ct => PromptForChannelPackagesAsync(channel, items, ct)
            ));
        }
 
        // If for some reason we have no choices, fall back to the first candidate
        if (rootChoices.Count == 0)
        {
            return candidatePackages.First();
        }
 
        // Prompt user for the top-level selection
        var topSelection = await interactionService.PromptForSelectionAsync(
            NewCommandStrings.SelectATemplateVersion,
            rootChoices,
            c => c.Label,
            cancellationToken: cancellationToken);
 
        return await topSelection.Action(cancellationToken);
    }
 
    public virtual async Task<string> PromptForOutputPath(string path, ParseResult parseResult, Func<string, ValidationResult>? validator = null, Func<string, string>? outputPathResolver = null, CancellationToken cancellationToken = default)
    {
        var resolvedValidator = validator;
        if (validator is not null && outputPathResolver is not null)
        {
            resolvedValidator = candidatePath => validator(outputPathResolver(candidatePath));
        }
 
        var outputPath = await interactionService.PromptForFilePathAsync(
            NewCommandStrings.EnterTheOutputPath,
            validator: resolvedValidator,
            binding: PromptBinding.Create(parseResult, NewCommand.s_outputOption, path),
            directory: true,
            retryOnValidationFailure: true,
            cancellationToken: cancellationToken
            );
 
        return outputPathResolver?.Invoke(outputPath) ?? outputPath;
    }
 
    public virtual async Task<string> PromptForProjectNameAsync(string defaultName, ParseResult parseResult, CancellationToken cancellationToken)
    {
        return await interactionService.PromptForStringAsync(
            NewCommandStrings.EnterTheProjectName,
            binding: PromptBinding.Create(parseResult, NewCommand.s_nameOption, defaultName),
            validator: name => ProjectNameValidator.IsProjectNameValid(name)
                ? ValidationResult.Success()
                : ValidationResult.Error(NewCommandStrings.InvalidProjectName),
            cancellationToken: cancellationToken);
    }
 
    public virtual async Task<ITemplate> PromptForTemplateAsync(ITemplate[] validTemplates, CancellationToken cancellationToken)
    {
        return await interactionService.PromptForSelectionAsync(
            NewCommandStrings.SelectAProjectTemplate,
            validTemplates,
            t => t.Description.EscapeMarkup(),
            cancellationToken: cancellationToken
        );
    }
}
 
internal static partial class ProjectNameValidator
{
    // Regex for project name validation:
    // - Can be any characters except path separators (/ and \)
    // - Length: 1-254 characters
    // - Must not be empty or whitespace only
    [GeneratedRegex(@"^[^/\\]{1,254}$", RegexOptions.Compiled)]
    internal static partial Regex GetProjectNameRegex();
 
    public static bool IsProjectNameValid(string projectName)
    {
        if (string.IsNullOrWhiteSpace(projectName))
        {
            return false;
        }
 
        var regex = GetProjectNameRegex();
        return regex.IsMatch(projectName);
    }
}