File: Commands\NewCommand.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.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.Text.RegularExpressions;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Templating;
using Aspire.Cli.Utils;
using Spectre.Console;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
 
namespace Aspire.Cli.Commands;
 
internal sealed class NewCommand : BaseCommand, IPackageMetaPrefetchingCommand
{
    internal override HelpGroup HelpGroup => HelpGroup.AppCommands;
 
    private readonly INewCommandPrompter _prompter;
    private readonly ITemplateProvider _templateProvider;
    private readonly ITemplate[] _templates;
    private readonly IFeatures _features;
    private readonly IPackagingService _packagingService;
    private readonly IConfigurationService _configurationService;
    private readonly AgentInitCommand _agentInitCommand;
    private readonly ICliHostEnvironment _hostEnvironment;
 
    private static readonly Option<string> s_nameOption = new("--name", "-n")
    {
        Description = NewCommandStrings.NameArgumentDescription,
        Recursive = true
    };
    private static readonly Option<string?> s_outputOption = new("--output", "-o")
    {
        Description = NewCommandStrings.OutputArgumentDescription,
        Recursive = true
    };
    private 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
    };
 
    private readonly Option<string?> _channelOption;
 
    /// <summary>
    /// NewCommand prefetches both template and CLI package metadata.
    /// </summary>
    public bool PrefetchesTemplatePackageMetadata => true;
 
    /// <summary>
    /// NewCommand prefetches CLI package metadata for update notifications.
    /// </summary>
    public bool PrefetchesCliPackageMetadata => true;
 
    public NewCommand(
        INewCommandPrompter prompter,
        IInteractionService interactionService,
        ITemplateProvider templateProvider,
        AspireCliTelemetry telemetry,
        IFeatures features,
        ICliUpdateNotifier updateNotifier,
        CliExecutionContext executionContext,
        IPackagingService packagingService,
        IConfigurationService configurationService,
        AgentInitCommand agentInitCommand,
        ICliHostEnvironment hostEnvironment)
        : base("new", NewCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry)
    {
        _prompter = prompter;
        _templateProvider = templateProvider;
        _features = features;
        _packagingService = packagingService;
        _configurationService = configurationService;
        _agentInitCommand = agentInitCommand;
        _hostEnvironment = hostEnvironment;
 
        Options.Add(s_nameOption);
        Options.Add(s_outputOption);
        Options.Add(s_sourceOption);
        Options.Add(s_versionOption);
 
        // Customize description based on whether staging channel is enabled
        var isStagingEnabled = _features.IsFeatureEnabled(KnownFeatures.StagingChannelEnabled, false);
        _channelOption = new Option<string?>("--channel")
        {
            Description = isStagingEnabled
                ? NewCommandStrings.ChannelOptionDescriptionWithStaging
                : NewCommandStrings.ChannelOptionDescription,
            Recursive = true
        };
        Options.Add(_channelOption);
 
        // 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, features, updateNotifier, executionContext, InteractionService, Telemetry);
            Subcommands.Add(templateCommand);
        }
    }
 
    private static ITemplate[] GetTemplatesForPrompt(ITemplate[] availableTemplates)
    {
        var templatesForPrompt = availableTemplates.ToList();
 
        // Sort templates alphabetically by description, keeping empty templates at the end
        templatesForPrompt.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 templatesForPrompt.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 templatesForPrompt = GetTemplatesForPrompt(availableTemplates);
        if (templatesForPrompt.Length == 0)
        {
            InteractionService.DisplayError("No templates are available for the current environment.");
            return null;
        }
 
        var result = await _prompter.PromptForTemplateAsync(templatesForPrompt, cancellationToken);
 
        // The prompt is cleared after selection.
        // Write out the selected template again for context before proceeding.
        if (result != null)
        {
            InteractionService.DisplayPlainText($"{NewCommandStrings.SelectAProjectTemplate} {result.Description}");
        }
        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, CancellationToken cancellationToken)
    {
        return await InteractionService.ShowStatusAsync(
            NewCommandStrings.ResolvingTemplateVersion,
            async () =>
            {
                var channels = await _packagingService.GetChannelsAsync(cancellationToken);
 
                var configuredChannelName = parseResult.GetValue(_channelOption);
                if (string.IsNullOrWhiteSpace(configuredChannelName))
                {
                    configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken);
                }
 
                var selectedChannel = string.IsNullOrWhiteSpace(configuredChannelName)
                    ? channels.FirstOrDefault(c => c.Type is PackageChannelType.Implicit) ?? channels.FirstOrDefault()
                    : channels.FirstOrDefault(c => string.Equals(c.Name, configuredChannelName, StringComparison.OrdinalIgnoreCase));
 
                if (selectedChannel is null)
                {
                    var errorMessage = string.IsNullOrWhiteSpace(configuredChannelName)
                        ? "No package channels are available."
                        : $"No channel found matching '{configuredChannelName}'. Valid options are: {string.Join(", ", channels.Select(c => c.Name))}";
 
                    return new ResolveTemplateVersionResult { ErrorMessage = errorMessage };
                }
 
                var packages = await selectedChannel.GetTemplatePackagesAsync(ExecutionContext.WorkingDirectory, cancellationToken);
                var package = packages
                    .Where(p => Semver.SemVersion.TryParse(p.Version, Semver.SemVersionStyles.Strict, out _))
                    .OrderByDescending(p => Semver.SemVersion.Parse(p.Version, Semver.SemVersionStyles.Strict), Semver.SemVersion.PrecedenceComparer)
                    .FirstOrDefault();
 
                if (package is null)
                {
                    return new ResolveTemplateVersionResult { ErrorMessage = $"No template versions found in channel '{selectedChannel.Name}'." };
                }
 
                // Only persist explicit channel names (e.g. local, daily) — implicit channels
                // (stable/nuget.org) should not be written so aspire add uses its default behavior.
                var channelName = selectedChannel.Type is PackageChannelType.Explicit ? selectedChannel.Name : null;
 
                return new ResolveTemplateVersionResult { Version = package.Version, ChannelName = channelName };
            });
    }
 
    protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        using var activity = Telemetry.StartDiagnosticActivity(this.Name);
 
        // 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 ExitCodeConstants.InvalidCommand;
        }
 
        var version = parseResult.GetValue(s_versionOption);
        string? resolvedChannelName = null;
        if (ShouldResolveCliTemplateVersion(template) &&
            string.IsNullOrWhiteSpace(version))
        {
            var resolveResult = await ResolveCliTemplateVersionAsync(parseResult, cancellationToken);
            if (!resolveResult.Success)
            {
                InteractionService.DisplayError(resolveResult.ErrorMessage);
                return ExitCodeConstants.InvalidCommand;
            }
 
            version = resolveResult.Version;
            resolvedChannelName = resolveResult.ChannelName;
        }
 
        var inputs = new TemplateInputs
        {
            Name = parseResult.GetValue(s_nameOption),
            Output = parseResult.GetValue(s_outputOption),
            Source = parseResult.GetValue(s_sourceOption),
            Version = version,
            Channel = parseResult.GetValue(_channelOption) ?? resolvedChannelName,
            Language = template.LanguageId
        };
        var templateResult = await template.ApplyTemplateAsync(inputs, parseResult, cancellationToken);
        if (templateResult.OutputPath is not null && ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _))
        {
            extensionInteractionService.OpenEditor(templateResult.OutputPath);
        }
 
        var workspaceRoot = new DirectoryInfo(templateResult.OutputPath ?? ExecutionContext.WorkingDirectory.FullName);
        return await _agentInitCommand.PromptAndChainAsync(_hostEnvironment, InteractionService, templateResult.ExitCode, workspaceRoot, cancellationToken);
    }
 
    private static bool ShouldResolveCliTemplateVersion(ITemplate template)
    {
        return template.Runtime is TemplateRuntime.Cli;
    }
}
 
internal interface INewCommandPrompter
{
    Task<ITemplate> PromptForTemplateAsync(ITemplate[] validTemplates, CancellationToken cancellationToken);
    Task<string> PromptForProjectNameAsync(string defaultName, CancellationToken cancellationToken);
    Task<string> PromptForOutputPath(string v, CancellationToken cancellationToken);
}
 
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,
                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);
 
        return await topSelection.Action(cancellationToken);
    }
 
    public virtual async Task<string> PromptForOutputPath(string path, CancellationToken cancellationToken)
    {
        // Escape markup characters in the path to prevent Spectre.Console from trying to parse them as markup
        // when displaying it as the default value in the prompt
        return await interactionService.PromptForFilePathAsync(
            NewCommandStrings.EnterTheOutputPath,
            defaultValue: path.EscapeMarkup(),
            directory: true,
            cancellationToken: cancellationToken
            );
    }
 
    public virtual async Task<string> PromptForProjectNameAsync(string defaultName, CancellationToken cancellationToken)
    {
        // Escape markup characters in the default name to prevent Spectre.Console from trying to parse them as markup
        // when displaying it as the default value in the prompt
        return await interactionService.PromptForStringAsync(
            NewCommandStrings.EnterTheProjectName,
            defaultValue: defaultName.EscapeMarkup(),
            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
        );
    }
}
 
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);
    }
}