Skip to content

Commit 9549c5c

Browse files
Fix non-interactive self-update channel selection (#17512)
Co-authored-by: James Newton-King <james@newtonking.com>
1 parent 4d4e5f2 commit 9549c5c

2 files changed

Lines changed: 150 additions & 7 deletions

File tree

src/Aspire.Cli/Commands/UpdateCommand.cs

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -510,13 +510,41 @@ private async Task<CommandResult> ExecuteSelfUpdateAsync(ParseResult parseResult
510510
var channels = isStagingEnabled
511511
? new[] { PackageChannelNames.Stable, PackageChannelNames.Staging, PackageChannelNames.Daily }
512512
: new[] { PackageChannelNames.Stable, PackageChannelNames.Daily };
513-
var channelBinding = PromptBinding.Create(parseResult, _channelOption);
514-
channel = await InteractionService.PromptForSelectionAsync(
515-
"Select the channel to update to:",
516-
channels,
517-
q => q,
518-
binding: channelBinding,
519-
cancellationToken: cancellationToken);
513+
514+
// In non-interactive mode, avoid prompting. Prefer the CLI identity channel when it
515+
// maps to an update channel; use stable for local dev builds; otherwise require --channel.
516+
var nonInteractive = parseResult.GetValue(RootCommand.NonInteractiveOption);
517+
if (nonInteractive)
518+
{
519+
var identityChannel = ExecutionContext.IdentityChannel;
520+
if (!string.IsNullOrWhiteSpace(identityChannel)
521+
&& !string.Equals(identityChannel, PackageChannelNames.Local, StringComparisons.ChannelName)
522+
&& channels.FirstOrDefault(c => string.Equals(c, identityChannel, StringComparisons.ChannelName)) is { } matchedChannel)
523+
{
524+
channel = matchedChannel;
525+
}
526+
else if (string.Equals(identityChannel, PackageChannelNames.Local, StringComparisons.ChannelName))
527+
{
528+
channel = PackageChannelNames.Stable;
529+
}
530+
else
531+
{
532+
var channelOptionDisplayName = $"'{_channelOption.Name}'";
533+
InteractionService.DisplayError(
534+
string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NonInteractiveOptionRequired, channelOptionDisplayName));
535+
throw new NonInteractiveException(channelOptionDisplayName);
536+
}
537+
}
538+
else
539+
{
540+
var channelBinding = PromptBinding.Create(parseResult, _channelOption);
541+
channel = await InteractionService.PromptForSelectionAsync(
542+
"Select the channel to update to:",
543+
channels,
544+
q => q,
545+
binding: channelBinding,
546+
cancellationToken: cancellationToken);
547+
}
520548
}
521549

522550
try

tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2632,6 +2632,121 @@ public async Task UpdateCommand_NonInteractive_WithYesAndChannel_SucceedsWithout
26322632
Assert.NotNull(capturedContext);
26332633
}
26342634

2635+
[Theory]
2636+
[InlineData("daily", "daily")]
2637+
[InlineData("stable", "stable")]
2638+
[InlineData("DAILY", "daily")] // case-insensitive match; canonical name from channels
2639+
public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelMatchesKnownChannel_UsesItWithoutPrompting(string identityChannel, string expectedChannel)
2640+
{
2641+
using var workspace = TemporaryWorkspace.Create(outputHelper);
2642+
2643+
var (_, capturedChannel, promptInvoked, _) = await RunNonInteractiveSelfUpdateAsync(
2644+
workspace, identityChannel: identityChannel);
2645+
2646+
Assert.False(promptInvoked, "Identity-channel match should bypass the channel prompt.");
2647+
Assert.Equal(expectedChannel, capturedChannel);
2648+
}
2649+
2650+
[Fact]
2651+
public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelIsLocal_DefaultsToStable()
2652+
{
2653+
using var workspace = TemporaryWorkspace.Create(outputHelper);
2654+
2655+
var (_, capturedChannel, promptInvoked, _) = await RunNonInteractiveSelfUpdateAsync(
2656+
workspace, identityChannel: PackageChannelNames.Local);
2657+
2658+
Assert.False(promptInvoked, "Non-interactive mode should not prompt; should default to stable.");
2659+
Assert.Equal(PackageChannelNames.Stable, capturedChannel);
2660+
}
2661+
2662+
[Fact]
2663+
public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelIsStalePr_RequiresExplicitChannel()
2664+
{
2665+
using var workspace = TemporaryWorkspace.Create(outputHelper);
2666+
2667+
var (exitCode, capturedChannel, promptInvoked, interactionService) = await RunNonInteractiveSelfUpdateAsync(
2668+
workspace, identityChannel: "pr-99999");
2669+
2670+
Assert.Equal(CliExitCodes.MissingRequiredArgument, exitCode);
2671+
Assert.False(promptInvoked, "Non-interactive mode should not prompt when channel cannot be resolved.");
2672+
Assert.Null(capturedChannel);
2673+
Assert.Contains(
2674+
interactionService.DisplayedErrors,
2675+
e => e.Contains("--channel", StringComparison.Ordinal) && e.Contains("non-interactive", StringComparison.OrdinalIgnoreCase));
2676+
}
2677+
2678+
[Fact]
2679+
public async Task UpdateCommand_SelfUpdate_ExplicitChannelOverridesIdentityChannel()
2680+
{
2681+
using var workspace = TemporaryWorkspace.Create(outputHelper);
2682+
2683+
var (_, capturedChannel, promptInvoked, _) = await RunNonInteractiveSelfUpdateAsync(
2684+
workspace, identityChannel: PackageChannelNames.Daily, updateArgs: "update --self --non-interactive --channel stable -y");
2685+
2686+
Assert.False(promptInvoked, "Explicit --channel should bypass the prompt.");
2687+
Assert.Equal(PackageChannelNames.Stable, capturedChannel);
2688+
}
2689+
2690+
[Fact]
2691+
public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelIsStalePr_ExplicitChannelSucceeds()
2692+
{
2693+
using var workspace = TemporaryWorkspace.Create(outputHelper);
2694+
2695+
var (_, capturedChannel, promptInvoked, _) = await RunNonInteractiveSelfUpdateAsync(
2696+
workspace, identityChannel: "pr-99999", updateArgs: "update --self --non-interactive --channel daily -y");
2697+
2698+
Assert.False(promptInvoked, "Explicit --channel should bypass the prompt even with stale identity.");
2699+
Assert.Equal(PackageChannelNames.Daily, capturedChannel);
2700+
}
2701+
2702+
private async Task<(int ExitCode, string? CapturedChannel, bool PromptInvoked, TestInteractionService InteractionService)> RunNonInteractiveSelfUpdateAsync(
2703+
TemporaryWorkspace workspace,
2704+
string identityChannel,
2705+
string updateArgs = "update --self --non-interactive -y")
2706+
{
2707+
var promptForSelectionInvoked = false;
2708+
string? capturedChannel = null;
2709+
TestInteractionService? interactionService = null;
2710+
2711+
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
2712+
{
2713+
options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: identityChannel);
2714+
2715+
options.InteractionServiceFactory = _ =>
2716+
{
2717+
interactionService = new TestInteractionService()
2718+
{
2719+
PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
2720+
{
2721+
promptForSelectionInvoked = true;
2722+
return PackageChannelNames.Stable;
2723+
}
2724+
};
2725+
return interactionService;
2726+
};
2727+
2728+
options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
2729+
{
2730+
DownloadLatestCliAsyncCallback = (channel, ct) =>
2731+
{
2732+
capturedChannel = channel;
2733+
var archivePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test-cli.tar.gz");
2734+
File.WriteAllText(archivePath, "fake archive");
2735+
return Task.FromResult(archivePath);
2736+
}
2737+
};
2738+
});
2739+
2740+
using var provider = services.BuildServiceProvider();
2741+
2742+
var command = provider.GetRequiredService<RootCommand>();
2743+
var result = command.Parse(updateArgs);
2744+
2745+
var exitCode = await result.InvokeAsync().DefaultTimeout();
2746+
2747+
return (exitCode, capturedChannel, promptForSelectionInvoked, interactionService!);
2748+
}
2749+
26352750
private static string CreateCustomToolPathInstall(string toolPath)
26362751
{
26372752
var processPath = Path.Combine(toolPath, GetAspireExecutableName());

0 commit comments

Comments
 (0)