File: Projects\RepositoryToolUpdater.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.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Interaction;
using Aspire.Cli.Npm;
using Aspire.Cli.Packaging;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Logging;
using Semver;
using Spectre.Console;
 
namespace Aspire.Cli.Projects;
 
/// <summary>
/// Updates repository-local Aspire CLI references without installing a CLI executable.
/// </summary>
internal sealed class RepositoryToolUpdater(INpmRunner npmRunner, IInteractionService interactionService, ILogger<RepositoryToolUpdater> logger)
{
    internal const string DotNetPackageId = "Aspire.Cli";
    internal const string NpmPackageId = "@microsoft/aspire-cli";
    private static readonly string[] s_dependencySections = ["dependencies", "devDependencies", "optionalDependencies"];
    private static readonly JsonSerializerOptions s_jsonOptions = new()
    {
        WriteIndented = true,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    };
 
    public async Task<IReadOnlyList<RepositoryToolManifest>> FindManifestsAsync(DirectoryInfo directory, CancellationToken cancellationToken)
    {
        logger.LogDebug("Finding repository CLI manifests from {Directory}", directory.FullName);
        var manifests = new List<RepositoryToolManifest>();
        var searchDotNet = true;
        var searchNpm = true;
 
        // Follow local-tool lookup order and isRoot when searching ancestor directories.
        // https://learn.microsoft.com/dotnet/core/tools/local-tools-how-to-use
        for (DirectoryInfo? current = directory; current is not null; current = current.Parent)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (searchDotNet)
            {
                foreach (var path in new[] { Path.Combine(current.FullName, ".config", "dotnet-tools.json"), Path.Combine(current.FullName, "dotnet-tools.json") })
                {
                    var manifest = await ReadManifestAsync(path, isNpm: false, cancellationToken);
                    if (manifest is null)
                    {
                        continue;
                    }
 
                    if (manifest.References.Count > 0)
                    {
                        manifests.Add(manifest);
                    }
 
                    if (manifest.References.Count > 0 || manifest.IsRoot)
                    {
                        searchDotNet = false;
                        break;
                    }
                }
            }
 
            if (searchNpm)
            {
                var manifest = await ReadManifestAsync(Path.Combine(current.FullName, "package.json"), isNpm: true, cancellationToken);
                if (manifest is { References.Count: > 0 })
                {
                    manifests.Add(manifest);
                    searchNpm = false;
                }
            }
 
            if (!searchDotNet && !searchNpm)
            {
                break;
            }
        }
 
        return manifests;
    }
 
    public async Task<RepositoryToolUpdateResult> UpdateAsync(IReadOnlyList<RepositoryToolManifest> manifests, PackageChannel channel, PromptBinding<bool> confirmBinding, CancellationToken cancellationToken)
    {
        var updateStep = await GetUpdateStepAsync(manifests, channel, cancellationToken);
        if (updateStep is null)
        {
            return RepositoryToolUpdateResult.NoChanges;
        }
 
        interactionService.DisplayMessage(KnownEmojis.Package, updateStep.GetFormattedDisplayText(), allowMarkup: true);
        if (await interactionService.PromptConfirmAsync(UpdateCommandStrings.PerformUpdatesPrompt, confirmBinding, cancellationToken: cancellationToken))
        {
            await updateStep.Callback();
            return RepositoryToolUpdateResult.Applied;
        }
 
        return RepositoryToolUpdateResult.Declined;
    }
 
    /// <summary>
    /// Resolves repository CLI changes without writing files so they can join the project update plan.
    /// </summary>
    public async Task<UpdateStep?> GetUpdateStepAsync(IReadOnlyList<RepositoryToolManifest> manifests, PackageChannel channel, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var updates = new List<(RepositoryToolManifest Manifest, RepositoryToolReference Reference, string Version)>();
        var skippedReference = false;
        foreach (var manifest in manifests)
        {
            string? targetVersion = null;
            foreach (var reference in manifest.References)
            {
                // Preserve simple npm range intent, e.g. "^13.5.0" or "~13.5.0". File,
                // workspace, alias, and compound ranges are not version pins we can safely rewrite.
                var prefix = manifest.IsNpm && reference.Version.StartsWith('^') ? "^"
                    : manifest.IsNpm && reference.Version.StartsWith('~') ? "~" : string.Empty;
                if (!SemVersion.TryParse(reference.Version[prefix.Length..], SemVersionStyles.Strict, out var currentVersion))
                {
                    interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture,
                        UpdateCommandStrings.UnsupportedToolVersionFormat, manifest.File.FullName, reference.Version));
                    skippedReference = true;
                    continue;
                }
 
                targetVersion ??= await GetTargetVersionAsync(manifest, channel, cancellationToken);
                var newVersion = SemVersion.Parse(targetVersion, SemVersionStyles.Strict);
                if (SemVersion.PrecedenceComparer.Compare(newVersion, currentVersion) == 0 ||
                    (channel.Type == PackageChannelType.Implicit && SemVersion.PrecedenceComparer.Compare(newVersion, currentVersion) < 0))
                {
                    continue;
                }
 
                updates.Add((manifest, reference, prefix + targetVersion));
            }
        }
 
        if (updates.Count == 0)
        {
            if (manifests.Count > 0 && !skippedReference)
            {
                interactionService.DisplayMessage(KnownEmojis.CheckMarkButton, UpdateCommandStrings.RepositoryToolsUpToDate);
            }
 
            return null;
        }
 
        var displayText = string.Join(Environment.NewLine, updates.Select(update =>
            string.Format(CultureInfo.CurrentCulture, UpdateCommandStrings.RepositoryToolUpdateFormat,
                update.Manifest.File.FullName.EscapeMarkup(), update.Manifest.PackageId.EscapeMarkup(),
                update.Reference.Version.EscapeMarkup(), update.Version.EscapeMarkup())));
        return new RepositoryToolsUpdateStep(displayText, () => ApplyUpdatesAsync(updates, cancellationToken));
    }
 
    /// <summary>
    /// Displays restore/install guidance for the given repository CLI manifests.
    /// </summary>
    public void DisplayRestoreGuidance(IReadOnlyList<RepositoryToolManifest> manifests)
    {
        if (manifests.Any(manifest => !manifest.IsNpm))
        {
            interactionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.RestoreRepositoryDotNetTool);
        }
        if (manifests.Any(manifest => manifest.IsNpm))
        {
            interactionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.RestoreRepositoryNpmTool);
        }
    }
 
    private async Task ApplyUpdatesAsync(
        IReadOnlyList<(RepositoryToolManifest Manifest, RepositoryToolReference Reference, string Version)> updates,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var changedManifests = new List<RepositoryToolManifest>();
        var originalFiles = new Dictionary<string, byte[]>();
        foreach (var manifestUpdates in updates.GroupBy(update => update.Manifest))
        {
            var original = manifestUpdates.Key;
            var manifest = await ReadManifestAsync(original.File.FullName, original.IsNpm, cancellationToken);
            // Guest regeneration can edit unrelated package.json fields before this step.
            // Preserve those edits, but reject changes to the CLI references the user approved.
            if (manifest is null || manifest.IsRoot != original.IsRoot ||
                !manifest.References.Select(reference => (reference.Properties.GetPath(), reference.Key, reference.Version))
                    .SequenceEqual(original.References.Select(reference => (reference.Properties.GetPath(), reference.Key, reference.Version))))
            {
                throw new ProjectUpdaterException(string.Format(CultureInfo.CurrentCulture, UpdateCommandStrings.ToolManifestChangedFormat, original.File.FullName));
            }
 
            originalFiles.Add(manifest.File.FullName, await File.ReadAllBytesAsync(manifest.File.FullName, cancellationToken));
            foreach (var (_, reference, version) in manifestUpdates)
            {
                var currentReference = manifest.References.Single(candidate =>
                    candidate.Key == reference.Key && candidate.Properties.GetPath() == reference.Properties.GetPath());
                currentReference.Properties[currentReference.Key] = version;
            }
            changedManifests.Add(manifest);
        }
 
        var writtenManifests = new List<RepositoryToolManifest>();
        try
        {
            foreach (var manifest in changedManifests)
            {
                var newLine = manifest.OriginalContent.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
                var content = manifest.Content.ToJsonString(s_jsonOptions).ReplaceLineEndings(newLine) + newLine;
                writtenManifests.Add(manifest);
                await File.WriteAllTextAsync(manifest.File.FullName, content, cancellationToken);
            }
        }
        catch
        {
            // A failed write or cancellation must not leave only some of the manifests updated.
            foreach (var manifest in writtenManifests)
            {
                await File.WriteAllBytesAsync(manifest.File.FullName, originalFiles[manifest.File.FullName], CancellationToken.None);
            }
 
            throw;
        }
 
        foreach (var manifest in changedManifests)
        {
            logger.LogDebug("Updated repository CLI reference in {ManifestPath}", manifest.File.FullName);
        }
 
        interactionService.DisplaySuccess(UpdateCommandStrings.RepositoryToolsUpdated);
        DisplayRestoreGuidance(changedManifests);
    }
 
    private async Task<string> GetTargetVersionAsync(RepositoryToolManifest manifest, PackageChannel channel, CancellationToken cancellationToken)
    {
        // npm's stable dist-tag is independent of NuGet publication. For an explicit
        // non-stable channel, require the exact channel version to exist on npm rather
        // than silently switching the repository back to stable.
        var npmStable = manifest.IsNpm && channel.PinnedVersion is null &&
            (channel.Type == PackageChannelType.Implicit || string.Equals(channel.Name, PackageChannelNames.Stable, StringComparisons.ChannelName));
        string? version = null;
        if (!npmStable)
        {
            var packages = await channel.GetPackagesAsync(DotNetPackageId, manifest.File.Directory!, cancellationToken);
            version = packages
                .Where(package => string.Equals(package.Id, DotNetPackageId, StringComparisons.NuGetPackageId))
                .OrderByDescending(package => SemVersion.Parse(package.Version, SemVersionStyles.Strict), SemVersion.PrecedenceComparer)
                .FirstOrDefault()?.Version;
            if (version is null)
            {
                throw new ProjectUpdaterException(string.Format(CultureInfo.CurrentCulture, UpdateCommandStrings.NoPackageFoundFormat, DotNetPackageId, channel.Name));
            }
        }
 
        if (manifest.IsNpm)
        {
            var package = npmRunner.IsAvailable
                ? await npmRunner.ResolvePackageAsync(NpmPackageId, version ?? "latest", cancellationToken)
                : null;
            if (package is null)
            {
                throw new ProjectUpdaterException(string.Format(CultureInfo.CurrentCulture, UpdateCommandStrings.FailedResolveNpmToolFormat, version ?? "latest"));
            }
 
            return package.Version.ToString();
        }
 
        return version ?? throw new ProjectUpdaterException(string.Format(CultureInfo.CurrentCulture,
            UpdateCommandStrings.NoPackageFoundFormat, DotNetPackageId, channel.Name));
    }
 
    private static async Task<RepositoryToolManifest?> ReadManifestAsync(string path, bool isNpm, CancellationToken cancellationToken)
    {
        if (!File.Exists(path))
        {
            return null;
        }
 
        try
        {
            var originalContent = await File.ReadAllTextAsync(path, cancellationToken);
            var content = JsonNode.Parse(originalContent)?.AsObject() ?? throw new JsonException("Expected a JSON object.");
            var isRoot = !isNpm && content["isRoot"]?.GetValue<bool>() == true;
            var references = new List<RepositoryToolReference>();
            if (isNpm)
            {
                foreach (var section in s_dependencySections)
                {
                    if (content[section] is JsonObject dependencies && dependencies[NpmPackageId] is { } version)
                    {
                        references.Add(new(dependencies, NpmPackageId, version.GetValue<string>()));
                    }
                }
            }
            else if (content["tools"] is JsonObject tools)
            {
                foreach (var (name, tool) in tools)
                {
                    if (string.Equals(name, DotNetPackageId, StringComparisons.NuGetPackageId) && tool is JsonObject properties)
                    {
                        references.Add(new(properties, "version", properties["version"]?.GetValue<string>() ?? throw new JsonException("Missing tool version.")));
                    }
                }
            }
 
            return new(new FileInfo(path), content, originalContent, references, isNpm, isRoot);
        }
        catch (Exception ex) when (ex is JsonException or InvalidOperationException or IOException or UnauthorizedAccessException)
        {
            throw new ProjectUpdaterException(string.Format(CultureInfo.CurrentCulture, UpdateCommandStrings.FailedReadToolManifestFormat, path, ex.Message));
        }
    }
 
    private sealed record RepositoryToolsUpdateStep(string DisplayText, Func<Task> Callback)
        : UpdateStep(UpdateCommandStrings.UpdateRepositoryTools, Callback)
    {
        public override string GetFormattedDisplayText() => DisplayText;
    }
}
 
internal sealed record RepositoryToolManifest(FileInfo File, JsonObject Content, string OriginalContent, IReadOnlyList<RepositoryToolReference> References, bool IsNpm, bool IsRoot)
{
    public string PackageId => IsNpm ? RepositoryToolUpdater.NpmPackageId : RepositoryToolUpdater.DotNetPackageId;
}
 
internal sealed record RepositoryToolReference(JsonObject Properties, string Key, string Version);
 
internal enum RepositoryToolUpdateResult
{
    NoChanges,
    Declined,
    Applied
}