Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions src/Aspire.Cli/Commands/NewCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -199,10 +205,10 @@ private async Task<string> PromptForAppHostLanguageAsync(IReadOnlyList<string> 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))
{
Expand All @@ -214,19 +220,24 @@ private ITemplate[] GetTemplatesForPrompt(ParseResult parseResult)
return templatesForPrompt.ToArray();
}

private async Task<ITemplate?> GetProjectTemplateAsync(ParseResult parseResult, CancellationToken cancellationToken)
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 = _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.");
Expand Down Expand Up @@ -301,7 +312,12 @@ protected override async Task<int> 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;
Expand Down
24 changes: 16 additions & 8 deletions src/Aspire.Cli/Templating/CliTemplateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,24 @@ public CliTemplateFactory(
_logger = logger;
}

public IEnumerable<ITemplate> GetTemplates()
{
return GetTemplateDefinitions();
}

public Task<IEnumerable<ITemplate>> GetTemplatesAsync(CancellationToken cancellationToken = default)
{
IEnumerable<ITemplate> templates =
return Task.FromResult(GetTemplateDefinitions());
}

public Task<IEnumerable<ITemplate>> GetInitTemplatesAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult<IEnumerable<ITemplate>>([]);
}

private IEnumerable<ITemplate> GetTemplateDefinitions()
{
return
[
new CallbackTemplate(
KnownTemplateId.TypeScriptStarter,
Expand All @@ -95,13 +110,6 @@ public Task<IEnumerable<ITemplate>> GetTemplatesAsync(CancellationToken cancella
languageId.Equals(KnownLanguageId.TypeScriptAlias, StringComparison.OrdinalIgnoreCase),
selectableAppHostLanguages: [KnownLanguageId.CSharp, KnownLanguageId.TypeScript])
];

return Task.FromResult(templates);
}

public Task<IEnumerable<ITemplate>> GetInitTemplatesAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult<IEnumerable<ITemplate>>([]);
}

private static string ApplyTokens(string content, string projectName, string projectNameLower, string aspireVersion, TemplatePorts ports, string hostName = "localhost")
Expand Down
44 changes: 44 additions & 0 deletions src/Aspire.Cli/Templating/DotNetTemplateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,6 +55,18 @@ internal class DotNetTemplateFactory(
Description = TemplatingStrings.EnterXUnitVersion_Description
};

public IEnumerable<ITemplate> GetTemplates()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These template show up as subcommands even when they are not applicable?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2177294GetTemplates() now checks IsDotNetOnPath() first. If dotnet isn't found on the system PATH or in the private SDK installation, it returns empty and the .NET templates won't be registered as subcommands.

{
if (!IsDotNetOnPath())
{
return [];
}

var showAllTemplates = features.IsFeatureEnabled(KnownFeatures.ShowAllTemplates, false);
var nonInteractive = !hostEnvironment.SupportsInteractiveInput;
return GetTemplatesCore(showAllTemplates, nonInteractive);
}

public async Task<IEnumerable<ITemplate>> GetTemplatesAsync(CancellationToken cancellationToken = default)
{
if (!await IsDotNetSdkAvailableAsync(cancellationToken))
Expand Down Expand Up @@ -89,6 +102,37 @@ private async Task<bool> 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<ITemplate> GetTemplatesCore(bool showAllTemplates, bool nonInteractive = false)
{
yield return new CallbackTemplate(
Expand Down
10 changes: 10 additions & 0 deletions src/Aspire.Cli/Templating/ITemplateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ namespace Aspire.Cli.Templating;

internal interface ITemplateFactory
{
/// <summary>
/// Gets template definitions synchronously for command registration.
/// This must not perform any I/O or async work.
/// </summary>
IEnumerable<ITemplate> GetTemplates();

/// <summary>
/// Gets templates that are available for use, performing any necessary
/// runtime availability checks (e.g. SDK availability).
/// </summary>
Task<IEnumerable<ITemplate>> GetTemplatesAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<ITemplate>> GetInitTemplatesAsync(CancellationToken cancellationToken = default);
}
9 changes: 8 additions & 1 deletion src/Aspire.Cli/Templating/ITemplateProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ namespace Aspire.Cli.Templating;
internal interface ITemplateProvider
{
/// <summary>
/// Gets templates available to the <c>aspire new</c> command.
/// Gets template definitions synchronously for command registration (e.g. subcommands).
/// This must not perform any I/O or async work.
/// </summary>
IEnumerable<ITemplate> GetTemplates();

/// <summary>
/// Gets templates available to the <c>aspire new</c> command, performing any
/// necessary runtime availability checks.
/// </summary>
/// <returns>The templates available for project creation.</returns>
Task<IEnumerable<ITemplate>> GetTemplatesAsync(CancellationToken cancellationToken = default);
Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Templating/TemplateProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public TemplateProvider(IEnumerable<ITemplateFactory> factories)

}

public IEnumerable<ITemplate> GetTemplates()
{
return _factories.SelectMany(static f => f.GetTemplates());
}

public async Task<IEnumerable<ITemplate>> GetTemplatesAsync(CancellationToken cancellationToken = default)
{
var templates = await Task.WhenAll(_factories.Select(f => f.GetTemplatesAsync(cancellationToken)));
Expand Down
Loading