From 7f1a6e00c67cce3c4b62be189b046494d3de28ce Mon Sep 17 00:00:00 2001 From: Alireza Afzali Date: Wed, 27 May 2026 01:09:18 +0300 Subject: [PATCH 1/2] Fix non-interactive self-update channel selection --- src/Aspire.Cli/Commands/UpdateCommand.cs | 35 +++- .../Commands/UpdateCommandTests.cs | 178 ++++++++++++++++++ 2 files changed, 206 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 6103472f6c3..0606f8acc5f 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -479,13 +479,34 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult var channels = isStagingEnabled ? new[] { PackageChannelNames.Stable, PackageChannelNames.Staging, PackageChannelNames.Daily } : new[] { PackageChannelNames.Stable, PackageChannelNames.Daily }; - var channelBinding = PromptBinding.Create(parseResult, _channelOption); - channel = await InteractionService.PromptForSelectionAsync( - "Select the channel to update to:", - channels, - q => q, - binding: channelBinding, - cancellationToken: cancellationToken); + + // In non-interactive mode, avoid prompting. Prefer the channel the current CLI + // was resolved from when it maps to an update channel; otherwise fall back to stable. + var nonInteractive = parseResult.GetValue(RootCommand.NonInteractiveOption); + if (nonInteractive) + { + var identityChannel = ExecutionContext.IdentityChannel; + if (!string.IsNullOrWhiteSpace(identityChannel) + && !string.Equals(identityChannel, PackageChannelNames.Local, StringComparisons.ChannelName) + && channels.Any(c => string.Equals(c, identityChannel, StringComparisons.ChannelName))) + { + channel = identityChannel; + } + else + { + channel = PackageChannelNames.Stable; + } + } + else + { + var channelBinding = PromptBinding.Create(parseResult, _channelOption); + channel = await InteractionService.PromptForSelectionAsync( + "Select the channel to update to:", + channels, + q => q, + binding: channelBinding, + cancellationToken: cancellationToken); + } } try diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 1ca9bb6be7c..ff33d16a8fa 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -2511,6 +2511,184 @@ public async Task UpdateCommand_NonInteractive_WithYesAndChannel_SucceedsWithout Assert.NotNull(capturedContext); } + [Theory] + [InlineData("daily")] + [InlineData("stable")] + public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelMatchesKnownChannel_UsesItWithoutPrompting(string identityChannel) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var promptForSelectionInvoked = false; + string? capturedChannel = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: identityChannel); + + options.InteractionServiceFactory = _ => new TestInteractionService() + { + PromptForSelectionCallback = (prompt, choices, formatter, ct) => + { + promptForSelectionInvoked = true; + return PackageChannelNames.Stable; + } + }; + + options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot) + { + DownloadLatestCliAsyncCallback = (channel, ct) => + { + capturedChannel = channel; + var archivePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test-cli.tar.gz"); + File.WriteAllText(archivePath, "fake archive"); + return Task.FromResult(archivePath); + } + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("update --self --non-interactive -y"); + + await result.InvokeAsync().DefaultTimeout(); + + Assert.False(promptForSelectionInvoked, "Identity-channel match should bypass the channel prompt."); + Assert.Equal(identityChannel, capturedChannel); + } + + [Fact] + public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelIsLocal_DefaultsToStable() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var promptForSelectionInvoked = false; + string? capturedChannel = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Local); + + options.InteractionServiceFactory = _ => new TestInteractionService() + { + PromptForSelectionCallback = (prompt, choices, formatter, ct) => + { + promptForSelectionInvoked = true; + return PackageChannelNames.Stable; + } + }; + + options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot) + { + DownloadLatestCliAsyncCallback = (channel, ct) => + { + capturedChannel = channel; + var archivePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test-cli.tar.gz"); + File.WriteAllText(archivePath, "fake archive"); + return Task.FromResult(archivePath); + } + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("update --self --non-interactive -y"); + + await result.InvokeAsync().DefaultTimeout(); + + Assert.False(promptForSelectionInvoked, "Non-interactive mode should not prompt; should default to stable."); + Assert.Equal(PackageChannelNames.Stable, capturedChannel); + } + + [Fact] + public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelIsStalePr_DefaultsToStable() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var promptForSelectionInvoked = false; + string? capturedChannel = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: "pr-99999"); + + options.InteractionServiceFactory = _ => new TestInteractionService() + { + PromptForSelectionCallback = (prompt, choices, formatter, ct) => + { + promptForSelectionInvoked = true; + return PackageChannelNames.Stable; + } + }; + + options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot) + { + DownloadLatestCliAsyncCallback = (channel, ct) => + { + capturedChannel = channel; + var archivePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test-cli.tar.gz"); + File.WriteAllText(archivePath, "fake archive"); + return Task.FromResult(archivePath); + } + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("update --self --non-interactive -y"); + + await result.InvokeAsync().DefaultTimeout(); + + Assert.False(promptForSelectionInvoked, "Non-interactive mode should not prompt; should default to stable."); + Assert.Equal(PackageChannelNames.Stable, capturedChannel); + } + + [Fact] + public async Task UpdateCommand_SelfUpdate_ExplicitChannelOverridesIdentityChannel() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var promptForSelectionInvoked = false; + string? capturedChannel = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Daily); + + options.InteractionServiceFactory = _ => new TestInteractionService() + { + PromptForSelectionCallback = (prompt, choices, formatter, ct) => + { + promptForSelectionInvoked = true; + return PackageChannelNames.Stable; + } + }; + + options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot) + { + DownloadLatestCliAsyncCallback = (channel, ct) => + { + capturedChannel = channel; + var archivePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test-cli.tar.gz"); + File.WriteAllText(archivePath, "fake archive"); + return Task.FromResult(archivePath); + } + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("update --self --non-interactive --channel stable -y"); + + await result.InvokeAsync().DefaultTimeout(); + + Assert.False(promptForSelectionInvoked, "Explicit --channel should bypass the prompt."); + Assert.Equal(PackageChannelNames.Stable, capturedChannel); + } + private static string CreateCustomToolPathInstall(string toolPath) { var processPath = Path.Combine(toolPath, GetAspireExecutableName()); From 74b5846da33c4ee7868816b98be27c93400bd60f Mon Sep 17 00:00:00 2001 From: Alireza Afzali Date: Wed, 27 May 2026 11:23:29 +0300 Subject: [PATCH 2/2] Address review feedback for non-interactive self-update channel resolution. Require --channel when identity does not map to a known update channel (e.g. stale pr-*), keep local defaulting to stable, and assign canonical channel names from the channels list for case-insensitive identity matches. --- src/Aspire.Cli/Commands/UpdateCommand.cs | 17 ++++++--- .../Commands/UpdateCommandTests.cs | 37 ++++++++++++------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 0606f8acc5f..d56f71ef80c 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -480,22 +480,29 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult ? new[] { PackageChannelNames.Stable, PackageChannelNames.Staging, PackageChannelNames.Daily } : new[] { PackageChannelNames.Stable, PackageChannelNames.Daily }; - // In non-interactive mode, avoid prompting. Prefer the channel the current CLI - // was resolved from when it maps to an update channel; otherwise fall back to stable. + // In non-interactive mode, avoid prompting. Prefer the CLI identity channel when it + // maps to an update channel; use stable for local dev builds; otherwise require --channel. var nonInteractive = parseResult.GetValue(RootCommand.NonInteractiveOption); if (nonInteractive) { var identityChannel = ExecutionContext.IdentityChannel; if (!string.IsNullOrWhiteSpace(identityChannel) && !string.Equals(identityChannel, PackageChannelNames.Local, StringComparisons.ChannelName) - && channels.Any(c => string.Equals(c, identityChannel, StringComparisons.ChannelName))) + && channels.FirstOrDefault(c => string.Equals(c, identityChannel, StringComparisons.ChannelName)) is { } matchedChannel) { - channel = identityChannel; + channel = matchedChannel; } - else + else if (string.Equals(identityChannel, PackageChannelNames.Local, StringComparisons.ChannelName)) { channel = PackageChannelNames.Stable; } + else + { + var channelOptionDisplayName = $"'{_channelOption.Name}'"; + InteractionService.DisplayError( + string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NonInteractiveOptionRequired, channelOptionDisplayName)); + throw new NonInteractiveException(channelOptionDisplayName); + } } else { diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index ff33d16a8fa..5d11f558b49 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -2512,9 +2512,10 @@ public async Task UpdateCommand_NonInteractive_WithYesAndChannel_SucceedsWithout } [Theory] - [InlineData("daily")] - [InlineData("stable")] - public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelMatchesKnownChannel_UsesItWithoutPrompting(string identityChannel) + [InlineData("daily", "daily")] + [InlineData("stable", "stable")] + [InlineData("DAILY", "daily")] // case-insensitive match; canonical name from channels + public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelMatchesKnownChannel_UsesItWithoutPrompting(string identityChannel, string expectedChannel) { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -2554,7 +2555,7 @@ public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelMat await result.InvokeAsync().DefaultTimeout(); Assert.False(promptForSelectionInvoked, "Identity-channel match should bypass the channel prompt."); - Assert.Equal(identityChannel, capturedChannel); + Assert.Equal(expectedChannel, capturedChannel); } [Fact] @@ -2602,24 +2603,29 @@ public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelIsL } [Fact] - public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelIsStalePr_DefaultsToStable() + public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelIsStalePr_RequiresExplicitChannel() { using var workspace = TemporaryWorkspace.Create(outputHelper); var promptForSelectionInvoked = false; string? capturedChannel = null; + TestInteractionService? interactionService = null; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: "pr-99999"); - options.InteractionServiceFactory = _ => new TestInteractionService() + options.InteractionServiceFactory = _ => { - PromptForSelectionCallback = (prompt, choices, formatter, ct) => + interactionService = new TestInteractionService() { - promptForSelectionInvoked = true; - return PackageChannelNames.Stable; - } + PromptForSelectionCallback = (prompt, choices, formatter, ct) => + { + promptForSelectionInvoked = true; + return PackageChannelNames.Stable; + } + }; + return interactionService; }; options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot) @@ -2639,10 +2645,15 @@ public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelIsS var command = provider.GetRequiredService(); var result = command.Parse("update --self --non-interactive -y"); - await result.InvokeAsync().DefaultTimeout(); + var exitCode = await result.InvokeAsync().DefaultTimeout(); - Assert.False(promptForSelectionInvoked, "Non-interactive mode should not prompt; should default to stable."); - Assert.Equal(PackageChannelNames.Stable, capturedChannel); + Assert.Equal(CliExitCodes.MissingRequiredArgument, exitCode); + Assert.False(promptForSelectionInvoked, "Non-interactive mode should not prompt when channel cannot be resolved."); + Assert.Null(capturedChannel); + Assert.NotNull(interactionService); + Assert.Contains( + interactionService.DisplayedErrors, + e => e.Contains("--channel", StringComparison.Ordinal) && e.Contains("non-interactive", StringComparison.OrdinalIgnoreCase)); } [Fact]