diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 0002a2353b2..6e938847991 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,33 +1,33 @@ - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + 9585d3bbfad8a356770096fcda944349da4145f1 - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + 9585d3bbfad8a356770096fcda944349da4145f1 - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + 9585d3bbfad8a356770096fcda944349da4145f1 - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + 9585d3bbfad8a356770096fcda944349da4145f1 - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + 9585d3bbfad8a356770096fcda944349da4145f1 - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + 9585d3bbfad8a356770096fcda944349da4145f1 - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + 9585d3bbfad8a356770096fcda944349da4145f1 https://dev.azure.com/dnceng/internal/_git/dotnet-extensions diff --git a/eng/Versions.props b/eng/Versions.props index cc14524afa9..2184328ecea 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -30,13 +30,13 @@ 8.0.100-rtm.23512.16 - 0.22.4 - 0.22.4 - 0.22.4 - 0.22.4 - 0.22.4 - 0.22.4 - 0.22.4 + 0.22.6 + 0.22.6 + 0.22.6 + 0.22.6 + 0.22.6 + 0.22.6 + 0.22.6 10.0.0-beta.26110.1 10.0.0-beta.26110.1 diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 790f0decaef..651cdb617bb 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Interaction; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; +using Spectre.Console; namespace Aspire.Cli.Backchannel; @@ -125,7 +126,7 @@ public async Task ResolveConnectionAsync( var selectedDisplay = await interactionService.PromptForSelectionAsync( selectPrompt, choices.Select(c => c.Display).ToArray(), - c => c, + c => c.EscapeMarkup(), cancellationToken); selectedConnection = choices.FirstOrDefault(c => c.Display == selectedDisplay).Connection; @@ -148,7 +149,7 @@ public async Task ResolveConnectionAsync( var selectedDisplay = await interactionService.PromptForSelectionAsync( selectPrompt, choices.Select(c => c.Display).ToArray(), - c => c, + c => c.EscapeMarkup(), cancellationToken); selectedConnection = choices.FirstOrDefault(c => c.Display == selectedDisplay).Connection; diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 7a7e1288f69..c9682c1fb34 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -12,6 +12,7 @@ using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Semver; +using Spectre.Console; using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Commands; @@ -325,7 +326,7 @@ internal class AddCommandPrompter(IInteractionService interactionService) : IAdd // Helper to keep labels consistently formatted: "Version (source)" static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, PackageChannel Channel) item) { - return $"{item.Package.Version} ({item.Channel.SourceDetails})"; + return $"{item.Package.Version.EscapeMarkup()} ({item.Channel.SourceDetails.EscapeMarkup()})"; } async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForChannelPackagesAsync( @@ -395,7 +396,7 @@ static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, Pac var item = channelGroup.HighestVersion; rootChoices.Add(( - Label: channel.Name, + Label: channel.Name.EscapeMarkup(), // For explicit channels, we still show submenu but with only the highest version Action: ct => PromptForChannelPackagesAsync(channel, new[] { item }, ct) )); @@ -442,11 +443,11 @@ private static string PackageNameWithFriendlyNameIfAvailable((string FriendlyNam { if (packageWithFriendlyName.FriendlyName is { } friendlyName) { - return $"[bold]{friendlyName}[/] ({packageWithFriendlyName.Package.Id})"; + return $"[bold]{friendlyName.EscapeMarkup()}[/] ({packageWithFriendlyName.Package.Id.EscapeMarkup()})"; } else { - return packageWithFriendlyName.Package.Id; + return packageWithFriendlyName.Package.Id.EscapeMarkup(); } } } diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index db860435fd0..76f6caadbb7 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -245,7 +245,7 @@ later as needed. var selectedProjects = await InteractionService.PromptForSelectionsAsync( "Select projects to add to the AppHost:", initContext.ExecutableProjects, - project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name), + project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name).EscapeMarkup(), cancellationToken); initContext.ExecutableProjectsToAddToAppHost = selectedProjects; @@ -298,7 +298,7 @@ ServiceDefaults project contains helper code to make it easier initContext.ProjectsToAddServiceDefaultsTo = await InteractionService.PromptForSelectionsAsync( "Select projects to add ServiceDefaults reference to:", initContext.ExecutableProjectsToAddToAppHost, - project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name), + project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name).EscapeMarkup(), cancellationToken); break; case "none": diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 1ce14e647b7..c350f575b93 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -282,7 +282,7 @@ internal class NewCommandPrompter(IInteractionService interactionService) : INew static string FormatPackageLabel((NuGetPackage Package, PackageChannel Channel) item) { // Keep it concise: "Version (source)" - return $"{item.Package.Version} ({item.Channel.SourceDetails})"; + return $"{item.Package.Version.EscapeMarkup()} ({item.Channel.SourceDetails.EscapeMarkup()})"; } async Task<(NuGetPackage Package, PackageChannel Channel)> PromptForChannelPackagesAsync( @@ -330,7 +330,7 @@ static string FormatPackageLabel((NuGetPackage Package, PackageChannel Channel) var items = channelGroup.ToArray(); rootChoices.Add(( - Label: channel.Name, + Label: channel.Name.EscapeMarkup(), Action: ct => PromptForChannelPackagesAsync(channel, items, ct) )); } @@ -380,7 +380,7 @@ public virtual async Task PromptForTemplateAsync(ITemplate[] validTem return await interactionService.PromptForSelectionAsync( NewCommandStrings.SelectAProjectTemplate, validTemplates, - t => t.Description, + t => t.Description.EscapeMarkup(), cancellationToken ); } diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index 1cb2587d6ea..bdc3ce168e9 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -804,7 +804,7 @@ private async Task HandlePromptActivityAsync(PublishingActivity activity, IAppHo var (value, displayText) = await InteractionService.PromptForSelectionAsync( promptText, options, - choice => choice.Value, + choice => choice.Value.EscapeMarkup(), cancellationToken); if (value == CustomChoiceValue) diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index f70d336e615..f7b50eb8f3b 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -26,7 +26,7 @@ public virtual async Task PromptForPublisherAsync(IEnumerable pu return await interactionService.PromptForSelectionAsync( PublishCommandStrings.SelectAPublisher, publishers, - p => p, + p => p.EscapeMarkup(), cancellationToken ); } diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index c75e943e38b..ee6fb888ca7 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -178,7 +178,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell channel = await InteractionService.PromptForSelectionAsync( UpdateCommandStrings.SelectChannelPrompt, allChannels, - (c) => $"{c.Name} ({c.SourceDetails})", + (c) => $"{c.Name.EscapeMarkup()} ({c.SourceDetails.EscapeMarkup()})", cancellationToken); } else diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index 4c7d8487ebd..a653548422b 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -145,7 +145,7 @@ public async Task PromptForSelectionAsync(string promptText, IEnumerable() .Title(promptText) - .UseConverter(item => choiceFormatter(item).EscapeMarkup()) + .UseConverter(choiceFormatter) .AddChoices(choices) .PageSize(10) .EnableSearch(); @@ -174,7 +174,7 @@ public async Task> PromptForSelectionsAsync(string promptTex var prompt = new MultiSelectionPrompt() .Title(promptText) - .UseConverter(item => choiceFormatter(item).EscapeMarkup()) + .UseConverter(choiceFormatter) .AddChoices(choices) .PageSize(10); diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 43d4f455a8e..48a4c4c088e 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -10,6 +10,7 @@ using Aspire.Cli.Utils; using Aspire.Hosting.Utils; using Microsoft.Extensions.Logging; +using Spectre.Console; namespace Aspire.Cli.Projects; @@ -199,7 +200,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F projectFile = await interactionService.PromptForSelectionAsync( InteractionServiceStrings.SelectAppHostToUse, appHostProjects, - file => $"{file.Name} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, file.FullName)})", + file => $"{file.Name.EscapeMarkup()} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, file.FullName).EscapeMarkup()})", cancellationToken ); } @@ -277,7 +278,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F selectedAppHost = multipleAppHostProjectsFoundBehavior switch { MultipleAppHostProjectsFoundBehavior.Throw => throw new ProjectLocatorException(ErrorStrings.MultipleProjectFilesFound), - MultipleAppHostProjectsFoundBehavior.Prompt => await interactionService.PromptForSelectionAsync(InteractionServiceStrings.SelectAppHostToUse, results.BuildableAppHost, projectFile => $"{projectFile.Name} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, projectFile.FullName)})", cancellationToken), + MultipleAppHostProjectsFoundBehavior.Prompt => await interactionService.PromptForSelectionAsync(InteractionServiceStrings.SelectAppHostToUse, results.BuildableAppHost, projectFile => $"{projectFile.Name.EscapeMarkup()} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, projectFile.FullName).EscapeMarkup()})", cancellationToken), MultipleAppHostProjectsFoundBehavior.None => null, _ => selectedAppHost }; diff --git a/src/Aspire.Cli/Projects/SolutionLocator.cs b/src/Aspire.Cli/Projects/SolutionLocator.cs index 8458fb85cd3..0c95bd66ef1 100644 --- a/src/Aspire.Cli/Projects/SolutionLocator.cs +++ b/src/Aspire.Cli/Projects/SolutionLocator.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Microsoft.Extensions.Logging; +using Spectre.Console; namespace Aspire.Cli.Projects; @@ -39,7 +40,7 @@ internal sealed class SolutionLocator(ILogger logger, IInteract var selectedSolution = await interactionService.PromptForSelectionAsync( InitCommandStrings.MultipleSolutionsFound, solutionFiles, - solutionFile => $"{solutionFile.Name} ({Path.GetRelativePath(startDirectory.FullName, solutionFile.FullName)})", + solutionFile => $"{solutionFile.Name.EscapeMarkup()} ({Path.GetRelativePath(startDirectory.FullName, solutionFile.FullName).EscapeMarkup()})", cancellationToken); return selectedSolution; diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index 090fe5f1f35..bc489a77d07 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -547,4 +547,70 @@ public void DisplaySubtleMessage_WithMarkupCharacters_EscapesByDefault() var outputString = output.ToString(); Assert.Contains("[Module]", outputString); } + + [Fact] + public void SelectionPrompt_ConverterPreservesIntentionalMarkup() + { + // Arrange - verifies that PromptForSelectionAsync does NOT escape the formatter output, + // allowing callers to include intentional Spectre markup (e.g., [bold]...[/]). + // This is a regression test for https://github.com/dotnet/aspire/pull/14422 where + // blanket EscapeMarkup() in the converter broke [bold] rendering in 'aspire add'. + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.Standard, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + // Build a SelectionPrompt the same way ConsoleInteractionService does, + // using a formatter that returns intentional markup (like AddCommand does). + Func choiceFormatter = item => $"[bold]{item}[/] (Aspire.Hosting.{item})"; + + var prompt = new SelectionPrompt() + .Title("Select an integration:") + .UseConverter(choiceFormatter) + .AddChoices(["PostgreSQL", "Redis"]); + + // Act - verify the converter output preserves the [bold] markup + // by checking that the converter is the formatter itself (not wrapped with EscapeMarkup) + var converterOutput = choiceFormatter("PostgreSQL"); + + // Assert - the formatter should produce raw markup, not escaped markup + Assert.Equal("[bold]PostgreSQL[/] (Aspire.Hosting.PostgreSQL)", converterOutput); + Assert.DoesNotContain("[[bold]]", converterOutput); // Must NOT be escaped + } + + [Fact] + public void SelectionPrompt_ConverterWithBracketsInData_MustBeEscapedByCaller() + { + // Arrange - verifies that callers are responsible for escaping dynamic data + // that may contain bracket characters, while preserving intentional markup. + // This tests the pattern used by AddCommand.PackageNameWithFriendlyNameIfAvailable. + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + // Simulate a package name that contains brackets (e.g., from an external source) + var friendlyName = "Azure Storage [Preview]"; + var packageId = "Aspire.Hosting.Azure.Storage"; + + // The formatter should escape dynamic values but preserve intentional markup + var formattedOutput = $"[bold]{friendlyName.EscapeMarkup()}[/] ({packageId.EscapeMarkup()})"; + + // Assert - intentional markup preserved, dynamic brackets escaped + Assert.Equal("[bold]Azure Storage [[Preview]][/] (Aspire.Hosting.Azure.Storage)", formattedOutput); + + // Verify Spectre can render this without throwing + var exception = Record.Exception(() => console.MarkupLine(formattedOutput)); + Assert.Null(exception); + + var outputString = output.ToString(); + Assert.Contains("Azure Storage [Preview]", outputString); + Assert.Contains("Aspire.Hosting.Azure.Storage", outputString); + } }