diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 41b75856014..9bfe82dd8df 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -23,6 +23,7 @@ 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; @@ -75,6 +76,7 @@ public NewCommand( : base("new", NewCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _prompter = prompter; + _templateProvider = templateProvider; _features = features; _packagingService = packagingService; _configurationService = configurationService; @@ -102,7 +104,11 @@ public NewCommand( }; Options.Add(_languageOption); - _templates = templateProvider.GetTemplatesAsync(CancellationToken.None).GetAwaiter().GetResult().ToArray(); + // 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) { @@ -199,10 +205,10 @@ private async Task PromptForAppHostLanguageAsync(IReadOnlyList s return (true, selectedLanguageId); } - private ITemplate[] GetTemplatesForPrompt(ParseResult parseResult) + private ITemplate[] GetTemplatesForPrompt(ITemplate[] availableTemplates, ParseResult parseResult) { var explicitLanguageId = ParseExplicitLanguageId(parseResult); - var templatesForPrompt = _templates.ToList(); + var templatesForPrompt = availableTemplates.ToList(); if (!string.IsNullOrWhiteSpace(explicitLanguageId)) { @@ -214,19 +220,24 @@ private ITemplate[] GetTemplatesForPrompt(ParseResult parseResult) return templatesForPrompt.ToArray(); } - private async Task GetProjectTemplateAsync(ParseResult parseResult, CancellationToken cancellationToken) + private async Task 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 = _templates.SingleOrDefault(t => t.Name.Equals(parseResult.CommandResult.Command.Name, StringComparison.OrdinalIgnoreCase)); + 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(parseResult); + var templatesForPrompt = GetTemplatesForPrompt(availableTemplates, parseResult); if (templatesForPrompt.Length == 0) { InteractionService.DisplayError("No templates are available for the current environment."); @@ -301,7 +312,12 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { using var activity = Telemetry.StartDiagnosticActivity(this.Name); - var template = await GetProjectTemplateAsync(parseResult, cancellationToken); + // 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; diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.cs index f916f8a39f5..fffd0575765 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.cs @@ -67,9 +67,24 @@ public CliTemplateFactory( _logger = logger; } + public IEnumerable GetTemplates() + { + return GetTemplateDefinitions(); + } + public Task> GetTemplatesAsync(CancellationToken cancellationToken = default) { - IEnumerable templates = + return Task.FromResult(GetTemplateDefinitions()); + } + + public Task> GetInitTemplatesAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>([]); + } + + private IEnumerable GetTemplateDefinitions() + { + return [ new CallbackTemplate( KnownTemplateId.TypeScriptStarter, @@ -95,13 +110,6 @@ public Task> GetTemplatesAsync(CancellationToken cancella languageId.Equals(KnownLanguageId.TypeScriptAlias, StringComparison.OrdinalIgnoreCase), selectableAppHostLanguages: [KnownLanguageId.CSharp, KnownLanguageId.TypeScript]) ]; - - return Task.FromResult(templates); - } - - public Task> GetInitTemplatesAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult>([]); } private static string ApplyTokens(string content, string projectName, string projectNameLower, string aspireVersion, TemplatePorts ports, string hostName = "localhost") diff --git a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs index 8f88caf1e52..5f29df6b43a 100644 --- a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.Globalization; +using System.Runtime.InteropServices; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; using Aspire.Cli.Configuration; @@ -54,6 +55,18 @@ internal class DotNetTemplateFactory( Description = TemplatingStrings.EnterXUnitVersion_Description }; + public IEnumerable GetTemplates() + { + if (!IsDotNetOnPath()) + { + return []; + } + + var showAllTemplates = features.IsFeatureEnabled(KnownFeatures.ShowAllTemplates, false); + var nonInteractive = !hostEnvironment.SupportsInteractiveInput; + return GetTemplatesCore(showAllTemplates, nonInteractive); + } + public async Task> GetTemplatesAsync(CancellationToken cancellationToken = default) { if (!await IsDotNetSdkAvailableAsync(cancellationToken)) @@ -89,6 +102,37 @@ private async Task IsDotNetSdkAvailableAsync(CancellationToken cancellatio } } + private bool IsDotNetOnPath() + { + // Check the private SDK installation first. + var sdkInstallPath = Path.Combine(executionContext.SdksDirectory.FullName, "dotnet", DotNetSdkInstaller.MinimumSdkVersion); + if (Directory.Exists(sdkInstallPath)) + { + return true; + } + + // Fall back to checking for dotnet on the system PATH. + var dotnetFileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"; + var pathVariable = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + + foreach (var directory in pathVariable.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + try + { + if (File.Exists(Path.Combine(directory, dotnetFileName))) + { + return true; + } + } + catch + { + // Skip directories that can't be accessed. + } + } + + return false; + } + private IEnumerable GetTemplatesCore(bool showAllTemplates, bool nonInteractive = false) { yield return new CallbackTemplate( diff --git a/src/Aspire.Cli/Templating/ITemplateFactory.cs b/src/Aspire.Cli/Templating/ITemplateFactory.cs index 27b3bbb76ce..d4b413674a6 100644 --- a/src/Aspire.Cli/Templating/ITemplateFactory.cs +++ b/src/Aspire.Cli/Templating/ITemplateFactory.cs @@ -5,6 +5,16 @@ namespace Aspire.Cli.Templating; internal interface ITemplateFactory { + /// + /// Gets template definitions synchronously for command registration. + /// This must not perform any I/O or async work. + /// + IEnumerable GetTemplates(); + + /// + /// Gets templates that are available for use, performing any necessary + /// runtime availability checks (e.g. SDK availability). + /// Task> GetTemplatesAsync(CancellationToken cancellationToken = default); Task> GetInitTemplatesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Aspire.Cli/Templating/ITemplateProvider.cs b/src/Aspire.Cli/Templating/ITemplateProvider.cs index d22e69b9816..a739bf6449e 100644 --- a/src/Aspire.Cli/Templating/ITemplateProvider.cs +++ b/src/Aspire.Cli/Templating/ITemplateProvider.cs @@ -9,7 +9,14 @@ namespace Aspire.Cli.Templating; internal interface ITemplateProvider { /// - /// Gets templates available to the aspire new command. + /// Gets template definitions synchronously for command registration (e.g. subcommands). + /// This must not perform any I/O or async work. + /// + IEnumerable GetTemplates(); + + /// + /// Gets templates available to the aspire new command, performing any + /// necessary runtime availability checks. /// /// The templates available for project creation. Task> GetTemplatesAsync(CancellationToken cancellationToken = default); diff --git a/src/Aspire.Cli/Templating/TemplateProvider.cs b/src/Aspire.Cli/Templating/TemplateProvider.cs index 4bb10f8a140..17823acf8e6 100644 --- a/src/Aspire.Cli/Templating/TemplateProvider.cs +++ b/src/Aspire.Cli/Templating/TemplateProvider.cs @@ -20,6 +20,11 @@ public TemplateProvider(IEnumerable factories) } + public IEnumerable GetTemplates() + { + return _factories.SelectMany(static f => f.GetTemplates()); + } + public async Task> GetTemplatesAsync(CancellationToken cancellationToken = default) { var templates = await Task.WhenAll(_factories.Select(f => f.GetTemplatesAsync(cancellationToken)));