File: Utils\CliUpdateNotifier.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using Aspire.Cli.Commands;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
using Semver;
 
namespace Aspire.Cli.Utils;
 
internal interface ICliUpdateNotifier
{
    Task CheckForCliUpdatesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken);
    Task<CliVersionStatus> GetVersionStatusAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken);
    void NotifyIfUpdateAvailable();
    bool IsUpdateAvailable();
}
 
internal sealed record CliVersionStatus(
    string? CurrentVersion,
    string? LatestVersion,
    string? UpdateCommand,
    string? UpdateCheckError = null,
    string? LatestVersionChannel = null);
 
/// <summary>
/// Coarse-grained labels for the channel a recommended CLI update is being
/// pulled from. <see cref="PackageUpdateHelpers.GetNewerVersion"/> picks
/// between <c>newestStable</c> and <c>newestPrerelease</c> when computing
/// the recommendation, so labelling by stable vs prerelease is faithful to
/// the underlying decision rule. We deliberately don't try to distinguish
/// staging from daily here — the version string alone can't reliably do so,
/// and the user-visible doctor message only needs to convey "where to
/// look", not the specific feed identity.
/// </summary>
internal static class PackageUpdateRecommendationChannels
{
    public const string Stable = "stable";
    public const string Prerelease = "prerelease";
}
 
internal class CliUpdateNotifier(
    ILogger<CliUpdateNotifier> logger,
    INuGetPackageCache nuGetPackageCache,
    IInteractionService interactionService,
    IProcessPathProvider processPathProvider,
    CliExecutionContext executionContext) : ICliUpdateNotifier
{
    private IEnumerable<Shared.NuGetPackageCli>? _availablePackages;
 
    public async Task CheckForCliUpdatesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
    {
        _availablePackages = await GetCliPackagesAsync(workingDirectory, cancellationToken);
    }
 
    public void NotifyIfUpdateAvailable()
    {
        ValidateCliPackageMetadataPrefetching();
        var status = GetCachedVersionStatus();
        if (status.LatestVersion is not null)
        {
            interactionService.DisplayVersionUpdateNotification(status.LatestVersion, status.UpdateCommand);
        }
    }
 
    public async Task<CliVersionStatus> GetVersionStatusAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
    {
        try
        {
            // Callers that need a synchronous answer cannot rely on the background
            // prefetcher racing to populate the cache before command exit.
            // Refresh through the same method used by background update notifications so
            // NuGet source selection and cache mutation stay consistent.
            await CheckForCliUpdatesAsync(workingDirectory, cancellationToken);
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            logger.LogDebug(ex, "Failed to check for Aspire CLI updates.");
            return GetCachedVersionStatus(ex.Message);
        }
 
        return GetCachedVersionStatus();
    }
 
    public bool IsUpdateAvailable()
    {
        ValidateCliPackageMetadataPrefetching();
        return GetCachedVersionStatus().LatestVersion is not null;
    }
 
    [Conditional("DEBUG")]
    private void ValidateCliPackageMetadataPrefetching()
    {
        if (executionContext.Command is BaseCommand { PrefetchesCliPackageMetadata: false } command)
        {
            throw new PackageMetadataPrefetchingValidationException($"Command '{command.Name}' consumes cached CLI package metadata but does not enable {nameof(BaseCommand.PrefetchesCliPackageMetadata)}.");
        }
    }
 
    protected virtual SemVersion? GetCurrentVersion()
    {
        // physical-binary-version-by-design (see docs/specs/cli-identity-sidecar.md):
        // the update check compares the ACTUAL installed binary against the latest available
        // package to decide whether to recommend an update, so it must read the real assembly
        // version rather than an emulated ASPIRE_CLI_VERSION identity.
        return PackageUpdateHelpers.GetCurrentPackageVersion();
    }
 
    private CliVersionStatus GetCachedVersionStatus(string? updateCheckError = null)
    {
        // Keep all version comparison and update-command selection in one place so
        // callers cannot disagree when package metadata has already been fetched.
        var currentVersion = GetCurrentVersion();
        var currentVersionString = currentVersion?.ToString() ?? PackageUpdateHelpers.GetCurrentAssemblyVersion();
 
        if (updateCheckError is not null)
        {
            return new CliVersionStatus(currentVersionString, null, null, updateCheckError);
        }
 
        if (_availablePackages is null)
        {
            return new CliVersionStatus(currentVersionString, null, null);
        }
 
        if (currentVersion is null)
        {
            logger.LogDebug("Unable to determine current CLI version for update check.");
            return new CliVersionStatus(currentVersionString, null, null);
        }
 
        var newerVersion = PackageUpdateHelpers.GetNewerVersion(logger, currentVersion, _availablePackages);
        var updateCommand = newerVersion is null
            ? null
            : DotNetToolDetection.GetDotNetToolUpdateCommand(processPathProvider.ProcessPath)
                ?? NpmInstallDetection.GetNpmUpdateCommand()
                ?? "aspire update";
        // Derive the lane the recommendation comes from so doctor can show
        // 'Latest version is X (channel: stable)' vs '(channel: prerelease)'.
        // GetNewerVersion picks between newestStable and newestPrerelease
        // by exactly this rule, so re-classifying from the returned
        // version's prerelease flag is faithful to the decision the
        // package helper made.
        var latestChannel = newerVersion is null
            ? null
            : (newerVersion.IsPrerelease ? PackageUpdateRecommendationChannels.Prerelease : PackageUpdateRecommendationChannels.Stable);
        return new CliVersionStatus(currentVersionString, newerVersion?.ToString(), updateCommand, UpdateCheckError: null, LatestVersionChannel: latestChannel);
    }
 
    private async Task<IEnumerable<Shared.NuGetPackageCli>> GetCliPackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
    {
        return await nuGetPackageCache.GetCliPackagesAsync(
            workingDirectory: workingDirectory,
            prerelease: true,
            nugetConfigFile: null,
            cancellationToken: cancellationToken);
    }
}