diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 6103472f6c3..d56f71ef80c 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -479,13 +479,41 @@ 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 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.FirstOrDefault(c => string.Equals(c, identityChannel, StringComparisons.ChannelName)) is { } matchedChannel) + { + channel = matchedChannel; + } + 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 + { + 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..5d11f558b49 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -2511,6 +2511,195 @@ public async Task UpdateCommand_NonInteractive_WithYesAndChannel_SucceedsWithout Assert.NotNull(capturedContext); } + [Theory] + [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); + + 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(expectedChannel, 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_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 = _ => + { + interactionService = new TestInteractionService() + { + PromptForSelectionCallback = (prompt, choices, formatter, ct) => + { + promptForSelectionInvoked = true; + return PackageChannelNames.Stable; + } + }; + return interactionService; + }; + + 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"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + 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] + 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());