File: NuGet\BundleNuGetPackageCache.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;
using System.Text.Json.Serialization;
using Aspire.Cli.Bundles;
using Aspire.Cli.Configuration;
using Aspire.Cli.Layout;
using Aspire.Cli.Resources;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
 
namespace Aspire.Cli.NuGet;
 
/// <summary>
/// NuGet package cache implementation that uses the bundle's NuGetHelper tool
/// instead of the .NET SDK's `dotnet package search` command.
/// </summary>
internal sealed class BundleNuGetPackageCache : INuGetPackageCache
{
    private readonly IBundleService _bundleService;
    private readonly LayoutProcessRunner _layoutProcessRunner;
    private readonly ILogger<BundleNuGetPackageCache> _logger;
    private readonly IFeatures _features;
 
    public BundleNuGetPackageCache(
        IBundleService bundleService,
        LayoutProcessRunner layoutProcessRunner,
        ILogger<BundleNuGetPackageCache> logger,
        IFeatures features)
    {
        _bundleService = bundleService;
        _layoutProcessRunner = layoutProcessRunner;
        _logger = logger;
        _features = features;
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(
        DirectoryInfo workingDirectory,
        bool prerelease,
        FileInfo? nugetConfigFile,
        CancellationToken cancellationToken)
    {
        var packages = await SearchPackagesInternalAsync(
            workingDirectory,
            query: "Aspire.ProjectTemplates",
            exactMatch: false,
            prerelease,
            nugetConfigFile,
            cancellationToken).ConfigureAwait(false);
 
        return packages.Where(p => p.Id.Equals("Aspire.ProjectTemplates", StringComparison.OrdinalIgnoreCase));
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetIntegrationPackagesAsync(
        DirectoryInfo workingDirectory,
        bool prerelease,
        FileInfo? nugetConfigFile,
        CancellationToken cancellationToken)
    {
        var packages = await SearchPackagesInternalAsync(
            workingDirectory,
            query: "Aspire.Hosting",
            exactMatch: false,
            prerelease,
            nugetConfigFile,
            cancellationToken).ConfigureAwait(false);
 
        return FilterPackages(packages, filter: null);
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetCliPackagesAsync(
        DirectoryInfo workingDirectory,
        bool prerelease,
        FileInfo? nugetConfigFile,
        CancellationToken cancellationToken)
    {
        var packages = await SearchPackagesInternalAsync(
            workingDirectory,
            query: "Aspire.Cli",
            exactMatch: false,
            prerelease,
            nugetConfigFile,
            cancellationToken).ConfigureAwait(false);
 
        return packages.Where(p => p.Id.Equals("Aspire.Cli", StringComparison.OrdinalIgnoreCase));
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(
        DirectoryInfo workingDirectory,
        string packageId,
        Func<string, bool>? filter,
        bool prerelease,
        FileInfo? nugetConfigFile,
        bool useCache,
        CancellationToken cancellationToken)
    {
        var packages = await SearchPackagesInternalAsync(
            workingDirectory,
            query: packageId,
            exactMatch: false,
            prerelease,
            nugetConfigFile,
            cancellationToken).ConfigureAwait(false);
 
        return FilterPackages(packages, filter);
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetPackageVersionsAsync(
        DirectoryInfo workingDirectory,
        string exactPackageId,
        bool prerelease,
        FileInfo? nugetConfigFile,
        bool useCache,
        CancellationToken cancellationToken)
    {
        var packages = await SearchPackagesInternalAsync(
            workingDirectory,
            query: exactPackageId,
            exactMatch: true,
            prerelease,
            nugetConfigFile,
            cancellationToken).ConfigureAwait(false);
 
        bool FilterExactIdMatch(string? id) => string.Equals(id, exactPackageId, StringComparison.Ordinal);
        return FilterPackages(packages, FilterExactIdMatch);
    }
 
    private async Task<IEnumerable<NuGetPackage>> SearchPackagesInternalAsync(
        DirectoryInfo workingDirectory,
        string query,
        bool exactMatch,
        bool prerelease,
        FileInfo? nugetConfigFile,
        CancellationToken cancellationToken)
    {
        // Ensure the bundle is extracted and lease the version before launching aspire-managed.
        using var layoutLease = await _bundleService.EnsureExtractedAndAcquireLayoutAsync("cli", "nuget-search", cancellationToken).ConfigureAwait(false);
        var layout = layoutLease?.Layout;
        if (layout is null)
        {
            throw new InvalidOperationException("Bundle layout not found. Cannot perform NuGet search in bundle mode.");
        }
 
        var managedPath = layout.GetManagedPath();
        if (managedPath is null || !File.Exists(managedPath))
        {
            throw new InvalidOperationException("aspire-managed not found in layout.");
        }
 
        // Build arguments for NuGet search command (via aspire-managed nuget subcommand)
        var args = new List<string>
        {
            "nuget",
            "search",
            "--query", query,
            "--take", "1000",
            "--format", "json"
        };
 
        if (prerelease)
        {
            args.Add("--prerelease");
        }
 
        // Pass working directory for nuget.config discovery
        args.Add("--working-dir");
        args.Add(workingDirectory.FullName);
 
        // If explicit nuget.config is provided, use it
        if (nugetConfigFile is not null)
        {
            args.Add("--nuget-config");
            args.Add(nugetConfigFile.FullName);
        }
 
        // Enable verbose output for debugging - goes to stderr so won't mix with JSON on stdout
        if (_logger.IsEnabled(LogLevel.Debug))
        {
            args.Add("--verbose");
        }
 
        _logger.LogDebug("Running NuGet search via aspire-managed: {Query}", query);
        _logger.LogDebug("aspire-managed path: {ManagedPath}", managedPath);
        _logger.LogDebug("NuGet search args: {Args}", string.Join(" ", args));
        _logger.LogDebug("Working directory: {WorkingDir}", workingDirectory.FullName);
 
        var environmentVariables = new Dictionary<string, string>();
        layoutLease?.AddEnvironment(environmentVariables);
 
        var (exitCode, output, error) = await _layoutProcessRunner.RunAsync(
            managedPath,
            args,
            workingDirectory: workingDirectory.FullName,
            environmentVariables: environmentVariables,
            // A package search against a slow/unresponsive NuGet source can hang. LayoutProcessRunner uses
            // this to bind the helper to the CLI's Windows kill-on-close job (and, on non-Windows, to
            // instead arm the cooperative parent-liveness watchdog) so a hard-killed CLI cannot leak it.
            killOnParentExit: true,
            ct: cancellationToken).ConfigureAwait(false);
 
        // Log stderr output (verbose info from NuGetHelper)
        if (!string.IsNullOrWhiteSpace(error))
        {
            _logger.LogDebug("NuGetHelper stderr: {Error}", error);
        }
 
        if (exitCode != 0)
        {
            _logger.LogError("NuGet search failed with exit code {ExitCode}", exitCode);
            _logger.LogError("NuGet search stderr: {Error}", error);
            _logger.LogError("NuGet search stdout: {Output}", output);
            throw new NuGetPackageCacheException(string.Format(CultureInfo.CurrentCulture, ErrorStrings.FailedToSearchForPackages, exitCode));
        }
 
        _logger.LogDebug("NuGet search returned {Length} bytes", output?.Length ?? 0);
 
        try
        {
            if (string.IsNullOrEmpty(output))
            {
                _logger.LogWarning("NuGet search returned empty output");
                return [];
            }
 
            // The aspire-managed helper writes the search result as JSON to stdout, but a NuGet credential
            // provider can write diagnostics before or after that payload. Extract exactly one complete root object
            // with the expected bundle-search shape so provider diagnostics cannot be deserialized as the result.
            // See https://github.com/microsoft/aspire/issues/19339.
            var result = JsonSerializer.Deserialize(PackageUpdateHelpers.ExtractJsonPayload(output, IsBundleSearchPayload), BundleSearchJsonContext.Default.BundleSearchResult);
            if (result?.Packages is null)
            {
                return [];
            }
 
            // Convert to NuGetPackage format
            if (!exactMatch)
            {
                return result.Packages.Select(p => new NuGetPackage
                {
                    Id = p.Id,
                    Version = p.Version,
                    Source = p.Source ?? string.Empty
                }).ToList();
            }
            else
            {
                var exactMatchResultPackage = result.Packages
                    .FirstOrDefault(p => p.Id.Equals(query, StringComparison.Ordinal));
                if (exactMatchResultPackage is null || exactMatchResultPackage.AllVersions is null)
                {
                    return [];
                }
                return exactMatchResultPackage.AllVersions.Select(packageVersion => new NuGetPackage
                {
                    Id = exactMatchResultPackage.Id,
                    Version = packageVersion,
                    Source = exactMatchResultPackage.Source ?? string.Empty
                }).ToList();
            }
        }
        catch (JsonException ex)
        {
            _logger.LogError(ex, "Failed to parse search results");
            throw new NuGetPackageCacheException(ErrorStrings.FailedToParsePackageSearchResults);
        }
    }
 
    private static bool IsBundleSearchPayload(JsonElement root)
    {
        return root.ValueKind == JsonValueKind.Object &&
            root.TryGetProperty("packages", out var packages) &&
            packages.ValueKind == JsonValueKind.Array &&
            root.TryGetProperty("totalHits", out var totalHits) &&
            totalHits.ValueKind == JsonValueKind.Number;
    }
 
    private IEnumerable<NuGetPackage> FilterPackages(IEnumerable<NuGetPackage> packages, Func<string, bool>? filter)
    {
        var showDeprecatedPackages = _features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false);
        var effectiveFilter = (NuGetPackage p) =>
        {
            if (filter is not null)
            {
                return filter(p.Id);
            }
 
            var isOfficialPackage = PackageIdFilters.IsOfficialOrCommunityToolkitPackage(p.Id);
 
            // Apply deprecated package filter unless the user wants to show deprecated packages
            if (isOfficialPackage && !showDeprecatedPackages)
            {
                return !DeprecatedPackages.IsDeprecated(p.Id);
            }
 
            return isOfficialPackage;
        };
 
        return packages.Where(effectiveFilter);
    }
}
 
#region JSON Models for NuGetHelper output
 
internal sealed class BundleSearchResult
{
    public List<BundlePackageInfo>? Packages { get; set; }
    public int TotalHits { get; set; }
}
 
internal sealed class BundlePackageInfo
{
    public string Id { get; set; } = "";
    public string Version { get; set; } = "";
    public string? Description { get; set; }
    public string? Authors { get; set; }
    public List<string>? AllVersions { get; set; }
    public string? Source { get; set; }
    public bool Deprecated { get; set; }
}
 
[JsonSerializable(typeof(BundleSearchResult))]
[JsonSerializable(typeof(BundlePackageInfo))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal sealed partial class BundleSearchJsonContext : JsonSerializerContext
{
}
 
#endregion