diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index be68a9c02b6..291da3ad965 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -66,9 +66,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var source = parseResult.GetValue("--source"); - var packages = await AnsiConsole.Status().StartAsync( + var packages = await InteractionUtils.ShowStatusAsync( "Searching for Aspire packages...", - context => _nuGetPackageCache.GetPackagesAsync(effectiveAppHostProjectFile.Directory!, prerelease, source, cancellationToken) + () => _nuGetPackageCache.GetIntegrationPackagesAsync(effectiveAppHostProjectFile.Directory!, prerelease, source, cancellationToken) ); var version = parseResult.GetValue("--version"); diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 2b81943a465..44a0de26cec 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -4,7 +4,6 @@ using System.CommandLine; using System.Diagnostics; using Aspire.Cli.Utils; -using Semver; using Spectre.Console; namespace Aspire.Cli.Commands; @@ -43,6 +42,10 @@ public NewCommand(DotNetCliRunner runner, INuGetPackageCache nuGetPackageCache) var templateVersionOption = new Option("--version", "-v"); templateVersionOption.Description = "The version of the project templates to use."; Options.Add(templateVersionOption); + + var prereleaseOption = new Option("--prerelease"); + prereleaseOption.Description = "Include prerelease versions when searching for project templates."; + Options.Add(prereleaseOption); } private static async Task<(string TemplateName, string TemplateDescription, string? PathAppendage)> GetProjectTemplateAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -106,7 +109,7 @@ private static async Task GetOutputPathAsync(ParseResult parseResult, st return Path.GetFullPath(outputPath); } - private static async Task GetProjectTemplatesVersionAsync(ParseResult parseResult, CancellationToken cancellationToken) + private async Task GetProjectTemplatesVersionAsync(ParseResult parseResult, bool prerelease, string? source, CancellationToken cancellationToken) { if (parseResult.GetValue("--version") is { } version) { @@ -114,20 +117,15 @@ private static async Task GetProjectTemplatesVersionAsync(ParseResult pa } else { - version = await InteractionUtils.PromptForStringAsync( - "Project templates version:", - defaultValue: VersionHelper.GetDefaultTemplateVersion(), - validator: (string value) => { - if (SemVersion.TryParse(value, out var parsedVersion)) - { - return ValidationResult.Success(); - } - - return ValidationResult.Error("Invalid version format. Please enter a valid version."); - }, - cancellationToken); + var workingDirectory = new DirectoryInfo(Environment.CurrentDirectory); - return version; + var candidatePackages = await InteractionUtils.ShowStatusAsync( + "Searching for available project template versions...", + () => _nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, prerelease, source, cancellationToken) + ); + + var selectedPackage = await InteractionUtils.PromptForTemplatesVersionAsync(candidatePackages, cancellationToken); + return selectedPackage.Version; } } @@ -138,8 +136,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var template = await GetProjectTemplateAsync(parseResult, cancellationToken); var name = await GetProjectNameAsync(parseResult, cancellationToken); var outputPath = await GetOutputPathAsync(parseResult, template.PathAppendage, cancellationToken); - var version = await GetProjectTemplatesVersionAsync(parseResult, cancellationToken); + var prerelease = parseResult.GetValue("--prerelease"); var source = parseResult.GetValue("--source"); + var version = await GetProjectTemplatesVersionAsync(parseResult, prerelease, source, cancellationToken); var templateInstallResult = await AnsiConsole.Status() .Spinner(Spinner.Known.Dots3) diff --git a/src/Aspire.Cli/NuGetPackageCache.cs b/src/Aspire.Cli/NuGetPackageCache.cs index 402c99a9f92..ec3a7de7c15 100644 --- a/src/Aspire.Cli/NuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGetPackageCache.cs @@ -8,7 +8,8 @@ namespace Aspire.Cli; internal interface INuGetPackageCache { - Task> GetPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, string? source, CancellationToken cancellationToken); + Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, string? source, CancellationToken cancellationToken); + Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, string? source, CancellationToken cancellationToken); } internal sealed class NuGetPackageCache(ILogger logger, DotNetCliRunner cliRunner) : INuGetPackageCache @@ -17,7 +18,17 @@ internal sealed class NuGetPackageCache(ILogger logger, DotNe private const int SearchPageSize = 100; - public async Task> GetPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, string? source, CancellationToken cancellationToken) + public async Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, string? source, CancellationToken cancellationToken) + { + return await GetPackagesAsync(workingDirectory, "Aspire.ProjectTemplates", prerelease, source, cancellationToken); + } + + public async Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, string? source, CancellationToken cancellationToken) + { + return await GetPackagesAsync(workingDirectory, "Aspire.Hosting", prerelease, source, cancellationToken); + } + + internal async Task> GetPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, string? source, CancellationToken cancellationToken) { using var activity = _activitySource.StartActivity(); @@ -32,7 +43,7 @@ public async Task> GetPackagesAsync(DirectoryInfo work // This search should pick up Aspire.Hosting.* and CommunityToolkit.Aspire.Hosting.* var result = await cliRunner.SearchPackagesAsync( workingDirectory, - "Aspire.Hosting", + query, prerelease, SearchPageSize, skip, @@ -69,8 +80,9 @@ public async Task> GetPackagesAsync(DirectoryInfo work static bool IsOfficialOrCommunityToolkitPackage(string packageName) { - var isHostingOrCommunityToolkitNamespaced = packageName.StartsWith("Aspire.Hosting.", StringComparison.OrdinalIgnoreCase) || - packageName.StartsWith("CommunityToolkit.Aspire.Hosting.", StringComparison.OrdinalIgnoreCase); + var isHostingOrCommunityToolkitNamespaced = packageName.StartsWith("Aspire.Hosting.", StringComparison.Ordinal) || + packageName.StartsWith("CommunityToolkit.Aspire.Hosting.", StringComparison.Ordinal) || + packageName.Equals("Aspire.ProjectTemplates", StringComparison.Ordinal); var isExcluded = packageName.StartsWith("Aspire.Hosting.AppHost") || packageName.StartsWith("Aspire.Hosting.Sdk") || diff --git a/src/Aspire.Cli/Utils/InteractionUtils.cs b/src/Aspire.Cli/Utils/InteractionUtils.cs index cf517af303e..900fc5faa36 100644 --- a/src/Aspire.Cli/Utils/InteractionUtils.cs +++ b/src/Aspire.Cli/Utils/InteractionUtils.cs @@ -8,6 +8,24 @@ namespace Aspire.Cli.Utils; internal static class InteractionUtils { + public static async Task ShowStatusAsync(string statusText, Func> action) + { + return await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots3) + .SpinnerStyle(Style.Parse("purple")) + .StartAsync(statusText, (context) => action()); + } + + public static async Task PromptForTemplatesVersionAsync(IEnumerable candidatePackages, CancellationToken cancellationToken) + { + return await PromptForSelectionAsync( + "Select a template version:", + candidatePackages, + (p) => $"{p.Version} ({p.Source})", + cancellationToken + ); + } + public static async Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(promptText, nameof(promptText));