diff --git a/src/Aspire.Cli/Commands/DescribeCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs index 45bed5dfcfe..f5436443ddb 100644 --- a/src/Aspire.Cli/Commands/DescribeCommand.cs +++ b/src/Aspire.Cli/Commands/DescribeCommand.cs @@ -262,7 +262,7 @@ private void DisplayResourcesTable(IReadOnlyList snapshots) { if (snapshots.Count == 0) { - _interactionService.DisplayPlainText("No resources found."); + _interactionService.DisplayMessage(KnownEmojis.Information, "No resources found."); return; } diff --git a/src/Aspire.Cli/Commands/SecretGetCommand.cs b/src/Aspire.Cli/Commands/SecretGetCommand.cs index 264409e5406..284404b76b8 100644 --- a/src/Aspire.Cli/Commands/SecretGetCommand.cs +++ b/src/Aspire.Cli/Commands/SecretGetCommand.cs @@ -61,7 +61,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } // Write value to stdout (machine-readable) - InteractionService.DisplayPlainText(value); + InteractionService.DisplayRawText(value, consoleOverride: ConsoleOutput.Standard); return ExitCodeConstants.Success; } } diff --git a/src/Aspire.Cli/Commands/SecretPathCommand.cs b/src/Aspire.Cli/Commands/SecretPathCommand.cs index d2d85fda8b0..afb7375f30f 100644 --- a/src/Aspire.Cli/Commands/SecretPathCommand.cs +++ b/src/Aspire.Cli/Commands/SecretPathCommand.cs @@ -43,7 +43,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.FailedToFindProject; } - InteractionService.DisplayPlainText(result.Store.FilePath); + InteractionService.DisplayRawText(result.Store.FilePath, consoleOverride: ConsoleOutput.Standard); return ExitCodeConstants.Success; } } diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index ba15c1af916..87deb6385d5 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Backchannel; using Aspire.Cli.Resources; using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; using Spectre.Console; using Spectre.Console.Rendering; @@ -22,6 +23,8 @@ internal class ConsoleInteractionService : IInteractionService private readonly IAnsiConsole _errorConsole; private readonly CliExecutionContext _executionContext; private readonly ICliHostEnvironment _hostEnvironment; + private readonly ILogger _stdoutLogger; + private readonly ILogger _stderrLogger; private int _inStatus; /// @@ -29,17 +32,23 @@ internal class ConsoleInteractionService : IInteractionService /// private IAnsiConsole MessageConsole => Console == ConsoleOutput.Error ? _errorConsole : _outConsole; + // Limit logging to prompts and messages. Don't log raw text output since it may contain sensitive information. + private ILogger MessageLogger => Console == ConsoleOutput.Error ? _stderrLogger : _stdoutLogger; + public ConsoleOutput Console { get; set; } - public ConsoleInteractionService(ConsoleEnvironment consoleEnvironment, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment) + public ConsoleInteractionService(ConsoleEnvironment consoleEnvironment, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, ILoggerFactory loggerFactory) { ArgumentNullException.ThrowIfNull(consoleEnvironment); ArgumentNullException.ThrowIfNull(executionContext); ArgumentNullException.ThrowIfNull(hostEnvironment); + ArgumentNullException.ThrowIfNull(loggerFactory); _outConsole = consoleEnvironment.Out; _errorConsole = consoleEnvironment.Error; _executionContext = executionContext; _hostEnvironment = hostEnvironment; + _stdoutLogger = loggerFactory.CreateLogger("Aspire.Cli.Console.Stdout"); + _stderrLogger = loggerFactory.CreateLogger("Aspire.Cli.Console.Stderr"); } public async Task ShowStatusAsync(string statusText, Func> action, KnownEmoji? emoji = null, bool allowMarkup = false) @@ -69,6 +78,10 @@ public async Task ShowStatusAsync(string statusText, Func> action, // Text has already been escaped and emoji prepended, so pass as markup DisplaySubtleMessage(statusText, allowMarkup: true); } + else + { + MessageLogger.LogInformation("Status: {StatusText}", statusText); + } return await action(); } @@ -86,6 +99,8 @@ public async Task ShowStatusAsync(string statusText, Func> action, public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false) { + MessageLogger.LogInformation("Status: {StatusText}", statusText); + if (!allowMarkup) { statusText = statusText.EscapeMarkup(); @@ -135,6 +150,8 @@ public async Task PromptForStringAsync(string promptText, string? defaul throw new InvalidOperationException(InteractionServiceStrings.InteractiveInputNotSupported); } + MessageLogger.LogInformation("Prompt: {PromptText} (default: {DefaultValue}, secret: {IsSecret})", promptText, isSecret ? "****" : defaultValue ?? "(none)", isSecret); + var prompt = new TextPrompt(promptText) { IsSecret = isSecret, @@ -153,7 +170,9 @@ public async Task PromptForStringAsync(string promptText, string? defaul prompt.Validate(validator); } - return await _outConsole.PromptAsync(prompt, cancellationToken); + var result = await MessageConsole.PromptAsync(prompt, cancellationToken); + MessageLogger.LogInformation("Prompt result: {Result}", isSecret ? "****" : result); + return result; } public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) @@ -185,6 +204,8 @@ public async Task PromptForSelectionAsync(string promptText, IEnumerable() .Title(promptText) .UseConverter(safeFormatter) @@ -194,7 +215,9 @@ public async Task PromptForSelectionAsync(string promptText, IEnumerable> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, CancellationToken cancellationToken = default) where T : notnull @@ -219,6 +242,8 @@ public async Task> PromptForSelectionsAsync(string promptTex var safeFormatter = MakeSafeFormatter(choiceFormatter); + MessageLogger.LogInformation("Selection prompt: {PromptText}", promptText); + var prompt = new MultiSelectionPrompt() .Title(promptText) .UseConverter(safeFormatter) @@ -235,7 +260,8 @@ public async Task> PromptForSelectionsAsync(string promptTex } } - var result = await _outConsole.PromptAsync(prompt, cancellationToken); + var result = await MessageConsole.PromptAsync(prompt, cancellationToken); + MessageLogger.LogInformation("Selection results: {Results}", string.Join(", ", result.Select(safeFormatter))); return result; } @@ -299,6 +325,14 @@ public void DisplayError(string errorMessage) public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { + if (MessageLogger.IsEnabled(LogLevel.Information)) + { + // Only attempt to parse/remove markup when the message is expected to contain it. + // Plain text messages may contain characters like '[' that would be rejected by the markup parser. + var logMessage = allowMarkup ? message.RemoveMarkup() : message; + MessageLogger.LogInformation("{Message}", ConsoleHelpers.FormatEmojiPrefix(emoji, MessageConsole, replaceEmoji: true) + logMessage); + } + var displayMessage = allowMarkup ? message : message.EscapeMarkup(); MessageConsole.MarkupLine(ConsoleHelpers.FormatEmojiPrefix(emoji, MessageConsole) + displayMessage); } @@ -311,9 +345,9 @@ public void DisplayPlainText(string message) public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { + var effectiveConsole = consoleOverride ?? Console; // Write raw text directly to avoid console wrapping. // When consoleOverride is null, respect the Console setting. - var effectiveConsole = consoleOverride ?? Console; var target = effectiveConsole == ConsoleOutput.Error ? _errorConsole : _outConsole; target.Profile.Out.Writer.WriteLine(text); } @@ -386,18 +420,22 @@ public void DisplayCancellationMessage() DisplayMessage(KnownEmojis.StopSign, $"[teal bold]{InteractionServiceStrings.StoppingAspire}[/]", allowMarkup: true); } - public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) + public async Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) { if (!_hostEnvironment.SupportsInteractiveInput) { throw new InvalidOperationException(InteractionServiceStrings.InteractiveInputNotSupported); } - return _outConsole.ConfirmAsync(promptText, defaultValue, cancellationToken); + MessageLogger.LogInformation("Confirm: {PromptText} (default: {DefaultValue})", promptText, defaultValue); + var result = await MessageConsole.ConfirmAsync(promptText, defaultValue, cancellationToken); + MessageLogger.LogInformation("Confirm result: {Result}", result); + return result; } public void DisplaySubtleMessage(string message, bool allowMarkup = false) { + MessageLogger.LogInformation("{Message}", message); var displayMessage = allowMarkup ? message : message.EscapeMarkup(); MessageConsole.MarkupLine($"[dim]{displayMessage}[/]"); } @@ -422,5 +460,4 @@ public void DisplayVersionUpdateNotification(string newerVersion, string? update _errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.MoreInfoNewCliVersion, UpdateUrl)); } - } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index b7c605d425d..2131d0badc7 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -801,7 +801,8 @@ private static void AddInteractionServices(HostApplicationBuilder builder) consoleEnvironment.Out.Profile.Width = 256; // VS code terminal will handle wrapping so set a large width here. var executionContext = provider.GetRequiredService(); var hostEnvironment = provider.GetRequiredService(); - var consoleInteractionService = new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment); + var loggerFactory = provider.GetRequiredService(); + var consoleInteractionService = new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment, loggerFactory); return new ExtensionInteractionService(consoleInteractionService, provider.GetRequiredService(), extensionPromptEnabled); @@ -814,7 +815,8 @@ private static void AddInteractionServices(HostApplicationBuilder builder) var consoleEnvironment = provider.GetRequiredService(); var executionContext = provider.GetRequiredService(); var hostEnvironment = provider.GetRequiredService(); - return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment); + var loggerFactory = provider.GetRequiredService(); + return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment, loggerFactory); }); } } diff --git a/src/Aspire.Cli/Utils/ConsoleHelpers.cs b/src/Aspire.Cli/Utils/ConsoleHelpers.cs index a5986100817..fca5beca161 100644 --- a/src/Aspire.Cli/Utils/ConsoleHelpers.cs +++ b/src/Aspire.Cli/Utils/ConsoleHelpers.cs @@ -14,12 +14,13 @@ internal static class ConsoleHelpers /// /// Formats an emoji prefix with trailing space for aligned console output. /// - public static string FormatEmojiPrefix(KnownEmoji emoji, IAnsiConsole console) + public static string FormatEmojiPrefix(KnownEmoji emoji, IAnsiConsole console, bool replaceEmoji = false) { const int emojiTargetWidth = 3; // 2 for emoji and 1 trailing space var cellLength = EmojiWidth.GetCachedCellWidth(emoji.Name, console); var padding = Math.Max(1, emojiTargetWidth - cellLength); - return $":{emoji.Name}:" + new string(' ', padding); + var spectreEmojiText = $":{emoji.Name}:"; + return (replaceEmoji ? Emoji.Replace(spectreEmojiText) : spectreEmojiText) + new string(' ', padding); } } diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index 34fe7383c2c..23fa754443b 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -6,6 +6,7 @@ using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging.Abstractions; using Spectre.Console; using System.Text; @@ -17,7 +18,7 @@ public class ConsoleInteractionServiceTests private static ConsoleInteractionService CreateInteractionService(IAnsiConsole console, CliExecutionContext executionContext, ICliHostEnvironment? hostEnvironment = null) { var consoleEnvironment = new ConsoleEnvironment(console, console); - return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment ?? TestHelpers.CreateInteractiveHostEnvironment()); + return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment ?? TestHelpers.CreateInteractiveHostEnvironment(), NullLoggerFactory.Instance); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index da16f947dc6..e89b6fdca5c 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -392,7 +392,8 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var consoleEnvironment = serviceProvider.GetRequiredService(); var executionContext = serviceProvider.GetRequiredService(); var hostEnvironment = serviceProvider.GetRequiredService(); - return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment); + var loggerFactory = serviceProvider.GetRequiredService(); + return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment, loggerFactory); }; public Func CertificateToolRunnerFactory { get; set; } = (IServiceProvider _) =>