diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 109d9009369..26a7cfa4974 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -76,6 +76,7 @@ + @@ -97,6 +98,7 @@ + diff --git a/src/Aspire.Cli/Commands/DashboardCommand.cs b/src/Aspire.Cli/Commands/DashboardCommand.cs new file mode 100644 index 00000000000..3cd8bd3b32c --- /dev/null +++ b/src/Aspire.Cli/Commands/DashboardCommand.cs @@ -0,0 +1,438 @@ +// 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.Configuration; +using Aspire.Cli.Diagnostics; +using Aspire.Cli.DotNet; +using Aspire.Cli.Interaction; +using Aspire.Cli.Layout; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Aspire.Hosting; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Aspire.Cli.Commands; + +/// +/// Command that starts a standalone Aspire Dashboard instance. +/// +internal sealed class DashboardCommand : BaseCommand +{ + internal override HelpGroup HelpGroup => HelpGroup.Monitoring; + + private readonly IInteractionService _interactionService; + private readonly ILayoutDiscovery _layoutDiscovery; + private readonly LayoutProcessRunner _layoutProcessRunner; + private readonly FileLoggerProvider _fileLoggerProvider; + private readonly ILogger _logger; + + private static readonly Option s_frontendUrlOption = new("--frontend-url") + { + Description = DashboardCommandStrings.FrontendUrlOptionDescription + }; + + private static readonly Option s_otlpGrpcUrlOption = new("--otlp-grpc-url") + { + Description = DashboardCommandStrings.OtlpGrpcUrlOptionDescription + }; + + private static readonly Option s_otlpHttpUrlOption = new("--otlp-http-url") + { + Description = DashboardCommandStrings.OtlpHttpUrlOptionDescription + }; + + private static readonly Option s_allowAnonymousOption = new("--allow-anonymous") + { + Description = DashboardCommandStrings.AllowAnonymousOptionDescription + }; + + private static readonly Option s_configFilePathOption = new("--config-file-path") + { + Description = DashboardCommandStrings.ConfigFilePathOptionDescription + }; + + public DashboardCommand( + IInteractionService interactionService, + ILayoutDiscovery layoutDiscovery, + LayoutProcessRunner layoutProcessRunner, + FileLoggerProvider fileLoggerProvider, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + ILogger logger, + AspireCliTelemetry telemetry) + : base("dashboard", DashboardCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) + { + _interactionService = interactionService; + _layoutDiscovery = layoutDiscovery; + _layoutProcessRunner = layoutProcessRunner; + _fileLoggerProvider = fileLoggerProvider; + _logger = logger; + + Options.Add(s_frontendUrlOption); + Options.Add(s_otlpGrpcUrlOption); + Options.Add(s_otlpHttpUrlOption); + Options.Add(s_allowAnonymousOption); + Options.Add(s_configFilePathOption); + TreatUnmatchedTokensAsErrors = false; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var layout = _layoutDiscovery.DiscoverLayout(); + if (layout is null) + { + _interactionService.DisplayError(DashboardCommandStrings.BundleLayoutNotFound); + return ExitCodeConstants.DashboardFailure; + } + + var managedPath = layout.GetManagedPath(); + if (managedPath is null || !File.Exists(managedPath)) + { + _interactionService.DisplayError(DashboardCommandStrings.ManagedBinaryNotFound); + return ExitCodeConstants.DashboardFailure; + } + + var dashboardArgs = new List { "dashboard" }; + + // Build args from typed options. These are added before unmatched tokens + // so that raw pass-through arguments (unmatched tokens) take precedence. + var unmatchedTokens = parseResult.UnmatchedTokens; + var allowAnonymous = parseResult.GetValue(s_allowAnonymousOption); + AddOptionArgs(parseResult, dashboardArgs, unmatchedTokens, ExecutionContext); + + // Set a browser token for frontend auth unless anonymous access is enabled. + // The token is passed via environment variable (not command-line arg) to + // avoid exposing it in process listings (e.g. ps, Task Manager). + string? browserToken = null; + Dictionary? environmentVariables = null; + if (!allowAnonymous && !ConfigSettingHasValue(unmatchedTokens, ExecutionContext, "ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS")) + { + if (!ConfigSettingHasValue(unmatchedTokens, ExecutionContext, "DASHBOARD__FRONTEND__BROWSERTOKEN")) + { + browserToken = TokenGenerator.GenerateToken(); + environmentVariables = new Dictionary + { + ["DASHBOARD__FRONTEND__BROWSERTOKEN"] = browserToken + }; + } + } + + dashboardArgs.AddRange(unmatchedTokens); + + // Resolve URLs for the summary display. + var dashboardInfo = ResolveDashboardInfo(dashboardArgs, unmatchedTokens, ExecutionContext, browserToken); + + return await ExecuteForegroundAsync(managedPath, dashboardArgs, dashboardInfo, environmentVariables, cancellationToken).ConfigureAwait(false); + } + + private static void AddOptionArgs(ParseResult parseResult, List args, IReadOnlyList unmatchedTokens, CliExecutionContext executionContext) + { + AddStringOptionArg(parseResult, args, unmatchedTokens, executionContext, s_frontendUrlOption, "ASPNETCORE_URLS", defaultValue: "http://localhost:18888"); + AddStringOptionArg(parseResult, args, unmatchedTokens, executionContext, s_otlpGrpcUrlOption, "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", defaultValue: "http://localhost:4317"); + AddStringOptionArg(parseResult, args, unmatchedTokens, executionContext, s_otlpHttpUrlOption, "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL", defaultValue: "http://localhost:4318"); + AddBoolOptionArg(parseResult, args, unmatchedTokens, executionContext, s_allowAnonymousOption, "ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS"); + AddStringOptionArg(parseResult, args, unmatchedTokens, executionContext, s_configFilePathOption, "ASPIRE_DASHBOARD_CONFIG_FILE_PATH", defaultValue: null); + } + + private static void AddStringOptionArg(ParseResult parseResult, List args, IReadOnlyList unmatchedTokens, + CliExecutionContext executionContext, Option option, string envVarName, string? defaultValue) + { + if (ConfigSettingHasValue(unmatchedTokens, executionContext, envVarName)) + { + return; + } + + var value = parseResult.GetResult(option) is not null + ? parseResult.GetValue(option) + : defaultValue; + + if (value is not null) + { + args.Add($"--{envVarName}={value}"); + } + } + + private static void AddBoolOptionArg(ParseResult parseResult, List args, IReadOnlyList unmatchedTokens, + CliExecutionContext executionContext, Option option, string envVarName) + { + if (ConfigSettingHasValue(unmatchedTokens, executionContext, envVarName)) + { + return; + } + + var result = parseResult.GetResult(option); + + // Skip when the result comes from the option's default value rather than + // explicit user input. Without the Implicit check, Option defaults + // (e.g. false) would always emit "--ALLOW_ANONYMOUS=false" even when the + // user never specified --allow-anonymous. + if (result is not null && !result.Implicit) + { + var value = parseResult.GetValue(option); + args.Add($"--{envVarName}={value.ToString().ToLowerInvariant()}"); + } + } + + internal static bool ConfigSettingHasValue(IReadOnlyList unmatchedTokens, CliExecutionContext executionContext, string envVarName) + { + // Check if already provided via unmatched tokens. + var prefix = $"--{envVarName}="; + for (var i = 0; i < unmatchedTokens.Count; i++) + { + if (unmatchedTokens[i].StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Also handle bare "--KEY" (boolean flag) or "--KEY value" (space-separated) forms. + // A bare key is treated as present because it will be forwarded to the + // child process via AddRange(unmatchedTokens), which uses last-wins + // semantics. For booleans (e.g. "--ALLOW_ANONYMOUS") it means true; + // for strings the bare key overrides our default regardless. + if (unmatchedTokens[i].Equals($"--{envVarName}", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + // Check if already set as an environment variable. + if (executionContext.GetEnvironmentVariable(envVarName) is not null) + { + return true; + } + + return false; + } + + internal static DashboardInfo ResolveDashboardInfo(List dashboardArgs, IReadOnlyList unmatchedTokens, CliExecutionContext executionContext, string? browserToken) + { + var frontendUrl = ResolveSettingValue(dashboardArgs, unmatchedTokens, executionContext, "ASPNETCORE_URLS") ?? "http://localhost:18888"; + var otlpGrpcUrl = ResolveSettingValue(dashboardArgs, unmatchedTokens, executionContext, "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL") ?? "http://localhost:4317"; + var otlpHttpUrl = ResolveSettingValue(dashboardArgs, unmatchedTokens, executionContext, "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL") ?? "http://localhost:4318"; + + // Take the first URL if multiple are specified (semicolon-separated). + var parts = frontendUrl.Split(';', StringSplitOptions.RemoveEmptyEntries); + var firstUrl = parts.Length > 0 ? parts[0].TrimEnd('/') : "http://localhost:18888"; + + var dashboardUrl = browserToken is not null + ? $"{firstUrl}/login?t={browserToken}" + : firstUrl; + + return new DashboardInfo(dashboardUrl, otlpGrpcUrl, otlpHttpUrl); + } + + /// + /// Resolves a setting value by checking, in order: args (--KEY=value), unmatched tokens + /// (--KEY value with space separator), and environment variables. + /// + internal static string? ResolveSettingValue(List args, IReadOnlyList unmatchedTokens, CliExecutionContext executionContext, string key) + { + // First check --KEY=value in args (last-wins). + var result = ResolveArgValue(args, key); + if (result is not null) + { + return result; + } + + // Check unmatched tokens for space-separated form: --KEY value + var bareKey = $"--{key}"; + for (var i = 0; i < unmatchedTokens.Count; i++) + { + if (unmatchedTokens[i].Equals(bareKey, StringComparison.OrdinalIgnoreCase) && i + 1 < unmatchedTokens.Count) + { + return unmatchedTokens[i + 1]; + } + } + + // Fall back to environment variable. + return executionContext.GetEnvironmentVariable(key); + } + + internal static string? ResolveArgValue(List args, string key) + { + // Scan for --KEY=value (last-wins). + string? result = null; + var prefix = $"--{key}="; + foreach (var arg in args) + { + if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + result = arg.Substring(prefix.Length); + } + } + + return result; + } + + internal sealed record DashboardInfo(string DashboardUrl, string OtlpGrpcUrl, string OtlpHttpUrl); + + private static string GetExitCodeMessage(int exitCode) + { + return exitCode switch + { + DashboardExitCodes.UnexpectedError => DashboardCommandStrings.DashboardExitedUnexpectedError, + DashboardExitCodes.ValidationFailure => DashboardCommandStrings.DashboardExitedValidationFailure, + DashboardExitCodes.AddressInUse => DashboardCommandStrings.DashboardExitedAddressInUse, + _ => string.Format(CultureInfo.CurrentCulture, DashboardCommandStrings.DashboardExitedWithError, exitCode), + }; + } + + private void RenderDashboardSummary(DashboardInfo info, string logFilePath) + { + _interactionService.DisplayEmptyLine(); + var grid = new Grid(); + grid.AddColumn(); + grid.AddColumn(); + + var dashboardLabel = DashboardCommandStrings.DashboardLabel; + var otlpGrpcLabel = DashboardCommandStrings.OtlpGrpcLabel; + var otlpHttpLabel = DashboardCommandStrings.OtlpHttpLabel; + var logsLabel = DashboardCommandStrings.LogsLabel; + + var labels = new List { dashboardLabel, otlpGrpcLabel, otlpHttpLabel, logsLabel }; + + var longestLabelLength = labels.Max(s => s.Length) + 1; // +1 for colon + grid.Columns[0].Width = longestLabelLength; + + // Dashboard row + var escapedDashboardUrl = Markup.Escape(info.DashboardUrl); + grid.AddRow( + new Align(new Markup($"[bold green]{dashboardLabel}[/]:"), HorizontalAlignment.Right), + new Markup($"[link={escapedDashboardUrl}]{escapedDashboardUrl}[/]")); + grid.AddRow(Text.Empty, Text.Empty); + + // OTLP gRPC row + grid.AddRow( + new Align(new Markup($"[bold green]{otlpGrpcLabel}[/]:"), HorizontalAlignment.Right), + new Text(info.OtlpGrpcUrl)); + grid.AddRow(Text.Empty, Text.Empty); + + // OTLP HTTP row + grid.AddRow( + new Align(new Markup($"[bold green]{otlpHttpLabel}[/]:"), HorizontalAlignment.Right), + new Text(info.OtlpHttpUrl)); + grid.AddRow(Text.Empty, Text.Empty); + + // Logs row + grid.AddRow( + new Align(new Markup($"[bold green]{logsLabel}[/]:"), HorizontalAlignment.Right), + new Text(logFilePath)); + + var padder = new Padder(grid, new Padding(3, 0)); + _interactionService.DisplayRenderable(padder); + } + + private async Task ExecuteForegroundAsync(string managedPath, List dashboardArgs, DashboardInfo dashboardInfo, IDictionary? environmentVariables, CancellationToken cancellationToken) + { + _logger.LogDebug("Starting dashboard in foreground: {ManagedPath}", managedPath); + + var outputCollector = new OutputCollector(_fileLoggerProvider, "Dashboard"); + var readyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var options = new ProcessInvocationOptions + { + StandardOutputCallback = line => + { + outputCollector.AppendOutput(line); + + // The dashboard writes "Now listening on: {urls}" when it's ready to accept requests. + // Wait for that message before showing the dashboard URL to the user. + // This message isn't localized, so we can reliably look for it in the output regardless of the user's language/locale. + if (line.Contains("Now listening on:", StringComparison.OrdinalIgnoreCase)) + { + readyTcs.TrySetResult(); + } + }, + StandardErrorCallback = line => + { + outputCollector.AppendError(line); + }, + }; + + using var process = _layoutProcessRunner.Start(managedPath, dashboardArgs, environmentVariables: environmentVariables, options: options); + + // Wait for the dashboard to become ready, the process to exit, or a timeout. + var processExitTask = process.WaitForExitAsync(cancellationToken); + var readyOrFailed = Task.WhenAny(readyTcs.Task, processExitTask); + + var completedTask = await _interactionService.ShowStatusAsync( + DashboardCommandStrings.StartingDashboard, + async () => + { + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + try + { + return await readyOrFailed.WaitAsync(linkedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + // Timeout — return the processExitTask so the caller detects it wasn't the ready signal. + return processExitTask; + } + }); + + if (cancellationToken.IsCancellationRequested) + { + _interactionService.DisplayMessage(KnownEmojis.StopSign, $"[teal bold]{DashboardCommandStrings.StoppingDashboard}[/]", allowMarkup: true); + + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + + return ExitCodeConstants.Success; + } + + if (completedTask != readyTcs.Task) + { + // Dashboard didn't become ready — either it exited or timed out. + var exitMessage = process.HasExited + ? GetExitCodeMessage(process.ExitCode) + : DashboardCommandStrings.DashboardStartTimedOut; + + _interactionService.DisplayError(exitMessage); + _interactionService.DisplayMessage(KnownEmojis.PageFacingUp, string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath)); + + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + + return ExitCodeConstants.DashboardFailure; + } + + // Dashboard is ready. + RenderDashboardSummary(dashboardInfo, ExecutionContext.LogFilePath); + _interactionService.DisplayEmptyLine(); + + try + { + await processExitTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _interactionService.DisplayMessage(KnownEmojis.StopSign, $"[teal bold]{DashboardCommandStrings.StoppingDashboard}[/]", allowMarkup: true); + + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + + return ExitCodeConstants.Success; + } + + if (process.ExitCode != 0) + { + _interactionService.DisplayError(GetExitCodeMessage(process.ExitCode)); + } + + return process.ExitCode == 0 ? ExitCodeConstants.Success : ExitCodeConstants.DashboardFailure; + } +} diff --git a/src/Aspire.Cli/Commands/ExecCommand.cs b/src/Aspire.Cli/Commands/ExecCommand.cs index ba87ea2e045..a22e19eadb5 100644 --- a/src/Aspire.Cli/Commands/ExecCommand.cs +++ b/src/Aspire.Cli/Commands/ExecCommand.cs @@ -147,7 +147,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.FailedToDotnetRunAppHost; } - var runOptions = new DotNetCliRunnerInvocationOptions + var runOptions = new ProcessInvocationOptions { StandardOutputCallback = runOutputCollector.AppendOutput, StandardErrorCallback = runOutputCollector.AppendError, diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index b83f3814977..62c36a2e069 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -212,7 +212,7 @@ private async Task InitializeExistingSolutionAsync(InitContext initContext, initContext.GetSolutionProjectsOutputCollector = new OutputCollector(); var (getSolutionExitCode, solutionProjects) = await InteractionService.ShowStatusAsync("Reading solution...", async () => { - var options = new DotNetCliRunnerInvocationOptions + var options = new ProcessInvocationOptions { StandardOutputCallback = initContext.GetSolutionProjectsOutputCollector.AppendOutput, StandardErrorCallback = initContext.GetSolutionProjectsOutputCollector.AppendError @@ -356,7 +356,7 @@ await nugetConfigPrompter.PromptToCreateOrUpdateAsync( "Getting templates...", async () => { - var options = new DotNetCliRunnerInvocationOptions + var options = new ProcessInvocationOptions { StandardOutputCallback = initContext.InstallTemplateOutputCollector.AppendOutput, StandardErrorCallback = initContext.InstallTemplateOutputCollector.AppendError @@ -384,7 +384,7 @@ await nugetConfigPrompter.PromptToCreateOrUpdateAsync( "Creating Aspire projects from template...", async () => { - var options = new DotNetCliRunnerInvocationOptions + var options = new ProcessInvocationOptions { StandardOutputCallback = initContext.NewProjectOutputCollector.AppendOutput, StandardErrorCallback = initContext.NewProjectOutputCollector.AppendError @@ -439,7 +439,7 @@ await nugetConfigPrompter.PromptToCreateOrUpdateAsync( InitCommandStrings.AddingAppHostProjectToSolution, async () => { - var options = new DotNetCliRunnerInvocationOptions + var options = new ProcessInvocationOptions { StandardOutputCallback = initContext.AddAppHostToSolutionOutputCollector.AppendOutput, StandardErrorCallback = initContext.AddAppHostToSolutionOutputCollector.AppendError @@ -465,7 +465,7 @@ await nugetConfigPrompter.PromptToCreateOrUpdateAsync( InitCommandStrings.AddingServiceDefaultsProjectToSolution, async () => { - var options = new DotNetCliRunnerInvocationOptions + var options = new ProcessInvocationOptions { StandardOutputCallback = initContext.AddServiceDefaultsToSolutionOutputCollector.AppendOutput, StandardErrorCallback = initContext.AddServiceDefaultsToSolutionOutputCollector.AppendError @@ -497,7 +497,7 @@ await nugetConfigPrompter.PromptToCreateOrUpdateAsync( var addRefResult = await InteractionService.ShowStatusAsync( $"Adding {project.ProjectFile.Name} to AppHost...", async () => { - var options = new DotNetCliRunnerInvocationOptions + var options = new ProcessInvocationOptions { StandardOutputCallback = outputCollector.AppendOutput, StandardErrorCallback = outputCollector.AppendError @@ -531,7 +531,7 @@ await nugetConfigPrompter.PromptToCreateOrUpdateAsync( var addRefResult = await InteractionService.ShowStatusAsync( $"Adding ServiceDefaults reference to {project.ProjectFile.Name}...", async () => { - var options = new DotNetCliRunnerInvocationOptions + var options = new ProcessInvocationOptions { StandardOutputCallback = outputCollector.AppendOutput, StandardErrorCallback = outputCollector.AppendError @@ -638,7 +638,7 @@ private async Task EvaluateSolutionProjectsAsync(InitContext initContext, Cancel foreach (var project in initContext.SolutionProjects) { - var options = new DotNetCliRunnerInvocationOptions + var options = new ProcessInvocationOptions { StandardOutputCallback = initContext.EvaluateSolutionProjectsOutputCollector.AppendOutput, StandardErrorCallback = initContext.EvaluateSolutionProjectsOutputCollector.AppendError diff --git a/src/Aspire.Cli/Commands/RestoreCommand.cs b/src/Aspire.Cli/Commands/RestoreCommand.cs index 6f780ac53fe..41240817f59 100644 --- a/src/Aspire.Cli/Commands/RestoreCommand.cs +++ b/src/Aspire.Cli/Commands/RestoreCommand.cs @@ -96,7 +96,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var restoreExitCode = await _interactionService.ShowStatusAsync( RestoreCommandStrings.RestoringSdkCode, - async () => await _runner.RestoreAsync(effectiveAppHostFile, new DotNetCliRunnerInvocationOptions(), cancellationToken), + async () => await _runner.RestoreAsync(effectiveAppHostFile, new ProcessInvocationOptions(), cancellationToken), emoji: KnownEmojis.Gear); if (restoreExitCode == 0) diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 38b24cf00df..7a0c06743f8 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -131,6 +131,7 @@ public RootCommand( AgentCommand agentCommand, TelemetryCommand telemetryCommand, ExportCommand exportCommand, + DashboardCommand dashboardCommand, DocsCommand docsCommand, SecretCommand secretCommand, SdkCommand sdkCommand, @@ -222,6 +223,7 @@ public RootCommand( Subcommands.Add(telemetryCommand); Subcommands.Add(exportCommand); Subcommands.Add(docsCommand); + Subcommands.Add(dashboardCommand); Subcommands.Add(secretCommand); #if DEBUG diff --git a/src/Aspire.Cli/DotNet/DotNetCliExecutionFactory.cs b/src/Aspire.Cli/DotNet/DotNetCliExecutionFactory.cs deleted file mode 100644 index fbd85db507e..00000000000 --- a/src/Aspire.Cli/DotNet/DotNetCliExecutionFactory.cs +++ /dev/null @@ -1,130 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using System.Globalization; -using System.Runtime.InteropServices; -using Aspire.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace Aspire.Cli.DotNet; - -internal sealed class DotNetCliExecutionFactory( - ILogger logger, - IConfiguration configuration, - CliExecutionContext executionContext) : IDotNetCliExecutionFactory -{ - internal static int GetCurrentProcessId() => Environment.ProcessId; - - internal static long GetCurrentProcessStartTimeUnixSeconds() - { - var startTime = Process.GetCurrentProcess().StartTime; - return ((DateTimeOffset)startTime).ToUnixTimeSeconds(); - } - - public IDotNetCliExecution CreateExecution(string[] args, IDictionary? env, DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options) - { - var suppressLogging = options.SuppressLogging; - - if (!suppressLogging) - { - logger.LogDebug("Running {FullName} with args: {Args}", workingDirectory.FullName, string.Join(" ", args)); - - if (env is not null) - { - foreach (var envKvp in env) - { - logger.LogDebug("Running {FullName} with env: {EnvKey}={EnvValue}", workingDirectory.FullName, envKvp.Key, envKvp.Value); - } - } - } - - var startInfo = new ProcessStartInfo("dotnet") - { - WorkingDirectory = workingDirectory.FullName, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - - if (env is not null) - { - foreach (var envKvp in env) - { - startInfo.EnvironmentVariables[envKvp.Key] = envKvp.Value; - } - } - - foreach (var a in args) - { - startInfo.ArgumentList.Add(a); - } - - // The AppHost uses this environment variable to signal to the CliOrphanDetector which process - // it should monitor in order to know when to stop the CLI. As long as the process still exists - // the orphan detector will allow the CLI to keep running. If the environment variable does - // not exist the orphan detector will exit. - startInfo.EnvironmentVariables[KnownConfigNames.CliProcessId] = GetCurrentProcessId().ToString(CultureInfo.InvariantCulture); - - // Set the CLI process start time for robust orphan detection to prevent PID reuse issues. - // The AppHost will verify both PID and start time to ensure it's monitoring the correct process. - startInfo.EnvironmentVariables[KnownConfigNames.CliProcessStarted] = GetCurrentProcessStartTimeUnixSeconds().ToString(CultureInfo.InvariantCulture); - - // Always set MSBUILDTERMINALLOGGER=false for all dotnet command executions to ensure consistent terminal logger behavior - startInfo.EnvironmentVariables[KnownConfigNames.MsBuildTerminalLogger] = "false"; - - // Suppress the .NET welcome message that appears on first run - startInfo.EnvironmentVariables["DOTNET_NOLOGO"] = "1"; - - // Configure DOTNET_ROOT to point to the private SDK installation if it exists - ConfigurePrivateSdkEnvironment(startInfo); - - // Set debug session info if available - var debugSessionInfo = configuration[KnownConfigNames.DebugSessionInfo]; - if (!string.IsNullOrEmpty(debugSessionInfo)) - { - startInfo.EnvironmentVariables[KnownConfigNames.DebugSessionInfo] = debugSessionInfo; - } - - var process = new Process { StartInfo = startInfo }; - return new DotNetCliExecution(process, logger, options); - } - - /// - /// Configures environment variables to use the private SDK installation if it exists. - /// - /// The process start info to configure. - private void ConfigurePrivateSdkEnvironment(ProcessStartInfo startInfo) - { - // Get the effective minimum SDK version to determine which private SDK to use - var sdkVersion = DotNetSdkInstaller.GetEffectiveMinimumSdkVersion(configuration); - var sdksDirectory = executionContext.SdksDirectory.FullName; - var sdkInstallPath = Path.Combine(sdksDirectory, "dotnet", sdkVersion); - var dotnetExecutablePath = Path.Combine( - sdkInstallPath, - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet" - ); - - // Check if the private SDK exists - if (Directory.Exists(sdkInstallPath)) - { - // Set the executable path to be the private SDK. - startInfo.FileName = dotnetExecutablePath; - - // Set DOTNET_ROOT to point to the private SDK installation - startInfo.EnvironmentVariables["DOTNET_ROOT"] = sdkInstallPath; - - // Also set DOTNET_MULTILEVEL_LOOKUP to 0 to prevent fallback to system SDKs - startInfo.EnvironmentVariables["DOTNET_MULTILEVEL_LOOKUP"] = "0"; - - // Prepend the private SDK path to PATH so the dotnet executable from the private installation is found first - var currentPath = startInfo.EnvironmentVariables["PATH"] ?? Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - startInfo.EnvironmentVariables["PATH"] = $"{sdkInstallPath}{Path.PathSeparator}{currentPath}"; - - logger.LogDebug("Using private SDK installation at {SdkPath}", sdkInstallPath); - } - } -} diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 2f88d686563..acf4de7f891 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Net.Sockets; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -26,23 +27,23 @@ namespace Aspire.Cli.DotNet; internal interface IDotNetCliRunner { - Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task RestoreAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task InitUserSecretsAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); + Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task RestoreAsync(FileInfo projectFilePath, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task BuildAsync(FileInfo projectFilePath, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, ProcessInvocationOptions options, CancellationToken cancellationToken); + Task InitUserSecretsAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken); } -internal sealed class DotNetCliRunnerInvocationOptions +internal sealed class ProcessInvocationOptions { public Action? StandardOutputCallback { get; set; } public Action? StandardErrorCallback { get; set; } @@ -68,7 +69,7 @@ internal sealed class DotNetCliRunner( IFeatures features, IInteractionService interactionService, CliExecutionContext executionContext, - IDotNetCliExecutionFactory executionFactory) : IDotNetCliRunner + IProcessExecutionFactory executionFactory) : IDotNetCliRunner { private readonly IDiskCache _diskCache = diskCache; @@ -102,10 +103,17 @@ private async Task ExecuteAsync( FileInfo? projectFile, DirectoryInfo workingDirectory, TaskCompletionSource? backchannelCompletionSource, - DotNetCliRunnerInvocationOptions options, + ProcessInvocationOptions options, CancellationToken cancellationToken) { - var execution = executionFactory.CreateExecution(args, env, workingDirectory, options); + // Build the final environment variables by merging caller-provided env with dotnet-specific settings. + var finalEnv = env?.ToDictionary() ?? new Dictionary(); + ConfigureDotNetEnvironment(finalEnv); + + // Resolve the dotnet executable path, preferring the private SDK installation if available. + var dotnetPath = ResolveDotNetPath(finalEnv); + + using var execution = executionFactory.CreateExecution(dotnetPath, args, finalEnv, workingDirectory, options); // Get socket path from env if present string? socketPath = null; @@ -146,7 +154,78 @@ await extensionInteractionService.LaunchAppHostAsync( return await execution.WaitForExitAsync(cancellationToken); } - private async Task StartBackchannelAsync(IDotNetCliExecution? execution, string socketPath, TaskCompletionSource backchannelCompletionSource, CancellationToken cancellationToken) + internal static int GetCurrentProcessId() => Environment.ProcessId; + + internal static long GetCurrentProcessStartTimeUnixSeconds() + { + var startTime = Process.GetCurrentProcess().StartTime; + return ((DateTimeOffset)startTime).ToUnixTimeSeconds(); + } + + /// + /// Configures dotnet-specific environment variables for CLI process executions. + /// + private void ConfigureDotNetEnvironment(IDictionary env) + { + // The AppHost uses this environment variable to signal to the CliOrphanDetector which process + // it should monitor in order to know when to stop the CLI. As long as the process still exists + // the orphan detector will allow the CLI to keep running. If the environment variable does + // not exist the orphan detector will exit. + env[KnownConfigNames.CliProcessId] = GetCurrentProcessId().ToString(CultureInfo.InvariantCulture); + + // Set the CLI process start time for robust orphan detection to prevent PID reuse issues. + // The AppHost will verify both PID and start time to ensure it's monitoring the correct process. + env[KnownConfigNames.CliProcessStarted] = GetCurrentProcessStartTimeUnixSeconds().ToString(CultureInfo.InvariantCulture); + + // Always set MSBUILDTERMINALLOGGER=false for all dotnet command executions to ensure consistent terminal logger behavior + env[KnownConfigNames.MsBuildTerminalLogger] = "false"; + + // Suppress the .NET welcome message that appears on first run + env["DOTNET_NOLOGO"] = "1"; + + // Set debug session info if available + var debugSessionInfo = configuration[KnownConfigNames.DebugSessionInfo]; + if (!string.IsNullOrEmpty(debugSessionInfo)) + { + env[KnownConfigNames.DebugSessionInfo] = debugSessionInfo; + } + } + + /// + /// Resolves the dotnet executable path, preferring a private SDK installation if available. + /// When a private SDK is found, the appropriate environment variables (DOTNET_ROOT, PATH, etc.) + /// are set on the provided dictionary. + /// + /// The path to the dotnet executable. + private string ResolveDotNetPath(IDictionary env) + { + var sdkVersion = DotNetSdkInstaller.GetEffectiveMinimumSdkVersion(configuration); + var sdksDirectory = executionContext.SdksDirectory.FullName; + var sdkInstallPath = Path.Combine(sdksDirectory, "dotnet", sdkVersion); + var dotnetExecutablePath = Path.Combine( + sdkInstallPath, + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet" + ); + + if (Directory.Exists(sdkInstallPath)) + { + env["DOTNET_ROOT"] = sdkInstallPath; + env["DOTNET_MULTILEVEL_LOOKUP"] = "0"; + + // Prepend the private SDK path to PATH. Check if the caller already provided a PATH override. + var currentPath = env.TryGetValue("PATH", out var userPath) + ? userPath + : Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + env["PATH"] = $"{sdkInstallPath}{Path.PathSeparator}{currentPath}"; + + logger.LogDebug("Using private SDK installation at {SdkPath}", sdkInstallPath); + return dotnetExecutablePath; + } + + return "dotnet"; + } + + private async Task StartBackchannelAsync(IProcessExecution? execution, string socketPath, TaskCompletionSource backchannelCompletionSource, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -228,7 +307,7 @@ private async Task StartBackchannelAsync(IDotNetCliExecution? execution, string // Cache expiry/max age handled inside DiskCache implementation. - public async Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -311,7 +390,7 @@ private async Task StartBackchannelAsync(IDotNetCliExecution? execution, string } } - public async Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -412,7 +491,7 @@ private async Task StartBackchannelAsync(IDotNetCliExecution? execution, string return (1, null); } - public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -487,7 +566,7 @@ public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, cancellationToken: cancellationToken); } - public async Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(kind: ActivityKind.Client); @@ -616,7 +695,7 @@ internal static bool TryParsePackageVersionFromStdout(string stdout, [NotNullWhe } } - public async Task NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -631,7 +710,7 @@ public async Task NewProjectAsync(string templateName, string name, string cancellationToken: cancellationToken); } - public async Task RestoreAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task RestoreAsync(FileInfo projectFilePath, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -647,7 +726,7 @@ public async Task RestoreAsync(FileInfo projectFilePath, DotNetCliRunnerInv cancellationToken: cancellationToken); } - public async Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task BuildAsync(FileInfo projectFilePath, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -670,7 +749,7 @@ public async Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotN options: options, cancellationToken: cancellationToken); } - public async Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -734,7 +813,7 @@ public async Task AddPackageAsync(FileInfo projectFilePath, string packageN return result; } - public async Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -763,7 +842,7 @@ public async Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo return result; } - public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo workingDirectory, ProcessInvocationOptions options, CancellationToken cancellationToken) { // The purpose of this method is to compute a hash that can be used as a substitute for an explicitly passed // in NuGet.config file hash. This is useful for when `aspire add` is invoked and we present options from the @@ -826,7 +905,7 @@ public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w return result; } - public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -995,7 +1074,7 @@ public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w } } - public async Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -1035,7 +1114,7 @@ public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w } } - public async Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -1101,7 +1180,7 @@ public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w return (exitCode, projects); } - public async Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, ProcessInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -1130,7 +1209,7 @@ public async Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo r return result; } - public Task InitUserSecretsAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task InitUserSecretsAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken) { return ExecuteAsync(["user-secrets", "init", "--project", projectFile.FullName], env: null, projectFile: null, projectFile.Directory!, backchannelCompletionSource: null, options, cancellationToken); } diff --git a/src/Aspire.Cli/DotNet/IDotNetCliExecution.cs b/src/Aspire.Cli/DotNet/IProcessExecution.cs similarity index 79% rename from src/Aspire.Cli/DotNet/IDotNetCliExecution.cs rename to src/Aspire.Cli/DotNet/IProcessExecution.cs index a8d596b3faf..8dafaecbaf3 100644 --- a/src/Aspire.Cli/DotNet/IDotNetCliExecution.cs +++ b/src/Aspire.Cli/DotNet/IProcessExecution.cs @@ -4,9 +4,9 @@ namespace Aspire.Cli.DotNet; /// -/// Represents a configured dotnet CLI execution that can be started and awaited. +/// Represents a configured process execution that can be started and awaited. /// -internal interface IDotNetCliExecution +internal interface IProcessExecution : IDisposable { /// /// Gets the file name of the executable to run. @@ -45,4 +45,10 @@ internal interface IDotNetCliExecution /// Gets the exit code of the process. Only valid after returns true. /// int ExitCode { get; } + + /// + /// Kills the process. + /// + /// When true, kills the entire process tree; otherwise kills only the root process. + void Kill(bool entireProcessTree); } diff --git a/src/Aspire.Cli/DotNet/IDotNetCliExecutionFactory.cs b/src/Aspire.Cli/DotNet/IProcessExecutionFactory.cs similarity index 54% rename from src/Aspire.Cli/DotNet/IDotNetCliExecutionFactory.cs rename to src/Aspire.Cli/DotNet/IProcessExecutionFactory.cs index 5f5b192791e..bd3e68c12e8 100644 --- a/src/Aspire.Cli/DotNet/IDotNetCliExecutionFactory.cs +++ b/src/Aspire.Cli/DotNet/IProcessExecutionFactory.cs @@ -4,22 +4,23 @@ namespace Aspire.Cli.DotNet; /// -/// Creates configured dotnet CLI executions. +/// Creates configured process executions. /// -internal interface IDotNetCliExecutionFactory +internal interface IProcessExecutionFactory { /// - /// Creates a configured dotnet CLI execution ready to be started. + /// Creates a configured process execution ready to be started. /// - /// The command-line arguments to pass to dotnet. - /// Optional environment variables to set for the process. If backchannel communication - /// is needed, the socket path should be set via ASPIRE__BACKCHANNEL__UNIXSOCKETPATH. + /// The executable path to run. + /// The command-line arguments to pass to the process. + /// Optional environment variables to set for the process. /// The working directory for the process. /// Invocation options for the command. - /// A configured ready to be started. - IDotNetCliExecution CreateExecution( + /// A configured ready to be started. + IProcessExecution CreateExecution( + string fileName, string[] args, IDictionary? env, DirectoryInfo workingDirectory, - DotNetCliRunnerInvocationOptions options); + ProcessInvocationOptions options); } diff --git a/src/Aspire.Cli/DotNet/DotNetCliExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs similarity index 81% rename from src/Aspire.Cli/DotNet/DotNetCliExecution.cs rename to src/Aspire.Cli/DotNet/ProcessExecution.cs index bf7d715aa23..5b1bcb9be6a 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -7,17 +7,17 @@ namespace Aspire.Cli.DotNet; /// -/// Represents a configured dotnet CLI execution backed by a real process. +/// Represents a configured process execution backed by a real OS process. /// -internal sealed class DotNetCliExecution : IDotNetCliExecution +internal sealed class ProcessExecution : IProcessExecution { private readonly Process _process; private readonly ILogger _logger; - private readonly DotNetCliRunnerInvocationOptions _options; + private readonly ProcessInvocationOptions _options; private Task? _stdoutForwarder; private Task? _stderrForwarder; - internal DotNetCliExecution(Process process, ILogger logger, DotNetCliRunnerInvocationOptions options) + internal ProcessExecution(Process process, ILogger logger, ProcessInvocationOptions options) { _process = process; _logger = logger; @@ -51,14 +51,14 @@ public bool Start() { if (!suppressLogging) { - _logger.LogDebug("Failed to start dotnet process with args: {Args}", string.Join(" ", Arguments)); + _logger.LogDebug("Failed to start process {FileName} with args: {Args}", FileName, string.Join(" ", Arguments)); } return false; } if (!suppressLogging) { - _logger.LogDebug("Started dotnet with PID: {ProcessId}", _process.Id); + _logger.LogDebug("Started {FileName} with PID: {ProcessId}", FileName, _process.Id); } // Start stream forwarders @@ -90,7 +90,7 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) if (!suppressLogging) { - _logger.LogDebug("Waiting for dotnet process to exit with PID: {ProcessId}", _process.Id); + _logger.LogDebug("Waiting for process to exit with PID: {ProcessId}", _process.Id); } await _process.WaitForExitAsync(cancellationToken); @@ -99,7 +99,7 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) { if (!suppressLogging) { - _logger.LogDebug("dotnet process with PID: {ProcessId} has not exited, killing it.", _process.Id); + _logger.LogDebug("Process with PID: {ProcessId} has not exited, killing it.", _process.Id); } _process.Kill(false); } @@ -107,7 +107,7 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) { if (!suppressLogging) { - _logger.LogDebug("dotnet process with PID: {ProcessId} has exited with code: {ExitCode}", _process.Id, _process.ExitCode); + _logger.LogDebug("Process with PID: {ProcessId} has exited with code: {ExitCode}", _process.Id, _process.ExitCode); } } @@ -141,6 +141,18 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) return _process.ExitCode; } + /// + public void Kill(bool entireProcessTree) + { + _process.Kill(entireProcessTree); + } + + /// + public void Dispose() + { + _process.Dispose(); + } + private async Task ForwardStreamToLoggerAsync(StreamReader reader, string identifier, Action? lineCallback, bool suppressLogging) { if (!suppressLogging) @@ -160,7 +172,8 @@ private async Task ForwardStreamToLoggerAsync(StreamReader reader, string identi if (!suppressLogging) { _logger.LogDebug( - "dotnet({ProcessId}) {Identifier}: {Line}", + "{FileName}({ProcessId}) {Identifier}: {Line}", + FileName, _process.Id, identifier, line diff --git a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs new file mode 100644 index 00000000000..3d7d549e158 --- /dev/null +++ b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.DotNet; + +/// +/// Creates process executions backed by real OS processes. +/// +internal sealed class ProcessExecutionFactory( + ILogger logger) : IProcessExecutionFactory +{ + public IProcessExecution CreateExecution(string fileName, string[] args, IDictionary? env, DirectoryInfo workingDirectory, ProcessInvocationOptions options) + { + var suppressLogging = options.SuppressLogging; + + if (!suppressLogging) + { + logger.LogDebug("Running {FullName} with args: {Args}", workingDirectory.FullName, string.Join(" ", args)); + + if (env is not null) + { + foreach (var envKvp in env) + { + logger.LogDebug("Running {FullName} with env: {EnvKey}={EnvValue}", workingDirectory.FullName, envKvp.Key, envKvp.Value); + } + } + } + + var startInfo = new ProcessStartInfo(fileName) + { + WorkingDirectory = workingDirectory.FullName, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + if (env is not null) + { + foreach (var envKvp in env) + { + startInfo.EnvironmentVariables[envKvp.Key] = envKvp.Value; + } + } + + foreach (var a in args) + { + startInfo.ArgumentList.Add(a); + } + + var process = new Process { StartInfo = startInfo }; + return new ProcessExecution(process, logger, options); + } +} diff --git a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs index 19d8e1716b8..3b59daf80f6 100644 --- a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs +++ b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs @@ -1,7 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; +using System.Text; +using Aspire.Cli.DotNet; using Aspire.Shared; namespace Aspire.Cli.Layout; @@ -24,92 +25,62 @@ internal static class RuntimeIdentifierHelper } /// -/// Utilities for running processes using layout tools. -/// All layout tools are self-contained executables — no muxer needed. +/// Runs processes using layout tools via an . /// -internal static class LayoutProcessRunner +internal sealed class LayoutProcessRunner(IProcessExecutionFactory executionFactory) { - /// - /// Runs a tool and captures output. The tool is always run directly as a native executable. - /// - public static async Task<(int ExitCode, string Output, string Error)> RunAsync( + /// + public async Task<(int ExitCode, string Output, string Error)> RunAsync( string toolPath, IEnumerable arguments, string? workingDirectory = null, IDictionary? environmentVariables = null, CancellationToken ct = default) { - using var process = CreateProcess(toolPath, arguments, workingDirectory, environmentVariables, redirectOutput: true); + var outputBuilder = new StringBuilder(); + var errorBuilder = new StringBuilder(); + + var options = new ProcessInvocationOptions + { + SuppressLogging = true, + StandardOutputCallback = line => outputBuilder.AppendLine(line), + StandardErrorCallback = line => errorBuilder.AppendLine(line), + }; - process.Start(); + var args = arguments.ToArray(); + var workDir = new DirectoryInfo(workingDirectory ?? Directory.GetCurrentDirectory()); - var outputTask = process.StandardOutput.ReadToEndAsync(ct); - var errorTask = process.StandardError.ReadToEndAsync(ct); + using var execution = executionFactory.CreateExecution(toolPath, args, environmentVariables, workDir, options); + + if (!execution.Start()) + { + throw new InvalidOperationException($"Failed to start process: {toolPath}"); + } - await process.WaitForExitAsync(ct); + var exitCode = await execution.WaitForExitAsync(ct).ConfigureAwait(false); - return (process.ExitCode, await outputTask, await errorTask); + return (exitCode, outputBuilder.ToString(), errorBuilder.ToString()); } - /// - /// Starts a process without waiting for it to exit. - /// Returns the Process object for the caller to manage. - /// - public static Process Start( + /// + public IProcessExecution Start( string toolPath, IEnumerable arguments, string? workingDirectory = null, IDictionary? environmentVariables = null, - bool redirectOutput = false) + ProcessInvocationOptions? options = null) { - var process = CreateProcess(toolPath, arguments, workingDirectory, environmentVariables, redirectOutput); - process.Start(); - return process; - } + var args = arguments.ToArray(); + var workDir = new DirectoryInfo(workingDirectory ?? Directory.GetCurrentDirectory()); - /// - /// Creates a configured Process for running a bundle tool. - /// Tools are always self-contained executables — run directly. - /// - private static Process CreateProcess( - string toolPath, - IEnumerable arguments, - string? workingDirectory, - IDictionary? environmentVariables, - bool redirectOutput) - { - var process = new Process(); - - process.StartInfo.UseShellExecute = false; - process.StartInfo.CreateNoWindow = true; - process.StartInfo.FileName = toolPath; - - if (redirectOutput) - { - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.RedirectStandardError = true; - } - - // Add custom environment variables - if (environmentVariables is not null) - { - foreach (var (key, value) in environmentVariables) - { - process.StartInfo.Environment[key] = value; - } - } - - if (workingDirectory is not null) - { - process.StartInfo.WorkingDirectory = workingDirectory; - } + var execution = executionFactory.CreateExecution(toolPath, args, environmentVariables, workDir, options ?? new ProcessInvocationOptions()); - // Add arguments - foreach (var arg in arguments) + if (!execution.Start()) { - process.StartInfo.ArgumentList.Add(arg); + execution.Dispose(); + throw new InvalidOperationException($"Failed to start process: {toolPath}"); } - return process; + return execution; } } diff --git a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs index 6821eecfb93..6eac3b30246 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs @@ -18,15 +18,18 @@ namespace Aspire.Cli.NuGet; internal sealed class BundleNuGetPackageCache : INuGetPackageCache { private readonly IBundleService _bundleService; + private readonly LayoutProcessRunner _layoutProcessRunner; private readonly ILogger _logger; private readonly IFeatures _features; public BundleNuGetPackageCache( IBundleService bundleService, + LayoutProcessRunner layoutProcessRunner, ILogger logger, IFeatures features) { _bundleService = bundleService; + _layoutProcessRunner = layoutProcessRunner; _logger = logger; _features = features; } @@ -155,7 +158,7 @@ private async Task> SearchPackagesInternalAsync( _logger.LogDebug("NuGet search args: {Args}", string.Join(" ", args)); _logger.LogDebug("Working directory: {WorkingDir}", workingDirectory.FullName); - var (exitCode, output, error) = await LayoutProcessRunner.RunAsync( + var (exitCode, output, error) = await _layoutProcessRunner.RunAsync( managedPath, args, workingDirectory: workingDirectory.FullName, diff --git a/src/Aspire.Cli/NuGet/BundleNuGetService.cs b/src/Aspire.Cli/NuGet/BundleNuGetService.cs index 4a1193fe051..03b3c5edc6a 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetService.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetService.cs @@ -32,17 +32,20 @@ Task RestorePackagesAsync( /// /// NuGet service implementation that uses the bundle's NuGetHelper tool. /// -public sealed class BundleNuGetService : INuGetService +internal sealed class BundleNuGetService : INuGetService { private readonly ILayoutDiscovery _layoutDiscovery; + private readonly LayoutProcessRunner _layoutProcessRunner; private readonly ILogger _logger; private readonly string _cacheDirectory; public BundleNuGetService( ILayoutDiscovery layoutDiscovery, + LayoutProcessRunner layoutProcessRunner, ILogger logger) { _layoutDiscovery = layoutDiscovery; + _layoutProcessRunner = layoutProcessRunner; _logger = logger; _cacheDirectory = GetCacheDirectory(); } @@ -130,7 +133,7 @@ public async Task RestorePackagesAsync( _logger.LogDebug("aspire-managed path: {ManagedPath}", managedPath); _logger.LogDebug("NuGet restore args: {Args}", string.Join(" ", restoreArgs)); - var (exitCode, output, error) = await LayoutProcessRunner.RunAsync( + var (exitCode, output, error) = await _layoutProcessRunner.RunAsync( managedPath, restoreArgs, ct: ct); @@ -169,7 +172,7 @@ public async Task RestorePackagesAsync( _logger.LogDebug("Creating layout from {AssetsPath}", assetsPath); _logger.LogDebug("NuGet layout args: {Args}", string.Join(" ", layoutArgs)); - (exitCode, output, error) = await LayoutProcessRunner.RunAsync( + (exitCode, output, error) = await _layoutProcessRunner.RunAsync( managedPath, layoutArgs, ct: ct); diff --git a/src/Aspire.Cli/NuGet/NuGetPackageCache.cs b/src/Aspire.Cli/NuGet/NuGetPackageCache.cs index c6b8ff47ddf..ab25548a5d9 100644 --- a/src/Aspire.Cli/NuGet/NuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/NuGetPackageCache.cs @@ -101,7 +101,7 @@ public async Task> GetPackagesAsync(DirectoryInfo work skip, nugetConfigFile, useCache, // Pass through the useCache parameter - new DotNetCliRunnerInvocationOptions { SuppressLogging = true }, + new ProcessInvocationOptions { SuppressLogging = true }, cancellationToken ); diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 55881912e73..08b70592d62 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -323,7 +323,8 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(BuildConfigurationService); builder.Services.AddSingleton(); builder.Services.AddTelemetryServices(); - builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); // Register certificate tool runner - uses native CertificateManager directly (no subprocess needed) builder.Services.AddSingleton(sp => CertificateManager.Create(sp.GetRequiredService>())); @@ -454,6 +455,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 0ac674d1eca..013402f21f0 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -172,7 +172,7 @@ public async Task ValidateAppHostAsync(FileInfo appHost } // For project files, check if it's a valid Aspire AppHost using GetAppHostInformationAsync - var information = await _runner.GetAppHostInformationAsync(appHostFile, new DotNetCliRunnerInvocationOptions(), cancellationToken); + var information = await _runner.GetAppHostInformationAsync(appHostFile, new ProcessInvocationOptions(), cancellationToken); if (information.ExitCode == 0 && information.IsAspireHost) { @@ -261,7 +261,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken var shouldBuildInCli = !isExtensionHost || extensionHasBuildCapability; if (shouldBuildInCli) { - var buildOptions = new DotNetCliRunnerInvocationOptions + var buildOptions = new ProcessInvocationOptions { StandardOutputCallback = buildOutputCollector.AppendOutput, StandardErrorCallback = buildOutputCollector.AppendError, @@ -309,7 +309,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // Signal that build/preparation is complete context.BuildCompletionSource?.TrySetResult(true); - var runOptions = new DotNetCliRunnerInvocationOptions + var runOptions = new ProcessInvocationOptions { StandardOutputCallback = runOutputCollector.AppendOutput, StandardErrorCallback = runOutputCollector.AppendError, @@ -408,7 +408,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca if (!context.NoBuild) { var buildOutputCollector = new OutputCollector(_fileLoggerProvider, "Build"); - var buildOptions = new DotNetCliRunnerInvocationOptions + var buildOptions = new ProcessInvocationOptions { StandardOutputCallback = buildOutputCollector.AppendOutput, StandardErrorCallback = buildOutputCollector.AppendError, @@ -439,7 +439,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca var runOutputCollector = new OutputCollector(_fileLoggerProvider, "AppHost"); context.OutputCollector = runOutputCollector; - var runOptions = new DotNetCliRunnerInvocationOptions + var runOptions = new ProcessInvocationOptions { StandardOutputCallback = runOutputCollector.AppendOutput, StandardErrorCallback = runOutputCollector.AppendError, @@ -473,7 +473,7 @@ public async Task AddPackageAsync(AddPackageContext context, CancellationT var outputCollector = new OutputCollector(_fileLoggerProvider, "Package"); context.OutputCollector = outputCollector; - var options = new DotNetCliRunnerInvocationOptions + var options = new ProcessInvocationOptions { StandardOutputCallback = outputCollector.AppendOutput, StandardErrorCallback = outputCollector.AppendError, @@ -539,7 +539,7 @@ public async Task FindAndStopRunningInstanceAsync(FileInf await _runner.InitUserSecretsAsync( projectFile, - new DotNetCliRunnerInvocationOptions(), + new ProcessInvocationOptions(), cancellationToken); // Re-query @@ -554,7 +554,7 @@ await _runner.InitUserSecretsAsync( projectFile, items: [], properties: ["UserSecretsId"], - new DotNetCliRunnerInvocationOptions(), + new ProcessInvocationOptions(), cancellationToken); if (exitCode != 0 || jsonDocument is null) diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index eb58875f4a7..937e5d45957 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -424,7 +424,7 @@ private XDocument CreateProjectFile(IEnumerable integratio var outputCollector = new OutputCollector(); var projectFile = new FileInfo(Path.Combine(_projectModelPath, ProjectFileName)); - var options = new DotNetCliRunnerInvocationOptions + var options = new ProcessInvocationOptions { StandardOutputCallback = outputCollector.AppendOutput, StandardErrorCallback = outputCollector.AppendError diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 7bfdb9e3100..cadb8d3c844 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -243,7 +243,7 @@ await File.WriteAllTextAsync( var exitCode = await _dotNetCliRunner.BuildAsync( new FileInfo(projectFilePath), noRestore: false, - new DotNetCliRunnerInvocationOptions + new ProcessInvocationOptions { StandardOutputCallback = buildOutput.AppendOutput, StandardErrorCallback = buildOutput.AppendError diff --git a/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs new file mode 100644 index 00000000000..fecfd7d3df8 --- /dev/null +++ b/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs @@ -0,0 +1,175 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Aspire.Cli.Resources { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class DashboardCommandStrings { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal DashboardCommandStrings() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + public static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Aspire.Cli.Resources.DashboardCommandStrings", typeof(DashboardCommandStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + public static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + public static string Description { + get { + return ResourceManager.GetString("Description", resourceCulture); + } + } + + public static string BundleNotAvailable { + get { + return ResourceManager.GetString("BundleNotAvailable", resourceCulture); + } + } + + public static string BundleLayoutNotFound { + get { + return ResourceManager.GetString("BundleLayoutNotFound", resourceCulture); + } + } + + public static string ManagedBinaryNotFound { + get { + return ResourceManager.GetString("ManagedBinaryNotFound", resourceCulture); + } + } + + public static string DashboardStarted { + get { + return ResourceManager.GetString("DashboardStarted", resourceCulture); + } + } + + public static string DashboardExitedWithError { + get { + return ResourceManager.GetString("DashboardExitedWithError", resourceCulture); + } + } + + public static string StartingDashboard { + get { + return ResourceManager.GetString("StartingDashboard", resourceCulture); + } + } + + public static string DashboardStartTimedOut { + get { + return ResourceManager.GetString("DashboardStartTimedOut", resourceCulture); + } + } + + public static string FrontendUrlOptionDescription { + get { + return ResourceManager.GetString("FrontendUrlOptionDescription", resourceCulture); + } + } + + public static string OtlpGrpcUrlOptionDescription { + get { + return ResourceManager.GetString("OtlpGrpcUrlOptionDescription", resourceCulture); + } + } + + public static string OtlpHttpUrlOptionDescription { + get { + return ResourceManager.GetString("OtlpHttpUrlOptionDescription", resourceCulture); + } + } + + public static string AllowAnonymousOptionDescription { + get { + return ResourceManager.GetString("AllowAnonymousOptionDescription", resourceCulture); + } + } + + public static string ConfigFilePathOptionDescription { + get { + return ResourceManager.GetString("ConfigFilePathOptionDescription", resourceCulture); + } + } + + public static string DashboardLabel { + get { + return ResourceManager.GetString("DashboardLabel", resourceCulture); + } + } + + public static string OtlpGrpcLabel { + get { + return ResourceManager.GetString("OtlpGrpcLabel", resourceCulture); + } + } + + public static string OtlpHttpLabel { + get { + return ResourceManager.GetString("OtlpHttpLabel", resourceCulture); + } + } + + public static string LogsLabel { + get { + return ResourceManager.GetString("LogsLabel", resourceCulture); + } + } + + public static string DashboardExitedUnexpectedError { + get { + return ResourceManager.GetString("DashboardExitedUnexpectedError", resourceCulture); + } + } + + public static string DashboardExitedValidationFailure { + get { + return ResourceManager.GetString("DashboardExitedValidationFailure", resourceCulture); + } + } + + public static string DashboardExitedAddressInUse { + get { + return ResourceManager.GetString("DashboardExitedAddressInUse", resourceCulture); + } + } + + public static string StoppingDashboard { + get { + return ResourceManager.GetString("StoppingDashboard", resourceCulture); + } + } + + } +} diff --git a/src/Aspire.Cli/Resources/DashboardCommandStrings.resx b/src/Aspire.Cli/Resources/DashboardCommandStrings.resx new file mode 100644 index 00000000000..3a8bf704302 --- /dev/null +++ b/src/Aspire.Cli/Resources/DashboardCommandStrings.resx @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Start the Aspire dashboard + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + Dashboard started (PID {0}). + + + Dashboard exited with exit code {0}. + + + Starting dashboard... + + + Dashboard did not become ready within the expected time. + + + One or more HTTP endpoints through which the dashboard frontend is served + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + Allow anonymous access to the dashboard + + + The path for a JSON configuration file + + + Dashboard + + + OTLP/gRPC + + + OTLP/HTTP + + + Logs + + + Dashboard exited unexpectedly due to an error. See logs for details. + + + Dashboard configuration is invalid. Check the provided options and try again. + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + Stopping dashboard + + diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf new file mode 100644 index 00000000000..8c0af45e796 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf new file mode 100644 index 00000000000..f330a85a859 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf new file mode 100644 index 00000000000..cc9ea1d7feb --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf new file mode 100644 index 00000000000..8b3e5feafd7 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf new file mode 100644 index 00000000000..df07598d875 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf new file mode 100644 index 00000000000..16373b44275 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf new file mode 100644 index 00000000000..0bef1377ffb --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf new file mode 100644 index 00000000000..b817ee48693 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf new file mode 100644 index 00000000000..5a44d14dca7 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf new file mode 100644 index 00000000000..b9e70ef110a --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf new file mode 100644 index 00000000000..4db5a2ecb43 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf new file mode 100644 index 00000000000..27a4192f4be --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf new file mode 100644 index 00000000000..21ef6a847c4 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf @@ -0,0 +1,112 @@ + + + + + + Allow anonymous access to the dashboard + Allow anonymous access to the dashboard + + + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. + + + + The Aspire dashboard requires the CLI bundle. The bundle was not found. + The Aspire dashboard requires the CLI bundle. The bundle was not found. + + + + The path for a JSON configuration file + The path for a JSON configuration file + + + + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + Dashboard could not start because a configured port is already in use. Use a different port or stop the conflicting process. + + + + Dashboard exited unexpectedly due to an error. See logs for details. + Dashboard exited unexpectedly due to an error. See logs for details. + + + + Dashboard configuration is invalid. Check the provided options and try again. + Dashboard configuration is invalid. Check the provided options and try again. + + + + Dashboard exited with exit code {0}. + Dashboard exited with exit code {0}. + + + + Dashboard + Dashboard + + + + Dashboard did not become ready within the expected time. + Dashboard did not become ready within the expected time. + + + + Dashboard started (PID {0}). + Dashboard started (PID {0}). + + + + Start the Aspire dashboard + Start the Aspire dashboard + + + + One or more HTTP endpoints through which the dashboard frontend is served + One or more HTTP endpoints through which the dashboard frontend is served + + + + Logs + Logs + + + + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + + + + OTLP/gRPC + OTLP/gRPC + + + + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + The OTLP/gRPC endpoint. This endpoint hosts an OTLP service and receives telemetry using gRPC + + + + OTLP/HTTP + OTLP/HTTP + + + + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + + + Starting dashboard... + Starting dashboard... + + + + Stopping dashboard + Stopping dashboard + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs index a4788202094..30380beed72 100644 --- a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs @@ -496,7 +496,7 @@ private async Task ApplyTemplateAsync(CallbackTemplate template, TemplatingStrings.GettingTemplates, async () => { - var options = new DotNetCliRunnerInvocationOptions() + var options = new ProcessInvocationOptions() { StandardOutputCallback = templateInstallCollector.AppendOutput, StandardErrorCallback = templateInstallCollector.AppendOutput, @@ -533,7 +533,7 @@ private async Task ApplyTemplateAsync(CallbackTemplate template, TemplatingStrings.CreatingNewProject, async () => { - var options = new DotNetCliRunnerInvocationOptions() + var options = new ProcessInvocationOptions() { StandardOutputCallback = newProjectCollector.AppendOutput, StandardErrorCallback = newProjectCollector.AppendOutput, diff --git a/src/Aspire.Cli/Utils/AppHostHelper.cs b/src/Aspire.Cli/Utils/AppHostHelper.cs index f080a53e909..5ecf5b881c1 100644 --- a/src/Aspire.Cli/Utils/AppHostHelper.cs +++ b/src/Aspire.Cli/Utils/AppHostHelper.cs @@ -59,14 +59,14 @@ internal static class AppHostHelper $"{InteractionServiceStrings.CheckingProjectType}: {relativePath}", () => runner.GetAppHostInformationAsync( projectFile, - new DotNetCliRunnerInvocationOptions(), + new ProcessInvocationOptions(), cancellationToken), emoji: KnownEmojis.Microscope); return appHostInformationResult; } - internal static async Task BuildAppHostAsync(IDotNetCliRunner runner, IInteractionService interactionService, FileInfo projectFile, bool noRestore, DotNetCliRunnerInvocationOptions options, DirectoryInfo workingDirectory, CancellationToken cancellationToken) + internal static async Task BuildAppHostAsync(IDotNetCliRunner runner, IInteractionService interactionService, FileInfo projectFile, bool noRestore, ProcessInvocationOptions options, DirectoryInfo workingDirectory, CancellationToken cancellationToken) { var relativePath = Path.GetRelativePath(workingDirectory.FullName, projectFile.FullName); return await interactionService.ShowStatusAsync( diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 33fb1008644..64031db7068 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -298,6 +298,7 @@ + diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index e7dbbd35382..c2f0539fe85 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Net.Sockets; using System.Reflection; using System.Security.Claims; using System.Security.Cryptography; @@ -46,6 +47,21 @@ namespace Aspire.Dashboard; public sealed class DashboardWebApplication : IAsyncDisposable { + /// + /// Exit code returned for unexpected startup errors. + /// + public const int ExitCodeUnexpectedError = DashboardExitCodes.UnexpectedError; + + /// + /// Exit code returned when dashboard configuration is invalid. + /// + public const int ExitCodeValidationFailure = DashboardExitCodes.ValidationFailure; + + /// + /// Exit code returned when the configured address is already in use. + /// + public const int ExitCodeAddressInUse = DashboardExitCodes.AddressInUse; + private const string DashboardAuthCookieName = ".Aspire.Dashboard.Auth"; private const string DashboardAntiForgeryCookieName = ".Aspire.Dashboard.Antiforgery"; private readonly WebApplication _app; @@ -896,11 +912,37 @@ public int Run() { if (_validationFailures.Count > 0) { - return -1; + return ExitCodeValidationFailure; + } + + try + { + _app.Run(); + return 0; + } + catch (IOException ex) when (ContainsAddressInUse(ex)) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return ExitCodeAddressInUse; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return ExitCodeUnexpectedError; + } + } + + private static bool ContainsAddressInUse(Exception ex) + { + for (var current = ex.InnerException; current is not null; current = current.InnerException) + { + if (current is SocketException { SocketErrorCode: SocketError.AddressAlreadyInUse }) + { + return true; + } } - _app.Run(); - return 0; + return false; } public Task StartAsync(CancellationToken cancellationToken = default) diff --git a/src/Shared/DashboardExitCodes.cs b/src/Shared/DashboardExitCodes.cs new file mode 100644 index 00000000000..a42bf0a27a1 --- /dev/null +++ b/src/Shared/DashboardExitCodes.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting; + +internal static class DashboardExitCodes +{ + public const int UnexpectedError = 1; + public const int ValidationFailure = 2; + public const int AddressInUse = 3; +} diff --git a/tests/Aspire.Cli.Tests/Commands/DashboardCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DashboardCommandTests.cs new file mode 100644 index 00000000000..b64bcffdb1e --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/DashboardCommandTests.cs @@ -0,0 +1,440 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Commands; +using Aspire.Cli.Layout; +using Aspire.Cli.Resources; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Aspire.Shared; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class DashboardCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task DashboardCommand_BundleNotAvailable_DisplaysError() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var testInteractionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => testInteractionService; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(DashboardCommandStrings.BundleLayoutNotFound, errorMessage); + } + + [Fact] + public async Task DashboardCommand_Help_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Theory] + [InlineData("--frontend-url http://localhost:5000")] + [InlineData("--otlp-grpc-url http://localhost:4317")] + [InlineData("--otlp-http-url http://localhost:4318")] + [InlineData("--allow-anonymous")] + [InlineData("--config-file-path /path/to/config.json")] + public void DashboardCommand_ParsesOptionsWithoutErrors(string args) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"dashboard {args}"); + + Assert.Empty(result.Errors); + } + + [Fact] + public void DashboardCommand_ForwardsUnmatchedTokens() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard --ASPNETCORE_URLS=http://localhost:9999"); + + Assert.Empty(result.Errors); + Assert.Equal("--ASPNETCORE_URLS=http://localhost:9999", Assert.Single(result.UnmatchedTokens)); + } + + [Fact] + public void DashboardCommand_SkipsDefaultWhenEnvVarIsSet() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var envVars = new Dictionary + { + ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://custom:9999" + }; + var executionContext = CreateExecutionContext(workspace, envVars); + + var unmatchedTokens = Array.Empty(); + + Assert.True(DashboardCommand.ConfigSettingHasValue(unmatchedTokens, executionContext, "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL")); + Assert.False(DashboardCommand.ConfigSettingHasValue(unmatchedTokens, executionContext, "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL")); + } + + [Fact] + public void DashboardCommand_SkipsDefaultWhenUnmatchedTokenHasValue() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var executionContext = CreateExecutionContext(workspace, new Dictionary()); + + var unmatchedTokens = new[] { "--ASPNETCORE_URLS=http://localhost:9999" }; + + Assert.True(DashboardCommand.ConfigSettingHasValue(unmatchedTokens, executionContext, "ASPNETCORE_URLS")); + Assert.False(DashboardCommand.ConfigSettingHasValue(unmatchedTokens, executionContext, "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL")); + } + + [Fact] + public void DashboardCommand_SkipsDefaultWhenUnmatchedTokenHasSpaceSeparatedValue() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var executionContext = CreateExecutionContext(workspace, new Dictionary()); + + var unmatchedTokens = new[] { "--ASPNETCORE_URLS", "http://localhost:9999" }; + + Assert.True(DashboardCommand.ConfigSettingHasValue(unmatchedTokens, executionContext, "ASPNETCORE_URLS")); + } + + [Fact] + public async Task DashboardCommand_DefaultOptions_DoesNotEmitAllowAnonymous() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + string[]? capturedArgs = null; + var (services, _, executionFactory) = CreateServicesWithLayout(workspace); + executionFactory.AssertionCallback = (args, _, _, _) => { capturedArgs = args; }; + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.NotNull(capturedArgs); + Assert.DoesNotContain(capturedArgs, arg => arg.Contains("ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS")); + } + + [Fact] + public async Task DashboardCommand_DefaultOptions_PassesDefaultArgsToProcess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + string[]? capturedArgs = null; + var (services, _, executionFactory) = CreateServicesWithLayout(workspace); + executionFactory.AssertionCallback = (args, _, _, _) => { capturedArgs = args; }; + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.NotNull(capturedArgs); + Assert.Collection(capturedArgs, + arg => Assert.Equal("dashboard", arg), + arg => Assert.Equal("--ASPNETCORE_URLS=http://localhost:18888", arg), + arg => Assert.Equal("--ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL=http://localhost:4317", arg), + arg => Assert.Equal("--ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL=http://localhost:4318", arg)); + } + + [Theory] + [InlineData("--frontend-url http://localhost:5000", "--ASPNETCORE_URLS=http://localhost:5000")] + [InlineData("--otlp-grpc-url http://localhost:9317", "--ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL=http://localhost:9317")] + [InlineData("--otlp-http-url http://localhost:9318", "--ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL=http://localhost:9318")] + [InlineData("--allow-anonymous", "--ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true")] + [InlineData("--config-file-path /path/to/config.json", "--ASPIRE_DASHBOARD_CONFIG_FILE_PATH=/path/to/config.json")] + public async Task DashboardCommand_IndividualOption_PassesCorrectArgToProcess(string cliArgs, string expectedArg) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + string[]? capturedArgs = null; + var (services, _, executionFactory) = CreateServicesWithLayout(workspace); + executionFactory.AssertionCallback = (args, _, _, _) => { capturedArgs = args; }; + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse($"dashboard {cliArgs}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.NotNull(capturedArgs); + Assert.Contains(expectedArg, capturedArgs); + } + + [Fact] + public async Task DashboardCommand_WithoutAllowAnonymous_SetsBrowserTokenEnvVar() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + IDictionary? capturedEnv = null; + var (services, _, executionFactory) = CreateServicesWithLayout(workspace); + executionFactory.AssertionCallback = (_, env, _, _) => { capturedEnv = env; }; + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.NotNull(capturedEnv); + Assert.True(capturedEnv.ContainsKey("DASHBOARD__FRONTEND__BROWSERTOKEN")); + Assert.False(string.IsNullOrEmpty(capturedEnv["DASHBOARD__FRONTEND__BROWSERTOKEN"])); + } + + [Fact] + public async Task DashboardCommand_UnmatchedTokens_ForwardedToProcess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + string[]? capturedArgs = null; + var (services, _, executionFactory) = CreateServicesWithLayout(workspace); + executionFactory.AssertionCallback = (args, _, _, _) => { capturedArgs = args; }; + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard --CUSTOM_SETTING=myvalue"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.NotNull(capturedArgs); + Assert.Collection(capturedArgs, + arg => Assert.Equal("dashboard", arg), + arg => Assert.Equal("--ASPNETCORE_URLS=http://localhost:18888", arg), + arg => Assert.Equal("--ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL=http://localhost:4317", arg), + arg => Assert.Equal("--ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL=http://localhost:4318", arg), + arg => Assert.Equal("--CUSTOM_SETTING=myvalue", arg)); + } + + [Fact] + public async Task DashboardCommand_CombinedOptions_PassesAllArgsToProcess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + string[]? capturedArgs = null; + var (services, _, executionFactory) = CreateServicesWithLayout(workspace); + executionFactory.AssertionCallback = (args, _, _, _) => { capturedArgs = args; }; + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard --frontend-url http://localhost:5000 --otlp-grpc-url http://localhost:9317 --otlp-http-url http://localhost:9318 --allow-anonymous --config-file-path /my/config.json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.NotNull(capturedArgs); + Assert.Collection(capturedArgs, + arg => Assert.Equal("dashboard", arg), + arg => Assert.Equal("--ASPNETCORE_URLS=http://localhost:5000", arg), + arg => Assert.Equal("--ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL=http://localhost:9317", arg), + arg => Assert.Equal("--ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL=http://localhost:9318", arg), + arg => Assert.Equal("--ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true", arg), + arg => Assert.Equal("--ASPIRE_DASHBOARD_CONFIG_FILE_PATH=/my/config.json", arg)); + } + + [Fact] + public async Task DashboardCommand_ProcessExitsWithError_ReturnsFailure() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var testInteractionService = new TestInteractionService(); + var (services, _, executionFactory) = CreateServicesWithLayout(workspace, interactionService: testInteractionService); + executionFactory.AttemptCallback = (_, _) => (1, null); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); + } + + [Theory] + [InlineData("", "http://localhost:18888")] + [InlineData(";;;", "http://localhost:18888")] + [InlineData("http://first:5000;http://second:5001", "http://first:5000")] + [InlineData("http://custom:9000", "http://custom:9000")] + [InlineData("http://trailing:8080/", "http://trailing:8080")] + public void ResolveDashboardInfo_FrontendUrlVariants_ResolvesExpectedUrl(string urlValue, string expectedDashboardUrl) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var executionContext = CreateExecutionContext(workspace, new Dictionary()); + + var args = new List { "dashboard", $"--ASPNETCORE_URLS={urlValue}" }; + var unmatchedTokens = Array.Empty(); + + var info = DashboardCommand.ResolveDashboardInfo(args, unmatchedTokens, executionContext, browserToken: null); + + Assert.Equal(expectedDashboardUrl, info.DashboardUrl); + } + + [Fact] + public void ResolveDashboardInfo_EnvVarFrontendUrl_UsedInSummary() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var executionContext = CreateExecutionContext(workspace, new Dictionary + { + ["ASPNETCORE_URLS"] = "http://envhost:9999" + }); + + // No arg in the list — should fall back to the environment variable. + var args = new List { "dashboard" }; + var unmatchedTokens = Array.Empty(); + + var info = DashboardCommand.ResolveDashboardInfo(args, unmatchedTokens, executionContext, browserToken: null); + + Assert.Equal("http://envhost:9999", info.DashboardUrl); + } + + [Fact] + public void ResolveDashboardInfo_SpaceSeparatedUnmatchedToken_UsedInSummary() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var executionContext = CreateExecutionContext(workspace, new Dictionary()); + + // No --ASPNETCORE_URLS= in the args list; the value comes through space-separated unmatched tokens. + var args = new List { "dashboard" }; + var unmatchedTokens = new[] { "--ASPNETCORE_URLS", "http://space:7777" }; + + var info = DashboardCommand.ResolveDashboardInfo(args, unmatchedTokens, executionContext, browserToken: null); + + Assert.Equal("http://space:7777", info.DashboardUrl); + } + + [Fact] + public void ResolveDashboardInfo_EnvVarOtlpUrls_UsedInSummary() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var executionContext = CreateExecutionContext(workspace, new Dictionary + { + ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://grpc:1111", + ["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://http:2222" + }); + + var args = new List { "dashboard" }; + var unmatchedTokens = Array.Empty(); + + var info = DashboardCommand.ResolveDashboardInfo(args, unmatchedTokens, executionContext, browserToken: null); + + Assert.Equal("http://grpc:1111", info.OtlpGrpcUrl); + Assert.Equal("http://http:2222", info.OtlpHttpUrl); + } + + [Fact] + public void ResolveDashboardInfo_ArgTakesPrecedenceOverEnvVar() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var executionContext = CreateExecutionContext(workspace, new Dictionary + { + ["ASPNETCORE_URLS"] = "http://envhost:9999" + }); + + var args = new List { "dashboard", "--ASPNETCORE_URLS=http://arghost:5555" }; + var unmatchedTokens = Array.Empty(); + + var info = DashboardCommand.ResolveDashboardInfo(args, unmatchedTokens, executionContext, browserToken: null); + + Assert.Equal("http://arghost:5555", info.DashboardUrl); + } + + [Fact] + public void ResolveDashboardInfo_WithBrowserToken_AppendsLoginPath() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var executionContext = CreateExecutionContext(workspace, new Dictionary()); + + var args = new List { "dashboard", "--ASPNETCORE_URLS=http://localhost:18888" }; + var unmatchedTokens = Array.Empty(); + + var info = DashboardCommand.ResolveDashboardInfo(args, unmatchedTokens, executionContext, browserToken: "abc123"); + + Assert.Equal("http://localhost:18888/login?t=abc123", info.DashboardUrl); + } + + private (IServiceCollection Services, string ManagedPath, TestProcessExecutionFactory ExecutionFactory) CreateServicesWithLayout( + TemporaryWorkspace workspace, + TestInteractionService? interactionService = null) + { + var layoutDir = Path.Combine(workspace.WorkspaceRoot.FullName, "layout"); + var managedDir = Path.Combine(layoutDir, "managed"); + Directory.CreateDirectory(managedDir); + var managedPath = Path.Combine(managedDir, BundleDiscovery.GetExecutableFileName("aspire-managed")); + File.WriteAllText(managedPath, "fake"); + + var layout = new LayoutConfiguration + { + LayoutPath = layoutDir, + Components = new LayoutComponents { Managed = "managed" } + }; + + var executionFactory = new TestProcessExecutionFactory + { + AttemptCallback = (_, _) => (0, "Now listening on: http://localhost:18888") + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.LayoutDiscoveryFactory = _ => new FakeLayoutDiscovery(layout); + options.DotNetCliExecutionFactoryFactory = _ => executionFactory; + if (interactionService is not null) + { + options.InteractionServiceFactory = _ => interactionService; + } + }); + + return (services, managedPath, executionFactory); + } + + private static CliExecutionContext CreateExecutionContext(TemporaryWorkspace workspace, Dictionary envVars) + { + var dir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(dir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(dir.FullName, ".aspire", "cache")); + var logsDir = new DirectoryInfo(Path.Combine(dir.FullName, ".aspire", "logs")); + var logFile = Path.Combine(logsDir.FullName, "test.log"); + return new CliExecutionContext(dir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDir, logFile, environmentVariables: envVars); + } + + private sealed class FakeLayoutDiscovery(LayoutConfiguration layout) : ILayoutDiscovery + { + public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) => layout; + public string? GetComponentPath(LayoutComponent component, string? projectDirectory = null) => layout.GetComponentPath(component); + public bool IsBundleModeAvailable(string? projectDirectory = null) => true; + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index d8b0a243b4a..27efdc26b39 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -478,7 +478,7 @@ public async Task AppHostHelper_BuildAppHostAsync_IncludesRelativePathInStatusMe var appHostProjectFile = new FileInfo(appHostProjectPath); File.WriteAllText(appHostProjectFile.FullName, ""); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); await AppHostHelper.BuildAppHostAsync(testRunner, testInteractionService, appHostProjectFile, noRestore: false, options, workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); } @@ -941,7 +941,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrue_IncludesNonInteractiv var provider = services.BuildServiceProvider(); var logger = provider.GetRequiredService>(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = new CliExecutionContext( workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" @@ -991,7 +991,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalse_DoesNotIncludeNonInt var provider = services.BuildServiceProvider(); var logger = provider.GetRequiredService>(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = new CliExecutionContext( workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" @@ -1037,7 +1037,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrueAndDebugIsTrue_Include var provider = services.BuildServiceProvider(); var logger = provider.GetRequiredService>(); - var options = new DotNetCliRunnerInvocationOptions { Debug = true }; + var options = new ProcessInvocationOptions { Debug = true }; var executionContext = new CliExecutionContext( workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" @@ -1087,7 +1087,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrueAndDebugIsFalse_DoesNo var provider = services.BuildServiceProvider(); var logger = provider.GetRequiredService>(); - var options = new DotNetCliRunnerInvocationOptions { Debug = false }; + var options = new ProcessInvocationOptions { Debug = false }; var executionContext = new CliExecutionContext( workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" @@ -1132,7 +1132,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalseAndDebugIsTrue_DoesNo var provider = services.BuildServiceProvider(); var logger = provider.GetRequiredService>(); - var options = new DotNetCliRunnerInvocationOptions { Debug = true }; + var options = new ProcessInvocationOptions { Debug = true }; var executionContext = new CliExecutionContext( workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" @@ -1178,7 +1178,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrue_SetsSuppressLaunchBro var provider = services.BuildServiceProvider(); var logger = provider.GetRequiredService>(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = new CliExecutionContext( workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" @@ -1224,7 +1224,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalse_DoesNotSetSuppressLa var provider = services.BuildServiceProvider(); var logger = provider.GetRequiredService>(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = new CliExecutionContext( workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs index a7974739d47..8049ecb658f 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -35,7 +35,7 @@ public async Task DotNetCliCorrectlyAppliesNoLaunchProfileArgumentWhenSpecifiedI var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions() + var options = new ProcessInvocationOptions() { NoLaunchProfile = true }; @@ -75,7 +75,7 @@ public async Task BuildAsyncAlwaysInjectsDotnetCliUseMsBuildServerEnvironmentVar var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -104,7 +104,7 @@ public async Task RestoreAsyncRunsDotnetRestoreCommand() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -142,7 +142,7 @@ public async Task BuildAsyncUsesConfigurationValueForDotnetCliUseMsBuildServer() }); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -171,7 +171,7 @@ public async Task BuildAsyncIncludesNoRestoreFlagWhenNoRestoreIsTrue() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -200,7 +200,7 @@ public async Task BuildAsyncDoesNotIncludeNoRestoreFlagWhenNoRestoreIsFalse() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -229,7 +229,7 @@ public async Task RunAsyncInjectsDotnetCliUseMsBuildServerWhenNoBuildIsFalse() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -267,7 +267,7 @@ public async Task RunAsyncDoesNotInjectDotnetCliUseMsBuildServerWhenNoBuildIsTru var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -308,7 +308,7 @@ public async Task RunAsyncPreservesExistingEnvironmentVariables() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -351,7 +351,7 @@ public async Task NewProjectAsyncReturnsExitCode73WhenProjectAlreadyExists() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -389,7 +389,7 @@ public async Task RunAsyncSetsVersionCheckDisabledWhenUpdateNotificationsFeature }); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var runner = DotNetCliRunnerTestHelper.Create( provider, @@ -429,7 +429,7 @@ public async Task RunAsyncDoesNotSetVersionCheckDisabledWhenUpdateNotificationsF }); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var runner = DotNetCliRunnerTestHelper.Create( provider, @@ -471,7 +471,7 @@ public async Task RunAsyncDoesNotOverrideUserProvidedVersionCheckDisabledValue() }); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var runner = DotNetCliRunnerTestHelper.Create( provider, @@ -546,7 +546,7 @@ public async Task RunAsyncLaunchesAppHostInExtensionHostIfConnected() args: [], env: null, backchannelCompletionSource: new TaskCompletionSource(), - options: new DotNetCliRunnerInvocationOptions(), + options: new ProcessInvocationOptions(), cancellationToken: CancellationToken.None).DefaultTimeout(); await launchAppHostCalledTcs.Task.DefaultTimeout(); @@ -563,7 +563,7 @@ public async Task AddPackageAsyncUseFilesSwitchForSingleFileAppHost() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -616,7 +616,7 @@ public async Task AddPackageAsyncUsesPositionalArgumentForCsprojFile() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -683,7 +683,7 @@ public async Task AddPackageAsyncUsesPositionalArgumentForCsprojFileWithNoRestor var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -749,7 +749,7 @@ public async Task AddPackageAsyncWithSourceAndNoRestoreHasArgumentSourceAndNoRes var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -831,7 +831,7 @@ public async Task GetSolutionProjectsAsync_ParsesOutputCorrectly() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions + var options = new ProcessInvocationOptions { StandardOutputCallback = (line) => outputHelper.WriteLine($"stdout: {line}") }; @@ -872,7 +872,7 @@ public async Task AddProjectReferenceAsync_ExecutesCorrectCommand() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -902,7 +902,7 @@ public async Task RunAsyncAppliesNoLaunchProfileForSingleFileAppHost() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions() + var options = new ProcessInvocationOptions() { NoLaunchProfile = true }; @@ -948,7 +948,7 @@ public async Task RunAsyncDoesNotIncludeNoLaunchProfileForSingleFileAppHostWhenN var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions() + var options = new ProcessInvocationOptions() { NoLaunchProfile = false }; @@ -994,7 +994,7 @@ public async Task RunAsyncFiltersOutEmptyAndWhitespaceArguments() var provider = services.BuildServiceProvider(); // Use watch=true and NoLaunchProfile=false to ensure some empty strings are generated - var options = new DotNetCliRunnerInvocationOptions() + var options = new ProcessInvocationOptions() { NoLaunchProfile = false, Debug = false @@ -1038,7 +1038,7 @@ public async Task RunAsyncFiltersOutEmptyArgumentsForSingleFileAppHost() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions() + var options = new ProcessInvocationOptions() { NoLaunchProfile = false // This will generate an empty string for noProfileSwitch }; @@ -1089,7 +1089,7 @@ public async Task RunAsyncIncludesAllNonEmptyFlagsWhenEnabled() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions() + var options = new ProcessInvocationOptions() { NoLaunchProfile = true, Debug = true @@ -1138,7 +1138,7 @@ public async Task RunAsyncCorrectlyHandlesWatchWithoutDebug() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions() + var options = new ProcessInvocationOptions() { NoLaunchProfile = true, Debug = false // No debug, so no --verbose @@ -1186,7 +1186,7 @@ public async Task GetProjectItemsAndPropertiesAsync_UsesBuild_ForSingleFileAppHo var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -1222,7 +1222,7 @@ public async Task GetProjectItemsAndPropertiesAsync_UsesMsBuild_ForCsProjFile() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -1275,7 +1275,7 @@ public async Task SearchPackagesAsyncRetriesOnFailureAndSucceedsOnSecondAttempt( logger: logger ); - var options = new DotNetCliRunnerInvocationOptions { SuppressLogging = true }; + var options = new ProcessInvocationOptions { SuppressLogging = true }; var result = await runner.SearchPackagesAsync( workspace.WorkspaceRoot, @@ -1316,7 +1316,7 @@ public async Task SearchPackagesAsyncRetriesMaxTimesAndReturnsFailure() logger: logger ); - var options = new DotNetCliRunnerInvocationOptions { SuppressLogging = true }; + var options = new ProcessInvocationOptions { SuppressLogging = true }; var result = await runner.SearchPackagesAsync( workspace.WorkspaceRoot, @@ -1357,7 +1357,7 @@ public async Task SearchPackagesAsyncSucceedsOnFirstAttemptWithoutRetry() logger: logger ); - var options = new DotNetCliRunnerInvocationOptions { SuppressLogging = true }; + var options = new ProcessInvocationOptions { SuppressLogging = true }; var result = await runner.SearchPackagesAsync( workspace.WorkspaceRoot, @@ -1401,7 +1401,7 @@ public async Task RunAsyncIncludesNoBuildFlagWhenNoBuildIsTrue() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -1439,7 +1439,7 @@ public async Task RunAsyncDoesNotIncludeNoBuildFlagWhenNoBuildIsFalse() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -1477,7 +1477,7 @@ public async Task RunAsyncIncludesNoRestoreFlagWhenNoRestoreIsTrueAndNoBuildIsFa var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( @@ -1517,7 +1517,7 @@ public async Task RunAsyncDoesNotIncludeNoRestoreFlagWhenNoBuildIsTrue() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); - var options = new DotNetCliRunnerInvocationOptions(); + var options = new ProcessInvocationOptions(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var runner = DotNetCliRunnerTestHelper.Create( diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs index 4aef9e1b59e..d0d4d5b1358 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs @@ -206,7 +206,7 @@ await File.WriteAllTextAsync( var document = JsonDocument.Parse(json); return (0, document); }, - // FileInfo, string, string, string?, DotNetCliRunnerInvocationOptions, CancellationToken, int + // FileInfo, string, string, string?, ProcessInvocationOptions, CancellationToken, int AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) => { packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!, noRestore)); @@ -345,7 +345,7 @@ await File.WriteAllTextAsync( var document = JsonDocument.Parse(json); return (0, document); }, - // FileInfo, string, string, string?, DotNetCliRunnerInvocationOptions, CancellationToken, int + // FileInfo, string, string, string?, ProcessInvocationOptions, CancellationToken, int AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) => { packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!, noRestore)); diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index b3b801f5668..45e050dafb2 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -498,46 +498,46 @@ public void DisplayRenderable(IRenderable renderable) { } private sealed class TestDotNetCliRunner : IDotNetCliRunner { - public Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, ProcessInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task NewProjectAsync(string templateName, string projectName, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions? options, CancellationToken cancellationToken) + public Task NewProjectAsync(string templateName, string projectName, string outputPath, string[] extraArgs, ProcessInvocationOptions? options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task RestoreAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task RestoreAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task BuildAsync(FileInfo projectFile, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task BuildAsync(FileInfo projectFile, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task AddPackageAsync(FileInfo projectFile, string packageName, string version, string? packageSourceUrl, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task AddPackageAsync(FileInfo projectFile, string packageName, string version, string? packageSourceUrl, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, ProcessInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProjectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProjectFile, ProcessInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task<(int ExitCode, NuGetPackageCli[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, NuGetPackageCli[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, ProcessInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, ProcessInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, ProcessInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, ProcessInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task InitUserSecretsAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task InitUserSecretsAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken) => Task.FromResult(0); } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index 32101c9ae3a..3bbf76173e0 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -11,49 +11,49 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestDotNetCliRunner : IDotNetCliRunner { - public Func? AddPackageAsyncCallback { get; set; } - public Func? AddProjectToSolutionAsyncCallback { get; set; } - public Func? BuildAsyncCallback { get; set; } - public Func? RestoreAsyncCallback { get; set; } - public Func? GetAppHostInformationAsyncCallback { get; set; } - public Func? GetNuGetConfigPathsAsyncCallback { get; set; } - public Func? GetProjectItemsAndPropertiesAsyncCallback { get; set; } - public Func? InstallTemplateAsyncCallback { get; set; } - public Func? NewProjectAsyncCallback { get; set; } - public Func?, TaskCompletionSource?, DotNetCliRunnerInvocationOptions, CancellationToken, Task>? RunAsyncCallback { get; set; } - public Func? SearchPackagesAsyncCallback { get; set; } - public Func Projects)>? GetSolutionProjectsAsyncCallback { get; set; } - public Func? AddProjectReferenceAsyncCallback { get; set; } - - public Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Func? AddPackageAsyncCallback { get; set; } + public Func? AddProjectToSolutionAsyncCallback { get; set; } + public Func? BuildAsyncCallback { get; set; } + public Func? RestoreAsyncCallback { get; set; } + public Func? GetAppHostInformationAsyncCallback { get; set; } + public Func? GetNuGetConfigPathsAsyncCallback { get; set; } + public Func? GetProjectItemsAndPropertiesAsyncCallback { get; set; } + public Func? InstallTemplateAsyncCallback { get; set; } + public Func? NewProjectAsyncCallback { get; set; } + public Func?, TaskCompletionSource?, ProcessInvocationOptions, CancellationToken, Task>? RunAsyncCallback { get; set; } + public Func? SearchPackagesAsyncCallback { get; set; } + public Func Projects)>? GetSolutionProjectsAsyncCallback { get; set; } + public Func? AddProjectReferenceAsyncCallback { get; set; } + + public Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken) { return AddPackageAsyncCallback != null ? Task.FromResult(AddPackageAsyncCallback(projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken)) : throw new NotImplementedException(); } - public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken) { return AddProjectToSolutionAsyncCallback != null ? Task.FromResult(AddProjectToSolutionAsyncCallback(solutionFile, projectFile, options, cancellationToken)) : Task.FromResult(0); // If not overridden, just return success. } - public Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task BuildAsync(FileInfo projectFilePath, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken) { return BuildAsyncCallback != null ? Task.FromResult(BuildAsyncCallback(projectFilePath, noRestore, options, cancellationToken)) : throw new NotImplementedException(); } - public Task RestoreAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task RestoreAsync(FileInfo projectFilePath, ProcessInvocationOptions options, CancellationToken cancellationToken) { return RestoreAsyncCallback != null ? Task.FromResult(RestoreAsyncCallback(projectFilePath, options, cancellationToken)) : throw new NotImplementedException(); } - public Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken) { var informationalVersion = VersionHelper.GetDefaultTemplateVersion(); @@ -62,7 +62,7 @@ public Task RestoreAsync(FileInfo projectFilePath, DotNetCliRunnerInvocatio : Task.FromResult<(int, bool, string?)>((0, true, informationalVersion)); } - public Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, ProcessInvocationOptions options, CancellationToken cancellationToken) { return GetNuGetConfigPathsAsyncCallback != null ? Task.FromResult(GetNuGetConfigPathsAsyncCallback(workingDirectory, options, cancellationToken)) @@ -78,55 +78,55 @@ private static string[] GetGlobalNuGetPaths() }; } - public Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, ProcessInvocationOptions options, CancellationToken cancellationToken) { return GetProjectItemsAndPropertiesAsyncCallback != null ? Task.FromResult(GetProjectItemsAndPropertiesAsyncCallback(projectFile, items, properties, options, cancellationToken)) : throw new NotImplementedException(); } - public Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, ProcessInvocationOptions options, CancellationToken cancellationToken) { return InstallTemplateAsyncCallback != null ? Task.FromResult(InstallTemplateAsyncCallback(packageName, version, nugetSource, force, options, cancellationToken)) : Task.FromResult<(int, string?)>((0, version)); // If not overridden, just return success for the version specified. } - public Task NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, ProcessInvocationOptions options, CancellationToken cancellationToken) { return NewProjectAsyncCallback != null ? Task.FromResult(NewProjectAsyncCallback(templateName, name, outputPath, options, cancellationToken)) : Task.FromResult(0); // If not overridden, just return success. } - public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, ProcessInvocationOptions options, CancellationToken cancellationToken) { return RunAsyncCallback != null ? RunAsyncCallback(projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) : throw new NotImplementedException(); } - public Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, ProcessInvocationOptions options, CancellationToken cancellationToken) { return SearchPackagesAsyncCallback != null ? Task.FromResult(SearchPackagesAsyncCallback(workingDirectory, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken)) : throw new NotImplementedException(); } - public Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, ProcessInvocationOptions options, CancellationToken cancellationToken) { return GetSolutionProjectsAsyncCallback != null ? Task.FromResult(GetSolutionProjectsAsyncCallback(solutionFile, options, cancellationToken)) : Task.FromResult<(int, IReadOnlyList)>((0, Array.Empty())); } - public Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, ProcessInvocationOptions options, CancellationToken cancellationToken) { return AddProjectReferenceAsyncCallback != null ? Task.FromResult(AddProjectReferenceAsyncCallback(projectFile, referencedProject, options, cancellationToken)) : Task.FromResult(0); } - public Task InitUserSecretsAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task InitUserSecretsAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken) => Task.FromResult(0); } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliExecutionFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs similarity index 76% rename from tests/Aspire.Cli.Tests/TestServices/TestDotNetCliExecutionFactory.cs rename to tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs index a038130eb87..4bda98fcbc6 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliExecutionFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs @@ -13,31 +13,31 @@ namespace Aspire.Cli.Tests.TestServices; -internal sealed class TestDotNetCliExecutionFactory : IDotNetCliExecutionFactory +internal sealed class TestProcessExecutionFactory : IProcessExecutionFactory { private int _attemptCount; /// /// Gets or sets a callback that is invoked when is called. - /// If this returns an , that execution is returned directly. + /// If this returns an , that execution is returned directly. /// - public Func?, DirectoryInfo, DotNetCliRunnerInvocationOptions, IDotNetCliExecution>? CreateExecutionCallback { get; set; } + public Func?, DirectoryInfo, ProcessInvocationOptions, IProcessExecution>? CreateExecutionCallback { get; set; } /// /// Gets or sets an action that is invoked when is called, /// typically used for assertions on the arguments. /// - public Action?, DirectoryInfo, DotNetCliRunnerInvocationOptions>? AssertionCallback { get; set; } + public Action?, DirectoryInfo, ProcessInvocationOptions>? AssertionCallback { get; set; } /// /// Gets or sets a callback that is invoked for each execution attempt, receiving the attempt number (1-based) /// and options, and returning the exit code and optional stdout content. /// This is used for testing retry scenarios. /// - public Func? AttemptCallback { get; set; } + public Func? AttemptCallback { get; set; } /// - /// When set, the execution will use this exit code when is called. + /// When set, the execution will use this exit code when is called. /// public int DefaultExitCode { get; set; } @@ -51,7 +51,7 @@ internal sealed class TestDotNetCliExecutionFactory : IDotNetCliExecutionFactory /// public int AttemptCount => _attemptCount; - public IDotNetCliExecution CreateExecution(string[] args, IDictionary? env, DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options) + public IProcessExecution CreateExecution(string fileName, string[] args, IDictionary? env, DirectoryInfo workingDirectory, ProcessInvocationOptions options) { _attemptCount++; @@ -66,24 +66,26 @@ public IDotNetCliExecution CreateExecution(string[] args, IDictionary (DefaultExitCode, null)); - return new TestDotNetCliExecution(args, env, options, callback, () => _attemptCount); + return new TestProcessExecution(fileName, args, env, options, callback, () => _attemptCount); } } -internal sealed class TestDotNetCliExecution : IDotNetCliExecution +internal sealed class TestProcessExecution : IProcessExecution { - private readonly DotNetCliRunnerInvocationOptions _options; - private readonly Func _attemptCallback; + private readonly ProcessInvocationOptions _options; + private readonly Func _attemptCallback; private readonly Func _attemptCounter; private bool _started; - public TestDotNetCliExecution( + public TestProcessExecution( + string fileName, string[] args, IDictionary? env, - DotNetCliRunnerInvocationOptions options, - Func attemptCallback, + ProcessInvocationOptions options, + Func attemptCallback, Func attemptCounter) { + FileName = fileName; Arguments = args; EnvironmentVariables = env?.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value) ?? new Dictionary(); @@ -92,7 +94,7 @@ public TestDotNetCliExecution( _attemptCounter = attemptCounter; } - public string FileName => "dotnet"; + public string FileName { get; } public IReadOnlyList Arguments { get; } @@ -123,10 +125,18 @@ public Task WaitForExitAsync(CancellationToken cancellationToken) } return Task.FromResult(exitCode); } + + public void Kill(bool entireProcessTree) + { + } + + public void Dispose() + { + } } /// -/// Helper class for creating a with a +/// Helper class for creating a with a /// configured for assertion-based testing. /// internal static class DotNetCliRunnerTestHelper @@ -137,14 +147,14 @@ internal static class DotNetCliRunnerTestHelper public static DotNetCliRunner Create( IServiceProvider serviceProvider, CliExecutionContext executionContext, - Action?, DirectoryInfo, DotNetCliRunnerInvocationOptions> assertionCallback, + Action?, DirectoryInfo, ProcessInvocationOptions> assertionCallback, int exitCode = 0, ILogger? logger = null, AspireCliTelemetry? telemetry = null, IConfiguration? configuration = null, IDiskCache? diskCache = null) { - var executionFactory = new TestDotNetCliExecutionFactory + var executionFactory = new TestProcessExecutionFactory { AssertionCallback = assertionCallback, DefaultExitCode = exitCode @@ -164,18 +174,18 @@ public static DotNetCliRunner Create( /// /// Creates a with an attempt callback for testing retry scenarios. - /// Returns both the runner and the factory so the test can check . + /// Returns both the runner and the factory so the test can check . /// - public static (DotNetCliRunner Runner, TestDotNetCliExecutionFactory ExecutionFactory) CreateWithRetry( + public static (DotNetCliRunner Runner, TestProcessExecutionFactory ExecutionFactory) CreateWithRetry( IServiceProvider serviceProvider, CliExecutionContext executionContext, - Func attemptCallback, + Func attemptCallback, ILogger? logger = null, AspireCliTelemetry? telemetry = null, IConfiguration? configuration = null, IDiskCache? diskCache = null) { - var executionFactory = new TestDotNetCliExecutionFactory + var executionFactory = new TestProcessExecutionFactory { AttemptCallback = attemptCallback }; diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 757ea829731..16ea8dcbb8b 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -139,6 +139,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work // Bundle layout services - return null/no-op implementations to trigger SDK mode fallback // This ensures backward compatibility: no layout found = use legacy SDK mode services.AddSingleton(options.LayoutDiscoveryFactory); + services.AddTransient(); services.AddSingleton(options.BundleServiceFactory); services.AddSingleton(); @@ -190,6 +191,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -412,9 +414,9 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser return new CertificateService(certificateToolRunner, interactiveService, telemetry, hostEnvironment); }; - public Func DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) => + public Func DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) => { - return new TestDotNetCliExecutionFactory(); + return new TestProcessExecutionFactory(); }; public Func DotNetCliRunnerFactory { get; set; } = (IServiceProvider serviceProvider) => @@ -425,7 +427,7 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var features = serviceProvider.GetRequiredService(); var diskCache = serviceProvider.GetRequiredService(); var executionContext = serviceProvider.GetRequiredService(); - var executionFactory = serviceProvider.GetRequiredService(); + var executionFactory = serviceProvider.GetRequiredService(); var interactionService = serviceProvider.GetRequiredService(); return new DotNetCliRunner(logger, serviceProvider, telemetry, configuration, diskCache, features, interactionService, executionContext, executionFactory); diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index d4134ffd182..91bd90467ab 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Net; +using System.Net.Sockets; using System.Security.Cryptography.X509Certificates; using System.Text.Json.Nodes; using Aspire.Dashboard.Configuration; @@ -16,6 +17,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.EnvironmentVariables; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -992,6 +994,59 @@ public async Task Configuration_DisableAI_EnsureValueSetOnOptions(bool? value) Assert.Equal(!(value ?? false), aiContextProvider.Enabled); } + [Fact] + public async Task Run_AddressAlreadyInUse_ReturnsExitCodeAddressInUse() + { + // Bind a port so the dashboard can't use it. + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + await using var app = new DashboardWebApplication(preConfigureBuilder: builder => + { + RemoveEnvironmentVariableSources(builder); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = $"http://127.0.0.1:{port}", + [DashboardConfigNames.DashboardOtlpGrpcUrlName.ConfigKey] = "http://127.0.0.1:0", + [DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey] = "http://127.0.0.1:0", + [DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey] = nameof(OtlpAuthMode.Unsecured), + [DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = nameof(FrontendAuthMode.Unsecured), + }); + }); + + var exitCode = app.Run(); + + Assert.Equal(DashboardWebApplication.ExitCodeAddressInUse, exitCode); + } + + [Fact] + public async Task Run_ValidationFailure_ReturnsExitCodeValidationFailure() + { + // Omit required configuration so the dashboard fails validation. + await using var app = new DashboardWebApplication(preConfigureBuilder: builder => + { + RemoveEnvironmentVariableSources(builder); + // No frontend URL or auth mode configured — validation will fail. + }); + + var exitCode = app.Run(); + + Assert.Equal(DashboardWebApplication.ExitCodeValidationFailure, exitCode); + } + + private static void RemoveEnvironmentVariableSources(WebApplicationBuilder builder) + { + var sources = ((IConfigurationBuilder)builder.Configuration).Sources; + foreach (var item in sources.ToList()) + { + if (item is EnvironmentVariablesConfigurationSource) + { + sources.Remove(item); + } + } + } + private static void AssertIPv4OrIPv6Endpoint(Func endPointAccessor) { // Check that the address is IPv4 or IPv6 any.