Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
79 changes: 79 additions & 0 deletions src/Aspire.Cli/Commands/UpdateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using Aspire.Cli.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Semver;
using Spectre.Console;

namespace Aspire.Cli.Commands;
Expand Down Expand Up @@ -242,6 +243,12 @@ protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResul
ConfirmBinding = confirmBinding,
NuGetConfigDirBinding = nugetConfigDirBinding
};
var cliUpdateResult = await TryUpdateCliBeforeGuestProjectUpdateAsync(project, projectFile, channel, confirmBinding, parseResult, cancellationToken);
if (cliUpdateResult is not null)
{
return cliUpdateResult;
}

await project.UpdatePackagesAsync(updateContext, cancellationToken);

// After successful project update, check if CLI update is available and prompt
Expand Down Expand Up @@ -312,6 +319,78 @@ protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResul
return CommandResult.FromExitCode(0);
}

private async Task<CommandResult?> TryUpdateCliBeforeGuestProjectUpdateAsync(
IAppHostProject project,
FileInfo projectFile,
PackageChannel channel,
PromptBinding<bool> confirmBinding,
ParseResult parseResult,
CancellationToken cancellationToken)
{
if (_cliDownloader is null ||
string.IsNullOrEmpty(channel.CliDownloadBaseUrl) ||
project.LanguageId.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase) ||
projectFile.Directory is not { } projectDirectory)
{
return null;
}

var targetSdkVersion = await GetLatestGuestSdkVersionAsync(channel, projectDirectory, cancellationToken);
if (targetSdkVersion is null ||
!SemVersion.TryParse(VersionHelper.GetDefaultSdkVersion(), SemVersionStyles.Strict, out var currentCliVersion) ||
SemVersion.PrecedenceComparer.Compare(targetSdkVersion, currentCliVersion) <= 0)
{
return null;
}

var shouldUpdateCli = await InteractionService.PromptConfirmAsync(
UpdateCommandStrings.UpdateCliBeforeGuestProjectUpdatePrompt,
binding: confirmBinding,
cancellationToken: cancellationToken);

if (!shouldUpdateCli)
{
return null;
}

var dotNetToolUpdateCommand = GetDotNetToolUpdateCommand();
if (dotNetToolUpdateCommand is not null)
{
InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.DotNetToolSelfUpdateMessage);
InteractionService.DisplayPlainText($" {dotNetToolUpdateCommand}");
Comment thread
sebastienros marked this conversation as resolved.
InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.ProjectUpdateSkippedAfterCliUpdateMessage);
return CommandResult.Success();
}

var selfUpdateResult = await ExecuteSelfUpdateAsync(parseResult, cancellationToken, channel.Name);
if (selfUpdateResult.ExitCode == ExitCodeConstants.Success)
{
InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.ProjectUpdateSkippedAfterCliUpdateMessage);
}

return selfUpdateResult;
}

private async Task<SemVersion?> GetLatestGuestSdkVersionAsync(PackageChannel channel, DirectoryInfo projectDirectory, CancellationToken cancellationToken)
{
try
{
var sdkPackage = await channel.GetLatestGuestAppHostSdkPackageAsync(projectDirectory, cancellationToken);
return sdkPackage is not null && SemVersion.TryParse(sdkPackage.Version, SemVersionStyles.Strict, out var version)
? version
: null;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check target Aspire SDK version before project update");
return null;
}
}
Comment thread
sebastienros marked this conversation as resolved.

private async Task<CommandResult> ExecuteSelfUpdateAsync(ParseResult parseResult, CancellationToken cancellationToken, string? selectedChannel = null)
{
var channel = selectedChannel ?? parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption);
Expand Down
27 changes: 27 additions & 0 deletions src/Aspire.Cli/Packaging/PackageChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ namespace Aspire.Cli.Packaging;

internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null, ILogger? logger = null)
{
private const string GuestAppHostSdkPackageId = "Aspire.Hosting";

public string Name { get; } = name;
public PackageChannelQuality Quality { get; } = quality;
public PackageMapping[]? Mappings { get; } = mappings;
Expand Down Expand Up @@ -219,6 +221,31 @@ public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(string packageId,
return filteredPackages;
}

public async Task<NuGetPackage?> GetLatestGuestAppHostSdkPackageAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
// Guest AppHost sdk.version resolves to the base Aspire.Hosting package because
// the managed server restores that package to evaluate and generate the AppHost.
var packages = await GetPackagesAsync(GuestAppHostSdkPackageId, workingDirectory, cancellationToken);

NuGetPackage? latestPackage = null;
SemVersion? latestVersion = null;
foreach (var package in packages)
{
if (!SemVersion.TryParse(package.Version, SemVersionStyles.Strict, out var version))
{
continue;
}

if (latestVersion is null || SemVersion.PrecedenceComparer.Compare(version, latestVersion) > 0)
{
latestPackage = package;
latestVersion = version;
}
}

return latestPackage;
}

public async Task<IEnumerable<NuGetPackage>> GetPackageVersionsAsync(string packageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
var tasks = new List<Task<IEnumerable<NuGetPackage>>>();
Expand Down
11 changes: 7 additions & 4 deletions src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,8 @@ private XDocument CreateProjectFile(IEnumerable<IntegrationReference> integratio
/// </summary>
public async Task<(string ProjectPath, string? ChannelName)> CreateProjectFilesAsync(
IEnumerable<IntegrationReference> integrations,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
string? requestedChannel = null)
{
// Clean obj folder to ensure fresh NuGet restore
var objPath = Path.Combine(_projectModelPath, "obj");
Expand Down Expand Up @@ -321,7 +322,8 @@ private XDocument CreateProjectFile(IEnumerable<IntegrationReference> integratio
}

var channels = await _packagingService.GetChannelsAsync(cancellationToken);
var configuredChannelName = AspireConfigFile.Load(_appPath)?.Channel
var configuredChannelName = requestedChannel
?? AspireConfigFile.Load(_appPath)?.Channel
?? AspireJsonConfiguration.Load(_appPath)?.Channel;

// Resolve channel sources and add them via RestoreAdditionalProjectSources
Expand Down Expand Up @@ -417,9 +419,10 @@ private XDocument CreateProjectFile(IEnumerable<IntegrationReference> integratio
public async Task<AppHostServerPrepareResult> PrepareAsync(
string sdkVersion,
IEnumerable<IntegrationReference> integrations,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
string? requestedChannel = null)
{
var (_, channelName) = await CreateProjectFilesAsync(integrations, cancellationToken);
var (_, channelName) = await CreateProjectFilesAsync(integrations, cancellationToken, requestedChannel);
var (buildSuccess, buildOutput) = await BuildAsync(cancellationToken);

if (!buildSuccess)
Expand Down
32 changes: 18 additions & 14 deletions src/Aspire.Cli/Projects/GuestAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,10 @@ private string GetPrepareSdkVersion(AspireConfigFile config)
IAppHostServerProject appHostServerProject,
string sdkVersion,
List<IntegrationReference> integrations,
string? requestedChannel,
CancellationToken cancellationToken)
{
var result = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken);
var result = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken, requestedChannel);
return (result.Success, result.Output, result.ChannelName, result.NeedsCodeGeneration);
}

Expand All @@ -235,15 +236,22 @@ private string GetPrepareSdkVersion(AspireConfigFile config)
/// </summary>
/// <returns><see langword="true"/> if the code was generated successfully; otherwise, <see langword="false"/>.</returns>
internal async Task<bool> BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken)
{
var config = LoadConfiguration(directory);
return await BuildAndGenerateSdkAsync(directory, config, cancellationToken);
}

private async Task<bool> BuildAndGenerateSdkAsync(DirectoryInfo directory, AspireConfigFile config, CancellationToken cancellationToken)
{
var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken);

// Step 1: Load config - source of truth for SDK version and packages
var config = LoadConfiguration(directory);
// Step 1: Use the supplied config as the source of truth. Update uses an
// in-memory config here so a failed generation does not leave
// aspire.config.json pinned to versions the current CLI cannot run.
var integrations = await GetIntegrationReferencesAsync(config, directory, cancellationToken);
var sdkVersion = GetPrepareSdkVersion(config);

var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, cancellationToken);
var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, config.Channel, cancellationToken);
if (!buildSuccess)
{
if (buildOutput is not null)
Expand Down Expand Up @@ -355,7 +363,7 @@ public async Task<int> RunAsync(AppHostProjectContext context, CancellationToken
async () =>
{
// Prepare the AppHost server (build for dev mode, restore for prebuilt)
var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, cancellationToken);
var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, config.Channel, cancellationToken);
if (!prepareSuccess)
{
return (Success: false, Output: prepareOutput, Error: "Failed to prepare app host.", ChannelName: (string?)null, NeedsCodeGen: false);
Expand Down Expand Up @@ -848,7 +856,7 @@ public async Task<int> PublishAsync(PublishContext context, CancellationToken ca
var sdkVersion = GetPrepareSdkVersion(config);

// Prepare the AppHost server (build for dev mode, restore for prebuilt)
var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, cancellationToken);
var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, config.Channel, cancellationToken);
if (!prepareSuccess)
{
// Set OutputCollector so PipelineCommandBase can display errors
Expand Down Expand Up @@ -1148,11 +1156,7 @@ public async Task<UpdatePackagesResult> UpdatePackagesAsync(UpdatePackagesContex
// Check for SDK version update (silently - it's an implementation detail)
try
{
var sdkPackages = await context.Channel.GetPackagesAsync("Aspire.Hosting", directory, cancellationToken);
var latestSdkPackage = sdkPackages
.Where(p => SemVersion.TryParse(p.Version, SemVersionStyles.Strict, out _))
.OrderByDescending(p => SemVersion.Parse(p.Version, SemVersionStyles.Strict), SemVersion.PrecedenceComparer)
.FirstOrDefault();
var latestSdkPackage = await context.Channel.GetLatestGuestAppHostSdkPackageAsync(directory, cancellationToken);

if (latestSdkPackage is not null && latestSdkPackage.Version != config.SdkVersion)
{
Expand Down Expand Up @@ -1237,15 +1241,13 @@ public async Task<UpdatePackagesResult> UpdatePackagesAsync(UpdatePackagesContex
{
config.AddOrUpdatePackage(packageId, newVersion);
}
SaveConfiguration(config, directory);

// Rebuild and regenerate SDK code with updated packages
_interactionService.DisplayEmptyLine();
var regenerateResult = await _interactionService.ShowStatusAsync(
UpdateCommandStrings.RegeneratingSdkCode,
async () =>
{
var regenerateSuccess = await BuildAndGenerateSdkAsync(directory, cancellationToken);
var regenerateSuccess = await BuildAndGenerateSdkAsync(directory, config, cancellationToken);

if (!regenerateSuccess)
{
Expand All @@ -1260,6 +1262,8 @@ public async Task<UpdatePackagesResult> UpdatePackagesAsync(UpdatePackagesContex
return regenerateResult;
}

SaveConfiguration(config, directory);

_interactionService.DisplayMessage(KnownEmojis.Package, UpdateCommandStrings.RegeneratedSdkCode);

_interactionService.DisplayEmptyLine();
Expand Down
4 changes: 3 additions & 1 deletion src/Aspire.Cli/Projects/IAppHostServerProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ internal interface IAppHostServerProject
/// <param name="sdkVersion">The Aspire SDK version to use.</param>
/// <param name="integrations">The integration references (NuGet packages and/or project references) required by the app host.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="requestedChannel">The package channel to use for this prepare operation, or <see langword="null" /> to use the project configuration.</param>
/// <returns>The preparation result indicating success/failure and any output.</returns>
Task<AppHostServerPrepareResult> PrepareAsync(
string sdkVersion,
IEnumerable<IntegrationReference> integrations,
CancellationToken cancellationToken = default);
CancellationToken cancellationToken = default,
string? requestedChannel = null);

/// <summary>
/// Runs the AppHost server process.
Expand Down
6 changes: 3 additions & 3 deletions src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,12 @@ public string GetServerPath()
public async Task<AppHostServerPrepareResult> PrepareAsync(
string sdkVersion,
IEnumerable<IntegrationReference> integrations,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
string? requestedChannel = null)
{
var integrationList = integrations.ToList();
var packageRefs = integrationList.Where(r => r.IsPackageReference).ToList();
var projectRefs = integrationList.Where(r => r.IsProjectReference).ToList();
string? requestedChannel = null;

try
{
Expand All @@ -140,7 +140,7 @@ public async Task<AppHostServerPrepareResult> PrepareAsync(
// Resolve the channel the project requests for restore (aspire.config.json#channel,
// with a legacy .aspire/settings.json#channel fallback). This is independent of the
// running CLI's identity hive (CliExecutionContext.IdentityChannel).
requestedChannel = ResolveRequestedChannel();
requestedChannel ??= ResolveRequestedChannel();

if (projectRefs.Count > 0)
{
Expand Down
2 changes: 2 additions & 0 deletions src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/Aspire.Cli/Resources/UpdateCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@
<data name="UpdateCliAfterProjectUpdatePrompt" xml:space="preserve">
<value>An update is available for the Aspire CLI. Would you like to update it now?</value>
</data>
<data name="UpdateCliBeforeGuestProjectUpdatePrompt" xml:space="preserve">
<value>The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project?</value>
</data>
<data name="ChannelOptionDescription" xml:space="preserve">
<value>Channel to update to (stable, daily)</value>
</data>
Expand All @@ -138,6 +141,9 @@
<data name="DotNetToolSelfUpdateMessage" xml:space="preserve">
<value>To update the Aspire CLI when installed as a .NET tool, run:</value>
</data>
<data name="ProjectUpdateSkippedAfterCliUpdateMessage" xml:space="preserve">
<value>Project update skipped. Update the Aspire CLI, then re-run `aspire update`.</value>
</data>
<data name="MigratedToNewSdkFormat" xml:space="preserve">
<value>Migrated to new project format: &lt;Project Sdk="Aspire.AppHost.Sdk/{0}"&gt;</value>
</data>
Expand Down
Loading
Loading