Skip to content

Commit 1d9629e

Browse files
authored
Log ConsoleInteractionService output to CLI log file (#15521)
1 parent 0a44053 commit 1d9629e

File tree

8 files changed

+59
-17
lines changed

8 files changed

+59
-17
lines changed

src/Aspire.Cli/Commands/DescribeCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ private void DisplayResourcesTable(IReadOnlyList<ResourceSnapshot> snapshots)
262262
{
263263
if (snapshots.Count == 0)
264264
{
265-
_interactionService.DisplayPlainText("No resources found.");
265+
_interactionService.DisplayMessage(KnownEmojis.Information, "No resources found.");
266266
return;
267267
}
268268

src/Aspire.Cli/Commands/SecretGetCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
6161
}
6262

6363
// Write value to stdout (machine-readable)
64-
InteractionService.DisplayPlainText(value);
64+
InteractionService.DisplayRawText(value, consoleOverride: ConsoleOutput.Standard);
6565
return ExitCodeConstants.Success;
6666
}
6767
}

src/Aspire.Cli/Commands/SecretPathCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
4343
return ExitCodeConstants.FailedToFindProject;
4444
}
4545

46-
InteractionService.DisplayPlainText(result.Store.FilePath);
46+
InteractionService.DisplayRawText(result.Store.FilePath, consoleOverride: ConsoleOutput.Standard);
4747
return ExitCodeConstants.Success;
4848
}
4949
}

src/Aspire.Cli/Interaction/ConsoleInteractionService.cs

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Aspire.Cli.Backchannel;
66
using Aspire.Cli.Resources;
77
using Aspire.Cli.Utils;
8+
using Microsoft.Extensions.Logging;
89
using Spectre.Console;
910
using Spectre.Console.Rendering;
1011

@@ -22,24 +23,32 @@ internal class ConsoleInteractionService : IInteractionService
2223
private readonly IAnsiConsole _errorConsole;
2324
private readonly CliExecutionContext _executionContext;
2425
private readonly ICliHostEnvironment _hostEnvironment;
26+
private readonly ILogger _stdoutLogger;
27+
private readonly ILogger _stderrLogger;
2528
private int _inStatus;
2629

2730
/// <summary>
2831
/// Console used for human-readable messages; routes to stderr when <see cref="Console"/> is set to <see cref="ConsoleOutput.Error"/>.
2932
/// </summary>
3033
private IAnsiConsole MessageConsole => Console == ConsoleOutput.Error ? _errorConsole : _outConsole;
3134

35+
// Limit logging to prompts and messages. Don't log raw text output since it may contain sensitive information.
36+
private ILogger MessageLogger => Console == ConsoleOutput.Error ? _stderrLogger : _stdoutLogger;
37+
3238
public ConsoleOutput Console { get; set; }
3339

34-
public ConsoleInteractionService(ConsoleEnvironment consoleEnvironment, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment)
40+
public ConsoleInteractionService(ConsoleEnvironment consoleEnvironment, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, ILoggerFactory loggerFactory)
3541
{
3642
ArgumentNullException.ThrowIfNull(consoleEnvironment);
3743
ArgumentNullException.ThrowIfNull(executionContext);
3844
ArgumentNullException.ThrowIfNull(hostEnvironment);
45+
ArgumentNullException.ThrowIfNull(loggerFactory);
3946
_outConsole = consoleEnvironment.Out;
4047
_errorConsole = consoleEnvironment.Error;
4148
_executionContext = executionContext;
4249
_hostEnvironment = hostEnvironment;
50+
_stdoutLogger = loggerFactory.CreateLogger("Aspire.Cli.Console.Stdout");
51+
_stderrLogger = loggerFactory.CreateLogger("Aspire.Cli.Console.Stderr");
4352
}
4453

4554
public async Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action, KnownEmoji? emoji = null, bool allowMarkup = false)
@@ -69,6 +78,10 @@ public async Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action,
6978
// Text has already been escaped and emoji prepended, so pass as markup
7079
DisplaySubtleMessage(statusText, allowMarkup: true);
7180
}
81+
else
82+
{
83+
MessageLogger.LogInformation("Status: {StatusText}", statusText);
84+
}
7285
return await action();
7386
}
7487

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

87100
public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false)
88101
{
102+
MessageLogger.LogInformation("Status: {StatusText}", statusText);
103+
89104
if (!allowMarkup)
90105
{
91106
statusText = statusText.EscapeMarkup();
@@ -135,6 +150,8 @@ public async Task<string> PromptForStringAsync(string promptText, string? defaul
135150
throw new InvalidOperationException(InteractionServiceStrings.InteractiveInputNotSupported);
136151
}
137152

153+
MessageLogger.LogInformation("Prompt: {PromptText} (default: {DefaultValue}, secret: {IsSecret})", promptText, isSecret ? "****" : defaultValue ?? "(none)", isSecret);
154+
138155
var prompt = new TextPrompt<string>(promptText)
139156
{
140157
IsSecret = isSecret,
@@ -153,7 +170,9 @@ public async Task<string> PromptForStringAsync(string promptText, string? defaul
153170
prompt.Validate(validator);
154171
}
155172

156-
return await _outConsole.PromptAsync(prompt, cancellationToken);
173+
var result = await MessageConsole.PromptAsync(prompt, cancellationToken);
174+
MessageLogger.LogInformation("Prompt result: {Result}", isSecret ? "****" : result);
175+
return result;
157176
}
158177

159178
public Task<string> PromptForFilePathAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default)
@@ -185,6 +204,8 @@ public async Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T
185204
// the text is safe for both rendering and search highlighting.
186205
var safeFormatter = MakeSafeFormatter(choiceFormatter);
187206

207+
MessageLogger.LogInformation("Selection prompt: {PromptText}", promptText);
208+
188209
var prompt = new SelectionPrompt<T>()
189210
.Title(promptText)
190211
.UseConverter(safeFormatter)
@@ -194,7 +215,9 @@ public async Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T
194215

195216
prompt.SearchHighlightStyle = s_searchHighlightStyle;
196217

197-
return await _outConsole.PromptAsync(prompt, cancellationToken);
218+
var result = await MessageConsole.PromptAsync(prompt, cancellationToken);
219+
MessageLogger.LogInformation("Selection result: {Result}", safeFormatter(result));
220+
return result;
198221
}
199222

200223
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
@@ -219,6 +242,8 @@ public async Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptTex
219242

220243
var safeFormatter = MakeSafeFormatter(choiceFormatter);
221244

245+
MessageLogger.LogInformation("Selection prompt: {PromptText}", promptText);
246+
222247
var prompt = new MultiSelectionPrompt<T>()
223248
.Title(promptText)
224249
.UseConverter(safeFormatter)
@@ -235,7 +260,8 @@ public async Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptTex
235260
}
236261
}
237262

238-
var result = await _outConsole.PromptAsync(prompt, cancellationToken);
263+
var result = await MessageConsole.PromptAsync(prompt, cancellationToken);
264+
MessageLogger.LogInformation("Selection results: {Results}", string.Join(", ", result.Select(safeFormatter)));
239265
return result;
240266
}
241267

@@ -299,6 +325,14 @@ public void DisplayError(string errorMessage)
299325

300326
public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false)
301327
{
328+
if (MessageLogger.IsEnabled(LogLevel.Information))
329+
{
330+
// Only attempt to parse/remove markup when the message is expected to contain it.
331+
// Plain text messages may contain characters like '[' that would be rejected by the markup parser.
332+
var logMessage = allowMarkup ? message.RemoveMarkup() : message;
333+
MessageLogger.LogInformation("{Message}", ConsoleHelpers.FormatEmojiPrefix(emoji, MessageConsole, replaceEmoji: true) + logMessage);
334+
}
335+
302336
var displayMessage = allowMarkup ? message : message.EscapeMarkup();
303337
MessageConsole.MarkupLine(ConsoleHelpers.FormatEmojiPrefix(emoji, MessageConsole) + displayMessage);
304338
}
@@ -311,9 +345,9 @@ public void DisplayPlainText(string message)
311345

312346
public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null)
313347
{
348+
var effectiveConsole = consoleOverride ?? Console;
314349
// Write raw text directly to avoid console wrapping.
315350
// When consoleOverride is null, respect the Console setting.
316-
var effectiveConsole = consoleOverride ?? Console;
317351
var target = effectiveConsole == ConsoleOutput.Error ? _errorConsole : _outConsole;
318352
target.Profile.Out.Writer.WriteLine(text);
319353
}
@@ -386,18 +420,22 @@ public void DisplayCancellationMessage()
386420
DisplayMessage(KnownEmojis.StopSign, $"[teal bold]{InteractionServiceStrings.StoppingAspire}[/]", allowMarkup: true);
387421
}
388422

389-
public Task<bool> ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default)
423+
public async Task<bool> ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default)
390424
{
391425
if (!_hostEnvironment.SupportsInteractiveInput)
392426
{
393427
throw new InvalidOperationException(InteractionServiceStrings.InteractiveInputNotSupported);
394428
}
395429

396-
return _outConsole.ConfirmAsync(promptText, defaultValue, cancellationToken);
430+
MessageLogger.LogInformation("Confirm: {PromptText} (default: {DefaultValue})", promptText, defaultValue);
431+
var result = await MessageConsole.ConfirmAsync(promptText, defaultValue, cancellationToken);
432+
MessageLogger.LogInformation("Confirm result: {Result}", result);
433+
return result;
397434
}
398435

399436
public void DisplaySubtleMessage(string message, bool allowMarkup = false)
400437
{
438+
MessageLogger.LogInformation("{Message}", message);
401439
var displayMessage = allowMarkup ? message : message.EscapeMarkup();
402440
MessageConsole.MarkupLine($"[dim]{displayMessage}[/]");
403441
}
@@ -422,5 +460,4 @@ public void DisplayVersionUpdateNotification(string newerVersion, string? update
422460

423461
_errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.MoreInfoNewCliVersion, UpdateUrl));
424462
}
425-
426463
}

src/Aspire.Cli/Program.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,8 @@ private static void AddInteractionServices(HostApplicationBuilder builder)
804804
consoleEnvironment.Out.Profile.Width = 256; // VS code terminal will handle wrapping so set a large width here.
805805
var executionContext = provider.GetRequiredService<CliExecutionContext>();
806806
var hostEnvironment = provider.GetRequiredService<ICliHostEnvironment>();
807-
var consoleInteractionService = new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment);
807+
var loggerFactory = provider.GetRequiredService<ILoggerFactory>();
808+
var consoleInteractionService = new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment, loggerFactory);
808809
return new ExtensionInteractionService(consoleInteractionService,
809810
provider.GetRequiredService<IExtensionBackchannel>(),
810811
extensionPromptEnabled);
@@ -817,7 +818,8 @@ private static void AddInteractionServices(HostApplicationBuilder builder)
817818
var consoleEnvironment = provider.GetRequiredService<ConsoleEnvironment>();
818819
var executionContext = provider.GetRequiredService<CliExecutionContext>();
819820
var hostEnvironment = provider.GetRequiredService<ICliHostEnvironment>();
820-
return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment);
821+
var loggerFactory = provider.GetRequiredService<ILoggerFactory>();
822+
return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment, loggerFactory);
821823
});
822824
}
823825
}

src/Aspire.Cli/Utils/ConsoleHelpers.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ internal static class ConsoleHelpers
1414
/// <summary>
1515
/// Formats an emoji prefix with trailing space for aligned console output.
1616
/// </summary>
17-
public static string FormatEmojiPrefix(KnownEmoji emoji, IAnsiConsole console)
17+
public static string FormatEmojiPrefix(KnownEmoji emoji, IAnsiConsole console, bool replaceEmoji = false)
1818
{
1919
const int emojiTargetWidth = 3; // 2 for emoji and 1 trailing space
2020

2121
var cellLength = EmojiWidth.GetCachedCellWidth(emoji.Name, console);
2222
var padding = Math.Max(1, emojiTargetWidth - cellLength);
23-
return $":{emoji.Name}:" + new string(' ', padding);
23+
var spectreEmojiText = $":{emoji.Name}:";
24+
return (replaceEmoji ? Emoji.Replace(spectreEmojiText) : spectreEmojiText) + new string(' ', padding);
2425
}
2526
}

tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Aspire.Cli.Interaction;
77
using Aspire.Cli.Resources;
88
using Aspire.Cli.Utils;
9+
using Microsoft.Extensions.Logging.Abstractions;
910
using Spectre.Console;
1011

1112
using System.Text;
@@ -17,7 +18,7 @@ public class ConsoleInteractionServiceTests
1718
private static ConsoleInteractionService CreateInteractionService(IAnsiConsole console, CliExecutionContext executionContext, ICliHostEnvironment? hostEnvironment = null)
1819
{
1920
var consoleEnvironment = new ConsoleEnvironment(console, console);
20-
return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment ?? TestHelpers.CreateInteractiveHostEnvironment());
21+
return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment ?? TestHelpers.CreateInteractiveHostEnvironment(), NullLoggerFactory.Instance);
2122
}
2223

2324
[Fact]

tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,8 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser
392392
var consoleEnvironment = serviceProvider.GetRequiredService<ConsoleEnvironment>();
393393
var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
394394
var hostEnvironment = serviceProvider.GetRequiredService<ICliHostEnvironment>();
395-
return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment);
395+
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
396+
return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment, loggerFactory);
396397
};
397398

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

0 commit comments

Comments
 (0)