Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Aspire.Cli/Commands/DeployCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ internal sealed class DeployCommand : PublishCommandBase
{
private readonly Option<bool> _clearCacheOption;

public DeployCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext)
: base("deploy", DeployCommandStrings.Description, runner, interactionService, projectLocator, telemetry, sdkInstaller, features, updateNotifier, executionContext)
public DeployCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment)
: base("deploy", DeployCommandStrings.Description, runner, interactionService, projectLocator, telemetry, sdkInstaller, features, updateNotifier, executionContext, hostEnvironment)
{
_clearCacheOption = new Option<bool>("--clear-cache")
{
Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Cli/Commands/PublishCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ internal sealed class PublishCommand : PublishCommandBase
{
private readonly IPublishCommandPrompter _prompter;

public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IPublishCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext)
: base("publish", PublishCommandStrings.Description, runner, interactionService, projectLocator, telemetry, sdkInstaller, features, updateNotifier, executionContext)
public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IPublishCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment)
: base("publish", PublishCommandStrings.Description, runner, interactionService, projectLocator, telemetry, sdkInstaller, features, updateNotifier, executionContext, hostEnvironment)
{
ArgumentNullException.ThrowIfNull(prompter);
_prompter = prompter;
Expand Down
27 changes: 23 additions & 4 deletions src/Aspire.Cli/Commands/PublishCommandBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ internal abstract class PublishCommandBase : BaseCommand
protected readonly IDotNetSdkInstaller _sdkInstaller;

private readonly IFeatures _features;
private readonly ICliHostEnvironment _hostEnvironment;

protected abstract string OperationCompletedPrefix { get; }
protected abstract string OperationFailedPrefix { get; }
Expand All @@ -40,20 +41,22 @@ private static bool IsCompletionStateError(string completionState) =>
private static bool IsCompletionStateWarning(string completionState) =>
completionState == CompletionStates.CompletedWithWarning;

protected PublishCommandBase(string name, string description, IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext)
protected PublishCommandBase(string name, string description, IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment)
: base(name, description, features, updateNotifier, executionContext, interactionService)
{
ArgumentNullException.ThrowIfNull(runner);
ArgumentNullException.ThrowIfNull(projectLocator);
ArgumentNullException.ThrowIfNull(telemetry);
ArgumentNullException.ThrowIfNull(sdkInstaller);
ArgumentNullException.ThrowIfNull(features);
ArgumentNullException.ThrowIfNull(hostEnvironment);

_runner = runner;
_projectLocator = projectLocator;
_telemetry = telemetry;
_sdkInstaller = sdkInstaller;
_features = features;
_hostEnvironment = hostEnvironment;

var projectOption = new Option<FileInfo?>("--project")
{
Expand Down Expand Up @@ -122,6 +125,12 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell

var env = new Dictionary<string, string>();

// Set interactivity enabled based on host environment capabilities
if (!_hostEnvironment.SupportsInteractiveInput)
{
env[KnownConfigNames.InteractivityEnabled] = "false";
}

var waitForDebugger = parseResult.GetValue<bool?>("--wait-for-debugger") ?? false;
if (waitForDebugger)
{
Expand Down Expand Up @@ -345,7 +354,7 @@ public async Task<bool> ProcessAndDisplayPublishingActivitiesAsync(IAsyncEnumera
{
var stepCounter = 1;
var steps = new Dictionary<string, StepInfo>();
var logger = new ConsoleActivityLogger();
var logger = new ConsoleActivityLogger(_hostEnvironment);
logger.StartSpinner();
PublishingActivity? publishingActivity = null;

Expand Down Expand Up @@ -731,16 +740,26 @@ private class TaskInfo
/// <summary>
/// Starts the terminal infinite progress bar.
/// </summary>
private static void StartTerminalProgressBar()
private void StartTerminalProgressBar()
{
// Skip terminal progress bar in non-interactive environments
if (!_hostEnvironment.SupportsInteractiveOutput)
{
return;
}
Console.Write("\u001b]9;4;3\u001b\\");
}

/// <summary>
/// Stops the terminal progress bar.
/// </summary>
private static void StopTerminalProgressBar()
private void StopTerminalProgressBar()
{
// Skip terminal progress bar in non-interactive environments
if (!_hostEnvironment.SupportsInteractiveOutput)
{
return;
}
Console.Write("\u001b]9;4;0\u001b\\");
}
}
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Commands/RootCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ public RootCommand(
debugOption.Recursive = true;
Options.Add(debugOption);

var nonInteractiveOption = new Option<bool>("--non-interactive");
Copy link
Member

Choose a reason for hiding this comment

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

Should consider hiding this?

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, interesting thought. Does that mean we'd only want this for publish/deploy?

nonInteractiveOption.Description = "Run the command in non-interactive mode, disabling all interactive prompts and spinners";
nonInteractiveOption.Recursive = true;
Options.Add(nonInteractiveOption);

var waitForDebuggerOption = new Option<bool>("--wait-for-debugger");
waitForDebuggerOption.Description = RootCommandStrings.WaitForDebuggerArgumentDescription;
waitForDebuggerOption.Recursive = true;
Expand Down
13 changes: 8 additions & 5 deletions src/Aspire.Cli/Interaction/ConsoleInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,22 @@ internal class ConsoleInteractionService : IInteractionService

private readonly IAnsiConsole _ansiConsole;
private readonly CliExecutionContext _executionContext;
private readonly ICliHostEnvironment _hostEnvironment;

public ConsoleInteractionService(IAnsiConsole ansiConsole, CliExecutionContext executionContext)
public ConsoleInteractionService(IAnsiConsole ansiConsole, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment)
{
ArgumentNullException.ThrowIfNull(ansiConsole);
ArgumentNullException.ThrowIfNull(executionContext);
ArgumentNullException.ThrowIfNull(hostEnvironment);
_ansiConsole = ansiConsole;
_executionContext = executionContext;
_hostEnvironment = hostEnvironment;
}

public async Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action)
{
// In debug mode, avoid interactive progress as it conflicts with debug logging
if (_executionContext.DebugMode)
// In debug mode or non-interactive environments, avoid interactive progress as it conflicts with debug logging
if (_executionContext.DebugMode || !_hostEnvironment.SupportsInteractiveOutput)
{
DisplaySubtleMessage(statusText);
return await action();
Expand All @@ -43,8 +46,8 @@ public async Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action)

public void ShowStatus(string statusText, Action action)
{
// In debug mode, avoid interactive progress as it conflicts with debug logging
if (_executionContext.DebugMode)
// In debug mode or non-interactive environments, avoid interactive progress as it conflicts with debug logging
if (_executionContext.DebugMode || !_hostEnvironment.SupportsInteractiveOutput)
{
DisplaySubtleMessage(statusText);
action();
Expand Down
14 changes: 12 additions & 2 deletions src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ private static string GetGlobalSettingsPath()

private static async Task<IHost> BuildApplicationAsync(string[] args)
{
// Check for --non-interactive flag early
var nonInteractive = args?.Any(a => a == "--non-interactive") ?? false;
Copy link
Member

Choose a reason for hiding this comment

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

We may have a better way of doing this. The CliExecutionContext exposes a Command property which becomes available as soon as the command is parsed. That means code that needs to determine something line interactivity can simply go to the execution context and await the CommandSelected property on the execution context (the TCS returns BaseCommand). From there you can grab the argument that you want.

That said -- you'll want to have an implementation of ICliHostEnvironment which makes it easy to set that without having all of the command infrastructure in the container.


var settings = new HostApplicationBuilderSettings
{
Configuration = new ConfigurationManager()
Expand Down Expand Up @@ -109,6 +112,11 @@ private static async Task<IHost> BuildApplicationAsync(string[] args)
// Shared services.
builder.Services.AddSingleton(_ => BuildCliExecutionContext(debugMode));
builder.Services.AddSingleton(BuildAnsiConsole);
builder.Services.AddSingleton<ICliHostEnvironment>(provider =>
{
var configuration = provider.GetRequiredService<IConfiguration>();
return new CliHostEnvironment(configuration, nonInteractive);
});
AddInteractionServices(builder);
builder.Services.AddSingleton<IProjectLocator, ProjectLocator>();
builder.Services.AddSingleton<ISolutionLocator, SolutionLocator>();
Expand Down Expand Up @@ -262,7 +270,8 @@ private static void AddInteractionServices(HostApplicationBuilder builder)
var ansiConsole = provider.GetRequiredService<IAnsiConsole>();
ansiConsole.Profile.Width = 256; // VS code terminal will handle wrapping so set a large width here.
var executionContext = provider.GetRequiredService<CliExecutionContext>();
var consoleInteractionService = new ConsoleInteractionService(ansiConsole, executionContext);
var hostEnvironment = provider.GetRequiredService<ICliHostEnvironment>();
var consoleInteractionService = new ConsoleInteractionService(ansiConsole, executionContext, hostEnvironment);
return new ExtensionInteractionService(consoleInteractionService,
provider.GetRequiredService<IExtensionBackchannel>(),
extensionPromptEnabled);
Expand All @@ -279,7 +288,8 @@ private static void AddInteractionServices(HostApplicationBuilder builder)
{
var ansiConsole = provider.GetRequiredService<IAnsiConsole>();
var executionContext = provider.GetRequiredService<CliExecutionContext>();
return new ConsoleInteractionService(ansiConsole, executionContext);
var hostEnvironment = provider.GetRequiredService<ICliHostEnvironment>();
return new ConsoleInteractionService(ansiConsole, executionContext, hostEnvironment);
});
}
}
Expand Down
157 changes: 157 additions & 0 deletions src/Aspire.Cli/Utils/CliHostEnvironment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Extensions.Configuration;

namespace Aspire.Cli.Utils;

/// <summary>
/// Provides information about the CLI host environment capabilities.
/// </summary>
internal interface ICliHostEnvironment
{
/// <summary>
/// Gets whether the host supports interactive input (e.g., prompts, user input).
/// </summary>
bool SupportsInteractiveInput { get; }

/// <summary>
/// Gets whether the host supports interactive output (e.g., spinners, progress bars).
/// </summary>
bool SupportsInteractiveOutput { get; }

/// <summary>
/// Gets whether the host supports colors and ANSI codes.
/// </summary>
bool SupportsAnsi { get; }
Copy link
Member

Choose a reason for hiding this comment

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

This is amazing! Thank you, I was going to report an issue about this and I was asking on Aspirifriday's about it as well. 🔥

}

/// <summary>
/// Default implementation that detects CLI host environment capabilities from configuration.
/// </summary>
internal sealed class CliHostEnvironment : ICliHostEnvironment
{
/// <summary>
/// Gets whether the host supports interactive input (e.g., prompts, user input).
/// </summary>
public bool SupportsInteractiveInput { get; }

/// <summary>
/// Gets whether the host supports interactive output (e.g., spinners, progress bars).
/// </summary>
public bool SupportsInteractiveOutput { get; }

/// <summary>
/// Gets whether the host supports colors and ANSI codes.
/// </summary>
public bool SupportsAnsi { get; }

public CliHostEnvironment(IConfiguration configuration, bool nonInteractive)
{
// If --non-interactive is explicitly set, disable interactive input and output
if (nonInteractive)
{
SupportsInteractiveInput = false;
SupportsInteractiveOutput = false;
}
else
{
SupportsInteractiveInput = DetectInteractiveInput(configuration);
SupportsInteractiveOutput = DetectInteractiveOutput(configuration);
}

SupportsAnsi = DetectAnsiSupport(configuration);
}

private static bool DetectInteractiveInput(IConfiguration configuration)
{
// Check if explicitly disabled via configuration
var nonInteractive = configuration["ASPIRE_NON_INTERACTIVE"];
if (!string.IsNullOrEmpty(nonInteractive) &&
(nonInteractive.Equals("true", StringComparison.OrdinalIgnoreCase) ||
nonInteractive.Equals("1", StringComparison.Ordinal)))
{
return false;
}

// Check if running in CI environment (no interactive input possible)
if (IsCI(configuration))
{
return false;
}

return true;
}

private static bool DetectInteractiveOutput(IConfiguration configuration)
{
// Check if explicitly disabled via configuration
var nonInteractive = configuration["ASPIRE_NON_INTERACTIVE"];
if (!string.IsNullOrEmpty(nonInteractive) &&
(nonInteractive.Equals("true", StringComparison.OrdinalIgnoreCase) ||
nonInteractive.Equals("1", StringComparison.Ordinal)))
{
return false;
}

// Check if running in CI environment (spinners pollute logs)
if (IsCI(configuration))
{
return false;
}

return true;
}

private static bool DetectAnsiSupport(IConfiguration configuration)
{
// ANSI codes are supported even in CI environments for colored output
// Only disable if explicitly configured
var noColor = configuration["NO_COLOR"];
if (!string.IsNullOrEmpty(noColor))
{
return false;
}

return true;
}

private static bool IsCI(IConfiguration configuration)
{
// Check for common CI environment variables
// https://github.com/watson/ci-info/blob/master/vendors.json
var ciEnvVars = new[]
{
"CI", // Generic CI indicator
"GITHUB_ACTIONS",
"AZURE_PIPELINES",
"TF_BUILD", // Azure Pipelines alternative
"JENKINS_URL",
"GITLAB_CI",
"CIRCLECI",
"TRAVIS",
"BUILDKITE",
"APPVEYOR",
"TEAMCITY_VERSION",
"BITBUCKET_BUILD_NUMBER",
"CODEBUILD_BUILD_ID", // AWS CodeBuild
};

foreach (var envVar in ciEnvVars)
{
var value = configuration[envVar];
if (!string.IsNullOrEmpty(value))
{
// For CI variable, only return true if it's "true" or "1"
if (envVar == "CI")
{
return value.Equals("true", StringComparison.OrdinalIgnoreCase) ||
value.Equals("1", StringComparison.Ordinal);
}
return true;
}
}

return false;
}
}
Loading
Loading