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;