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
14 changes: 13 additions & 1 deletion src/Aspire.Cli/CliExecutionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,24 @@

namespace Aspire.Cli;

internal sealed class CliExecutionContext(DirectoryInfo workingDirectory, DirectoryInfo hivesDirectory, DirectoryInfo cacheDirectory, DirectoryInfo sdksDirectory, bool debugMode = false, IReadOnlyDictionary<string, string?>? environmentVariables = null, DirectoryInfo? homeDirectory = null)
internal sealed class CliExecutionContext(DirectoryInfo workingDirectory, DirectoryInfo hivesDirectory, DirectoryInfo cacheDirectory, DirectoryInfo sdksDirectory, DirectoryInfo logsDirectory, string logFilePath, bool debugMode = false, IReadOnlyDictionary<string, string?>? environmentVariables = null, DirectoryInfo? homeDirectory = null)
{
public DirectoryInfo WorkingDirectory { get; } = workingDirectory;
public DirectoryInfo HivesDirectory { get; } = hivesDirectory;
public DirectoryInfo CacheDirectory { get; } = cacheDirectory;
public DirectoryInfo SdksDirectory { get; } = sdksDirectory;
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The LogsDirectory property is added to CliExecutionContext but is never used anywhere in the codebase. While this may be intended for future work (as mentioned in the PR description for Phase 2-4), consider either using it now (e.g., to display the log file path on errors) or documenting why it's being added proactively. This would help future developers understand its purpose.

Suggested change
public DirectoryInfo SdksDirectory { get; } = sdksDirectory;
public DirectoryInfo SdksDirectory { get; } = sdksDirectory;
/// <summary>
/// Gets the directory where CLI log files are stored for this execution context.
/// This property is reserved for use by current and future logging features.
/// </summary>

Copilot uses AI. Check for mistakes.

/// <summary>
/// Gets the directory where CLI log files are stored.
/// Used by cache clear command to clean up old log files.
/// </summary>
public DirectoryInfo LogsDirectory { get; } = logsDirectory;

/// <summary>
/// Gets the path to the current session's log file.
/// </summary>
public string LogFilePath { get; } = logFilePath;

public DirectoryInfo HomeDirectory { get; } = homeDirectory ?? new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
public bool DebugMode { get; } = debugMode;

Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Commands/AddCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
{
InteractionService.DisplayLines(outputCollector.GetLines());
}
InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.PackageInstallationFailed, ExitCodeConstants.FailedToAddPackage));
InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.PackageInstallationFailed, ExitCodeConstants.FailedToAddPackage, ExecutionContext.LogFilePath));
return ExitCodeConstants.FailedToAddPackage;
}

Expand Down
39 changes: 39 additions & 0 deletions src/Aspire.Cli/Commands/CacheCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,45 @@ protected override Task<int> ExecuteAsync(ParseResult parseResult, CancellationT
}
}

// Also clear the logs directory (skip current process's log file)
var logsDirectory = ExecutionContext.LogsDirectory;
// Log files are named cli-{timestamp}-{pid}.log, so we need to check the suffix
var currentLogFileSuffix = $"-{Environment.ProcessId}.log";
if (logsDirectory.Exists)
{
foreach (var file in logsDirectory.GetFiles("*", SearchOption.AllDirectories))
{
// Skip the current process's log file to avoid deleting it while in use
if (file.Name.EndsWith(currentLogFileSuffix, StringComparison.OrdinalIgnoreCase))
{
continue;
}

try
{
file.Delete();
filesDeleted++;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
// Continue deleting other files even if some fail
}
}

// Delete subdirectories
foreach (var directory in logsDirectory.GetDirectories())
{
try
{
directory.Delete(recursive: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
// Continue deleting other directories even if some fail
}
}
}

if (filesDeleted == 0)
{
InteractionService.DisplayMessage("information", CacheCommandStrings.CacheAlreadyEmpty);
Expand Down
6 changes: 3 additions & 3 deletions src/Aspire.Cli/Commands/ExecCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
env[KnownConfigNames.WaitForDebugger] = "true";
}

appHostCompatibilityCheck = await AppHostHelper.CheckAppHostCompatibilityAsync(_runner, InteractionService, effectiveAppHostProjectFile, Telemetry, ExecutionContext.WorkingDirectory, cancellationToken);
appHostCompatibilityCheck = await AppHostHelper.CheckAppHostCompatibilityAsync(_runner, InteractionService, effectiveAppHostProjectFile, Telemetry, ExecutionContext.WorkingDirectory, ExecutionContext.LogFilePath, cancellationToken);
if (!appHostCompatibilityCheck?.IsCompatibleAppHost ?? throw new InvalidOperationException(RunCommandStrings.IsCompatibleAppHostIsNull))
{
return ExitCodeConstants.FailedToDotnetRunAppHost;
Expand Down Expand Up @@ -253,7 +253,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
if (result != 0)
{
InteractionService.DisplayLines(runOutputCollector.GetLines());
InteractionService.DisplayError(RunCommandStrings.ProjectCouldNotBeRun);
InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.ProjectCouldNotBeRun, ExecutionContext.LogFilePath));
return result;
}
else
Expand All @@ -264,7 +264,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
else
{
InteractionService.DisplayLines(runOutputCollector.GetLines());
InteractionService.DisplayError(RunCommandStrings.ProjectCouldNotBeRun);
InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.ProjectCouldNotBeRun, ExecutionContext.LogFilePath));
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
}
Expand Down
44 changes: 44 additions & 0 deletions src/Aspire.Cli/Commands/RootCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.CommandLine;
using System.CommandLine.Help;
using Microsoft.Extensions.Logging;

#if DEBUG
using System.Globalization;
Expand All @@ -22,6 +23,13 @@ internal sealed class RootCommand : BaseRootCommand
public static readonly Option<bool> DebugOption = new(CommonOptionNames.Debug, CommonOptionNames.DebugShort)
{
Description = RootCommandStrings.DebugArgumentDescription,
Recursive = true,
Hidden = true // Hidden for backward compatibility, use --debug-level instead
};

public static readonly Option<LogLevel?> DebugLevelOption = new("--debug-level", "-v")
{
Description = RootCommandStrings.DebugLevelArgumentDescription,
Recursive = true
};

Expand Down Expand Up @@ -58,6 +66,41 @@ internal sealed class RootCommand : BaseRootCommand
DefaultValueFactory = _ => false
};

/// <summary>
/// Global options that should be passed through to child CLI processes when spawning.
/// Add new global options here to ensure they are forwarded during detached mode execution.
/// </summary>
private static readonly (Option Option, Func<ParseResult, string[]?> GetArgs)[] s_childProcessOptions =
[
(DebugOption, pr => pr.GetValue(DebugOption) ? ["--debug"] : null),
(DebugLevelOption, pr =>
{
var level = pr.GetValue(DebugLevelOption);
return level.HasValue ? ["--debug-level", level.Value.ToString()] : null;
}),
(WaitForDebuggerOption, pr => pr.GetValue(WaitForDebuggerOption) ? ["--wait-for-debugger"] : null),
];

/// <summary>
/// Gets the command-line arguments for global options that should be passed to a child CLI process.
/// </summary>
/// <param name="parseResult">The parse result from the current command invocation.</param>
/// <returns>Arguments to pass to the child process.</returns>
public static IEnumerable<string> GetChildProcessArgs(ParseResult parseResult)
{
foreach (var (_, getArgs) in s_childProcessOptions)
{
var args = getArgs(parseResult);
if (args is not null)
{
foreach (var arg in args)
{
yield return arg;
}
}
}
}

private readonly IInteractionService _interactionService;

public RootCommand(
Expand Down Expand Up @@ -116,6 +159,7 @@ public RootCommand(
#endif

Options.Add(DebugOption);
Options.Add(DebugLevelOption);
Options.Add(NonInteractiveOption);
Options.Add(NoLogoOption);
Options.Add(BannerOption);
Expand Down
84 changes: 32 additions & 52 deletions src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ internal sealed class RunCommand : BaseCommand
private readonly ILogger<RunCommand> _logger;
private readonly IAppHostProjectFactory _projectFactory;
private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor;
private readonly Diagnostics.FileLoggerProvider _fileLoggerProvider;

private static readonly Option<FileInfo?> s_projectOption = new("--project")
{
Expand Down Expand Up @@ -102,6 +103,7 @@ public RunCommand(
ILogger<RunCommand> logger,
IAppHostProjectFactory projectFactory,
IAuxiliaryBackchannelMonitor backchannelMonitor,
Diagnostics.FileLoggerProvider fileLoggerProvider,
TimeProvider? timeProvider)
: base("run", RunCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry)
{
Expand All @@ -118,6 +120,7 @@ public RunCommand(
_logger = logger;
_projectFactory = projectFactory;
_backchannelMonitor = backchannelMonitor;
_fileLoggerProvider = fileLoggerProvider;
_timeProvider = timeProvider ?? TimeProvider.System;

Options.Add(s_projectOption);
Expand Down Expand Up @@ -252,7 +255,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
{
InteractionService.DisplayLines(outputCollector.GetLines());
}
InteractionService.DisplayError(InteractionServiceStrings.ProjectCouldNotBeBuilt);
InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ProjectCouldNotBeBuilt, ExecutionContext.LogFilePath));
return await pendingRun;
}

Expand All @@ -261,12 +264,8 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
isExtensionHost ? InteractionServiceStrings.BuildingAppHost : RunCommandStrings.ConnectingToAppHost,
async () => await backchannelCompletionSource.Task.WaitAsync(cancellationToken));

// Set up log capture
var logFile = AppHostHelper.GetLogFilePath(
Environment.ProcessId,
ExecutionContext.HomeDirectory.FullName,
_timeProvider);
var pendingLogCapture = CaptureAppHostLogsAsync(logFile, backchannel, _interactionService, cancellationToken);
// Set up log capture - writes to unified CLI log file
var pendingLogCapture = CaptureAppHostLogsAsync(_fileLoggerProvider, backchannel, _interactionService, cancellationToken);

// Get dashboard URLs
var dashboardUrls = await InteractionService.ShowStatusAsync(
Expand All @@ -286,7 +285,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
appHostRelativePath,
dashboardUrls.BaseUrlWithLoginToken,
dashboardUrls.CodespacesUrlWithLoginToken,
logFile.FullName,
_fileLoggerProvider.LogFilePath,
isExtensionHost);

// Handle remote environments (Codespaces, Remote Containers, SSH)
Expand Down Expand Up @@ -375,21 +374,17 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message.EscapeMarkup());
Telemetry.RecordError(errorMessage, ex);
InteractionService.DisplayError(errorMessage);
if (context?.OutputCollector is { } outputCollector)
{
InteractionService.DisplayLines(outputCollector.GetLines());
}
// Don't display raw output - it's already in the log file
InteractionService.DisplayMessage("page_facing_up", string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath));
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
catch (Exception ex)
{
var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message.EscapeMarkup());
Telemetry.RecordError(errorMessage, ex);
InteractionService.DisplayError(errorMessage);
if (context?.OutputCollector is { } outputCollector)
{
InteractionService.DisplayLines(outputCollector.GetLines());
}
// Don't display raw output - it's already in the log file
InteractionService.DisplayMessage("page_facing_up", string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath));
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
}
Expand Down Expand Up @@ -516,22 +511,12 @@ internal static int RenderAppHostSummary(
return longestLabelLength;
}

private static async Task CaptureAppHostLogsAsync(FileInfo logFile, IAppHostCliBackchannel backchannel, IInteractionService interactionService, CancellationToken cancellationToken)
private static async Task CaptureAppHostLogsAsync(Diagnostics.FileLoggerProvider fileLoggerProvider, IAppHostCliBackchannel backchannel, IInteractionService interactionService, CancellationToken cancellationToken)
{
try
{
await Task.Yield();

if (!logFile.Directory!.Exists)
{
logFile.Directory.Create();
}

using var streamWriter = new StreamWriter(logFile.FullName, append: true)
{
AutoFlush = true
};

var logEntries = backchannel.GetAppHostLogEntriesAsync(cancellationToken);

await foreach (var entry in logEntries.WithCancellation(cancellationToken))
Expand All @@ -545,7 +530,19 @@ private static async Task CaptureAppHostLogsAsync(FileInfo logFile, IAppHostCliB
}
}

await streamWriter.WriteLineAsync($"{entry.Timestamp:HH:mm:ss} [{entry.LogLevel}] {entry.CategoryName}: {entry.Message}");
// Write to the unified log file via FileLoggerProvider
var timestamp = entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
var level = entry.LogLevel switch
{
LogLevel.Trace => "TRCE",
LogLevel.Debug => "DBUG",
LogLevel.Information => "INFO",
LogLevel.Warning => "WARN",
LogLevel.Error => "FAIL",
LogLevel.Critical => "CRIT",
_ => entry.LogLevel.ToString().ToUpperInvariant()
};
fileLoggerProvider.WriteLog($"[{timestamp}] [{level}] [AppHost/{entry.CategoryName}] {entry.Message}");
}
}
catch (OperationCanceledException)
Expand Down Expand Up @@ -668,15 +665,10 @@ private async Task<int> ExecuteDetachedAsync(ParseResult parseResult, FileInfo?
effectiveAppHostFile.FullName
};

// Pass through global options that were matched at the root level
if (parseResult.GetValue(RootCommand.DebugOption))
{
args.Add("--debug");
}
if (parseResult.GetValue(RootCommand.WaitForDebuggerOption))
{
args.Add("--wait-for-debugger");
}
// Pass through global options that should be forwarded to child CLI
args.AddRange(RootCommand.GetChildProcessArgs(parseResult));

// Pass through run-specific options
if (parseResult.GetValue(s_isolatedOption))
{
args.Add("--isolated");
Expand Down Expand Up @@ -841,12 +833,6 @@ private async Task<int> ExecuteDetachedAsync(ParseResult parseResult, FileInfo?
return ExitCodeConstants.FailedToDotnetRunAppHost;
}

// Compute the expected log file path for error message
var expectedLogFile = AppHostHelper.GetLogFilePath(
childProcess.Id,
ExecutionContext.HomeDirectory.FullName,
_timeProvider);

if (childExitedEarly)
{
_interactionService.DisplayError(string.Format(
Expand Down Expand Up @@ -876,7 +862,7 @@ private async Task<int> ExecuteDetachedAsync(ParseResult parseResult, FileInfo?
_interactionService.DisplayMessage("magnifying_glass_tilted_right", string.Format(
CultureInfo.CurrentCulture,
RunCommandStrings.CheckLogsForDetails,
expectedLogFile.FullName));
_fileLoggerProvider.LogFilePath));

return ExitCodeConstants.FailedToDotnetRunAppHost;
}
Expand All @@ -886,12 +872,6 @@ private async Task<int> ExecuteDetachedAsync(ParseResult parseResult, FileInfo?
// Get the dashboard URLs
var dashboardUrls = await backchannel.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false);

// Get the log file path
var logFile = AppHostHelper.GetLogFilePath(
appHostInfo?.ProcessId ?? childProcess.Id,
ExecutionContext.HomeDirectory.FullName,
_timeProvider);

var pid = appHostInfo?.ProcessId ?? childProcess.Id;

if (format == OutputFormat.Json)
Expand All @@ -902,7 +882,7 @@ private async Task<int> ExecuteDetachedAsync(ParseResult parseResult, FileInfo?
pid,
childProcess.Id,
dashboardUrls?.BaseUrlWithLoginToken,
logFile.FullName);
_fileLoggerProvider.LogFilePath);
var json = JsonSerializer.Serialize(result, RunCommandJsonContext.RelaxedEscaping.DetachOutputInfo);
_interactionService.DisplayRawText(json);
}
Expand All @@ -915,7 +895,7 @@ private async Task<int> ExecuteDetachedAsync(ParseResult parseResult, FileInfo?
appHostRelativePath,
dashboardUrls?.BaseUrlWithLoginToken,
codespacesUrl: null,
logFile.FullName,
_fileLoggerProvider.LogFilePath,
isExtensionHost,
pid);
_ansiConsole.WriteLine();
Expand Down
Loading
Loading