Skip to content
Open
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
42 changes: 35 additions & 7 deletions src/Aspire.Cli/Commands/UpdateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -479,13 +479,41 @@ private async Task<CommandResult> 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;
}
Comment on lines +489 to +494
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Updated ExecuteSelfUpdateAsync to use channels.FirstOrDefault(...) and assign matchedChannel so the channel string is normalized, consistent with the project-update identity path. Added [InlineData("DAILY", "daily")] to the non-interactive self-update tests.

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
Expand Down
189 changes: 189 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RootCommand>();
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);
}
};
});
Comment on lines +2569 to +2592
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matches existing self-update tests (TracksChannelParameter, etc.). I can add RunSelfUpdateAndCaptureChannelAsync in a follow-up, or in this PR if maintainers want that refactor here.


using var provider = services.BuildServiceProvider();

var command = provider.GetRequiredService<RootCommand>();
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<RootCommand>();
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<RootCommand>();
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());
Expand Down
Loading