// 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