|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.CommandLine;
using System.Globalization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Certificates;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using StreamJsonRpc;
namespace Aspire.Cli.Commands;
internal sealed class RunCommand : BaseCommand
{
private readonly IDotNetCliRunner _runner;
private readonly IInteractionService _interactionService;
private readonly ICertificateService _certificateService;
private readonly IProjectLocator _projectLocator;
private readonly IAnsiConsole _ansiConsole;
private readonly AspireCliTelemetry _telemetry;
private readonly IConfiguration _configuration;
private readonly IDotNetSdkInstaller _sdkInstaller;
private readonly IServiceProvider _serviceProvider;
private readonly IFeatures _features;
private readonly ICliHostEnvironment _hostEnvironment;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RunCommand> _logger;
private readonly IAppHostProjectFactory _projectFactory;
public RunCommand(
IDotNetCliRunner runner,
IInteractionService interactionService,
ICertificateService certificateService,
IProjectLocator projectLocator,
IAnsiConsole ansiConsole,
AspireCliTelemetry telemetry,
IConfiguration configuration,
IDotNetSdkInstaller sdkInstaller,
IFeatures features,
ICliUpdateNotifier updateNotifier,
IServiceProvider serviceProvider,
CliExecutionContext executionContext,
ICliHostEnvironment hostEnvironment,
ILogger<RunCommand> logger,
IAppHostProjectFactory projectFactory,
TimeProvider? timeProvider)
: base("run", RunCommandStrings.Description, features, updateNotifier, executionContext, interactionService)
{
ArgumentNullException.ThrowIfNull(runner);
ArgumentNullException.ThrowIfNull(interactionService);
ArgumentNullException.ThrowIfNull(certificateService);
ArgumentNullException.ThrowIfNull(projectLocator);
ArgumentNullException.ThrowIfNull(ansiConsole);
ArgumentNullException.ThrowIfNull(telemetry);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(sdkInstaller);
ArgumentNullException.ThrowIfNull(hostEnvironment);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(projectFactory);
_runner = runner;
_interactionService = interactionService;
_certificateService = certificateService;
_projectLocator = projectLocator;
_ansiConsole = ansiConsole;
_telemetry = telemetry;
_configuration = configuration;
_serviceProvider = serviceProvider;
_sdkInstaller = sdkInstaller;
_features = features;
_hostEnvironment = hostEnvironment;
_logger = logger;
_projectFactory = projectFactory;
_timeProvider = timeProvider ?? TimeProvider.System;
var projectOption = new Option<FileInfo?>("--project");
projectOption.Description = RunCommandStrings.ProjectArgumentDescription;
Options.Add(projectOption);
if (ExtensionHelper.IsExtensionHost(InteractionService, out _, out _))
{
var startDebugOption = new Option<bool>("--start-debug-session");
startDebugOption.Description = RunCommandStrings.StartDebugSessionArgumentDescription;
Options.Add(startDebugOption);
}
TreatUnmatchedTokensAsErrors = false;
}
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
var isExtensionHost = ExtensionHelper.IsExtensionHost(InteractionService, out _, out _);
var startDebugSession = isExtensionHost && parseResult.GetValue<bool>("--start-debug-session");
var runningInstanceDetectionEnabled = _features.IsFeatureEnabled(KnownFeatures.RunningInstanceDetectionEnabled, defaultValue: true);
// Force option kept for backward compatibility but no longer used since prompt was removed
// var force = runningInstanceDetectionEnabled && parseResult.GetValue<bool>("--force");
// A user may run `aspire run` in an Aspire terminal in VS Code. In this case, intercept and prompt
// VS Code to start a debug session using the current directory
if (ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _)
&& string.IsNullOrEmpty(_configuration[KnownConfigNames.ExtensionDebugSessionId]))
{
extensionInteractionService.DisplayConsolePlainText(RunCommandStrings.StartingDebugSessionInExtension);
await extensionInteractionService.StartDebugSessionAsync(ExecutionContext.WorkingDirectory.FullName, passedAppHostProjectFile?.FullName, startDebugSession);
return ExitCodeConstants.Success;
}
// Check if the .NET SDK is available
if (!await SdkInstallHelper.EnsureSdkInstalledAsync(_sdkInstaller, InteractionService, _features, _hostEnvironment, cancellationToken))
{
return ExitCodeConstants.SdkNotInstalled;
}
AppHostProjectContext? context = null;
try
{
using var activity = _telemetry.ActivitySource.StartActivity(this.Name);
var searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, MultipleAppHostProjectsFoundBehavior.Prompt, createSettingsFile: true, cancellationToken);
var effectiveAppHostFile = searchResult.SelectedProjectFile;
if (effectiveAppHostFile is null)
{
return ExitCodeConstants.FailedToFindProject;
}
// Use the project factory to get the appropriate handler for the apphost file
var project = _projectFactory.TryGetProject(effectiveAppHostFile);
if (project is null)
{
InteractionService.DisplayError("Unrecognized app host type.");
return ExitCodeConstants.FailedToFindProject;
}
// Check for running instance if feature is enabled
if (runningInstanceDetectionEnabled)
{
// Even if we fail to stop we won't block the apphost starting
// to make sure we don't ever break flow. It should mostly stop
// just fine though.
await project.CheckAndHandleRunningInstanceAsync(effectiveAppHostFile, ExecutionContext.HomeDirectory, cancellationToken);
}
// The completion sources are the contract between RunCommand and IAppHostProject
var buildCompletionSource = new TaskCompletionSource<bool>();
var backchannelCompletionSource = new TaskCompletionSource<IAppHostCliBackchannel>();
context = new AppHostProjectContext
{
AppHostFile = effectiveAppHostFile,
Watch = false,
Debug = parseResult.GetValue<bool>("--debug"),
NoBuild = false,
WaitForDebugger = parseResult.GetValue<bool>("--wait-for-debugger"),
StartDebugSession = startDebugSession,
EnvironmentVariables = new Dictionary<string, string>(),
UnmatchedTokens = parseResult.UnmatchedTokens.ToArray(),
WorkingDirectory = ExecutionContext.WorkingDirectory,
BuildCompletionSource = buildCompletionSource,
BackchannelCompletionSource = backchannelCompletionSource
};
// Start the project run as a pending task - we'll handle UX while it runs
var pendingRun = project.RunAsync(context, cancellationToken);
// Wait for the build to complete first (project handles its own build status spinners)
var buildSuccess = await buildCompletionSource.Task.WaitAsync(cancellationToken);
if (!buildSuccess)
{
// Build failed - display captured output and return exit code
if (context.OutputCollector is { } outputCollector)
{
InteractionService.DisplayLines(outputCollector.GetLines());
}
InteractionService.DisplayError(InteractionServiceStrings.ProjectCouldNotBeBuilt);
return await pendingRun;
}
// Now wait for the backchannel to be established
var backchannel = await InteractionService.ShowStatusAsync(
isExtensionHost ? InteractionServiceStrings.BuildingAppHost : RunCommandStrings.ConnectingToAppHost,
async () => await backchannelCompletionSource.Task.WaitAsync(cancellationToken));
// Set up log capture
var logFile = GetAppHostLogFile();
var pendingLogCapture = CaptureAppHostLogsAsync(logFile, backchannel, _interactionService, cancellationToken);
// Get dashboard URLs
var dashboardUrls = await InteractionService.ShowStatusAsync(
RunCommandStrings.StartingDashboard,
async () => await backchannel.GetDashboardUrlsAsync(cancellationToken));
if (dashboardUrls.DashboardHealthy is false)
{
InteractionService.DisplayError(RunCommandStrings.DashboardFailedToStart);
return ExitCodeConstants.DashboardFailure;
}
// Display the UX
_ansiConsole.WriteLine();
var topGrid = new Grid();
topGrid.AddColumn();
topGrid.AddColumn();
var topPadder = new Padder(topGrid, new Padding(3, 0));
var dashboardsLocalizedString = RunCommandStrings.Dashboard;
var logsLocalizedString = RunCommandStrings.Logs;
var endpointsLocalizedString = RunCommandStrings.Endpoints;
var appHostLocalizedString = RunCommandStrings.AppHost;
var longestLocalizedLength = new[] { dashboardsLocalizedString, logsLocalizedString, endpointsLocalizedString, appHostLocalizedString }
.Max(s => s.Length);
var longestLocalizedLengthWithColon = longestLocalizedLength + 1;
topGrid.Columns[0].Width = longestLocalizedLengthWithColon;
var appHostRelativePath = Path.GetRelativePath(ExecutionContext.WorkingDirectory.FullName, effectiveAppHostFile.FullName);
topGrid.AddRow(new Align(new Markup($"[bold green]{appHostLocalizedString}[/]:"), HorizontalAlignment.Right), new Text(appHostRelativePath));
topGrid.AddRow(Text.Empty, Text.Empty);
if (!isExtensionHost)
{
topGrid.AddRow(new Align(new Markup($"[bold green]{dashboardsLocalizedString}[/]:"), HorizontalAlignment.Right), new Markup($"[link={dashboardUrls.BaseUrlWithLoginToken}]{dashboardUrls.BaseUrlWithLoginToken}[/]"));
if (dashboardUrls.CodespacesUrlWithLoginToken is { } codespacesUrlWithLoginToken)
{
topGrid.AddRow(Text.Empty, new Markup($"[link={codespacesUrlWithLoginToken}]{codespacesUrlWithLoginToken}[/]"));
}
}
topGrid.AddRow(Text.Empty, Text.Empty);
topGrid.AddRow(new Align(new Markup($"[bold green]{logsLocalizedString}[/]:"), HorizontalAlignment.Right), new Text(logFile.FullName));
_ansiConsole.Write(topPadder);
// Handle remote environments (Codespaces, Remote Containers, SSH)
var isCodespaces = dashboardUrls.CodespacesUrlWithLoginToken is not null;
var isRemoteContainers = _configuration.GetValue<bool>("REMOTE_CONTAINERS", false);
var isSshRemote = _configuration.GetValue<string?>("VSCODE_IPC_HOOK_CLI") is not null
&& _configuration.GetValue<string?>("SSH_CONNECTION") is not null;
AppendCtrlCMessage(longestLocalizedLengthWithColon);
if (isCodespaces || isRemoteContainers || isSshRemote)
{
bool firstEndpoint = true;
try
{
var resourceStates = backchannel.GetResourceStatesAsync(cancellationToken);
await foreach (var resourceState in resourceStates.WithCancellation(cancellationToken))
{
ProcessResourceState(resourceState, (resource, endpoint) =>
{
ClearLines(2);
var endpointsGrid = new Grid();
endpointsGrid.AddColumn();
endpointsGrid.AddColumn();
endpointsGrid.Columns[0].Width = longestLocalizedLengthWithColon;
if (firstEndpoint)
{
endpointsGrid.AddRow(Text.Empty, Text.Empty);
}
endpointsGrid.AddRow(
firstEndpoint ? new Align(new Markup($"[bold green]{endpointsLocalizedString}[/]:"), HorizontalAlignment.Right) : Text.Empty,
new Markup($"[bold]{resource}[/] [grey]has endpoint[/] [link={endpoint}]{endpoint}[/]")
);
var endpointsPadder = new Padder(endpointsGrid, new Padding(3, 0));
_ansiConsole.Write(endpointsPadder);
firstEndpoint = false;
AppendCtrlCMessage(longestLocalizedLengthWithColon);
});
}
}
catch (ConnectionLostException) when (cancellationToken.IsCancellationRequested)
{
// Orderly shutdown
}
}
if (ExtensionHelper.IsExtensionHost(InteractionService, out var extInteractionService, out _))
{
extInteractionService.DisplayDashboardUrls(dashboardUrls);
extInteractionService.NotifyAppHostStartupCompleted();
}
await pendingLogCapture;
return await pendingRun;
}
catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken || ex is ExtensionOperationCanceledException)
{
InteractionService.DisplayCancellationMessage();
return ExitCodeConstants.Success;
}
catch (ProjectLocatorException ex)
{
return HandleProjectLocatorException(ex, InteractionService);
}
catch (AppHostIncompatibleException ex)
{
return InteractionService.DisplayIncompatibleVersionError(ex, ex.RequiredCapability);
}
catch (CertificateServiceException ex)
{
InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.CertificateTrustError, ex.Message.EscapeMarkup()));
return ExitCodeConstants.FailedToTrustCertificates;
}
catch (FailedToConnectBackchannelConnection ex)
{
InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message.EscapeMarkup()));
if (context?.OutputCollector is { } outputCollector)
{
InteractionService.DisplayLines(outputCollector.GetLines());
}
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
catch (Exception ex)
{
InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message.EscapeMarkup()));
if (context?.OutputCollector is { } outputCollector)
{
InteractionService.DisplayLines(outputCollector.GetLines());
}
return ExitCodeConstants.FailedToDotnetRunAppHost;
}
}
private void ClearLines(int lines)
{
if (lines <= 0)
{
return;
}
for (var i = 0; i < lines; i++)
{
_ansiConsole.Write("\u001b[1A");
_ansiConsole.Write("\u001b[2K"); // Clear the line
}
}
private void AppendCtrlCMessage(int longestLocalizedLengthWithColon)
{
if (ExtensionHelper.IsExtensionHost(_interactionService, out _, out _))
{
return;
}
var ctrlCGrid = new Grid();
ctrlCGrid.AddColumn();
ctrlCGrid.AddColumn();
ctrlCGrid.Columns[0].Width = longestLocalizedLengthWithColon;
ctrlCGrid.AddRow(Text.Empty, Text.Empty);
ctrlCGrid.AddRow(new Text(string.Empty), new Markup(RunCommandStrings.PressCtrlCToStopAppHost) { Overflow = Overflow.Ellipsis });
var ctrlCPadder = new Padder(ctrlCGrid, new Padding(3, 0));
_ansiConsole.Write(ctrlCPadder);
}
private FileInfo GetAppHostLogFile()
{
var homeDirectory = ExecutionContext.HomeDirectory.FullName;
var logsPath = Path.Combine(homeDirectory, ".aspire", "cli", "logs");
var logFilePath = Path.Combine(logsPath, $"apphost-{Environment.ProcessId}-{_timeProvider.GetUtcNow():yyyy-MM-dd-HH-mm-ss}.log");
var logFile = new FileInfo(logFilePath);
return logFile;
}
private static async Task CaptureAppHostLogsAsync(FileInfo logFile, 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))
{
if (ExtensionHelper.IsExtensionHost(interactionService, out var extensionInteractionService, out _))
{
if (entry.LogLevel is not LogLevel.Trace and not LogLevel.Debug)
{
// Send only information+ level logs to the extension host.
extensionInteractionService.WriteDebugSessionMessage(entry.Message, entry.LogLevel is not LogLevel.Error and not LogLevel.Critical, "\x1b[2m");
}
}
await streamWriter.WriteLineAsync($"{entry.Timestamp:HH:mm:ss} [{entry.LogLevel}] {entry.CategoryName}: {entry.Message}");
}
}
catch (OperationCanceledException)
{
// Swallow the exception if the operation was cancelled.
return;
}
catch (ConnectionLostException) when (cancellationToken.IsCancellationRequested)
{
// Just swallow this exception because this is an orderly shutdown of the backchannel.
return;
}
}
private readonly Dictionary<string, RpcResourceState> _resourceStates = new();
public void ProcessResourceState(RpcResourceState resourceState, Action<string, string> endpointWriter)
{
if (_resourceStates.TryGetValue(resourceState.Resource, out var existingResourceState))
{
if (resourceState.Endpoints.Except(existingResourceState.Endpoints) is { } endpoints && endpoints.Any())
{
foreach (var endpoint in endpoints)
{
endpointWriter(resourceState.Resource, endpoint);
}
}
_resourceStates[resourceState.Resource] = resourceState;
}
else
{
if (resourceState.Endpoints is { } endpoints && endpoints.Any())
{
foreach (var endpoint in endpoints)
{
endpointWriter(resourceState.Resource, endpoint);
}
}
_resourceStates[resourceState.Resource] = resourceState;
}
}
}
|