diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 2d03b6e6920..bc16361315b 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -19,38 +19,38 @@ namespace Aspire.Cli.Commands; internal sealed class RootCommand : BaseRootCommand { - public static readonly Option DebugOption = new("--debug", "-d") + public static readonly Option DebugOption = new(CommonOptionNames.Debug, CommonOptionNames.DebugShort) { Description = RootCommandStrings.DebugArgumentDescription, Recursive = true }; - public static readonly Option NonInteractiveOption = new("--non-interactive") + public static readonly Option NonInteractiveOption = new(CommonOptionNames.NonInteractive) { Description = "Run the command in non-interactive mode, disabling all interactive prompts and spinners", Recursive = true }; - public static readonly Option NoLogoOption = new("--nologo") + public static readonly Option NoLogoOption = new(CommonOptionNames.NoLogo) { Description = RootCommandStrings.NoLogoArgumentDescription, Recursive = true }; - public static readonly Option BannerOption = new("--banner") + public static readonly Option BannerOption = new(CommonOptionNames.Banner) { Description = RootCommandStrings.BannerArgumentDescription, Recursive = true }; - public static readonly Option WaitForDebuggerOption = new("--wait-for-debugger") + public static readonly Option WaitForDebuggerOption = new(CommonOptionNames.WaitForDebugger) { Description = RootCommandStrings.WaitForDebuggerArgumentDescription, Recursive = true, DefaultValueFactory = _ => false }; - public static readonly Option CliWaitForDebuggerOption = new("--cli-wait-for-debugger") + public static readonly Option CliWaitForDebuggerOption = new(CommonOptionNames.CliWaitForDebugger) { Description = RootCommandStrings.CliWaitForDebuggerArgumentDescription, Recursive = true, diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 3f30e98ae3d..954965a0eb6 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -614,7 +614,7 @@ public void ProcessResourceState(RpcResourceState resourceState, Action private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? passedAppHostProjectFile, bool isExtensionHost, CancellationToken cancellationToken) { - var format = parseResult.GetValue("--format"); + var format = parseResult.GetValue(s_formatOption); // Failure mode 1: Project not found var searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync( diff --git a/src/Aspire.Cli/CommonOptionNames.cs b/src/Aspire.Cli/CommonOptionNames.cs new file mode 100644 index 00000000000..a7a9fdc0c33 --- /dev/null +++ b/src/Aspire.Cli/CommonOptionNames.cs @@ -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; + +/// +/// Common command-line option names used for manual argument checks. +/// +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"; + + /// + /// Options that represent informational commands (e.g. --version, --help) which should + /// opt out of telemetry and suppress first-run experience. + /// + public static readonly string[] InformationalOptionNames = [Version, Help, HelpShort, HelpAlt]; +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 65429a6f1b3..124e8587cc8 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -62,7 +62,7 @@ private static string GetGlobalSettingsPath() internal static async Task BuildApplicationAsync(string[] args, Dictionary? 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 @@ -110,9 +110,9 @@ internal static async Task 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) @@ -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(); + 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(); var isFirstRun = !sentinel.Exists(); @@ -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(); + } } } @@ -442,10 +452,7 @@ public static async Task 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(); - 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(); var invokeConfig = new InvocationConfiguration() diff --git a/src/Aspire.Cli/Properties/launchSettings.json b/src/Aspire.Cli/Properties/launchSettings.json index 3f8c48abb40..76e92970a0e 100644 --- a/src/Aspire.Cli/Properties/launchSettings.json +++ b/src/Aspire.Cli/Properties/launchSettings.json @@ -46,6 +46,11 @@ "commandLineArgs": "deploy", "workingDirectory": "../../playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost" }, + "--version": { + "commandName": "Project", + "dotnetRunMessages": true, + "commandLineArgs": "--version" + }, "new": { "commandName": "Project", "dotnetRunMessages": true, diff --git a/src/Aspire.Cli/Telemetry/TelemetryManager.cs b/src/Aspire.Cli/Telemetry/TelemetryManager.cs index 390cc3811be..8579537af6b 100644 --- a/src/Aspire.Cli/Telemetry/TelemetryManager.cs +++ b/src/Aspire.Cli/Telemetry/TelemetryManager.cs @@ -36,9 +36,12 @@ internal sealed class TelemetryManager /// Initializes a new instance of the class. /// /// The configuration to read telemetry settings from. - public TelemetryManager(IConfiguration configuration) + /// The command-line arguments. + 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]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs index e01500953cb..8a2f19ed57d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs @@ -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; @@ -27,6 +27,7 @@ public async Task Banner_DisplayedOnFirstRun() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); @@ -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 => { diff --git a/tests/Aspire.Cli.Tests/CliSmokeTests.cs b/tests/Aspire.Cli.Tests/CliSmokeTests.cs index 89eacdebae4..a5458d407f6 100644 --- a/tests/Aspire.Cli.Tests/CliSmokeTests.cs +++ b/tests/Aspire.Cli.Tests/CliSmokeTests.cs @@ -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()); + } } diff --git a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs index b17f285f771..c58ec07c433 100644 --- a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs @@ -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); @@ -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); @@ -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); @@ -162,7 +162,7 @@ public async Task FirstTimeUseNotice_BannerNotDisplayedWithNoLogoEnvironmentVari var configuration = provider.GetRequiredService(); 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); @@ -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 @@ -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); } @@ -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); @@ -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); @@ -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); + } + } diff --git a/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs b/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs index 91cbdd14af9..bf57286c91a 100644 --- a/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs +++ b/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs @@ -2,10 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Telemetry; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; #if DEBUG using Microsoft.AspNetCore.InternalTesting; #endif -using Microsoft.Extensions.DependencyInjection; namespace Aspire.Cli.Tests.Telemetry; @@ -18,7 +19,7 @@ public async Task AzureMonitor_Enabled_ByDefault() // should be enabled by default when telemetry is not opted out var config = new Dictionary(); - using var host = await Program.BuildApplicationAsync(["--help"], config); + using var host = await Program.BuildApplicationAsync([], config); var telemetryManager = host.Services.GetService(); Assert.NotNull(telemetryManager); @@ -35,7 +36,7 @@ public async Task AzureMonitor_Disabled_WhenOptOutSetToTrueValues(string optOutV [AspireCliTelemetry.TelemetryOptOutConfigKey] = optOutValue }; - using var host = await Program.BuildApplicationAsync(["--help"], config); + using var host = await Program.BuildApplicationAsync([], config); var telemetryManager = host.Services.GetRequiredService(); // When telemetry is opted out, Azure Monitor should not be enabled @@ -50,7 +51,7 @@ public async Task OtlpExporter_EnabledInDebugOnly_WhenEndpointProvided() [AspireCliTelemetry.OtlpExporterEndpointConfigKey] = "http://localhost:4317" }; - using var host = await Program.BuildApplicationAsync(["--help"], config); + using var host = await Program.BuildApplicationAsync([], config); var telemetryManager = host.Services.GetRequiredService(); @@ -74,7 +75,7 @@ public async Task DiagnosticProvider_IncludesReportedActivitySource() [AspireCliTelemetry.ConsoleExporterLevelConfigKey] = "Diagnostic" }; - using var host = await Program.BuildApplicationAsync(["--help"], config); + using var host = await Program.BuildApplicationAsync([], config); var telemetryManager = host.Services.GetRequiredService(); Assert.True(telemetryManager.HasDiagnosticProvider); @@ -91,4 +92,27 @@ public async Task DiagnosticProvider_IncludesReportedActivitySource() Assert.NotNull(diagnosticActivity); } #endif + + [Fact] + public void AzureMonitor_Disabled_WhenVersionFlagProvided() + { + var configuration = new ConfigurationBuilder().Build(); + + var manager = new TelemetryManager(configuration, ["--version"]); + + Assert.False(manager.HasAzureMonitor); + } + + [Theory] + [InlineData("--help")] + [InlineData("-h")] + [InlineData("-?")] + public void AzureMonitor_Disabled_ForAllHelpFlags(string flag) + { + var configuration = new ConfigurationBuilder().Build(); + + var manager = new TelemetryManager(configuration, [flag]); + + Assert.False(manager.HasAzureMonitor); + } }