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.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Cli.Configuration;
using Aspire.Cli.Layout;
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 ILayoutDiscovery _layoutDiscovery;
    private readonly ILogger<BundleNuGetPackageCache> _logger;
    private readonly IFeatures _features;
 
    // List of deprecated packages that should be filtered by default
    private static readonly HashSet<string> s_deprecatedPackages = new(StringComparer.OrdinalIgnoreCase)
    {
        "Aspire.Hosting.Dapr"
    };
 
    public BundleNuGetPackageCache(
        ILayoutDiscovery layoutDiscovery,
        ILogger<BundleNuGetPackageCache> logger,
        IFeatures features)
    {
        _layoutDiscovery = layoutDiscovery;
        _logger = logger;
        _features = features;
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(
        DirectoryInfo workingDirectory,
        bool prerelease,
        FileInfo? nugetConfigFile,
        CancellationToken cancellationToken)
    {
        var packages = await SearchPackagesInternalAsync(
            workingDirectory,
            "Aspire.ProjectTemplates",
            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,
            "Aspire.Hosting",
            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,
            "Aspire.Cli",
            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,
            packageId,
            prerelease,
            nugetConfigFile,
            cancellationToken).ConfigureAwait(false);
 
        return FilterPackages(packages, filter);
    }
 
    private async Task<IEnumerable<NuGetPackage>> SearchPackagesInternalAsync(
        DirectoryInfo workingDirectory,
        string query,
        bool prerelease,
        FileInfo? nugetConfigFile,
        CancellationToken cancellationToken)
    {
        var layout = _layoutDiscovery.DiscoverLayout();
        if (layout is null)
        {
            throw new InvalidOperationException("Bundle layout not found. Cannot perform NuGet search in bundle mode.");
        }
 
        var helperPath = layout.GetNuGetHelperPath();
        if (helperPath is null || !File.Exists(helperPath))
        {
            throw new InvalidOperationException("NuGet helper tool not found at expected location.");
        }
 
        // Build arguments for NuGetHelper search command
        var args = new List<string>
        {
            "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 NuGetHelper: {Query}", query);
        _logger.LogDebug("NuGetHelper path: {HelperPath}", helperPath);
        _logger.LogDebug("NuGetHelper args: {Args}", string.Join(" ", args));
        _logger.LogDebug("Working directory: {WorkingDir}", workingDirectory.FullName);
 
        var (exitCode, output, error) = await LayoutProcessRunner.RunAsync(
            layout,
            helperPath,
            args,
            workingDirectory: workingDirectory.FullName,
            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($"Package search failed: {error}");
        }
 
        _logger.LogDebug("NuGet search returned {Length} bytes", output?.Length ?? 0);
 
        try
        {
            if (string.IsNullOrEmpty(output))
            {
                _logger.LogWarning("NuGet search returned empty output");
                return [];
            }
 
            var result = JsonSerializer.Deserialize(output, BundleSearchJsonContext.Default.BundleSearchResult);
            if (result?.Packages is null)
            {
                return [];
            }
 
            // Convert to NuGetPackage format
            return result.Packages.Select(p => new NuGetPackage
            {
                Id = p.Id,
                Version = p.Version,
                Source = p.Source ?? string.Empty
            }).ToList();
        }
        catch (JsonException ex)
        {
            _logger.LogError(ex, "Failed to parse search results");
            throw new NuGetPackageCacheException($"Failed to parse search results: {ex.Message}");
        }
    }
 
    private IEnumerable<NuGetPackage> FilterPackages(IEnumerable<NuGetPackage> packages, Func<string, bool>? filter)
    {
        var effectiveFilter = (NuGetPackage p) =>
        {
            if (filter is not null)
            {
                return filter(p.Id);
            }
 
            var isOfficialPackage = IsOfficialOrCommunityToolkitPackage(p.Id);
 
            // Apply deprecated package filter unless the user wants to show deprecated packages
            if (isOfficialPackage && !_features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false))
            {
                return !s_deprecatedPackages.Contains(p.Id);
            }
 
            return isOfficialPackage;
        };
 
        return packages.Where(effectiveFilter);
    }
 
    private static bool IsOfficialOrCommunityToolkitPackage(string packageName)
    {
        var isHostingOrCommunityToolkitNamespaced = packageName.StartsWith("Aspire.Hosting.", StringComparison.Ordinal) ||
               packageName.StartsWith("CommunityToolkit.Aspire.Hosting.", StringComparison.Ordinal) ||
               packageName.Equals("Aspire.ProjectTemplates", StringComparison.Ordinal) ||
               packageName.Equals("Aspire.Cli", StringComparison.Ordinal);
 
        var isExcluded = packageName.StartsWith("Aspire.Hosting.AppHost") ||
                         packageName.StartsWith("Aspire.Hosting.Sdk") ||
                         packageName.StartsWith("Aspire.Hosting.Orchestration") ||
                         packageName.StartsWith("Aspire.Hosting.Testing") ||
                         packageName.StartsWith("Aspire.Hosting.Msi");
 
        return isHostingOrCommunityToolkitNamespaced && !isExcluded;
    }
}
 
#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