diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 2f1fcbc324f..4fdd306ae0c 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -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; @@ -312,6 +313,12 @@ protected override async Task 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 @@ -382,6 +389,78 @@ protected override async Task ExecuteAsync(ParseResult parseResul return CommandResult.FromExitCode(0); } + private async Task TryUpdateCliBeforeGuestProjectUpdateAsync( + IAppHostProject project, + FileInfo projectFile, + PackageChannel channel, + PromptBinding 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}"); + InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.ProjectUpdateSkippedAfterCliUpdateMessage); + return CommandResult.Success(); + } + + var selfUpdateResult = await ExecuteSelfUpdateAsync(parseResult, cancellationToken, channel.Name); + if (selfUpdateResult.ExitCode == CliExitCodes.Success) + { + InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.ProjectUpdateSkippedAfterCliUpdateMessage); + } + + return selfUpdateResult; + } + + private async Task 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; + } + } + private bool IsStagingChannelAvailable() { return KnownFeatures.IsStagingChannelEnabled(_features, _configuration) diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index e2e6e3b3320..0cce5207aa4 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -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; @@ -219,6 +221,31 @@ public async Task> GetPackagesAsync(string packageId, return filteredPackages; } + public async Task 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> GetPackageVersionsAsync(string packageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken) { var tasks = new List>>(); diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 3541614949f..4c3d7c7d961 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -259,7 +259,8 @@ private XDocument CreateProjectFile(IEnumerable integratio /// public async Task<(string ProjectPath, string? ChannelName)> CreateProjectFilesAsync( IEnumerable integrations, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? requestedChannel = null) { // Clean obj folder to ensure fresh NuGet restore var objPath = Path.Combine(_projectModelPath, "obj"); @@ -325,7 +326,8 @@ private XDocument CreateProjectFile(IEnumerable integratio File.Copy(userNugetConfig, nugetConfigPath, overwrite: true); } - var configuredChannelName = AspireConfigFile.Load(_appPath)?.Channel + var configuredChannelName = requestedChannel + ?? AspireConfigFile.Load(_appPath)?.Channel ?? AspireJsonConfiguration.Load(_appPath)?.Channel; var channels = await _packagingService.GetChannelsAsync(cancellationToken, configuredChannelName); @@ -422,9 +424,10 @@ private XDocument CreateProjectFile(IEnumerable integratio public async Task PrepareAsync( string sdkVersion, IEnumerable 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) diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 637c07ec806..ee2829d9a32 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -224,9 +224,10 @@ private string GetPrepareSdkVersion(AspireConfigFile config) IAppHostServerProject appHostServerProject, string sdkVersion, List 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); } @@ -235,15 +236,22 @@ private string GetPrepareSdkVersion(AspireConfigFile config) /// /// if the code was generated successfully; otherwise, . internal async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken) + { + var config = LoadConfiguration(directory); + return await BuildAndGenerateSdkAsync(directory, config, cancellationToken); + } + + private async Task 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) @@ -356,7 +364,7 @@ public async Task 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); @@ -880,7 +888,7 @@ public async Task 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 @@ -1190,10 +1198,16 @@ public async Task AddPackageAsync(AddPackageContext context, CancellationT // Update configuration with the new package config.AddOrUpdatePackage(context.PackageId, context.PackageVersion); - SaveConfiguration(config, directory); // Build and regenerate SDK code with the new package - return await BuildAndGenerateSdkAsync(directory, cancellationToken); + var regenerateSuccess = await BuildAndGenerateSdkAsync(directory, config, cancellationToken); + if (!regenerateSuccess) + { + return false; + } + + SaveConfiguration(config, directory); + return true; } /// @@ -1219,11 +1233,7 @@ public async Task 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) { @@ -1314,15 +1324,13 @@ public async Task 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) { @@ -1337,6 +1345,8 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex return regenerateResult; } + SaveConfiguration(config, directory); + _interactionService.DisplayMessage(KnownEmojis.Package, UpdateCommandStrings.RegeneratedSdkCode); _interactionService.DisplayEmptyLine(); diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index a5fc0730826..a5e81a871df 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -41,11 +41,13 @@ internal interface IAppHostServerProject /// The Aspire SDK version to use. /// The integration references (NuGet packages and/or project references) required by the app host. /// Cancellation token. + /// The package channel to use for this prepare operation, or to use the project configuration. /// The preparation result indicating success/failure and any output. Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + string? requestedChannel = null); /// /// Runs the AppHost server process. diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 79e44fc9fd5..a082f06acc0 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -123,12 +123,12 @@ public string GetServerPath() public async Task PrepareAsync( string sdkVersion, IEnumerable 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 { @@ -140,7 +140,7 @@ public async Task 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) { diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs index 93207cc6173..8a08ecd0103 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs @@ -102,11 +102,13 @@ internal static string ProjectArgumentDescription { internal static string FallbackParsingWarning => ResourceManager.GetString("FallbackParsingWarning", resourceCulture); internal static string NoAppHostFoundUpdateCliPrompt => ResourceManager.GetString("NoAppHostFoundUpdateCliPrompt", resourceCulture); internal static string UpdateCliAfterProjectUpdatePrompt => ResourceManager.GetString("UpdateCliAfterProjectUpdatePrompt", resourceCulture); + internal static string UpdateCliBeforeGuestProjectUpdatePrompt => ResourceManager.GetString("UpdateCliBeforeGuestProjectUpdatePrompt", resourceCulture); internal static string ChannelOptionDescription => ResourceManager.GetString("ChannelOptionDescription", resourceCulture); internal static string ChannelOptionDescriptionWithStaging => ResourceManager.GetString("ChannelOptionDescriptionWithStaging", resourceCulture); internal static string QualityOptionDescription => ResourceManager.GetString("QualityOptionDescription", resourceCulture); internal static string QualityOptionDescriptionWithStaging => ResourceManager.GetString("QualityOptionDescriptionWithStaging", resourceCulture); internal static string DotNetToolSelfUpdateMessage => ResourceManager.GetString("DotNetToolSelfUpdateMessage", resourceCulture); + internal static string ProjectUpdateSkippedAfterCliUpdateMessage => ResourceManager.GetString("ProjectUpdateSkippedAfterCliUpdateMessage", resourceCulture); internal static string MigratedToNewSdkFormat => ResourceManager.GetString("MigratedToNewSdkFormat", resourceCulture); internal static string RemovedObsoleteAppHostPackage => ResourceManager.GetString("RemovedObsoleteAppHostPackage", resourceCulture); internal static string NoWritePermissionToInstallDirectory => ResourceManager.GetString("NoWritePermissionToInstallDirectory", resourceCulture); diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx index 2329e5a85a4..bb501cdadc1 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx @@ -123,6 +123,9 @@ An update is available for the Aspire CLI. Would you like to update it now? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + Channel to update to (stable, daily) @@ -138,6 +141,9 @@ To update the Aspire CLI when installed as a .NET tool, run: + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Migrated to new project format: <Project Sdk="Aspire.AppHost.Sdk/{0}"> diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf index 8a67bad785b..def7570fdb1 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf @@ -207,6 +207,11 @@ Projekt je aktuální! (nejsou potřeba žádné aktualizace) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) Úroveň kvality, na kterou se má aktualizovat (stabilní, denní) @@ -267,6 +272,11 @@ Pro Aspire CLI je k dispozici aktualizace. Chcete aktualizaci provést nyní? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} Aktualizovat balíček {0} z {1} na {2} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf index 061cd606b25..539a13c0821 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf @@ -207,6 +207,11 @@ Das Projekt ist auf dem neuesten Stand. (keine Updates erforderlich) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) Qualitätsstufe, auf die aktualisiert werden soll (stable, daily) @@ -267,6 +272,11 @@ Für die Aspire-CLI ist ein Update verfügbar. Möchten Sie es jetzt installieren? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} Paket {0} von {1} auf {2} aktualisieren diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf index 4d0956fdb7a..940b96a556e 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf @@ -207,6 +207,11 @@ ¡El proyecto está al día! (no es necesario actualizar) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) Nivel de calidad a actualizar a (estable, diario) @@ -267,6 +272,11 @@ Hay una actualización disponible para la CLI de Aspire. ¿Quieres actualizarla ahora? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} Actualizar paquete {0} de {1} a {2} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf index 91267ce4020..84ae03bf882 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf @@ -207,6 +207,11 @@ Le projet est à jour ! (aucune mise à jour nécessaire) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) Niveau de qualité vers lequel effectuer une mise à jour (stable, quotidien) @@ -267,6 +272,11 @@ Une mise à jour est disponible pour l’interface CLI Aspire. Voulez-vous la mettre à jour maintenant ? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} Mettre à jour le package {0} de {1} à {2} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf index adff171d6fd..05ded928e67 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf @@ -207,6 +207,11 @@ Il progetto è aggiornato. (non sono necessari aggiornamenti) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) Livello di qualità da aggiornare a (stabile, giornaliero) @@ -267,6 +272,11 @@ È disponibile un aggiornamento per l'interfaccia della riga di comando Aspire. Aggiornarla ora? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} Aggiorna il pacchetto {0} da {1} a {2} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf index f962c19dad5..150654e3253 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf @@ -207,6 +207,11 @@ プロジェクトは最新の状態です!(更新は必要ありません) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) 更新先の品質レベル (安定、毎日) @@ -267,6 +272,11 @@ Aspire CLI の更新プログラムが利用可能です。今すぐ更新しますか? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} パッケージ {0} を {1} から {2} に更新する diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf index b151ff2c3d0..c27ec7f92e2 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf @@ -207,6 +207,11 @@ 프로젝트가 최신 상태입니다! (업데이트 필요 없음) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) 업데이트할 품질 수준(안정, 데일리) @@ -267,6 +272,11 @@ Aspire CLI 업데이트가 있습니다. 지금 업데이트하시겠어요? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} 패키지 {0}을(를) {1}에서 {2}(으)로 업데이트 diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf index ce41cc99244..1ef6a68136e 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf @@ -207,6 +207,11 @@ Projekt jest aktualny. (nie są wymagane żadne aktualizacje) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) Poziom jakości, do którego należy zaktualizować (stabilny, dzienny) @@ -267,6 +272,11 @@ Dostępna jest aktualizacja narzędzia Aspire CLI. Czy chcesz teraz zaktualizować? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} Aktualizacja {0} z {1} do {2} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf index 0f2fb277160..d808bf6c77c 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf @@ -207,6 +207,11 @@ O Project foi atualizado! (nenhuma atualização necessária) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) Nível de qualidade para o qual atualizar (estável, diariamente) @@ -267,6 +272,11 @@ Há uma atualização disponível para a CLI do Aspire. Você quer atualizá-la agora? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} Atualizar o pacote {0} de {1} para {2} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf index 1084ea024b9..c050aa41ba6 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf @@ -207,6 +207,11 @@ Проект обновлен! (обновления не требуются) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) Уровень качества для обновления (стабильный, ежедневно) @@ -267,6 +272,11 @@ Доступно обновление для Aspire CLI. Хотите обновить его сейчас? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} Обновить пакет {0} с{1} до {2} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf index c3a644be0e5..4edb515403c 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf @@ -207,6 +207,11 @@ Proje güncel! (güncelleştirme gerekmiyor) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) Güncelleştirilecek kalite seviyesi (kararlı, günlük) @@ -267,6 +272,11 @@ Aspire CLI için bir güncelleştirme var. Şimdi güncelleştirmek istiyor musunuz? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} {1} olan {0} paketini {2} olarak güncelleyin diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf index 8ebb4e826df..dd7d9a7f972 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf @@ -207,6 +207,11 @@ 项目已是最新!(无需更新) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) 要更新到的质量级别(稳定、每日) @@ -267,6 +272,11 @@ Aspire CLI 有可用更新。是否现在更新? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} 将包 {0} 从 {1} 更新为 {2} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf index cdee5937beb..28ef1af6893 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf @@ -207,6 +207,11 @@ 專案為最新!(不需要更新) + + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. + + Quality level to update to (stable, daily) 要更新的品質等級 (穩定、每日) @@ -267,6 +272,11 @@ 更新可用於 Aspire CLI。您現在要更新嗎? + + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + The selected Aspire SDK is newer than this Aspire CLI. Update the Aspire CLI now and re-run `aspire update` to update this project? + + Update package {0} from {1} to {2} 將套件 {0} 從 {1} 更新到 {2} diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 3997b86c3d3..3093088d36b 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -308,6 +308,178 @@ public async Task UpdateCommand_WhenProjectUpdatedSuccessfully_AndChannelSupport Assert.Equal(CliExitCodes.Success, exitCode); } + [Fact] + public async Task UpdateCommand_GuestProject_WhenTargetSdkNewerThanCli_PromptsForCliUpdateBeforeProjectUpdateAndSkipsWhenAccepted() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var processPathScope = DotNetToolDetection.UseProcessPathForTesting("/home/test/.dotnet/tools/.store/aspire.cli/9.4.0/aspire.cli.linux-x64/9.4.0/tools/net10.0/linux-x64/aspire"); + var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"); + File.WriteAllText(appHostPath, "// test apphost"); + + var updateProjectInvoked = false; + string? confirmPrompt = null; + var interactionService = new TestInteractionService + { + ConfirmCallback = (prompt, _) => + { + confirmPrompt = prompt; + return true; + } + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = _ => new TestProjectLocator() + { + UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult(new FileInfo(appHostPath)) + }; + + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + { + CanHandleCallback = _ => true, + LanguageId = "typescript/nodejs", + DisplayName = "TypeScript (Node.js)", + DetectionPatterns = ["apphost.ts"], + UpdatePackagesAsyncCallback = (_, _) => + { + updateProjectInvoked = true; + return Task.FromResult(new UpdatePackagesResult { UpdatesApplied = true }); + } + }; + + options.InteractionServiceFactory = _ => interactionService; + options.PackagingServiceFactory = _ => new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>( + [CreatePackageChannelWithGuestSdkVersion("99.0.0", cliDownloadBaseUrl: "https://example.test/aspire")]) + }; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("update --apphost apphost.ts"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.False(updateProjectInvoked); + Assert.NotNull(confirmPrompt); + Assert.Contains("newer than this Aspire CLI", confirmPrompt); + Assert.Contains("re-run `aspire update`", confirmPrompt); + Assert.Contains(interactionService.DisplayedPlainText, text => text.Contains("dotnet tool update -g Aspire.Cli", StringComparison.Ordinal)); + Assert.Contains(interactionService.DisplayedMessages, message => message.Message.Contains("Project update skipped", StringComparison.Ordinal)); + } + + [Fact] + public async Task UpdateCommand_GuestProject_WhenTargetSdkNewerThanCliAndCliUpdateDeclined_ContinuesProjectUpdate() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"); + File.WriteAllText(appHostPath, "// test apphost"); + + var updateProjectInvoked = false; + var confirmCallbackInvoked = false; + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = _ => new TestProjectLocator() + { + UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult(new FileInfo(appHostPath)) + }; + + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + { + CanHandleCallback = _ => true, + LanguageId = "typescript/nodejs", + DisplayName = "TypeScript (Node.js)", + DetectionPatterns = ["apphost.ts"], + UpdatePackagesAsyncCallback = (_, _) => + { + updateProjectInvoked = true; + return Task.FromResult(new UpdatePackagesResult { UpdatesApplied = true }); + } + }; + + options.InteractionServiceFactory = _ => new TestInteractionService + { + ConfirmCallback = (_, _) => + { + confirmCallbackInvoked = true; + return false; + } + }; + options.PackagingServiceFactory = _ => new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>( + [CreatePackageChannelWithGuestSdkVersion("99.0.0", cliDownloadBaseUrl: "https://example.test/aspire")]) + }; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("update --apphost apphost.ts"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.True(confirmCallbackInvoked); + Assert.True(updateProjectInvoked); + } + + [Fact] + public async Task UpdateCommand_GuestProject_WhenChannelCannotDownloadCli_DoesNotPromptBeforeProjectUpdate() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"); + File.WriteAllText(appHostPath, "// test apphost"); + + var updateProjectInvoked = false; + var confirmCallbackInvoked = false; + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = _ => new TestProjectLocator() + { + UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult(new FileInfo(appHostPath)) + }; + + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + { + CanHandleCallback = _ => true, + LanguageId = "typescript/nodejs", + DisplayName = "TypeScript (Node.js)", + DetectionPatterns = ["apphost.ts"], + UpdatePackagesAsyncCallback = (_, _) => + { + updateProjectInvoked = true; + return Task.FromResult(new UpdatePackagesResult { UpdatesApplied = true }); + } + }; + + options.InteractionServiceFactory = _ => new TestInteractionService + { + ConfirmCallback = (_, _) => + { + confirmCallbackInvoked = true; + return false; + } + }; + options.PackagingServiceFactory = _ => new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>( + [CreatePackageChannelWithGuestSdkVersion("99.0.0", cliDownloadBaseUrl: null)]) + }; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("update --apphost apphost.ts"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.False(confirmCallbackInvoked); + Assert.True(updateProjectInvoked); + } + [Fact] public async Task UpdateCommand_WhenProjectUpdatedSuccessfullyAndRunningAsDotnetTool_DisplaysDotnetToolUpdateCommand() { @@ -2392,6 +2564,26 @@ public async Task UpdateCommand_SelfUpdate_DoesNotWriteChannelToGlobalConfigurat // to roll back any prior write. That rollback path is covered by the stable row of // UpdateCommand_SelfUpdate_DoesNotWriteChannelToGlobalConfiguration above (asserts both // DoesNotContain set + DoesNotContain delete) — no standalone test required here. + + private static PackageChannel CreatePackageChannelWithGuestSdkVersion(string sdkVersion, string? cliDownloadBaseUrl) + { + var fakeCache = new FakeNuGetPackageCache + { + GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) => + Task.FromResult>( + [ + new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = sdkVersion, Source = "test" } + ]) + }; + + return PackageChannel.CreateExplicitChannel( + "stable", + PackageChannelQuality.Stable, + [new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json")], + fakeCache, + configureGlobalPackagesFolder: false, + cliDownloadBaseUrl: cliDownloadBaseUrl); + } } // Helper class to track DisplayCancellationMessage calls diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index 71bf1ee28e6..26a119f1848 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -133,7 +133,8 @@ private sealed class RecordingAppHostServerProject : IAppHostServerProject public Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken = default, + string? requestedChannel = null) => throw new NotSupportedException(); public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index e8b3f73c711..a00f348c5fd 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -544,25 +544,19 @@ public void CreateGuestEnvironmentVariables_AspireEnvironmentTakesPrecedenceOver } /// - /// Regression test for wf3: aspire update on a project with an Implicit channel - /// (i.e. the user has not pinned a channel via --channel, per-project - /// aspire.config.json#channel, or a prompt selection) must NOT silently pin the - /// running CLI's identity channel into aspire.config.json#channel. + /// Regression test for issue #17077: aspire update must not leave + /// aspire.config.json advanced to newer package versions when guest SDK + /// regeneration fails. /// /// /// The test drives through the - /// code path that detects updates and saves the config to disk, then expects the call - /// to throw from BuildAndGenerateSdkAsync (because - /// throws). The channel save - /// happens before that throw, so we can inspect the on-disk - /// aspire.config.json#channel to assert it was NOT changed. + /// code path that detects updates, then expects the call to throw from + /// BuildAndGenerateSdkAsync because + /// throws. The on-disk config should still contain the original versions. /// [Fact] - public async Task UpdatePackagesAsync_ImplicitChannel_DoesNotPinIdentityIntoConfig() + public async Task UpdatePackagesAsync_WhenRegenerationFails_DoesNotMutateConfig() { - // Seed aspire.config.json with no channel pinned, an SDK version that is behind, - // and a single package entry. The fake nuget cache will return a newer version so - // the early "nothing to update" return is not taken. var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); await File.WriteAllTextAsync(configPath, """ { @@ -587,7 +581,6 @@ await File.WriteAllTextAsync(configPath, """ var interactionService = new TestInteractionService { - // Confirm the "Perform updates?" prompt so the channel-write code path runs. ConfirmCallback = (_, _) => true }; @@ -603,21 +596,60 @@ await File.WriteAllTextAsync(configPath, """ NuGetConfigDirBinding = PromptBinding.CreateDefault(null), }; - // BuildAndGenerateSdkAsync calls IAppHostServerProjectFactory.CreateAsync, which the - // test factory throws from. The channel save happens BEFORE that, so the on-disk - // assertion below is still meaningful. await Assert.ThrowsAnyAsync( () => project.UpdatePackagesAsync(context, CancellationToken.None)); var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName); Assert.NotNull(reloaded); - // Pre-fix, this would have been "pr-99999" (the identity channel). Post-fix, the - // implicit-channel update path leaves the channel untouched. + Assert.Equal("1.0.0", reloaded.SdkVersion); + Assert.NotNull(reloaded.Packages); + Assert.Equal("1.0.0", reloaded.Packages["Aspire.Hosting"]); Assert.Null(reloaded.Channel); } [Fact] - public async Task UpdatePackagesAsync_ExplicitStableChannel_PersistsStableChannel() + public async Task AddPackageAsync_WhenRegenerationFails_DoesNotMutateConfig() + { + var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, """ + { + "sdk": { "version": "1.0.0" }, + "packages": { "Aspire.Hosting": "1.0.0" } + } + """); + + var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts"); + await File.WriteAllTextAsync(appHostPath, "// test apphost"); + + var factory = new TestAppHostServerProjectFactory + { + CreateAsyncCallback = (appPath, _) => + Task.FromResult(new FakeFailingAppHostServerProject(appPath)) + }; + + var project = CreateGuestAppHostProject(appHostServerProjectFactory: factory); + + var result = await project.AddPackageAsync( + new AddPackageContext + { + AppHostFile = new FileInfo(appHostPath), + PackageId = "Aspire.Hosting.Redis", + PackageVersion = "2.0.0", + }, + CancellationToken.None); + + Assert.False(result); + + var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName); + Assert.NotNull(reloaded); + Assert.Equal("1.0.0", reloaded.SdkVersion); + Assert.NotNull(reloaded.Packages); + Assert.Equal("1.0.0", reloaded.Packages["Aspire.Hosting"]); + Assert.False(reloaded.Packages.ContainsKey("Aspire.Hosting.Redis")); + } + + [Fact] + public async Task UpdatePackagesAsync_ExplicitStableChannel_WhenRegenerationFails_DoesNotMutateConfig() { var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); await File.WriteAllTextAsync(configPath, """ @@ -666,13 +698,13 @@ await Assert.ThrowsAnyAsync( var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName); Assert.NotNull(reloaded); - Assert.Equal(PackageChannelNames.Stable, reloaded.Channel); - Assert.Equal("2.0.0", reloaded.SdkVersion); - Assert.Equal("2.0.0", reloaded.Packages?["Aspire.Hosting"]); + Assert.Equal(PackageChannelNames.Staging, reloaded.Channel); + Assert.Equal("1.0.0", reloaded.SdkVersion); + Assert.Equal("1.0.0", reloaded.Packages?["Aspire.Hosting"]); } [Fact] - public async Task UpdatePackagesAsync_ExplicitStagingChannel_PersistsStagingChannelWhenProjectIsUnpinned() + public async Task UpdatePackagesAsync_ExplicitStagingChannel_WhenRegenerationFails_DoesNotMutateConfig() { var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); await File.WriteAllTextAsync(configPath, """ @@ -720,9 +752,9 @@ await Assert.ThrowsAnyAsync( var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName); Assert.NotNull(reloaded); - Assert.Equal(PackageChannelNames.Staging, reloaded.Channel); - Assert.Equal("2.0.0", reloaded.SdkVersion); - Assert.Equal("2.0.0", reloaded.Packages?["Aspire.Hosting"]); + Assert.Null(reloaded.Channel); + Assert.Equal("1.0.0", reloaded.SdkVersion); + Assert.Equal("1.0.0", reloaded.Packages?["Aspire.Hosting"]); } [Fact] @@ -884,4 +916,5 @@ private GuestAppHostProject CreateGuestAppHostProject( fileLoggerProvider: new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()), profilingTelemetry: _profilingTelemetry); } + } diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs index b03c0c8db06..e785a761ba1 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs @@ -24,7 +24,8 @@ internal sealed class FakeFailingAppHostServerProject(string appDirectoryPath) : public Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken = default, + string? requestedChannel = null) => Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs index a73404115dd..254ff338da2 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs @@ -34,6 +34,12 @@ internal sealed class TestAppHostProjectFactory : IAppHostProjectFactory /// public Func>? ValidateAppHostAsyncCallback { get; set; } + public Func>? UpdatePackagesAsyncCallback { get; set; } + + public string LanguageId { get; set; } = "csharp"; + + public string DisplayName { get; set; } = "C# (.NET)"; + /// /// Optional detection patterns to advertise from the test project. /// @@ -83,7 +89,7 @@ public IAppHostProject GetProject(FileInfo appHostFile) public IAppHostProject? GetProjectByLanguageId(string languageId) { - if (languageId.Equals("csharp", StringComparison.OrdinalIgnoreCase)) + if (languageId.Equals(LanguageId, StringComparison.OrdinalIgnoreCase)) { return _testProject; } @@ -140,8 +146,8 @@ public TestAppHostProject(TestAppHostProjectFactory factory) } public bool IsUnsupported { get; set; } - public string LanguageId => "csharp"; - public string DisplayName => "C# (.NET)"; + public string LanguageId => _factory.LanguageId; + public string DisplayName => _factory.DisplayName; public string? AppHostFileName => "AppHost.csproj"; public bool IsUsingProjectReferences(FileInfo appHostFile) @@ -220,7 +226,9 @@ public Task AddPackageAsync(AddPackageContext context, CancellationToken c => throw new NotImplementedException(); public Task UpdatePackagesAsync(UpdatePackagesContext context, CancellationToken cancellationToken) - => throw new NotImplementedException(); + => _factory.UpdatePackagesAsyncCallback is not null + ? _factory.UpdatePackagesAsyncCallback(context, cancellationToken) + : throw new NotImplementedException(); public Task FindAndStopRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken) => Task.FromResult(RunningInstanceResult.NoRunningInstance);