diff --git a/Aspire.slnx b/Aspire.slnx index 52300ace4ae..1075a042c85 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -394,6 +394,7 @@ + diff --git a/src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs b/src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs index 0507b06af52..14d72f7bb59 100644 --- a/src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs +++ b/src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs @@ -3,7 +3,6 @@ using System.Collections.Immutable; using System.CommandLine; -using System.Globalization; using NuGet.Commands; using NuGet.Configuration; using NuGet.Frameworks; @@ -100,13 +99,6 @@ public static Command Create() var noNugetOrg = parseResult.GetValue(noNugetOrgOption); var verbose = parseResult.GetValue(verboseOption); - // Validate that both nuget-config and sources aren't provided together - if (!string.IsNullOrEmpty(nugetConfigPath) && sources.Length > 0) - { - Console.Error.WriteLine("Error: Cannot specify both --nuget-config and --source. Use one or the other."); - return 1; - } - // Parse packages (format: PackageId,Version) var packages = new List<(string Id, string Version)>(); foreach (var pkgArg in packageArgs) @@ -124,18 +116,18 @@ public static Command Create() packages.Add((parts[0], parts[1])); } - return await ExecuteRestoreAsync([.. packages], framework, output, packagesDir, sources, nugetConfigPath, workingDir, noNugetOrg, verbose).ConfigureAwait(false); + return await ExecuteRestoreAsync(packages, framework, output, packagesDir, sources, nugetConfigPath, workingDir, noNugetOrg, verbose).ConfigureAwait(false); }); return command; } private static async Task ExecuteRestoreAsync( - (string Id, string Version)[] packages, + List<(string Id, string Version)> packages, string framework, string output, string? packagesDir, - string[] sources, + string[] cliSources, string? nugetConfigPath, string? workingDir, bool noNugetOrg, @@ -144,19 +136,16 @@ private static async Task ExecuteRestoreAsync( var outputPath = Path.GetFullPath(output); Directory.CreateDirectory(outputPath); - packagesDir ??= Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".nuget", "packages"); - var logger = new NuGetLogger(verbose); try { - var nugetFramework = NuGetFramework.Parse(framework); + // Load NuGet settings once — handles working dir, config file, and machine-wide settings. + var settings = Settings.LoadDefaultSettings(workingDir, nugetConfigPath, new XPlatMachineWideSetting()); if (verbose) { - Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "Restoring {0} packages for {1}", packages.Length, framework)); + Console.WriteLine($"Restoring {packages.Count} packages for {framework}"); Console.WriteLine($"Output: {outputPath}"); Console.WriteLine($"Packages: {packagesDir}"); if (workingDir is not null) @@ -169,45 +158,43 @@ private static async Task ExecuteRestoreAsync( } } - // Load package sources - var packageSources = LoadPackageSources(sources, nugetConfigPath, workingDir, noNugetOrg, verbose); + // Resolve the default packages path from settings (env var, config, or ~/.nuget/packages). + // If --packages-dir is provided, RestoreArgs.GlobalPackagesFolder overrides this. + var defaultPackagesPath = SettingsUtility.GetGlobalPackagesFolder(settings); - // Build PackageSpec - var packageSpec = BuildPackageSpec(packages, nugetFramework, outputPath, packagesDir, packageSources); + // Resolve package sources using NuGet's PackageSourceProvider + var packageSources = ResolvePackageSources(settings, cliSources, noNugetOrg); + + var nugetFramework = NuGetFramework.Parse(framework); + + // Build PackageSpec and DependencyGraphSpec + var packageSpec = BuildPackageSpec(packages, nugetFramework, outputPath, defaultPackagesPath, packageSources, settings); - // Create DependencyGraphSpec var dgSpec = new DependencyGraphSpec(); dgSpec.AddProject(packageSpec); dgSpec.AddRestore(packageSpec.RestoreMetadata.ProjectUniqueName); - // Setup providers + // Pass settings to the provider so it reuses our pre-loaded settings var providerCache = new RestoreCommandProvidersCache(); - var providers = new List - { - new DependencyGraphSpecRequestProvider(providerCache, dgSpec) - }; + var dgProvider = new DependencyGraphSpecRequestProvider(providerCache, dgSpec, settings); - // Run restore + // Run restore — let NuGet handle source credentials, parallel execution, etc. using var cacheContext = new SourceCacheContext(); - var restoreContext = new RestoreArgs + var restoreArgs = new RestoreArgs { CacheContext = cacheContext, Log = logger, - PreLoadedRequestProviders = providers, + PreLoadedRequestProviders = [dgProvider], DisableParallel = Environment.ProcessorCount == 1, AllowNoOp = false, - GlobalPackagesFolder = packagesDir + GlobalPackagesFolder = packagesDir, + MachineWideSettings = new XPlatMachineWideSetting(), }; - if (verbose) - { - Console.WriteLine("Running restore..."); - } - - var results = await RestoreRunner.RunAsync(restoreContext).ConfigureAwait(false); + var results = await RestoreRunner.RunAsync(restoreArgs).ConfigureAwait(false); var summary = results.Count > 0 ? results[0] : null; - if (summary == null) + if (summary is null) { Console.Error.WriteLine("Error: Restore returned no results"); return 1; @@ -238,70 +225,18 @@ private static async Task ExecuteRestoreAsync( } } - private static List LoadPackageSources(string[] sources, string? nugetConfigPath, string? workingDir, bool noNugetOrg, bool verbose) + private static List ResolvePackageSources(ISettings settings, string[] cliSources, bool noNugetOrg) { - var packageSources = new List(); + // Load enabled sources from NuGet config + var provider = new PackageSourceProvider(settings); + var sources = provider.LoadPackageSources().Where(s => s.IsEnabled).ToList(); - // Add explicit sources first (they get priority) - foreach (var source in sources) + // Append CLI --source values (matching NuGet's behavior of merging, not replacing) + foreach (var cliSource in cliSources) { - packageSources.Add(new PackageSource(source)); - } - - // Load from specific config file if specified - if (!string.IsNullOrEmpty(nugetConfigPath) && File.Exists(nugetConfigPath)) - { - var configDir = Path.GetDirectoryName(nugetConfigPath)!; - var configFile = Path.GetFileName(nugetConfigPath); - var settings = Settings.LoadSpecificSettings(configDir, configFile); - var provider = new PackageSourceProvider(settings); - - foreach (var source in provider.LoadPackageSources()) + if (!sources.Any(s => s.Source.Equals(cliSource, StringComparison.OrdinalIgnoreCase))) { - if (source.IsEnabled && !packageSources.Any(s => s.Source == source.Source)) - { - packageSources.Add(source); - } - } - } - // Auto-discover nuget.config from working directory if specified - else if (!string.IsNullOrEmpty(workingDir) && Directory.Exists(workingDir)) - { - try - { - // LoadDefaultSettings walks up the directory tree looking for nuget.config files - var settings = Settings.LoadDefaultSettings(workingDir); - var provider = new PackageSourceProvider(settings); - - if (verbose) - { - // Show the config file paths that were loaded - var configPaths = settings.GetConfigFilePaths(); - Console.WriteLine($"Discovering NuGet config from: {workingDir}"); - foreach (var configPath in configPaths) - { - Console.WriteLine($" Loaded config: {configPath}"); - } - } - - foreach (var source in provider.LoadPackageSources()) - { - if (source.IsEnabled && !packageSources.Any(s => s.Source == source.Source)) - { - if (verbose) - { - Console.WriteLine($" Discovered source: {source.Name ?? source.Source}"); - } - packageSources.Add(source); - } - } - } - catch (Exception ex) - { - if (verbose) - { - Console.WriteLine($"Warning: Failed to load NuGet config from {workingDir}: {ex.ToString()}"); - } + sources.Add(new PackageSource(cliSource)); } } @@ -309,37 +244,28 @@ private static List LoadPackageSources(string[] sources, string? if (!noNugetOrg) { const string nugetOrgUrl = "https://api.nuget.org/v3/index.json"; - if (!packageSources.Any(s => s.Source.Equals(nugetOrgUrl, StringComparison.OrdinalIgnoreCase))) + if (!sources.Any(s => s.Source.Equals(nugetOrgUrl, StringComparison.OrdinalIgnoreCase))) { Console.WriteLine("Note: Adding nuget.org as fallback package source. Use --no-nuget-org to disable."); - packageSources.Add(new PackageSource(nugetOrgUrl, "nuget.org")); - } - } - - if (verbose) - { - Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "Using {0} package sources:", packageSources.Count)); - foreach (var source in packageSources) - { - Console.WriteLine($" - {source.Name ?? source.Source}"); + sources.Add(new PackageSource(nugetOrgUrl, "nuget.org")); } } - return packageSources; + return sources; } private static PackageSpec BuildPackageSpec( - (string Id, string Version)[] packages, + List<(string Id, string Version)> packages, NuGetFramework framework, string outputPath, string packagesPath, - List sources) + List sources, + ISettings settings) { var projectName = "AspireRestore"; var projectPath = Path.Combine(outputPath, "project.json"); var tfmShort = framework.GetShortFolderName(); - // Build dependencies var dependencies = packages.Select(p => new LibraryDependency { LibraryRange = new LibraryRange( @@ -348,7 +274,6 @@ private static PackageSpec BuildPackageSpec( LibraryDependencyTarget.Package) }).ToImmutableArray(); - // Build target framework info var tfInfo = new TargetFrameworkInformation { FrameworkName = framework, @@ -356,7 +281,6 @@ private static PackageSpec BuildPackageSpec( Dependencies = dependencies }; - // Build restore metadata var restoreMetadata = new ProjectRestoreMetadata { ProjectUniqueName = projectName, @@ -366,15 +290,14 @@ private static PackageSpec BuildPackageSpec( OutputPath = outputPath, PackagesPath = packagesPath, OriginalTargetFrameworks = [tfmShort], + ConfigFilePaths = settings.GetConfigFilePaths().ToList(), }; - // Add sources foreach (var source in sources) { restoreMetadata.Sources.Add(source); } - // Add target framework restoreMetadata.TargetFrameworks.Add(new ProjectRestoreMetadataFrameworkInfo(framework) { TargetAlias = tfmShort diff --git a/src/Aspire.Managed/Properties/launchSettings.json b/src/Aspire.Managed/Properties/launchSettings.json new file mode 100644 index 00000000000..b8ed589cd2f --- /dev/null +++ b/src/Aspire.Managed/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Aspire.Managed": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:50500;http://localhost:50501" + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Managed.Tests/Aspire.Managed.Tests.csproj b/tests/Aspire.Managed.Tests/Aspire.Managed.Tests.csproj new file mode 100644 index 00000000000..4ab6bd8607a --- /dev/null +++ b/tests/Aspire.Managed.Tests/Aspire.Managed.Tests.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + + + + + + + + + + + + + diff --git a/tests/Aspire.Managed.Tests/NuGet/RestoreCommandTests.cs b/tests/Aspire.Managed.Tests/NuGet/RestoreCommandTests.cs new file mode 100644 index 00000000000..bcc2dff6bed --- /dev/null +++ b/tests/Aspire.Managed.Tests/NuGet/RestoreCommandTests.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Managed.NuGet.Commands; +using Microsoft.DotNet.RemoteExecutor; +using Xunit; + +namespace Aspire.Managed.Tests.NuGet; + +public class RestoreCommandTests : IDisposable +{ + private readonly TestTempDirectory _tempDir = new(); + + public void Dispose() => _tempDir.Dispose(); + + [Fact] + public void RestoreCommand_RespectsNuGetConfigGlobalPackagesFolder() + { + var customPackagesDir = Path.GetFullPath(Path.Combine(_tempDir.Path, "custom-packages")); + var nugetConfigPath = Path.Combine(_tempDir.Path, "NuGet.config"); + + File.WriteAllText(nugetConfigPath, $""" + + + + + + + """); + + // Run in a separate process so NUGET_PACKAGES env var from the parent + // doesn't interfere. The env var takes precedence over config files + // in NuGet's resolution order. + var options = new RemoteInvokeOptions(); + options.StartInfo.Environment.Remove("NUGET_PACKAGES"); + + RemoteExecutor.Invoke(static async (tempDirPath) => + { + var command = RestoreCommand.Create(); + var outputDir = Path.Combine(tempDirPath, "obj"); + + await command.Parse(["--package", "Fake.Package,1.0.0", "--no-nuget-org", "--output", outputDir, "--working-dir", tempDirPath]).InvokeAsync(); + }, _tempDir.Path, options).Dispose(); + + // NuGet writes packageFolders into project.assets.json with the resolved packages directory. + var assetsContent = File.ReadAllText(Path.Combine(_tempDir.Path, "obj", "project.assets.json")); + Assert.Contains(JsonEncodedPath(customPackagesDir), assetsContent); + } + + [Fact] + public void RestoreCommand_RespectsNuGetPackagesEnvironmentVariable() + { + var customPackagesDir = Path.GetFullPath(Path.Combine(_tempDir.Path, "env-packages")); + + // Run in a separate process with NUGET_PACKAGES set to the custom directory. + // The env var takes priority over all config file settings. + var options = new RemoteInvokeOptions(); + options.StartInfo.Environment["NUGET_PACKAGES"] = customPackagesDir; + + RemoteExecutor.Invoke(static async (tempDirPath) => + { + var command = RestoreCommand.Create(); + var outputDir = Path.Combine(tempDirPath, "obj"); + + await command.Parse(["--package", "Fake.Package,1.0.0", "--no-nuget-org", "--output", outputDir, "--working-dir", tempDirPath]).InvokeAsync(); + }, _tempDir.Path, options).Dispose(); + + // NuGet writes packageFolders into project.assets.json with the resolved packages directory. + var assetsContent = File.ReadAllText(Path.Combine(_tempDir.Path, "obj", "project.assets.json")); + Assert.Contains(JsonEncodedPath(customPackagesDir), assetsContent); + } + + [Fact] + public void RestoreCommand_CliSourcesAreAppendedToConfigSources() + { + var nugetConfigPath = Path.Combine(_tempDir.Path, "NuGet.config"); + var configSourcePath = Path.Combine(_tempDir.Path, "config-source"); + var cliSourcePath = Path.Combine(_tempDir.Path, "cli-source"); + + File.WriteAllText(nugetConfigPath, $""" + + + + + + + + """); + + // Run in a separate process so the parent's NuGet config doesn't interfere. + var options = new RemoteInvokeOptions(); + options.StartInfo.Environment.Remove("NUGET_PACKAGES"); + + RemoteExecutor.Invoke(static async (nugetConfig, cliSourcePath, tempDirPath) => + { + var command = RestoreCommand.Create(); + var outputDir = Path.Combine(tempDirPath, "obj"); + + // Pass --source in addition to the config source. Both should be used. + await command.Parse([ + "--package", "Fake.Package,1.0.0", + "--no-nuget-org", + "--nuget-config", nugetConfig, + "--source", cliSourcePath, + "--output", outputDir, + "--working-dir", tempDirPath]).InvokeAsync(); + }, nugetConfigPath, cliSourcePath, _tempDir.Path, options).Dispose(); + + // NuGet writes the resolved sources into project.assets.json regardless of + // whether the restore succeeds. Verify both sources are present. + var assetsContent = File.ReadAllText(Path.Combine(_tempDir.Path, "obj", "project.assets.json")); + Assert.Contains(JsonEncodedPath(configSourcePath), assetsContent); + Assert.Contains(JsonEncodedPath(cliSourcePath), assetsContent); + } + + /// + /// Converts a file path to its JSON-escaped representation (e.g. backslashes doubled). + /// + private static string JsonEncodedPath(string path) => + path.Replace(@"\", @"\\"); +}