diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 919abae25da..d6625f36a95 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -48,6 +48,7 @@ + diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 1ef2f55db71..2254582a3c4 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -12,6 +12,7 @@ using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Semver; +using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Commands; diff --git a/src/Aspire.Cli/Commands/BaseCommand.cs b/src/Aspire.Cli/Commands/BaseCommand.cs index f4f317e88e9..1fbad6a0e17 100644 --- a/src/Aspire.Cli/Commands/BaseCommand.cs +++ b/src/Aspire.Cli/Commands/BaseCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Diagnostics; using Aspire.Cli.Configuration; using Aspire.Cli.Utils; @@ -29,7 +30,9 @@ protected BaseCommand(string name, string description, IFeatures features, ICliU // but we'll only wait so long before we get details back about updates // being available (it should already be in the cache for longer running // commands and some commands will opt out entirely) - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var cts = !Debugger.IsAttached + ? new CancellationTokenSource(TimeSpan.FromSeconds(10)) + : new CancellationTokenSource(); await updateNotifier.NotifyIfUpdateAvailableAsync(currentDirectory, cancellationToken: cts.Token); } catch diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 0edf2070d52..9043ce70c7f 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -12,6 +12,8 @@ using Aspire.Cli.Templating; using Aspire.Cli.Utils; using Spectre.Console; +using NuGetPackage = Aspire.Shared.NuGetPackageCli; + namespace Aspire.Cli.Commands; internal sealed class NewCommand : BaseCommand diff --git a/src/Aspire.Cli/DotNetCliRunner.cs b/src/Aspire.Cli/DotNetCliRunner.cs index 7ca72ac0340..5875c0c16cb 100644 --- a/src/Aspire.Cli/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNetCliRunner.cs @@ -11,9 +11,11 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Hosting; +using Aspire.Shared; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli; @@ -716,37 +718,10 @@ public async Task AddPackageAsync(FileInfo projectFilePath, string packageN return (ExitCodeConstants.FailedToAddPackage, null); } - var foundPackages = new List(); try { - using var document = JsonDocument.Parse(stdout); - - var searchResultsArray = document.RootElement.GetProperty("searchResult"); - - foreach (var sourceResult in searchResultsArray.EnumerateArray()) - { - var source = sourceResult.GetProperty("sourceName").GetString(); - var sourcePackagesArray = sourceResult.GetProperty("packages"); - - foreach (var packageResult in sourcePackagesArray.EnumerateArray()) - { - var id = packageResult.GetProperty("id").GetString(); - - // var version = prerelease switch { - // true => packageResult.GetProperty("version").GetString(), - // false => packageResult.GetProperty("latestVersion").GetString() - // }; - - var version = packageResult.GetProperty("latestVersion").GetString(); - - foundPackages.Add(new NuGetPackage - { - Id = id!, - Version = version!, - Source = source! - }); - } - } + var foundPackages = PackageUpdateHelpers.ParsePackageSearchResults(stdout); + return (result, foundPackages.ToArray()); } catch (JsonException ex) { @@ -754,14 +729,6 @@ public async Task AddPackageAsync(FileInfo projectFilePath, string packageN return (ExitCodeConstants.FailedToAddPackage, null); } - return (result, foundPackages.ToArray()); } } } - -internal class NuGetPackage -{ - public string Id { get; set; } = string.Empty; - public string Version { get; set; } = string.Empty; - public string Source { get; set; } = string.Empty; -} diff --git a/src/Aspire.Cli/NuGet/NuGetPackageCache.cs b/src/Aspire.Cli/NuGet/NuGetPackageCache.cs index f9d6472efa4..15653924d8b 100644 --- a/src/Aspire.Cli/NuGet/NuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/NuGetPackageCache.cs @@ -6,6 +6,7 @@ using Aspire.Cli.Telemetry; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.NuGet; diff --git a/src/Aspire.Cli/Utils/CliUpdateNotifier.cs b/src/Aspire.Cli/Utils/CliUpdateNotifier.cs index 88108fa7e71..f73e43b4bb9 100644 --- a/src/Aspire.Cli/Utils/CliUpdateNotifier.cs +++ b/src/Aspire.Cli/Utils/CliUpdateNotifier.cs @@ -3,6 +3,7 @@ using Aspire.Cli.Interaction; using Aspire.Cli.NuGet; +using Aspire.Shared; using Microsoft.Extensions.Logging; using Semver; @@ -31,7 +32,7 @@ public async Task NotifyIfUpdateAvailableAsync(DirectoryInfo workingDirectory, C } var availablePackages = await nuGetPackageCache.GetCliPackagesAsync(workingDirectory, prerelease: true, source: null, cancellationToken); - var newerVersion = GetNewerVersion(currentVersion, availablePackages); + var newerVersion = PackageUpdateHelpers.GetNewerVersion(currentVersion, availablePackages); if (newerVersion is not null) { @@ -46,63 +47,6 @@ public async Task NotifyIfUpdateAvailableAsync(DirectoryInfo workingDirectory, C protected virtual SemVersion? GetCurrentVersion() { - try - { - var versionString = VersionHelper.GetDefaultTemplateVersion(); - // Remove any build metadata (e.g., +sha.12345) for comparison - var cleanVersionString = versionString.Split('+')[0]; - return SemVersion.Parse(cleanVersionString, SemVersionStyles.Strict); - } - catch - { - return null; - } + return PackageUpdateHelpers.GetCurrentPackageVersion(); } - - private static SemVersion? GetNewerVersion(SemVersion currentVersion, IEnumerable availablePackages) - { - SemVersion? newestStable = null; - SemVersion? newestPrerelease = null; - - foreach (var package in availablePackages) - { - if (SemVersion.TryParse(package.Version, SemVersionStyles.Strict, out var version)) - { - if (version.IsPrerelease) - { - newestPrerelease = newestPrerelease is null || SemVersion.PrecedenceComparer.Compare(version, newestPrerelease) > 0 ? version : newestPrerelease; - } - else - { - newestStable = newestStable is null || SemVersion.PrecedenceComparer.Compare(version, newestStable) > 0 ? version : newestStable; - } - } - } - - // Apply notification rules - if (currentVersion.IsPrerelease) - { - // Rule 1: If using a prerelease version where the version is lower than the latest stable version, prompt to upgrade - if (newestStable is not null && SemVersion.PrecedenceComparer.Compare(currentVersion, newestStable) < 0) - { - return newestStable; - } - - // Rule 2: If using a prerelease version and there is a newer prerelease version, prompt to upgrade - if (newestPrerelease is not null && SemVersion.PrecedenceComparer.Compare(currentVersion, newestPrerelease) < 0) - { - return newestPrerelease; - } - } - else - { - // Rule 3: If using a stable version and there is a newer stable version, prompt to upgrade - if (newestStable is not null && SemVersion.PrecedenceComparer.Compare(currentVersion, newestStable) < 0) - { - return newestStable; - } - } - - return null; - } -} \ No newline at end of file +} diff --git a/src/Aspire.Cli/Utils/VersionHelper.cs b/src/Aspire.Cli/Utils/VersionHelper.cs index 1c75000e862..b1d90f27cba 100644 --- a/src/Aspire.Cli/Utils/VersionHelper.cs +++ b/src/Aspire.Cli/Utils/VersionHelper.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Resources; +using Aspire.Shared; namespace Aspire.Cli.Utils; @@ -9,13 +10,6 @@ internal static class VersionHelper { public static string GetDefaultTemplateVersion() { - // Write some code that gets the informational assembly version of the current assembly and returns it as a string. - var assembly = typeof(VersionHelper).Assembly; - var informationalVersion = assembly - .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) - .OfType() - .FirstOrDefault()?.InformationalVersion; - - return informationalVersion ?? throw new InvalidOperationException(ErrorStrings.UnableToRetrieveAssemblyVersion); + return PackageUpdateHelpers.GetCurrentAssemblyVersion() ?? throw new InvalidOperationException(ErrorStrings.UnableToRetrieveAssemblyVersion); } } diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index f47430b2637..f4c4a6192ec 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -41,6 +41,7 @@ + diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 1b46bd581fe..55d5082a77b 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -241,7 +241,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddHostedService(); _innerBuilder.Services.AddHostedService(); _innerBuilder.Services.AddHostedService(); - _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(options); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); diff --git a/src/Aspire.Hosting/VersionChecking/IVersionFetcher.cs b/src/Aspire.Hosting/VersionChecking/IPackageFetcher.cs similarity index 52% rename from src/Aspire.Hosting/VersionChecking/IVersionFetcher.cs rename to src/Aspire.Hosting/VersionChecking/IPackageFetcher.cs index 00cb11ad797..7af8645827a 100644 --- a/src/Aspire.Hosting/VersionChecking/IVersionFetcher.cs +++ b/src/Aspire.Hosting/VersionChecking/IPackageFetcher.cs @@ -1,11 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Semver; +using Aspire.Shared; namespace Aspire.Hosting.VersionChecking; -internal interface IVersionFetcher +internal interface IPackageFetcher { - Task TryFetchLatestVersionAsync(string appHostDirectory, CancellationToken cancellationToken); + Task> TryFetchPackagesAsync(string appHostDirectory, CancellationToken cancellationToken); } diff --git a/src/Aspire.Hosting/VersionChecking/Package.cs b/src/Aspire.Hosting/VersionChecking/Package.cs deleted file mode 100644 index fc9ed672007..00000000000 --- a/src/Aspire.Hosting/VersionChecking/Package.cs +++ /dev/null @@ -1,6 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Aspire.Hosting.VersionChecking; - -internal record Package(string Id, string LatestVersion); diff --git a/src/Aspire.Hosting/VersionChecking/PackageFetcher.cs b/src/Aspire.Hosting/VersionChecking/PackageFetcher.cs new file mode 100644 index 00000000000..996b6099ac6 --- /dev/null +++ b/src/Aspire.Hosting/VersionChecking/PackageFetcher.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Aspire.Hosting.Dcp.Process; +using Aspire.Shared; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.VersionChecking; + +internal sealed class PackageFetcher : IPackageFetcher +{ + public const string PackageId = "Aspire.Hosting.AppHost"; + + // Limit the number of packages fetched per search to avoid overwhelming the output. This should never happen unless there is a bug in the API. + // Package search returns the latest version per source and few packages will match "Aspire.Hosting.AppHost" search string. + private const int SearchPageSize = 1000; + + private readonly ILogger _logger; + + public PackageFetcher(ILogger logger) + { + _logger = logger; + } + + public async Task> TryFetchPackagesAsync(string appHostDirectory, CancellationToken cancellationToken) + { + var outputJson = new StringBuilder(); + var spec = new ProcessSpec("dotnet") + { + Arguments = $"package search {PackageId} --format json --prerelease --take {SearchPageSize}", + ThrowOnNonZeroReturnCode = false, + InheritEnv = true, + OnOutputData = output => + { + outputJson.Append(output); + _logger.LogDebug("dotnet (stdout): {Output}", output); + }, + OnErrorData = error => + { + _logger.LogDebug("dotnet (stderr): {Error}", error); + }, + WorkingDirectory = appHostDirectory + }; + + _logger.LogDebug("Running dotnet CLI to check for latest version of {PackageId} with arguments: {ArgumentList}", PackageId, spec.Arguments); + var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + + await using (processDisposable) + { + var processResult = await pendingProcessResult + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + if (processResult.ExitCode != 0) + { + _logger.LogDebug("The dotnet CLI call to check for latest version failed with exit code {ExitCode}.", processResult.ExitCode); + return []; + } + } + + // Filter packages to only consider "Aspire.Hosting.AppHost". + // Although the CLI command 'dotnet package search Aspire.Hosting.AppHost --format json' + // should already limit results according to NuGet search syntax + // (https://learn.microsoft.com/en-us/nuget/consume-packages/finding-and-choosing-packages#search-syntax), + // we add this extra check for robustness in case the CLI output includes unexpected packages. + return PackageUpdateHelpers.ParsePackageSearchResults(outputJson.ToString(), PackageId); + } +} diff --git a/src/Aspire.Hosting/VersionChecking/VersionCheckService.cs b/src/Aspire.Hosting/VersionChecking/VersionCheckService.cs index cb018881e4d..11e3b14d558 100644 --- a/src/Aspire.Hosting/VersionChecking/VersionCheckService.cs +++ b/src/Aspire.Hosting/VersionChecking/VersionCheckService.cs @@ -3,8 +3,10 @@ #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using Aspire.Shared; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -25,26 +27,24 @@ internal sealed class VersionCheckService : BackgroundService private readonly ILogger _logger; private readonly IConfiguration _configuration; private readonly DistributedApplicationOptions _options; - private readonly IVersionFetcher _versionFetcher; + private readonly IPackageFetcher _packageFetcher; private readonly DistributedApplicationExecutionContext _executionContext; private readonly TimeProvider _timeProvider; - private readonly SemVersion _appHostVersion; + private readonly SemVersion? _appHostVersion; public VersionCheckService(IInteractionService interactionService, ILogger logger, - IConfiguration configuration, DistributedApplicationOptions options, IVersionFetcher versionFetcher, + IConfiguration configuration, DistributedApplicationOptions options, IPackageFetcher packageFetcher, DistributedApplicationExecutionContext executionContext, TimeProvider timeProvider) { _interactionService = interactionService; _logger = logger; _configuration = configuration; _options = options; - _versionFetcher = versionFetcher; + _packageFetcher = packageFetcher; _executionContext = executionContext; _timeProvider = timeProvider; - var version = typeof(VersionCheckService).Assembly.GetName().Version!; - var patch = version.Build > 0 ? version.Build : 0; - _appHostVersion = new SemVersion(version.Major, version.Minor, patch); + _appHostVersion = PackageUpdateHelpers.GetCurrentPackageVersion(); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -56,6 +56,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) return; } + if (_appHostVersion == null) + { + _logger.LogDebug("App host version is not available, skipping version check."); + return; + } + try { await CheckForLatestAsync(stoppingToken).ConfigureAwait(false); @@ -72,6 +78,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) private async Task CheckForLatestAsync(CancellationToken cancellationToken) { + Debug.Assert(_appHostVersion != null); + var now = _timeProvider.GetUtcNow(); var checkForLatestVersion = true; if (_configuration[LastCheckDateKey] is string checkDateString && @@ -90,7 +98,9 @@ private async Task CheckForLatestAsync(CancellationToken cancellationToken) var appHostDirectory = _configuration["AppHost:Directory"]!; SecretsStore.TrySetUserSecret(_options.Assembly, LastCheckDateKey, now.ToString("o", CultureInfo.InvariantCulture)); - latestVersion = await _versionFetcher.TryFetchLatestVersionAsync(appHostDirectory, cancellationToken).ConfigureAwait(false); + var packages = await _packageFetcher.TryFetchPackagesAsync(appHostDirectory, cancellationToken).ConfigureAwait(false); + + latestVersion = PackageUpdateHelpers.GetNewerVersion(_appHostVersion, packages); } if (TryGetConfigVersion(KnownLatestVersionKey, out var storedKnownLatestVersion)) diff --git a/src/Aspire.Hosting/VersionChecking/VersionFetcher.cs b/src/Aspire.Hosting/VersionChecking/VersionFetcher.cs deleted file mode 100644 index 2a5b9c0c902..00000000000 --- a/src/Aspire.Hosting/VersionChecking/VersionFetcher.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text; -using System.Text.Json; -using Aspire.Hosting.Dcp.Process; -using Microsoft.Extensions.Logging; -using Semver; - -namespace Aspire.Hosting.VersionChecking; - -internal sealed class VersionFetcher : IVersionFetcher -{ - private const string PackageId = "Aspire.Hosting.AppHost"; - - private readonly ILogger _logger; - - public VersionFetcher(ILogger logger) - { - _logger = logger; - } - - public async Task TryFetchLatestVersionAsync(string appHostDirectory, CancellationToken cancellationToken) - { - var outputJson = new StringBuilder(); - var spec = new ProcessSpec("dotnet") - { - Arguments = $"package search {PackageId} --format json", - ThrowOnNonZeroReturnCode = false, - InheritEnv = true, - OnOutputData = output => - { - outputJson.Append(output); - _logger.LogDebug("dotnet (stdout): {Output}", output); - }, - OnErrorData = error => - { - _logger.LogDebug("dotnet (stderr): {Error}", error); - }, - WorkingDirectory = appHostDirectory - }; - - _logger.LogDebug("Running dotnet CLI to check for latest version of {PackageId} with arguments: {ArgumentList}", PackageId, spec.Arguments); - var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); - - await using (processDisposable) - { - var processResult = await pendingProcessResult - .WaitAsync(cancellationToken) - .ConfigureAwait(false); - - if (processResult.ExitCode != 0) - { - _logger.LogDebug("The dotnet CLI call to check for latest version failed with exit code {ExitCode}.", processResult.ExitCode); - return null; - } - } - - return GetLatestVersion(outputJson.ToString()); - } - - internal static SemVersion? GetLatestVersion(string outputJson) - { - var packages = ParseOutput(outputJson); - var versions = new List(); - foreach (var package in packages) - { - // Filter packages to only consider "Aspire.Hosting.AppHost". - // Although the CLI command 'dotnet package search Aspire.Hosting.AppHost --format json' - // should already limit results according to NuGet search syntax - // (https://learn.microsoft.com/en-us/nuget/consume-packages/finding-and-choosing-packages#search-syntax), - // we add this extra check for robustness in case the CLI output includes unexpected packages. - if (package.Id == PackageId && - SemVersion.TryParse(package.LatestVersion, out var version) && - !version.IsPrerelease) - { - versions.Add(version); - } - } - - return versions.OrderDescending(SemVersion.PrecedenceComparer).FirstOrDefault(); - } - - private static List ParseOutput(string outputJson) - { - var packages = new List(); - - using var document = JsonDocument.Parse(outputJson); - var root = document.RootElement; - - if (root.TryGetProperty("searchResult", out var searchResults)) - { - foreach (var result in searchResults.EnumerateArray()) - { - if (result.TryGetProperty("packages", out var packagesArray)) - { - foreach (var pkg in packagesArray.EnumerateArray()) - { - var id = pkg.GetProperty("id").GetString(); - var latestVersion = pkg.GetProperty("latestVersion").GetString(); - if (id != null && latestVersion != null) - { - packages.Add(new Package(id, latestVersion)); - } - } - } - } - } - - return packages; - } -} diff --git a/src/Shared/PackageUpdateHelpers.cs b/src/Shared/PackageUpdateHelpers.cs new file mode 100644 index 00000000000..d83e3549df4 --- /dev/null +++ b/src/Shared/PackageUpdateHelpers.cs @@ -0,0 +1,141 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Semver; +#if CLI +using NuGetPackage = Aspire.Shared.NuGetPackageCli; +#else +using NuGetPackage = Aspire.Shared.NuGetPackage; +#endif + +namespace Aspire.Shared; + +#if CLI +internal class NuGetPackageCli +#else +internal class NuGetPackage +#endif +{ + public string Id { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; +} + +internal static class PackageUpdateHelpers +{ + public static SemVersion? GetCurrentPackageVersion() + { + try + { + var versionString = GetCurrentAssemblyVersion(); + if (versionString == null) + { + return null; + } + + // Remove any build metadata (e.g., +sha.12345) for comparison + var cleanVersionString = versionString.Split('+')[0]; + return SemVersion.Parse(cleanVersionString, SemVersionStyles.Strict); + } + catch + { + return null; + } + } + + public static string? GetCurrentAssemblyVersion() + { + // Write some code that gets the informational assembly version of the current assembly and returns it as a string. + var assembly = typeof(PackageUpdateHelpers).Assembly; + var informationalVersion = assembly + .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .OfType() + .FirstOrDefault()?.InformationalVersion; + + return informationalVersion; + } + + public static SemVersion? GetNewerVersion(SemVersion currentVersion, IEnumerable availablePackages) + { + SemVersion? newestStable = null; + SemVersion? newestPrerelease = null; + + foreach (var package in availablePackages) + { + if (SemVersion.TryParse(package.Version, SemVersionStyles.Strict, out var version)) + { + if (version.IsPrerelease) + { + newestPrerelease = newestPrerelease is null || SemVersion.PrecedenceComparer.Compare(version, newestPrerelease) > 0 ? version : newestPrerelease; + } + else + { + newestStable = newestStable is null || SemVersion.PrecedenceComparer.Compare(version, newestStable) > 0 ? version : newestStable; + } + } + } + + // Apply notification rules + if (currentVersion.IsPrerelease) + { + // Rule 1: If using a prerelease version where the version is lower than the latest stable version, prompt to upgrade + if (newestStable is not null && SemVersion.PrecedenceComparer.Compare(currentVersion, newestStable) < 0) + { + return newestStable; + } + + // Rule 2: If using a prerelease version and there is a newer prerelease version, prompt to upgrade + if (newestPrerelease is not null && SemVersion.PrecedenceComparer.Compare(currentVersion, newestPrerelease) < 0) + { + return newestPrerelease; + } + } + else + { + // Rule 3: If using a stable version and there is a newer stable version, prompt to upgrade + if (newestStable is not null && SemVersion.PrecedenceComparer.Compare(currentVersion, newestStable) < 0) + { + return newestStable; + } + } + + return null; + } + + public static List ParsePackageSearchResults(string stdout, string? packageId = null) + { + var foundPackages = new List(); + + using var document = JsonDocument.Parse(stdout); + if (!document.RootElement.TryGetProperty("searchResult", out var searchResultsArray)) + { + return []; + } + + foreach (var sourceResult in searchResultsArray.EnumerateArray()) + { + var source = sourceResult.GetProperty("sourceName").GetString()!; + var sourcePackagesArray = sourceResult.GetProperty("packages"); + + foreach (var packageResult in sourcePackagesArray.EnumerateArray()) + { + var id = packageResult.GetProperty("id").GetString()!; + + var version = packageResult.GetProperty("latestVersion").GetString()!; + + if (packageId == null || id == packageId) + { + foundPackages.Add(new NuGetPackage + { + Id = id, + Version = version, + Source = source + }); + } + } + } + + return foundPackages; + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index febbc60426f..957e57493d1 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using Xunit; +using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Tests.Commands; diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 8ed3816d37f..1506fe7d4b1 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -9,6 +9,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using Xunit; +using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Tests.Commands; diff --git a/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs b/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs index 36a61c50814..3d185f30bda 100644 --- a/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs +++ b/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs @@ -6,6 +6,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using Xunit; +using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Tests.NuGet; @@ -43,4 +44,4 @@ public async Task NonAspireCliPackagesWillNotBeConsidered() package => Assert.Equal("Aspire.Cli", package.Id) ); } -} \ No newline at end of file +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index 2067fc0d874..af3acf38d56 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Utils; +using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Tests.TestServices; diff --git a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs index 1a223de3ae9..ce9ab54eb5c 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging; using Semver; using Xunit; +using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Tests.Utils; @@ -300,4 +301,4 @@ public Task> GetCliPackagesAsync(DirectoryInfo working { return Task.FromResult(_cliPackages); } -} \ No newline at end of file +} diff --git a/tests/Aspire.Hosting.Tests/VersionChecking/VersionFetcherTests.cs b/tests/Aspire.Hosting.Tests/VersionChecking/PackageUpdateHelpersTests.cs similarity index 63% rename from tests/Aspire.Hosting.Tests/VersionChecking/VersionFetcherTests.cs rename to tests/Aspire.Hosting.Tests/VersionChecking/PackageUpdateHelpersTests.cs index a8ba3a06664..383a8327d31 100644 --- a/tests/Aspire.Hosting.Tests/VersionChecking/VersionFetcherTests.cs +++ b/tests/Aspire.Hosting.Tests/VersionChecking/PackageUpdateHelpersTests.cs @@ -1,13 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Hosting.VersionChecking; +using Aspire.Shared; using Semver; using Xunit; namespace Aspire.Hosting.Tests.VersionChecking; -public class VersionFetcherTests +public class PackageUpdateHelpersTests { [Fact] public void GetLatestVersion_MultipleVersions_LatestVersion() @@ -50,7 +50,8 @@ public void GetLatestVersion_MultipleVersions_LatestVersion() """; // Act - var latestVersion = VersionFetcher.GetLatestVersion(json); + var packages = PackageUpdateHelpers.ParsePackageSearchResults(json, "Aspire.Hosting.AppHost"); + var latestVersion = PackageUpdateHelpers.GetNewerVersion(new SemVersion(1), packages); // Assert Assert.Equal(new SemVersion(19, 0, 0), latestVersion); @@ -97,12 +98,61 @@ public void GetLatestVersion_HasPrerelease_IgnorePrerelease() """; // Act - var latestVersion = VersionFetcher.GetLatestVersion(json); + var packages = PackageUpdateHelpers.ParsePackageSearchResults(json, "Aspire.Hosting.AppHost"); + var latestVersion = PackageUpdateHelpers.GetNewerVersion(new SemVersion(1), packages); // Assert Assert.Equal(new SemVersion(9, 3, 1), latestVersion); } + [Fact] + public void GetLatestVersion_HasPrerelease_UsePrerelease() + { + // Arrange + var json = """ + { + "version": 2, + "problems": [], + "searchResult": [ + { + "sourceName": "feed1", + "packages": [ + { + "id": "Aspire.Hosting.AppHost", + "latestVersion": "0.4.1" + } + ] + }, + { + "sourceName": "feed2", + "packages": [ + { + "id": "Aspire.Hosting.AppHost", + "latestVersion": "19.0.0-pre1" + } + ] + }, + { + "sourceName": "feed3", + "packages": [ + { + "id": "Aspire.Hosting.AppHost", + "latestVersion": "9.3.1" + } + ] + } + ] + } + """; + + // Act + var packages = PackageUpdateHelpers.ParsePackageSearchResults(json, "Aspire.Hosting.AppHost"); + var latestVersion = PackageUpdateHelpers.GetNewerVersion(new SemVersion(10, prerelease: ["dev"]), packages); + + // Assert + Assert.Equal(new SemVersion(19, 0, 0, prerelease: ["pre1"]), latestVersion); + } + [Fact] public void GetLatestVersion_NoVersions_NoVersion() { @@ -110,7 +160,8 @@ public void GetLatestVersion_NoVersions_NoVersion() var json = "{}"; // Act - var latestVersion = VersionFetcher.GetLatestVersion(json); + var packages = PackageUpdateHelpers.ParsePackageSearchResults(json, "Aspire.Hosting.AppHost"); + var latestVersion = PackageUpdateHelpers.GetNewerVersion(new SemVersion(1), packages); // Assert Assert.Null(latestVersion); @@ -157,7 +208,8 @@ public void GetLatestVersion_MixedPackageIds_OnlyConsidersAppHostPackages() """; // Act - var latestVersion = VersionFetcher.GetLatestVersion(json); + var packages = PackageUpdateHelpers.ParsePackageSearchResults(json, "Aspire.Hosting.AppHost"); + var latestVersion = PackageUpdateHelpers.GetNewerVersion(new SemVersion(1), packages); // Assert Assert.Equal(new SemVersion(9, 0, 0), latestVersion); diff --git a/tests/Aspire.Hosting.Tests/VersionChecking/VersionCheckServiceTests.cs b/tests/Aspire.Hosting.Tests/VersionChecking/VersionCheckServiceTests.cs index 7135b65b9a6..66698f662ed 100644 --- a/tests/Aspire.Hosting.Tests/VersionChecking/VersionCheckServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/VersionChecking/VersionCheckServiceTests.cs @@ -3,10 +3,10 @@ using System.Globalization; using Aspire.Hosting.VersionChecking; +using Aspire.Shared; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; -using Semver; using Xunit; namespace Aspire.Hosting.Tests.VersionChecking; @@ -21,20 +21,23 @@ public async Task ExecuteAsync_NewerVersion_DisplayMessage() // Arrange var interactionService = new TestInteractionService(); var configurationManager = new ConfigurationManager(); - var versionFetcher = new TestVersionFetcher(); + var packagesTcs = new TaskCompletionSource>(); + var packageFetcher = new TestPackageFetcher(packagesTcs.Task); var options = new DistributedApplicationOptions(); - var service = CreateVersionCheckService(interactionService: interactionService, versionFetcher: versionFetcher, configuration: configurationManager, options: options); + var service = CreateVersionCheckService(interactionService: interactionService, packageFetcher: packageFetcher, configuration: configurationManager, options: options); // Act _ = service.StartAsync(CancellationToken.None); + packagesTcs.TrySetResult([new NuGetPackage { Id = PackageFetcher.PackageId, Version = "100.0.0" }]); + var interaction = await interactionService.Interactions.Reader.ReadAsync().DefaultTimeout(); interaction.CompletionTcs.TrySetResult(InteractionResult.Ok(true)); await service.ExecuteTask!.DefaultTimeout(); // Assert - Assert.True(versionFetcher.FetchCalled); + Assert.True(packageFetcher.FetchCalled); } [Fact] @@ -47,9 +50,9 @@ public async Task ExecuteAsync_DisabledInConfiguration_NoFetch() { [KnownConfigNames.VersionCheckDisabled] = "true" }); - var versionFetcher = new TestVersionFetcher(); + var packageFetcher = new TestPackageFetcher(); var options = new DistributedApplicationOptions(); - var service = CreateVersionCheckService(interactionService: interactionService, versionFetcher: versionFetcher, configuration: configurationManager, options: options); + var service = CreateVersionCheckService(interactionService: interactionService, packageFetcher: packageFetcher, configuration: configurationManager, options: options); // Act _ = service.StartAsync(CancellationToken.None); @@ -57,7 +60,7 @@ public async Task ExecuteAsync_DisabledInConfiguration_NoFetch() await service.ExecuteTask!.DefaultTimeout(); // Assert - Assert.False(versionFetcher.FetchCalled); + Assert.False(packageFetcher.FetchCalled); } [Fact] @@ -75,11 +78,11 @@ public async Task ExecuteAsync_InsideLastCheckInterval_NoFetch() [VersionCheckService.LastCheckDateKey] = lastCheckDate.ToString("o", CultureInfo.InvariantCulture) }); - var versionTcs = new TaskCompletionSource(); - var versionFetcher = new TestVersionFetcher(versionTcs.Task); + var packagesTcs = new TaskCompletionSource>(); + var packageFetcher = new TestPackageFetcher(packagesTcs.Task); var service = CreateVersionCheckService( interactionService: interactionService, - versionFetcher: versionFetcher, + packageFetcher: packageFetcher, configuration: configurationManager, timeProvider: timeProvider); @@ -91,7 +94,7 @@ public async Task ExecuteAsync_InsideLastCheckInterval_NoFetch() interactionService.Interactions.Writer.Complete(); // Assert - Assert.False(versionFetcher.FetchCalled); + Assert.False(packageFetcher.FetchCalled); Assert.False(interactionService.Interactions.Reader.TryRead(out var _)); } @@ -111,11 +114,11 @@ public async Task ExecuteAsync_InsideLastCheckIntervalHasLastKnown_NoFetchAndDis [VersionCheckService.KnownLatestVersionKey] = "100.0.0" }); - var versionTcs = new TaskCompletionSource(); - var versionFetcher = new TestVersionFetcher(versionTcs.Task); + var packagesTcs = new TaskCompletionSource>(); + var packageFetcher = new TestPackageFetcher(packagesTcs.Task); var service = CreateVersionCheckService( interactionService: interactionService, - versionFetcher: versionFetcher, + packageFetcher: packageFetcher, configuration: configurationManager, timeProvider: timeProvider); @@ -128,7 +131,7 @@ public async Task ExecuteAsync_InsideLastCheckIntervalHasLastKnown_NoFetchAndDis await service.ExecuteTask!.DefaultTimeout(); // Assert - Assert.False(versionFetcher.FetchCalled); + Assert.False(packageFetcher.FetchCalled); } [Fact] @@ -137,21 +140,21 @@ public async Task ExecuteAsync_OlderVersion_NoMessage() // Arrange var interactionService = new TestInteractionService(); var configurationManager = new ConfigurationManager(); - var versionTcs = new TaskCompletionSource(); - var versionFetcher = new TestVersionFetcher(versionTcs.Task); - var service = CreateVersionCheckService(interactionService: interactionService, versionFetcher: versionFetcher, configuration: configurationManager); + var packagesTcs = new TaskCompletionSource>(); + var packageFetcher = new TestPackageFetcher(packagesTcs.Task); + var service = CreateVersionCheckService(interactionService: interactionService, packageFetcher: packageFetcher, configuration: configurationManager); // Act _ = service.StartAsync(CancellationToken.None); - versionTcs.SetResult(new SemVersion(0, 1)); + packagesTcs.SetResult([new NuGetPackage { Id = PackageFetcher.PackageId, Version = "0.1.0" }]); await service.ExecuteTask!.DefaultTimeout(); interactionService.Interactions.Writer.Complete(); // Assert - Assert.True(versionFetcher.FetchCalled); + Assert.True(packageFetcher.FetchCalled); Assert.False(interactionService.Interactions.Reader.TryRead(out var _)); } @@ -166,28 +169,28 @@ public async Task ExecuteAsync_IgnoredVersion_NoMessage() { [VersionCheckService.IgnoreVersionKey] = "100.0.0" }); - var versionTcs = new TaskCompletionSource(); - var versionFetcher = new TestVersionFetcher(versionTcs.Task); - var service = CreateVersionCheckService(interactionService: interactionService, versionFetcher: versionFetcher, configuration: configurationManager); + var packagesTcs = new TaskCompletionSource>(); + var packageFetcher = new TestPackageFetcher(packagesTcs.Task); + var service = CreateVersionCheckService(interactionService: interactionService, packageFetcher: packageFetcher, configuration: configurationManager); // Act _ = service.StartAsync(CancellationToken.None); - versionTcs.SetResult(new SemVersion(100, 0)); + packagesTcs.SetResult([new NuGetPackage { Id = PackageFetcher.PackageId, Version = "100.0.0" }]); await service.ExecuteTask!.DefaultTimeout(); interactionService.Interactions.Writer.Complete(); // Assert - Assert.True(versionFetcher.FetchCalled); + Assert.True(packageFetcher.FetchCalled); Assert.False(interactionService.Interactions.Reader.TryRead(out var _)); } private static VersionCheckService CreateVersionCheckService( IInteractionService? interactionService = null, - IVersionFetcher? versionFetcher = null, + IPackageFetcher? packageFetcher = null, IConfiguration? configuration = null, TimeProvider? timeProvider = null, DistributedApplicationOptions? options = null) @@ -197,7 +200,7 @@ private static VersionCheckService CreateVersionCheckService( NullLogger.Instance, configuration ?? new ConfigurationManager(), options ?? new DistributedApplicationOptions(), - versionFetcher ?? new TestVersionFetcher(), + packageFetcher ?? new TestPackageFetcher(), new DistributedApplicationExecutionContext(new DistributedApplicationOperation()), timeProvider ?? new TestTimeProvider()); } @@ -214,18 +217,18 @@ public override DateTimeOffset GetUtcNow() } } - private sealed class TestVersionFetcher : IVersionFetcher + private sealed class TestPackageFetcher : IPackageFetcher { - private readonly Task _versionTask; + private readonly Task> _versionTask; public bool FetchCalled { get; private set; } - public TestVersionFetcher(Task? versionTask = null) + public TestPackageFetcher(Task>? versionTask = null) { - _versionTask = versionTask ?? Task.FromResult(new SemVersion(100, 0, 0)); + _versionTask = versionTask ?? Task.FromResult>([]); } - public Task TryFetchLatestVersionAsync(string appHostDirectory, CancellationToken cancellationToken) + public Task> TryFetchPackagesAsync(string appHostDirectory, CancellationToken cancellationToken) { FetchCalled = true; return _versionTask;