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
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Commands/DescribeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ private void DisplayResourcesTable(IReadOnlyList<ResourceSnapshot> snapshots)
{
if (snapshots.Count == 0)
{
_interactionService.DisplayPlainText("No resources found.");
_interactionService.DisplayMessage(KnownEmojis.Information, "No resources found.");
return;
}

Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Commands/SecretGetCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
}

// Write value to stdout (machine-readable)
InteractionService.DisplayPlainText(value);
InteractionService.DisplayRawText(value, consoleOverride: ConsoleOutput.Standard);
return ExitCodeConstants.Success;
}
}
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Commands/SecretPathCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
return ExitCodeConstants.FailedToFindProject;
}

InteractionService.DisplayPlainText(result.Store.FilePath);
InteractionService.DisplayRawText(result.Store.FilePath, consoleOverride: ConsoleOutput.Standard);
return ExitCodeConstants.Success;
}
}
53 changes: 45 additions & 8 deletions src/Aspire.Cli/Interaction/ConsoleInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -22,24 +23,32 @@ 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;

/// <summary>
/// Console used for human-readable messages; routes to stderr when <see cref="Console"/> is set to <see cref="ConsoleOutput.Error"/>.
/// </summary>
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<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action, KnownEmoji? emoji = null, bool allowMarkup = false)
Expand Down Expand Up @@ -69,6 +78,10 @@ public async Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> 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();
}

Expand All @@ -86,6 +99,8 @@ public async Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action,

public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false)
{
MessageLogger.LogInformation("Status: {StatusText}", statusText);

if (!allowMarkup)
{
statusText = statusText.EscapeMarkup();
Expand Down Expand Up @@ -135,6 +150,8 @@ public async Task<string> 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<string>(promptText)
{
IsSecret = isSecret,
Expand All @@ -153,7 +170,9 @@ public async Task<string> 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<string> PromptForFilePathAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -185,6 +204,8 @@ public async Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T
// the text is safe for both rendering and search highlighting.
var safeFormatter = MakeSafeFormatter(choiceFormatter);

MessageLogger.LogInformation("Selection prompt: {PromptText}", promptText);

var prompt = new SelectionPrompt<T>()
.Title(promptText)
.UseConverter(safeFormatter)
Expand All @@ -194,7 +215,9 @@ public async Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T

prompt.SearchHighlightStyle = s_searchHighlightStyle;

return await _outConsole.PromptAsync(prompt, cancellationToken);
var result = await MessageConsole.PromptAsync(prompt, cancellationToken);
MessageLogger.LogInformation("Selection result: {Result}", safeFormatter(result));
return result;
}

public async Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter, IEnumerable<T>? preSelected = null, bool optional = false, CancellationToken cancellationToken = default) where T : notnull
Expand All @@ -219,6 +242,8 @@ public async Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptTex

var safeFormatter = MakeSafeFormatter(choiceFormatter);

MessageLogger.LogInformation("Selection prompt: {PromptText}", promptText);

var prompt = new MultiSelectionPrompt<T>()
.Title(promptText)
.UseConverter(safeFormatter)
Expand All @@ -235,7 +260,8 @@ public async Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(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;
}

Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -386,18 +420,22 @@ public void DisplayCancellationMessage()
DisplayMessage(KnownEmojis.StopSign, $"[teal bold]{InteractionServiceStrings.StoppingAspire}[/]", allowMarkup: true);
}

public Task<bool> ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default)
public async Task<bool> 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}[/]");
}
Expand All @@ -422,5 +460,4 @@ public void DisplayVersionUpdateNotification(string newerVersion, string? update

_errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.MoreInfoNewCliVersion, UpdateUrl));
}

}
6 changes: 4 additions & 2 deletions src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CliExecutionContext>();
var hostEnvironment = provider.GetRequiredService<ICliHostEnvironment>();
var consoleInteractionService = new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment);
var loggerFactory = provider.GetRequiredService<ILoggerFactory>();
var consoleInteractionService = new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment, loggerFactory);
return new ExtensionInteractionService(consoleInteractionService,
provider.GetRequiredService<IExtensionBackchannel>(),
extensionPromptEnabled);
Expand All @@ -814,7 +815,8 @@ private static void AddInteractionServices(HostApplicationBuilder builder)
var consoleEnvironment = provider.GetRequiredService<ConsoleEnvironment>();
var executionContext = provider.GetRequiredService<CliExecutionContext>();
var hostEnvironment = provider.GetRequiredService<ICliHostEnvironment>();
return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment);
var loggerFactory = provider.GetRequiredService<ILoggerFactory>();
return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment, loggerFactory);
});
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/Aspire.Cli/Utils/ConsoleHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ internal static class ConsoleHelpers
/// <summary>
/// Formats an emoji prefix with trailing space for aligned console output.
/// </summary>
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,8 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser
var consoleEnvironment = serviceProvider.GetRequiredService<ConsoleEnvironment>();
var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
var hostEnvironment = serviceProvider.GetRequiredService<ICliHostEnvironment>();
return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment);
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment, loggerFactory);
};

public Func<IServiceProvider, ICertificateToolRunner> CertificateToolRunnerFactory { get; set; } = (IServiceProvider _) =>
Expand Down
Loading