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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/Aspire.Cli/Commands/RootCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,38 +19,38 @@ namespace Aspire.Cli.Commands;

internal sealed class RootCommand : BaseRootCommand
{
public static readonly Option<bool> DebugOption = new("--debug", "-d")
public static readonly Option<bool> DebugOption = new(CommonOptionNames.Debug, CommonOptionNames.DebugShort)
{
Description = RootCommandStrings.DebugArgumentDescription,
Recursive = true
};

public static readonly Option<bool> NonInteractiveOption = new("--non-interactive")
public static readonly Option<bool> NonInteractiveOption = new(CommonOptionNames.NonInteractive)
{
Description = "Run the command in non-interactive mode, disabling all interactive prompts and spinners",
Recursive = true
};

public static readonly Option<bool> NoLogoOption = new("--nologo")
public static readonly Option<bool> NoLogoOption = new(CommonOptionNames.NoLogo)
{
Description = RootCommandStrings.NoLogoArgumentDescription,
Recursive = true
};

public static readonly Option<bool> BannerOption = new("--banner")
public static readonly Option<bool> BannerOption = new(CommonOptionNames.Banner)
{
Description = RootCommandStrings.BannerArgumentDescription,
Recursive = true
};

public static readonly Option<bool> WaitForDebuggerOption = new("--wait-for-debugger")
public static readonly Option<bool> WaitForDebuggerOption = new(CommonOptionNames.WaitForDebugger)
{
Description = RootCommandStrings.WaitForDebuggerArgumentDescription,
Recursive = true,
DefaultValueFactory = _ => false
};

public static readonly Option<bool> CliWaitForDebuggerOption = new("--cli-wait-for-debugger")
public static readonly Option<bool> CliWaitForDebuggerOption = new(CommonOptionNames.CliWaitForDebugger)
{
Description = RootCommandStrings.CliWaitForDebuggerArgumentDescription,
Recursive = true,
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,7 @@ public void ProcessResourceState(RpcResourceState resourceState, Action<string,
/// </remarks>
private async Task<int> ExecuteDetachedAsync(ParseResult parseResult, FileInfo? passedAppHostProjectFile, bool isExtensionHost, CancellationToken cancellationToken)
{
var format = parseResult.GetValue<OutputFormat?>("--format");
var format = parseResult.GetValue(s_formatOption);

// Failure mode 1: Project not found
var searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync(
Expand Down
29 changes: 29 additions & 0 deletions src/Aspire.Cli/CommonOptionNames.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Cli;

/// <summary>
/// Common command-line option names used for manual argument checks.
/// </summary>
internal static class CommonOptionNames
{
public const string Version = "--version";
public const string VersionShort = "-v";
public const string Help = "--help";
public const string HelpShort = "-h";
public const string HelpAlt = "-?";
public const string NoLogo = "--nologo";
public const string Banner = "--banner";
public const string Debug = "--debug";
public const string DebugShort = "-d";
public const string NonInteractive = "--non-interactive";
public const string WaitForDebugger = "--wait-for-debugger";
public const string CliWaitForDebugger = "--cli-wait-for-debugger";

/// <summary>
/// Options that represent informational commands (e.g. --version, --help) which should
/// opt out of telemetry and suppress first-run experience.
/// </summary>
public static readonly string[] InformationalOptionNames = [Version, Help, HelpShort, HelpAlt];
}
25 changes: 16 additions & 9 deletions src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ private static string GetGlobalSettingsPath()
internal static async Task<IHost> BuildApplicationAsync(string[] args, Dictionary<string, string?>? configurationValues = null)
{
// Check for --non-interactive flag early
var nonInteractive = args?.Any(a => a == "--non-interactive") ?? false;
var nonInteractive = args?.Any(a => a == CommonOptionNames.NonInteractive) ?? false;

// Check if running MCP start command - all logs should go to stderr to keep stdout clean for MCP protocol
// Support both old 'mcp start' and new 'agent mcp' commands
Expand Down Expand Up @@ -110,9 +110,9 @@ internal static async Task<IHost> BuildApplicationAsync(string[] args, Dictionar
// separate TracerProvider instances:
// - Azure Monitor provider with filtering (only exports activities with EXTERNAL_TELEMETRY=true)
// - Diagnostic provider for OTLP/console exporters (exports all activities, DEBUG only)
builder.Services.AddSingleton(new TelemetryManager(builder.Configuration));
builder.Services.AddSingleton(new TelemetryManager(builder.Configuration, args));

var debugMode = args?.Any(a => a == "--debug" || a == "-d") ?? false;
var debugMode = args?.Any(a => a == CommonOptionNames.Debug || a == CommonOptionNames.DebugShort) ?? false;
var extensionEndpoint = builder.Configuration[KnownConfigNames.ExtensionEndpoint];

if (debugMode && !isMcpStartCommand && extensionEndpoint is null)
Expand Down Expand Up @@ -352,8 +352,13 @@ private static IConfigurationService BuildConfigurationService(IServiceProvider
return new ConfigurationService(configuration, executionContext, globalSettingsFile);
}

internal static async Task DisplayFirstTimeUseNoticeIfNeededAsync(IServiceProvider serviceProvider, bool noLogo, bool showBanner, CancellationToken cancellationToken = default)
internal static async Task DisplayFirstTimeUseNoticeIfNeededAsync(IServiceProvider serviceProvider, string[] args, CancellationToken cancellationToken = default)
{
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
var isInformationalCommand = args.Any(a => CommonOptionNames.InformationalOptionNames.Contains(a));
var noLogo = args.Any(a => a == CommonOptionNames.NoLogo) || configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false) || isInformationalCommand;
var showBanner = args.Any(a => a == CommonOptionNames.Banner);

var sentinel = serviceProvider.GetRequiredService<IFirstTimeUseNoticeSentinel>();
var isFirstRun = !sentinel.Exists();

Expand All @@ -377,7 +382,12 @@ internal static async Task DisplayFirstTimeUseNoticeIfNeededAsync(IServiceProvid
consoleEnvironment.Error.WriteLine();
}

sentinel.CreateIfNotExists();
// Don't persist the sentinel for informational commands (--version, --help, etc.)
// so the first-run experience is shown on the next real command invocation.
if (!isInformationalCommand)
{
sentinel.CreateIfNotExists();
}
}
}

Expand Down Expand Up @@ -442,10 +452,7 @@ public static async Task<int> Main(string[] args)
await app.StartAsync().ConfigureAwait(false);

// Display first run experience if this is the first time the CLI is run on this machine
var configuration = app.Services.GetRequiredService<IConfiguration>();
var noLogo = args.Any(a => a == "--nologo") || configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false);
var showBanner = args.Any(a => a == "--banner");
await DisplayFirstTimeUseNoticeIfNeededAsync(app.Services, noLogo, showBanner, cts.Token);
await DisplayFirstTimeUseNoticeIfNeededAsync(app.Services, args, cts.Token);

var rootCommand = app.Services.GetRequiredService<RootCommand>();
var invokeConfig = new InvocationConfiguration()
Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
"commandLineArgs": "deploy",
"workingDirectory": "../../playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost"
},
"--version": {
"commandName": "Project",
"dotnetRunMessages": true,
"commandLineArgs": "--version"
},
"new": {
"commandName": "Project",
"dotnetRunMessages": true,
Expand Down
7 changes: 5 additions & 2 deletions src/Aspire.Cli/Telemetry/TelemetryManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,12 @@ internal sealed class TelemetryManager
/// Initializes a new instance of the <see cref="TelemetryManager"/> class.
/// </summary>
/// <param name="configuration">The configuration to read telemetry settings from.</param>
public TelemetryManager(IConfiguration configuration)
/// <param name="args">The command-line arguments.</param>
public TelemetryManager(IConfiguration configuration, string[]? args = null)
{
var telemetryOptOut = configuration.GetBool(AspireCliTelemetry.TelemetryOptOutConfigKey, defaultValue: false);
// Don't send telemetry for informational commands or if the user has opted out.
var hasOptOutArg = args?.Any(a => CommonOptionNames.InformationalOptionNames.Contains(a)) ?? false;
var telemetryOptOut = hasOptOutArg || configuration.GetBool(AspireCliTelemetry.TelemetryOptOutConfigKey, defaultValue: false);

#if DEBUG
var useOtlpExporter = !string.IsNullOrEmpty(configuration[AspireCliTelemetry.OtlpExporterEndpointConfigKey]);
Expand Down
9 changes: 5 additions & 4 deletions tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// 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.EndToEnd.Tests.Helpers;
Expand Down Expand Up @@ -27,6 +27,7 @@ public async Task Banner_DisplayedOnFirstRun()

var builder = Hex1bTerminal.CreateBuilder()
.WithHeadless()
.WithDimensions(160, 48)
.WithAsciinemaRecording(recordingPath)
.WithPtyProcess("/bin/bash", ["--norc"]);

Expand Down Expand Up @@ -57,13 +58,13 @@ public async Task Banner_DisplayedOnFirstRun()

// Delete the first-time use sentinel file to simulate first run
// The sentinel is stored at ~/.aspire/cli/cli.firstUseSentinel
// Using 'aspire --version' instead of 'aspire --help' because help output
// is long and would scroll the banner off the terminal screen.
// Using 'aspire cache clear' because it's not an informational
// command and so will show the banner.
sequenceBuilder
.ClearFirstRunSentinel(counter)
.VerifySentinelDeleted(counter)
.ClearScreen(counter)
.Type("aspire --version")
.Type("aspire cache clear")
.Enter()
.WaitUntil(s =>
{
Expand Down
29 changes: 29 additions & 0 deletions tests/Aspire.Cli.Tests/CliSmokeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,33 @@ public void DebugOutputWritesToStderr()

outputHelper.WriteLine(result.Process.StandardOutput.ReadToEnd());
}

[Fact]
public void VersionFlagSuppressesBanner()
{
using var result = RemoteExecutor.Invoke(async () =>
{
await using var outputWriter = new StringWriter();
var oldOutput = Console.Out;
Console.SetOut(outputWriter);

await Program.Main(["--version"]).DefaultTimeout();

Console.SetOut(oldOutput);
var output = outputWriter.ToString();

// Write to stdout so it can be captured by the test harness
Console.WriteLine($"Output: {output}");

// The output should only contain the version, not the animated banner
// The banner contains "Welcome to the" and ASCII art
Assert.DoesNotContain("Welcome to the", output);
Assert.DoesNotContain("█████", output);

// The output should contain a version number
Assert.Contains(".", output); // Version should have at least one dot
}, options: s_remoteInvokeOptions);

outputHelper.WriteLine(result.Process.StandardOutput.ReadToEnd());
}
}
75 changes: 64 additions & 11 deletions tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public async Task FirstTimeUseNotice_BannerDisplayedWhenSentinelDoesNotExist()
});
var provider = services.BuildServiceProvider();

await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: false);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);

Assert.True(bannerService.WasBannerDisplayed);
Assert.True(sentinel.WasCreated);
Expand All @@ -111,7 +111,7 @@ public async Task FirstTimeUseNotice_BannerNotDisplayedWhenSentinelExists()
});
var provider = services.BuildServiceProvider();

await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: false);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);

Assert.False(bannerService.WasBannerDisplayed);
Assert.False(sentinel.WasCreated);
Expand All @@ -133,7 +133,7 @@ public async Task FirstTimeUseNotice_BannerNotDisplayedWithNoLogoArgument()
});
var provider = services.BuildServiceProvider();

await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: true, showBanner: false);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.NoLogo]);

Assert.False(bannerService.WasBannerDisplayed);
Assert.True(sentinel.WasCreated);
Expand Down Expand Up @@ -162,7 +162,7 @@ public async Task FirstTimeUseNotice_BannerNotDisplayedWithNoLogoEnvironmentVari
var configuration = provider.GetRequiredService<IConfiguration>();
var noLogo = configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false);

await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo, showBanner: false);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);

Assert.False(bannerService.WasBannerDisplayed);
Assert.True(sentinel.WasCreated);
Expand All @@ -185,7 +185,7 @@ public async Task Banner_DisplayedWhenExplicitlyRequested()
});
var provider = services.BuildServiceProvider();

await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]);

Assert.True(bannerService.WasBannerDisplayed);
// Telemetry notice should NOT be shown since it's not first run
Expand All @@ -206,9 +206,9 @@ public async Task Banner_CanBeInvokedMultipleTimes()
var provider = services.BuildServiceProvider();

// Invoke multiple times (simulating multiple --banner calls)
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]);

Assert.Equal(3, bannerService.DisplayCount);
}
Expand Down Expand Up @@ -239,7 +239,7 @@ public async Task Banner_DisplayedOnFirstRunAndExplicitRequest()
});
var provider = services.BuildServiceProvider();

await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]);

Assert.True(bannerService.WasBannerDisplayed);
Assert.True(sentinel.WasCreated);
Expand All @@ -264,7 +264,7 @@ public async Task Banner_TelemetryNoticeShownOnFirstRun()
});
var provider = services.BuildServiceProvider();

await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: false);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);

var errorOutput = errorWriter.ToString();
Assert.Contains("Telemetry", errorOutput);
Expand All @@ -286,9 +286,62 @@ public async Task Banner_TelemetryNoticeNotShownOnSubsequentRuns()
});
var provider = services.BuildServiceProvider();

await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: false);
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);

var errorOutput = errorWriter.ToString();
Assert.DoesNotContain("Telemetry", errorOutput);
}

[Theory]
[InlineData("--version")]
[InlineData("--help")]
[InlineData("-h")]
[InlineData("-?")]
public async Task InformationalFlag_SuppressesBannerAndDoesNotCreateSentinel(string flag)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false };
var bannerService = new TestBannerService();

var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
options.BannerServiceFactory = _ => bannerService;
});
var provider = services.BuildServiceProvider();

await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [flag]);

// Informational flags set noLogo, which suppresses banner and telemetry notice
Assert.False(bannerService.WasBannerDisplayed);
// Sentinel should NOT be created for informational commands
Assert.False(sentinel.WasCreated);
}

[Fact]
public async Task InformationalFlag_DoesNotCreateSentinel_OnSubsequentFirstRun()
{
// Verifies that running --version on first run doesn't mark first-run as complete,
// so a subsequent normal invocation still shows the first-run experience.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false };
var bannerService = new TestBannerService();

var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
options.BannerServiceFactory = _ => bannerService;
});
var provider = services.BuildServiceProvider();

// First invocation with --version: should not create sentinel
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, ["--version"]);
Assert.False(sentinel.WasCreated);

// Second invocation without informational flag: should create sentinel and show banner
await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);
Assert.True(bannerService.WasBannerDisplayed);
Assert.True(sentinel.WasCreated);
}

}
Loading
Loading