File: Utils\EnvironmentChecker\AspireVersionCheck.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.Globalization;
using System.Text.Json.Nodes;
using Aspire.Cli.Acquisition;
using Aspire.Cli.Configuration;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Utils.EnvironmentChecker;
 
/// <summary>
/// Reports the installed Aspire CLI version, whether a newer CLI version is available, and
/// the Aspire SDK version used by the selected AppHost when one can be discovered.
/// </summary>
internal sealed class AspireVersionCheck(
    ICliUpdateNotifier updateNotifier,
    IProjectLocator projectLocator,
    IAppHostProjectFactory projectFactory,
    IIdentityChannelReader identityChannelReader,
    CliExecutionContext executionContext,
    ILogger<AspireVersionCheck> logger) : IEnvironmentCheck
{
    internal const string AppHostVersionCheckName = "apphost-version";
    internal const string CliVersionCheckName = "cli-version";
 
    // Version checks should appear first so users immediately see which Aspire bits
    // produced the rest of the doctor output before reading environment diagnostics.
    public int Order => 0;
 
    public async Task<IReadOnlyList<EnvironmentCheckResult>> CheckAsync(CancellationToken cancellationToken = default)
    {
        var results = new List<EnvironmentCheckResult>
        {
            await GetCliVersionCheckAsync(cancellationToken)
        };
 
        EnvironmentCheckResult? appHostVersionCheck;
        try
        {
            appHostVersionCheck = await GetAppHostVersionCheckAsync(cancellationToken);
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            logger.LogDebug(ex, "Failed to resolve AppHost version.");
 
            appHostVersionCheck = new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.AppHost,
                Name = AppHostVersionCheckName,
                Status = EnvironmentCheckStatus.Warning,
                Message = DoctorCommandStrings.AppHostVersionCheckFailedMessage,
                Details = ex.Message
            };
        }
 
        if (appHostVersionCheck is not null)
        {
            results.Add(appHostVersionCheck);
        }
 
        return results;
    }
 
    private async Task<EnvironmentCheckResult> GetCliVersionCheckAsync(CancellationToken cancellationToken)
    {
        // Read the identity channel up front so it can be attached to every
        // CLI-version result (pass / out-of-date / update-check-failed).
        // Identity channel is best-effort: misconfigured dev builds may have
        // missing metadata, and we don't want to fail doctor over a labelling
        // gap. ReadChannel() throws InvalidOperationException in that case;
        // logging at debug keeps the diagnostic available without surfacing
        // a scary warning in the human-readable output.
        var identityChannel = TryReadIdentityChannel();
 
        try
        {
            var status = await updateNotifier.GetVersionStatusAsync(executionContext.WorkingDirectory, cancellationToken);
            var currentVersion = string.IsNullOrWhiteSpace(status.CurrentVersion) ? DoctorCommandStrings.VersionUnknown : status.CurrentVersion;
 
            // Doctor should always report the installed CLI version. Treat update lookup
            // failures as a warning on that same check rather than hiding the version or
            // failing the command because offline/private-feed scenarios are common.
            if (status.UpdateCheckError is { Length: > 0 } updateCheckError)
            {
                return new EnvironmentCheckResult
                {
                    Category = EnvironmentCheckCategories.Aspire,
                    Name = CliVersionCheckName,
                    Status = EnvironmentCheckStatus.Warning,
                    Message = FormatCliVersionMessage(currentVersion, identityChannel),
                    Details = $"{DoctorCommandStrings.CliVersionUpdateCheckFailedMessage}: {updateCheckError}",
                    Metadata = BuildCliVersionMetadata(currentVersion, latestVersion: null, status.UpdateCommand, updateCheckError, identityChannel, latestVersionChannel: null)
                };
            }
 
            if (status.LatestVersion is { Length: > 0 } latestVersion)
            {
                return new EnvironmentCheckResult
                {
                    Category = EnvironmentCheckCategories.Aspire,
                    Name = CliVersionCheckName,
                    Status = EnvironmentCheckStatus.Warning,
                    // Both versions get their channel inline next to them so
                    // the message is unambiguous:
                    //   "...version 13.4.0-dev (channel: local) is out of
                    //    date. Latest version is X (channel: prerelease)"
                    // The current-version channel comes from the running
                    // CLI's baked AspireCliChannel; the latest-version
                    // channel comes from the update notifier's recommendation
                    // lane (stable vs prerelease).
                    Message = string.Format(
                        CultureInfo.CurrentCulture,
                        DoctorCommandStrings.CliVersionOutOfDateMessageFormat,
                        WithChannelSuffix(currentVersion, identityChannel),
                        WithChannelSuffix(latestVersion, status.LatestVersionChannel)),
                    Fix = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.CliVersionOutOfDateFixFormat, status.UpdateCommand ?? "aspire update"),
                    Metadata = BuildCliVersionMetadata(currentVersion, latestVersion, status.UpdateCommand, updateCheckError: null, identityChannel, status.LatestVersionChannel)
                };
            }
 
            return new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Aspire,
                Name = CliVersionCheckName,
                Status = EnvironmentCheckStatus.Pass,
                Message = FormatCliVersionMessage(currentVersion, identityChannel),
                Metadata = BuildCliVersionMetadata(currentVersion, latestVersion: null, status.UpdateCommand, updateCheckError: null, identityChannel, latestVersionChannel: null)
            };
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            logger.LogDebug(ex, "Failed to check Aspire CLI version.");
 
            return new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Aspire,
                Name = CliVersionCheckName,
                Status = EnvironmentCheckStatus.Warning,
                Message = DoctorCommandStrings.CliVersionUpdateCheckFailedMessage,
                Details = ex.Message,
                Metadata = BuildCliVersionMetadata(currentVersion: null, latestVersion: null, updateCommand: null, updateCheckError: ex.Message, identityChannel, latestVersionChannel: null)
            };
        }
    }
 
    private static string FormatCliVersionMessage(string currentVersion, string? identityChannel)
    {
        return string.Format(
            CultureInfo.CurrentCulture,
            DoctorCommandStrings.CliVersionMessageFormat,
            WithChannelSuffix(currentVersion, identityChannel));
    }
 
    /// <summary>
    /// Returns <paramref name="version"/> with the channel suffix appended
    /// inline (e.g. <c>"13.0.0 (channel: stable)"</c>) so the channel is
    /// unambiguously attached to that specific version in any message
    /// template that mentions multiple versions.
    /// </summary>
    private static string WithChannelSuffix(string version, string? channel)
    {
        if (string.IsNullOrEmpty(channel))
        {
            return version;
        }
 
        return version + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ChannelSuffixFormat, channel);
    }
 
    /// <summary>
    /// Appends the channel suffix to an arbitrary message. Used only for
    /// message templates that mention exactly one version (so there's no
    /// ambiguity about which version the channel qualifies). For templates
    /// with multiple versions, use <see cref="WithChannelSuffix"/> inline
    /// on the relevant version slot instead.
    /// </summary>
    private static string AppendChannelSuffix(string message, string? channel)
    {
        if (string.IsNullOrEmpty(channel))
        {
            return message;
        }
 
        return message + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ChannelSuffixFormat, channel);
    }
 
    private string? TryReadIdentityChannel()
    {
        // physical-binary-channel-by-design (see docs/specs/cli-identity-sidecar.md):
        // doctor reports the channel BAKED INTO the installed assembly, not the emulated
        // ASPIRE_CLI_CHANNEL / sidecar identity. Like `doctor --self`, this check is a
        // "what is actually installed" diagnostic, so it must describe physical reality.
        // When the CLI is emulating another build, that fact is surfaced separately by the
        // startup override notice (Program.DisplayFirstTimeUseNoticeIfNeededAsync), which
        // fires for human-readable `aspire doctor`. Routing this through
        // CliExecutionContext.IdentityChannel would make doctor misreport the install.
        if (identityChannelReader.TryReadChannel(out var channel, out var error))
        {
            return channel;
        }
 
        // Identity channel is informational; a misconfigured dev build
        // (no AspireCliChannel assembly metadata) must not break doctor.
        logger.LogDebug("Could not read identity channel for doctor output: {Error}", error);
        return null;
    }
 
    private async Task<EnvironmentCheckResult?> GetAppHostVersionCheckAsync(CancellationToken cancellationToken)
    {
        IReadOnlyList<FileInfo> appHostFiles;
        try
        {
            appHostFiles = await ResolveAppHostFilesAsync(cancellationToken);
        }
        catch (ProjectLocatorException ex) when (ex.FailureReason is ProjectLocatorFailureReason.NoProjectFileFound or ProjectLocatorFailureReason.ProjectFileDoesntExist)
        {
            // Doctor is useful outside an Aspire app too; no AppHost simply means there is
            // no AppHost version check to include.
            return null;
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (ProjectLocatorException ex)
        {
            return new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.AppHost,
                Name = AppHostVersionCheckName,
                Status = EnvironmentCheckStatus.Warning,
                Message = DoctorCommandStrings.AppHostVersionCheckFailedMessage,
                Details = ex.Message
            };
        }
        catch (Exception ex)
        {
            logger.LogDebug(ex, "Failed to find Aspire AppHost for version check.");
 
            return new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.AppHost,
                Name = AppHostVersionCheckName,
                Status = EnvironmentCheckStatus.Warning,
                Message = DoctorCommandStrings.AppHostVersionCheckFailedMessage,
                Details = ex.Message
            };
        }
 
        // Pinned channel is best-effort and informational: AppHost discovery already
        // succeeded, so an unreadable / malformed aspire.config.json must not flip
        // doctor into a failure state. A null pinnedChannel simply omits the field
        // from the JSON metadata and the channel suffix from the human-readable
        // message — same behavior as a project that has not pinned a channel.
        // Hoisted above the loop because the source directory is loop-invariant;
        // re-reading per-AppHost would do redundant I/O and duplicate log lines
        // on a misconfigured file.
        var pinnedChannel = TryReadPinnedChannel(executionContext.WorkingDirectory);
 
        foreach (var appHostFile in appHostFiles)
        {
            var relativePath = GetRelativePath(appHostFile);
 
            try
            {
                var (isAppHost, version) = await ResolveAppHostVersionAsync(appHostFile, cancellationToken);
                if (!isAppHost)
                {
                    continue;
                }
 
                if (string.IsNullOrWhiteSpace(version))
                {
                    return new EnvironmentCheckResult
                    {
                        Category = EnvironmentCheckCategories.AppHost,
                        Name = AppHostVersionCheckName,
                        Status = EnvironmentCheckStatus.Warning,
                        Message = AppendChannelSuffix(
                            string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.AppHostVersionUnknownMessageFormat, relativePath),
                            pinnedChannel),
                        Metadata = BuildAppHostVersionMetadata(relativePath, version: null, pinnedChannel)
                    };
                }
 
                return new EnvironmentCheckResult
                {
                    Category = EnvironmentCheckCategories.AppHost,
                    Name = AppHostVersionCheckName,
                    Status = EnvironmentCheckStatus.Pass,
                    // Channel goes inline next to the version, not at the
                    // end of the message: "AppHost version 13.0.0 (channel: stable) (path/to/AppHost.csproj)"
                    // rather than "AppHost version 13.0.0 (path/to/AppHost.csproj) (channel: stable)"
                    // — the format trails the version with the AppHost
                    // path, so a tail-appended channel would attach to the
                    // path instead.
                    Message = string.Format(
                        CultureInfo.CurrentCulture,
                        DoctorCommandStrings.AppHostVersionMessageFormat,
                        WithChannelSuffix(version, pinnedChannel),
                        relativePath),
                    Metadata = BuildAppHostVersionMetadata(relativePath, version, pinnedChannel)
                };
            }
            catch (OperationCanceledException)
            {
                throw;
            }
            catch (Exception ex)
            {
                logger.LogDebug(ex, "Failed to check Aspire AppHost version for {AppHostPath}.", appHostFile.FullName);
 
                return new EnvironmentCheckResult
                {
                    Category = EnvironmentCheckCategories.AppHost,
                    Name = AppHostVersionCheckName,
                    Status = EnvironmentCheckStatus.Warning,
                    Message = DoctorCommandStrings.AppHostVersionCheckFailedMessage,
                    Details = ex.Message,
                    Metadata = BuildAppHostVersionMetadata(relativePath, version: null, pinnedChannel)
                };
            }
        }
 
        return null;
    }
 
    /// <summary>
    /// Reads the pinned channel from <c>aspire.config.json</c> sitting in
    /// <paramref name="configDirectory"/> (the CLI's current working directory —
    /// the same anchor used by AppHost discovery). Returns <see langword="null"/>
    /// when the file is absent, malformed, or has no <c>channel</c> field. The
    /// lookup is best effort and never throws — doctor uses this only to enrich the
    /// AppHost-version line.
    /// </summary>
    private string? TryReadPinnedChannel(DirectoryInfo configDirectory)
    {
        var directory = configDirectory.FullName;
        if (string.IsNullOrEmpty(directory))
        {
            return null;
        }
 
        try
        {
            var config = AspireConfigFile.Load(directory);
            var channel = config?.Channel;
            return string.IsNullOrWhiteSpace(channel) ? null : channel;
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            logger.LogDebug(ex, "Could not read pinned channel from aspire.config.json in {Directory}.", directory);
            return null;
        }
    }
 
    private async Task<IReadOnlyList<FileInfo>> ResolveAppHostFilesAsync(CancellationToken cancellationToken)
    {
        // AppHost version reporting is intentionally shallow: use an AppHost explicitly
        // configured in the current directory, or exactly one AppHost-looking file in
        // the current directory. Avoid recursive discovery so doctor does not choose
        // between multiple AppHosts or pay the cost of project-wide AppHost search for
        // an informational version check.
        var configuredAppHost = await projectLocator.GetAppHostFromSettingsAsync(executionContext.WorkingDirectory, searchParentDirectories: false, cancellationToken);
        if (configuredAppHost is not null)
        {
            return [configuredAppHost];
        }
 
        var candidates = await projectLocator.FindAppHostProjectFilesAsync(
            executionContext.WorkingDirectory,
            AppHostDiscoveryScope.ExplicitDirectory,
            maxDepth: 0,
            cancellationToken);
 
        if (candidates.Count > 1)
        {
            logger.LogDebug(
                "Found multiple AppHost candidates in {WorkingDirectory}; skipping AppHost version check because no AppHost is configured.",
                executionContext.WorkingDirectory.FullName);
            return [];
        }
 
        return candidates;
    }
 
    private async Task<(bool IsAppHost, string? Version)> ResolveAppHostVersionAsync(FileInfo appHostFile, CancellationToken cancellationToken)
    {
        var project = projectFactory.TryGetProject(appHostFile);
        if (project is null)
        {
            return (false, null);
        }
 
        var validationResult = await project.ValidateAppHostAsync(appHostFile, cancellationToken);
        if (validationResult.IsValid)
        {
            return (true, validationResult.AspireHostingVersion ?? await project.GetAspireHostingVersionAsync(appHostFile, cancellationToken));
        }
 
        // A project named like an AppHost may fail validation because it is not currently
        // buildable. Keep reporting the candidate so doctor can explain that the version is unknown,
        // but suppress ordinary projects that only matched the broad language detection patterns.
        return (validationResult.IsPossiblyUnbuildable, null);
    }
 
    private string GetRelativePath(FileInfo file)
    {
        return Path.GetRelativePath(executionContext.WorkingDirectory.FullName, file.FullName);
    }
 
    private static JsonObject BuildCliVersionMetadata(string? currentVersion, string? latestVersion, string? updateCommand, string? updateCheckError, string? identityChannel, string? latestVersionChannel)
    {
        var metadata = new JsonObject();
 
        if (!string.IsNullOrWhiteSpace(currentVersion))
        {
            metadata["currentVersion"] = currentVersion;
        }
 
        if (!string.IsNullOrWhiteSpace(latestVersion))
        {
            metadata["latestVersion"] = latestVersion;
        }
 
        if (!string.IsNullOrWhiteSpace(updateCommand))
        {
            metadata["updateCommand"] = updateCommand;
        }
 
        if (!string.IsNullOrWhiteSpace(updateCheckError))
        {
            metadata["updateCheckError"] = updateCheckError;
        }
 
        if (!string.IsNullOrWhiteSpace(identityChannel))
        {
            metadata["identityChannel"] = identityChannel;
        }
 
        if (!string.IsNullOrWhiteSpace(latestVersionChannel))
        {
            metadata["latestVersionChannel"] = latestVersionChannel;
        }
 
        return metadata;
    }
 
    private static JsonObject BuildAppHostVersionMetadata(string relativePath, string? version, string? pinnedChannel)
    {
        var metadata = new JsonObject
        {
            ["appHostPath"] = relativePath
        };
 
        if (!string.IsNullOrWhiteSpace(version))
        {
            metadata["version"] = version;
        }
 
        if (!string.IsNullOrWhiteSpace(pinnedChannel))
        {
            metadata["pinnedChannel"] = pinnedChannel;
        }
 
        return metadata;
    }
}