|
// 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);
}
}
|