From c28f9a660235d083c753f44e0fc30afc232c841e Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 1 May 2026 12:40:03 -0700 Subject: [PATCH 1/2] [release/13.3] Fix aspire init template install for non-stable CLI builds (#16654) spire init ran `dotnet new install Aspire.ProjectTemplates@` with `nugetConfigFile: null` and `nugetSource: null`, bypassing the channel feed wiring used by `aspire new`. For non-stable CLI builds (staging/daily/PR), `Aspire.ProjectTemplates@` is only available on a per-commit darc feed (e.g. `darc-pub-microsoft-aspire-`), so install failed with exit code 103 in any C# repo containing a `.sln`. Extract the channel-aware template package resolution and install logic out of `DotNetTemplateFactory.ApplyTemplateAsync` and into `TemplateNuGetConfigService` as `ResolveTemplatePackageAsync` and `InstallTemplatePackageAsync`. Both `DotNetTemplateFactory` and `InitCommand` now consume the helper. The existing `aspire new` install path is preserved bit-for-bit (extraction is mechanical; `IncludePrHives: true` keeps PR-hive widening behavior). For `aspire init` this means: - The version sent to `dotnet new install` is now the channel-resolved one (e.g. `13.3.0`), not the raw `+sha` build metadata. - Init now honors the global `channel` configuration, matching `aspire new`. - On install failure, captured stdout/stderr is displayed before the error. - `ChannelNotFoundException` and `EmptyChoicesException` produce friendly errors instead of bubbling to the top-level "unexpected error" handler. - PR hives are intentionally NOT included in init's channel discovery so a developer with stale `~/.aspire/hives/*` doesn't get a different template than they'd get on a clean machine. Notes: - `TemplateNuGetConfigService` is a singleton; `IDotNetCliRunner` is transient and is therefore passed as a method parameter to `InstallTemplatePackageAsync` instead of being injected. - New regression tests cover: explicit channel passes the temp NuGet config, implicit channel leaves it null, PR hives don't widen init, and channel resolution failures produce friendly errors. Fixes #16654 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/InitCommand.cs | 55 +++- .../Templating/DotNetTemplateFactory.cs | 157 ++------- .../Templating/TemplateNuGetConfigService.cs | 204 +++++++++++- .../Commands/InitCommandTests.cs | 299 ++++++++++++++++++ .../Commands/NewCommandTests.cs | 18 +- .../Templating/DotNetTemplateFactoryTests.cs | 5 +- .../TestServices/TestDotNetCliRunner.cs | 4 +- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 5 +- 8 files changed, 575 insertions(+), 172 deletions(-) diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index e816f491db4..e0dc28c05c8 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -9,11 +9,13 @@ using Aspire.Cli.Certificates; using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; +using Aspire.Cli.Exceptions; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Scaffolding; using Aspire.Cli.Telemetry; +using Aspire.Cli.Templating; using Aspire.Cli.Utils; using Aspire.Shared; @@ -37,6 +39,7 @@ internal sealed class InitCommand : BaseCommand private readonly ICertificateService _certificateService; private readonly IScaffoldingService _scaffoldingService; private readonly ILanguageDiscovery _languageDiscovery; + private readonly TemplateNuGetConfigService _templateNuGetConfigService; private static readonly Option s_sourceOption = new("--source", "-s") { @@ -67,7 +70,8 @@ public InitCommand( IDotNetCliRunner runner, ICertificateService certificateService, IScaffoldingService scaffoldingService, - ILanguageDiscovery languageDiscovery) + ILanguageDiscovery languageDiscovery, + TemplateNuGetConfigService templateNuGetConfigService) : base("init", InitCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _executionContext = executionContext; @@ -78,6 +82,7 @@ public InitCommand( _certificateService = certificateService; _scaffoldingService = scaffoldingService; _languageDiscovery = languageDiscovery; + _templateNuGetConfigService = templateNuGetConfigService; _channelOption = new Option("--channel") { @@ -272,24 +277,46 @@ private async Task DropCSharpProjectSkeletonAsync(FileInfo solutionFile, Ca return ExitCodeConstants.Success; } + // Resolve the channel-aware template package version + feed mapping. This makes init + // honor the global `channel` configuration (matching `aspire new`) and ensures the + // staging/daily/PR feed is queried for non-stable CLI builds. PR hives are intentionally + // excluded — init should produce the same template on every machine for a given CLI build. + TemplatePackageSelection selection; + try + { + var query = new TemplatePackageQuery( + ChannelOverride: null, + VersionOverride: null, + SourceOverride: null, + IncludePrHives: false); + + selection = await _templateNuGetConfigService.ResolveTemplatePackageAsync(query, cancellationToken); + } + catch (ChannelNotFoundException ex) + { + InteractionService.DisplayError(ex.Message); + return ExitCodeConstants.FailedToInstallTemplates; + } + catch (EmptyChoicesException ex) + { + InteractionService.DisplayError(ex.Message); + return ExitCodeConstants.FailedToInstallTemplates; + } + // The aspire-apphost template ships in the Aspire.ProjectTemplates package. // `dotnet new` does not install templates implicitly, so on a fresh machine // (or after a CLI update) the template will be missing. Install first. - var aspireVersion = VersionHelper.GetDefaultTemplateVersion(); - var installResult = await InteractionService.ShowStatusAsync( + var installOutcome = await _templateNuGetConfigService.InstallTemplatePackageAsync( + selection, + _runner, "Installing Aspire project templates...", - () => _runner.InstallTemplateAsync( - packageName: "Aspire.ProjectTemplates", - version: aspireVersion, - nugetConfigFile: null, - nugetSource: null, - force: true, - options: new ProcessInvocationOptions(), - cancellationToken: cancellationToken)); - - if (installResult.ExitCode != 0) + statusEmoji: null, + cancellationToken); + + if (installOutcome.ExitCode != 0) { - InteractionService.DisplayError($"Failed to install Aspire.ProjectTemplates (exit code {installResult.ExitCode})."); + InteractionService.DisplayLines(installOutcome.OutputLines); + InteractionService.DisplayError($"Failed to install Aspire.ProjectTemplates (exit code {installOutcome.ExitCode})."); return ExitCodeConstants.FailedToInstallTemplates; } diff --git a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs index 9941bdcb5ac..4a901596332 100644 --- a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs @@ -9,14 +9,10 @@ using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; -using Aspire.Cli.Packaging; using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; -using NuGetPackage = Aspire.Shared.NuGetPackageCli; -using Semver; -using Spectre.Console; namespace Aspire.Cli.Templating; @@ -24,13 +20,10 @@ internal class DotNetTemplateFactory( IInteractionService interactionService, IDotNetCliRunner runner, ICertificateService certificateService, - IPackagingService packagingService, INewCommandPrompter prompter, - ITemplateVersionPrompter templateVersionPrompter, CliExecutionContext executionContext, IDotNetSdkInstaller sdkInstaller, IFeatures features, - IConfigurationService configurationService, AspireCliTelemetry telemetry, ICliHostEnvironment hostEnvironment, TemplateNuGetConfigService templateNuGetConfigService) @@ -457,50 +450,34 @@ private async Task ApplyTemplateAsync(CallbackTemplate template, { try { - var source = inputs.Source; - var selectedTemplateDetails = await GetProjectTemplatesVersionAsync(inputs, cancellationToken: cancellationToken); - // Some templates have additional arguments that need to be applied to the `dotnet new` command // when it is executed. This callback will get those arguments and potentially prompt for them. + // Run before resolving the template package so prompt/error precedence is unchanged. var extraArgs = await extraArgsCallback(parseResult, cancellationToken); - using var temporaryConfig = selectedTemplateDetails.Channel.Type == PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(selectedTemplateDetails.Channel.Mappings!) : null; - var templateInstallCollector = new OutputCollector(); - var templateInstallResult = await interactionService.ShowStatusAsync<(int ExitCode, string? TemplateVersion)>( - TemplatingStrings.GettingTemplates, - async () => - { - var options = new ProcessInvocationOptions() - { - StandardOutputCallback = templateInstallCollector.AppendOutput, - StandardErrorCallback = templateInstallCollector.AppendOutput, - }; + var query = new TemplatePackageQuery( + ChannelOverride: inputs.Channel, + VersionOverride: inputs.Version, + SourceOverride: inputs.Source, + IncludePrHives: true); - // Whilst we install the templates - if we are using an explicit channel we need to - // generate a temporary NuGet.config file to make sure we install the right package - // from the right feed. If we are using an implicit channel then we just use the - // ambient configuration (although we should still specify the source) because - // the user would have selected it. - - var result = await runner.InstallTemplateAsync( - packageName: "Aspire.ProjectTemplates", - version: selectedTemplateDetails.Package.Version, - nugetConfigFile: temporaryConfig?.ConfigFile, - nugetSource: selectedTemplateDetails.Package.Source, - force: true, - options: options, - cancellationToken: cancellationToken); - return result; - }, emoji: KnownEmojis.Ice); + var selectedTemplateDetails = await templateNuGetConfigService.ResolveTemplatePackageAsync(query, cancellationToken); + + var installOutcome = await templateNuGetConfigService.InstallTemplatePackageAsync( + selectedTemplateDetails, + runner, + TemplatingStrings.GettingTemplates, + statusEmoji: KnownEmojis.Ice, + cancellationToken); - if (templateInstallResult.ExitCode != 0) + if (installOutcome.ExitCode != 0) { - interactionService.DisplayLines(templateInstallCollector.GetLines()); - interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.TemplateInstallationFailed, templateInstallResult.ExitCode, executionContext.LogFilePath)); + interactionService.DisplayLines(installOutcome.OutputLines); + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.TemplateInstallationFailed, installOutcome.ExitCode, executionContext.LogFilePath)); return new TemplateResult(ExitCodeConstants.FailedToInstallTemplates); } - interactionService.DisplayMessage(KnownEmojis.Package, string.Format(CultureInfo.CurrentCulture, TemplatingStrings.UsingProjectTemplatesVersion, templateInstallResult.TemplateVersion)); + interactionService.DisplayMessage(KnownEmojis.Package, string.Format(CultureInfo.CurrentCulture, TemplatingStrings.UsingProjectTemplatesVersion, installOutcome.TemplateVersion)); var newProjectCollector = new OutputCollector(); var newProjectExitCode = await interactionService.ShowStatusAsync( @@ -614,102 +591,4 @@ private async Task GetOutputPathAsync(TemplateInputs inputs, Func GetProjectTemplatesVersionAsync(TemplateInputs inputs, CancellationToken cancellationToken) - { - var allChannels = await packagingService.GetChannelsAsync(cancellationToken); - - // Check if channel was provided via inputs (highest priority) - var channelName = inputs.Channel; - - // If no channel in inputs, check for global channel setting - if (string.IsNullOrEmpty(channelName)) - { - channelName = await configurationService.GetConfigurationAsync("channel", cancellationToken); - } - - IEnumerable channels; - var hasPrHives = executionContext.GetPrHiveCount() > 0; - bool hasChannelSetting = !string.IsNullOrEmpty(channelName); - - if (hasChannelSetting) - { - // If --channel option is provided or global channel setting exists, find the matching channel - // (--channel option takes precedence over global setting) - var matchingChannel = allChannels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); - if (matchingChannel is null) - { - throw new Exceptions.ChannelNotFoundException($"No channel found matching '{channelName}'. Valid options are: {string.Join(", ", allChannels.Select(c => c.Name))}"); - } - channels = new[] { matchingChannel }; - } - else - { - // If there are hives (PR build directories), include all channels. - // Otherwise, only use the implicit/default channel to avoid prompting. - channels = hasPrHives - ? allChannels - : allChannels.Where(c => c.Type is PackageChannelType.Implicit); - } - - var packagesFromChannels = await interactionService.ShowStatusAsync(TemplatingStrings.SearchingForAvailableTemplateVersions, async () => - { - var results = new List<(NuGetPackage Package, PackageChannel Channel)>(); - var packagesFromChannelsLock = new object(); - - await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => - { - var templatePackages = await channel.GetTemplatePackagesAsync(executionContext.WorkingDirectory, ct); - lock (packagesFromChannelsLock) - { - results.AddRange(templatePackages.Select(p => (p, channel))); - } - }); - - return results; - }); - - if (!packagesFromChannels.Any()) - { - throw new EmptyChoicesException(TemplatingStrings.NoTemplateVersionsFound); - } - - var orderedPackagesFromChannels = packagesFromChannels.OrderByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer); - - if (inputs.Version is { } version) - { - var explicitPackageFromChannel = orderedPackagesFromChannels.FirstOrDefault(p => p.Package.Version == version); - if (explicitPackageFromChannel.Package is not null) - { - return explicitPackageFromChannel; - } - } - - if (VersionHelper.TryGetCurrentCliVersionMatch( - orderedPackagesFromChannels, - p => p.Package.Version, - out var cliVersionPackageFromChannel, - channelName: channelName, - hasPrHives: hasPrHives)) - { - return cliVersionPackageFromChannel; - } - - // If channel was specified via --channel option or global setting (but no --version), - // automatically select the highest version from that channel without prompting - if (hasChannelSetting) - { - return orderedPackagesFromChannels.First(); - } - - // In non-interactive mode, automatically select the highest version - if (!hostEnvironment.SupportsInteractiveInput) - { - return orderedPackagesFromChannels.First(); - } - - var selectedPackageFromChannel = await templateVersionPrompter.PromptForTemplatesVersionAsync(orderedPackagesFromChannels, cancellationToken); - return selectedPackageFromChannel; - } - } diff --git a/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs b/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs index e6075182e9e..18728c9cabd 100644 --- a/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs +++ b/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs @@ -1,21 +1,34 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Commands; using Aspire.Cli.Configuration; +using Aspire.Cli.DotNet; +using Aspire.Cli.Exceptions; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; +using Aspire.Cli.Utils; +using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Templating; /// -/// Handles NuGet.config creation and updates for template output directories. +/// Handles NuGet.config creation and updates for template output directories, +/// and provides channel-aware template package resolution and installation. /// internal sealed class TemplateNuGetConfigService( IInteractionService interactionService, CliExecutionContext executionContext, IPackagingService packagingService, - IConfigurationService configurationService) + IConfigurationService configurationService, + ITemplateVersionPrompter templateVersionPrompter, + ICliHostEnvironment hostEnvironment) { + /// + /// The name of the NuGet package that ships the Aspire project templates. + /// + public const string TemplatesPackageName = "Aspire.ProjectTemplates"; + /// /// Applies NuGet.config create/update behavior for a resolved package channel. /// @@ -82,4 +95,191 @@ public async Task PromptToCreateOrUpdateNuGetConfigAsync(string? channelName, st await PromptToCreateOrUpdateNuGetConfigAsync(matchingChannel, outputPath, cancellationToken); } + + /// + /// Resolves the channel and template package version that should be used to install Aspire project templates. + /// + /// Inputs that control channel/version selection. + /// A cancellation token. + /// The selected template package and the channel it was resolved from. + /// Thrown when specifies a channel name that does not match any configured channel. + /// Thrown when no template package versions are available across the considered channels. + public async Task ResolveTemplatePackageAsync(TemplatePackageQuery query, CancellationToken cancellationToken) + { + var allChannels = await packagingService.GetChannelsAsync(cancellationToken); + + // Channel override (e.g. --channel) takes priority over the global setting. + var channelName = query.ChannelOverride; + if (string.IsNullOrEmpty(channelName)) + { + channelName = await configurationService.GetConfigurationAsync("channel", cancellationToken); + } + + // Honor PR hives only when the caller opts in. Init suppresses this so a developer + // with stale ~/.aspire/hives/* doesn't get a different template than on a clean machine. + var hasPrHives = query.IncludePrHives && executionContext.GetPrHiveCount() > 0; + var hasChannelSetting = !string.IsNullOrEmpty(channelName); + + IEnumerable channels; + if (hasChannelSetting) + { + var matchingChannel = allChannels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); + if (matchingChannel is null) + { + throw new ChannelNotFoundException($"No channel found matching '{channelName}'. Valid options are: {string.Join(", ", allChannels.Select(c => c.Name))}"); + } + channels = new[] { matchingChannel }; + } + else + { + // If there are hives (PR build directories), include all channels. + // Otherwise, only use the implicit/default channel to avoid prompting. + channels = hasPrHives + ? allChannels + : allChannels.Where(c => c.Type is PackageChannelType.Implicit); + } + + var packagesFromChannels = await interactionService.ShowStatusAsync(Resources.TemplatingStrings.SearchingForAvailableTemplateVersions, async () => + { + var results = new List<(NuGetPackage Package, PackageChannel Channel)>(); + var resultsLock = new object(); + + await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => + { + var templatePackages = await channel.GetTemplatePackagesAsync(executionContext.WorkingDirectory, ct); + lock (resultsLock) + { + results.AddRange(templatePackages.Select(p => (p, channel))); + } + }); + + return results; + }); + + if (!packagesFromChannels.Any()) + { + throw new EmptyChoicesException(Resources.TemplatingStrings.NoTemplateVersionsFound); + } + + var orderedPackagesFromChannels = packagesFromChannels.OrderByDescending(p => Semver.SemVersion.Parse(p.Package.Version), Semver.SemVersion.PrecedenceComparer); + + if (query.VersionOverride is { } version) + { + var explicitMatch = orderedPackagesFromChannels.FirstOrDefault(p => p.Package.Version == version); + if (explicitMatch.Package is not null) + { + return new TemplatePackageSelection(explicitMatch.Package, explicitMatch.Channel); + } + } + + if (VersionHelper.TryGetCurrentCliVersionMatch( + orderedPackagesFromChannels, + p => p.Package.Version, + out var cliVersionMatch, + channelName: channelName, + hasPrHives: hasPrHives)) + { + return new TemplatePackageSelection(cliVersionMatch.Package, cliVersionMatch.Channel); + } + + // If channel was specified via --channel option or global setting (but no --version), + // automatically select the highest version from that channel without prompting. + if (hasChannelSetting) + { + var first = orderedPackagesFromChannels.First(); + return new TemplatePackageSelection(first.Package, first.Channel); + } + + // In non-interactive mode, automatically select the highest version. + if (!hostEnvironment.SupportsInteractiveInput) + { + var first = orderedPackagesFromChannels.First(); + return new TemplatePackageSelection(first.Package, first.Channel); + } + + var prompted = await templateVersionPrompter.PromptForTemplatesVersionAsync(orderedPackagesFromChannels, cancellationToken); + return new TemplatePackageSelection(prompted.Package, prompted.Channel); + } + + /// + /// Installs the resolved Aspire project templates package, generating a temporary NuGet.config from the channel mappings when the channel is explicit. + /// + /// The template package + channel returned by . + /// The .NET CLI runner used to invoke dotnet new install. Passed in (rather than injected) because the runner has a transient DI lifetime. + /// Status text shown while the install runs. + /// Optional emoji prefix shown next to the status message. + /// A cancellation token. + /// The install exit code, the parsed template version (if available), and the captured stdout/stderr lines. + public async Task InstallTemplatePackageAsync( + TemplatePackageSelection selection, + IDotNetCliRunner runner, + string statusMessage, + KnownEmoji? statusEmoji, + CancellationToken cancellationToken) + { + // Whilst we install the templates - if we are using an explicit channel we need to + // generate a temporary NuGet.config file to make sure we install the right package + // from the right feed. If we are using an implicit channel then we just use the + // ambient configuration (although we should still specify the source) because + // the user would have selected it. + using var temporaryConfig = selection.Channel.Type == PackageChannelType.Explicit + ? await TemporaryNuGetConfig.CreateAsync(selection.Channel.Mappings!) + : null; + + var collector = new OutputCollector(); + + var (exitCode, templateVersion) = await interactionService.ShowStatusAsync<(int ExitCode, string? TemplateVersion)>( + statusMessage, + async () => + { + var options = new ProcessInvocationOptions + { + StandardOutputCallback = collector.AppendOutput, + StandardErrorCallback = collector.AppendOutput, + }; + + return await runner.InstallTemplateAsync( + packageName: TemplatesPackageName, + version: selection.Package.Version, + nugetConfigFile: temporaryConfig?.ConfigFile, + nugetSource: selection.Package.Source, + force: true, + options: options, + cancellationToken: cancellationToken); + }, + emoji: statusEmoji); + + return new TemplateInstallOutcome(exitCode, templateVersion, collector.GetLines().ToArray()); + } } + +/// +/// Inputs that control how picks a channel and version. +/// +/// Optional channel name override (e.g. from --channel). When null, the global channel configuration is consulted. +/// Optional explicit template version (e.g. from --version). +/// Optional source override carried for symmetry with ; not consulted by resolution today. +/// When true (e.g. for aspire new), local PR hive directories under ~/.aspire/hives participate in channel discovery; when false (e.g. for aspire init), they are ignored. +internal sealed record TemplatePackageQuery( + string? ChannelOverride, + string? VersionOverride, + string? SourceOverride, + bool IncludePrHives); + +/// +/// The template package and channel selected by . +/// +/// The selected template package (id, version, source). +/// The channel that produced . +internal sealed record TemplatePackageSelection(NuGetPackage Package, PackageChannel Channel); + +/// +/// Result of . +/// +/// Exit code from dotnet new install. +/// Parsed template version (when the install reported one). +/// Captured stdout/stderr lines from the install process for diagnostic display by the caller. +internal sealed record TemplateInstallOutcome( + int ExitCode, + string? TemplateVersion, + IReadOnlyList<(Aspire.Cli.Utils.OutputLineStream Stream, string Line)> OutputLines); diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index 2d6568a1747..3d8b0346536 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -5,10 +5,12 @@ using Aspire.Cli.Agents; using Aspire.Cli.Commands; using Aspire.Cli.Configuration; +using Aspire.Cli.Packaging; using Aspire.Cli.Projects; using Aspire.Cli.Scaffolding; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Aspire.Shared; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.InternalTesting; @@ -16,6 +18,33 @@ namespace Aspire.Cli.Tests.Commands; public class InitCommandTests(ITestOutputHelper outputHelper) { + /// + /// Configures the test packaging service factory to return a single implicit channel + /// whose template package cache yields one Aspire.ProjectTemplates entry. Init's project-mode path needs this + /// to resolve a template version before invoking dotnet new install. + /// + private static void ConfigureImplicitTemplateChannel(CliServiceCollectionTestOptions options, string version = "13.3.0") + { + options.PackagingServiceFactory = _ => + { + var fakeCache = new FakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (_, _, _, _) => + Task.FromResult>( + [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = version }]) + }; + + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); + + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([implicitChannel]) + }; + + return packagingService; + }; + } + [Theory] [InlineData("Test.csproj")] [InlineData("Test.fsproj")] @@ -36,6 +65,7 @@ public async Task InitCommand_WhenSolutionAndProjectInSameDirectory_CreatesProje var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { + ConfigureImplicitTemplateChannel(options); options.DotNetCliRunnerFactory = _ => { var runner = new TestDotNetCliRunner(); @@ -81,6 +111,7 @@ public async Task InitCommand_WhenSolutionDirectoryHasNoProjectFiles_CreatesProj var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { + ConfigureImplicitTemplateChannel(options); options.DotNetCliRunnerFactory = _ => { var runner = new TestDotNetCliRunner(); @@ -378,6 +409,274 @@ public async Task InitCommand_WhenAppHostAlreadyExists_DoesNotOverwriteIt() Assert.Equal(preExistingContent, await File.ReadAllTextAsync(appHostPath)); } + [Fact] + public async Task InitCommand_WhenSolutionExistsAndChannelIsExplicit_PassesTemporaryNuGetConfigToTemplateInstall() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); + File.WriteAllText(solutionFile.FullName, "Fake solution file"); + + FileInfo? capturedNuGetConfigFile = null; + string? capturedNuGetSource = null; + string? capturedTemplateNuGetConfigContents = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + // Match the bug repro: user has a global `channel` setting pointing at an explicit channel. + options.ConfigurationServiceFactory = _ => new FakeConfigurationServiceWithChannel("staging"); + + options.PackagingServiceFactory = _ => + { + var fakeCache = new FakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (_, _, _, _) => + Task.FromResult>( + [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "https://example.test/staging/v3/index.json", Version = "13.3.0" }]) + }; + + var explicitChannel = PackageChannel.CreateExplicitChannel( + "staging", + PackageChannelQuality.Both, + [new PackageMapping("Aspire*", "https://example.test/staging/v3/index.json")], + fakeCache); + + return new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([explicitChannel]) + }; + }; + + options.DotNetCliRunnerFactory = _ => + { + var runner = new TestDotNetCliRunner(); + runner.InstallTemplateAsyncCallback = (_, version, nugetConfigFile, nugetSource, _, _, _) => + { + capturedNuGetConfigFile = nugetConfigFile; + capturedNuGetSource = nugetSource; + if (nugetConfigFile is not null && File.Exists(nugetConfigFile.FullName)) + { + capturedTemplateNuGetConfigContents = File.ReadAllText(nugetConfigFile.FullName); + } + return (0, version); + }; + runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) => + { + Directory.CreateDirectory(outputPath); + return 0; + }; + return runner; + }; + }); + + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.NotNull(capturedNuGetConfigFile); + Assert.Equal("https://example.test/staging/v3/index.json", capturedNuGetSource); + Assert.NotNull(capturedTemplateNuGetConfigContents); + Assert.Contains("https://example.test/staging/v3/index.json", capturedTemplateNuGetConfigContents); + } + + [Fact] + public async Task InitCommand_WhenSolutionExistsAndChannelIsImplicit_LeavesNuGetConfigNull() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); + File.WriteAllText(solutionFile.FullName, "Fake solution file"); + + FileInfo? capturedNuGetConfigFile = new FileInfo("sentinel"); + string? capturedNuGetSource = "sentinel"; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + ConfigureImplicitTemplateChannel(options); + options.DotNetCliRunnerFactory = _ => + { + var runner = new TestDotNetCliRunner(); + runner.InstallTemplateAsyncCallback = (_, version, nugetConfigFile, nugetSource, _, _, _) => + { + capturedNuGetConfigFile = nugetConfigFile; + capturedNuGetSource = nugetSource; + return (0, version); + }; + runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) => + { + Directory.CreateDirectory(outputPath); + return 0; + }; + return runner; + }; + }); + + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.Null(capturedNuGetConfigFile); + // The implicit channel surfaces the package's Source field as the nugetSource even when no + // temporary config is generated, so nugetSource may be non-null. The contract this test guards + // is that nugetConfigFile stays null on the implicit channel. + } + + [Fact] + public async Task InitCommand_WhenSolutionExistsAndPrHivesPresent_DoesNotWidenToAllChannels() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); + File.WriteAllText(solutionFile.FullName, "Fake solution file"); + + // Simulate a stale PR hive on disk so executionContext.GetPrHiveCount() returns > 0. + var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")); + Directory.CreateDirectory(Path.Combine(hivesDir.FullName, "pr-12345", "packages")); + + string? capturedTemplateVersion = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.PackagingServiceFactory = _ => + { + // Implicit channel offers the expected stable version; PR hive channel offers a much + // newer version that should NOT be selected because init opts out of PR-hive widening. + var implicitCache = new FakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (_, _, _, _) => + Task.FromResult>( + [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = "13.3.0" }]) + }; + var prHiveCache = new FakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (_, _, _, _) => + Task.FromResult>( + [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = "99.0.0-pr.12345" }]) + }; + + var implicitChannel = PackageChannel.CreateImplicitChannel(implicitCache); + var prHiveChannel = PackageChannel.CreateExplicitChannel( + "pr-12345", + PackageChannelQuality.Both, + [new PackageMapping("Aspire*", hivesDir.FullName + "/pr-12345/packages")], + prHiveCache); + + return new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([implicitChannel, prHiveChannel]) + }; + }; + + options.DotNetCliRunnerFactory = _ => + { + var runner = new TestDotNetCliRunner(); + runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => + { + capturedTemplateVersion = version; + return (0, version); + }; + runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) => + { + Directory.CreateDirectory(outputPath); + return 0; + }; + return runner; + }; + }); + + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.Equal("13.3.0", capturedTemplateVersion); + } + + [Fact] + public async Task InitCommand_WhenChannelResolutionThrowsChannelNotFound_DisplaysFriendlyError() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); + File.WriteAllText(solutionFile.FullName, "Fake solution file"); + + var interactionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + + // Configure global channel = "missing-channel" so the resolver looks for a channel that doesn't exist. + options.ConfigurationServiceFactory = _ => new FakeConfigurationServiceWithChannel("missing-channel"); + + // Return only an implicit/default channel so the lookup fails to match "missing-channel". + options.PackagingServiceFactory = _ => + { + var fakeCache = new FakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (_, _, _, _) => + Task.FromResult>( + [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = "13.3.0" }]) + }; + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); + return new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([implicitChannel]) + }; + }; + + options.DotNetCliRunnerFactory = _ => + { + var runner = new TestDotNetCliRunner(); + runner.InstallTemplateAsyncCallback = (_, _, _, _, _, _, _) => + { + throw new InvalidOperationException("InstallTemplateAsync should not run when channel resolution fails."); + }; + return runner; + }; + }); + + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.FailedToInstallTemplates, exitCode); + Assert.Contains(interactionService.DisplayedErrors, e => e.Contains("missing-channel", StringComparison.Ordinal)); + } + + private sealed class FakeConfigurationServiceWithChannel(string channelValue) : IConfigurationService + { + public Task GetConfigurationAsync(string key, CancellationToken cancellationToken = default) + => Task.FromResult(string.Equals(key, "channel", StringComparison.Ordinal) ? channelValue : null); + + public Task SetConfigurationAsync(string key, string value, bool isGlobal = false, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task DeleteConfigurationAsync(string key, bool isGlobal = false, CancellationToken cancellationToken = default) + => Task.FromResult(false); + + public Task> GetAllConfigurationAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new Dictionary()); + + public Task> GetLocalConfigurationAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new Dictionary()); + + public Task> GetGlobalConfigurationAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new Dictionary()); + + public string GetSettingsFilePath(bool isGlobal) => string.Empty; + } + private sealed class TestScaffoldingService : IScaffoldingService { public Task ScaffoldAsync(ScaffoldContext context, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index c877d3af328..ba857fca58a 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -242,7 +242,7 @@ public async Task NewCommandWithChannelOptionUsesSpecifiedChannel() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) => + runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) => { return (0, version); }; @@ -318,7 +318,7 @@ public async Task NewCommandWithChannelOptionAutoSelectsHighestVersion() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) => + runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) => { selectedVersion = version; return (0, version); @@ -395,7 +395,7 @@ public async Task NewCommandWithPrChannelPrefersCurrentCliVersion() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) => + runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) => { selectedVersion = version; return (0, version); @@ -535,7 +535,7 @@ public async Task NewCommand_WhenCertificateServiceThrows_ReturnsNonZeroExitCode { var runner = CreateTestRunnerWithStandardPackages(); - runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, options, cancellationToken) => + runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, options, cancellationToken) => { return (0, version); // Success, return the template version }; @@ -573,7 +573,7 @@ public async Task NewCommandWithExitCode73ShowsUserFriendlyError() { var runner = CreateTestRunnerWithStandardPackages(); - runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, options, cancellationToken) => + runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, options, cancellationToken) => { return (0, version); // Success, return the template version }; @@ -1557,7 +1557,7 @@ public async Task NewCommandInExtensionModeAppendsProjectNameToOutputPath() options.DotNetCliRunnerFactory = (sp) => { var runner = CreateTestRunnerWithStandardPackages(); - runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) => + runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) => { return (0, version); }; @@ -1616,7 +1616,7 @@ public async Task NewCommandInExtensionModeDoesNotDoubleAppendProjectName() options.DotNetCliRunnerFactory = (sp) => { var runner = CreateTestRunnerWithStandardPackages(); - runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) => + runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) => { return (0, version); }; @@ -1673,7 +1673,7 @@ public async Task NewCommandInConsoleModeDoesNotAppendProjectName() options.DotNetCliRunnerFactory = (sp) => { var runner = CreateTestRunnerWithStandardPackages(); - runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) => + runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) => { return (0, version); }; @@ -1735,7 +1735,7 @@ async Task AssertOutputPathAsync(Func selectedPathFactory, Func< options.DotNetCliRunnerFactory = (sp) => { var runner = CreateTestRunnerWithStandardPackages(); - runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) => + runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) => { return (0, version); }; diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 949b57ab2f7..91e99ed5c3b 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -355,19 +355,16 @@ private static DotNetTemplateFactory CreateTemplateFactory(TestFeatures features var configurationService = new FakeConfigurationService(); var telemetry = TestTelemetryHelper.CreateInitializedTelemetry(); var hostEnvironment = new FakeCliHostEnvironment(nonInteractive); - var templateNuGetConfigService = new TemplateNuGetConfigService(interactionService, executionContext, packagingService, configurationService); + var templateNuGetConfigService = new TemplateNuGetConfigService(interactionService, executionContext, packagingService, configurationService, prompter, hostEnvironment); return new DotNetTemplateFactory( interactionService, runner, certificateService, - packagingService, - prompter, prompter, executionContext, sdkInstaller, features, - configurationService, telemetry, hostEnvironment, templateNuGetConfigService); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index 3ac176794f2..ed3527d7c6b 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -18,7 +18,7 @@ internal sealed class TestDotNetCliRunner : IDotNetCliRunner public Func? GetAppHostInformationAsyncCallback { get; set; } public Func? GetNuGetConfigPathsAsyncCallback { get; set; } public Func? GetProjectItemsAndPropertiesAsyncCallback { get; set; } - public Func? InstallTemplateAsyncCallback { get; set; } + public Func? InstallTemplateAsyncCallback { get; set; } public Func? NewProjectAsyncCallback { get; set; } public Func?, TaskCompletionSource?, ProcessInvocationOptions, CancellationToken, Task>? RunAsyncCallback { get; set; } public Func? SearchPackagesAsyncCallback { get; set; } @@ -88,7 +88,7 @@ private static string[] GetGlobalNuGetPaths() public Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, ProcessInvocationOptions options, CancellationToken cancellationToken) { return InstallTemplateAsyncCallback != null - ? Task.FromResult(InstallTemplateAsyncCallback(packageName, version, nugetSource, force, options, cancellationToken)) + ? Task.FromResult(InstallTemplateAsyncCallback(packageName, version, nugetConfigFile, nugetSource, force, options, cancellationToken)) : Task.FromResult<(int, string?)>((0, version)); // If not overridden, just return success for the version specified. } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 64ac8ccca8c..16100186728 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -114,6 +114,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(options.DotNetCliExecutionFactoryFactory); services.AddTransient(options.DotNetCliRunnerFactory); services.AddTransient(options.NuGetPackageCacheFactory); + services.AddSingleton(); services.AddSingleton(options.TemplateProviderFactory); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); @@ -526,8 +527,8 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var languageDiscovery = serviceProvider.GetRequiredService(); var scaffoldingService = serviceProvider.GetRequiredService(); var cliTemplateLogger = serviceProvider.GetRequiredService>(); - var templateNuGetConfigService = new TemplateNuGetConfigService(interactionService, executionContext, packagingService, configurationService); - var dotNetFactory = new DotNetTemplateFactory(interactionService, runner, certificateService, packagingService, prompter, templateVersionPrompter, executionContext, sdkInstaller, features, configurationService, telemetry, hostEnvironment, templateNuGetConfigService); + var templateNuGetConfigService = serviceProvider.GetRequiredService(); + var dotNetFactory = new DotNetTemplateFactory(interactionService, runner, certificateService, prompter, executionContext, sdkInstaller, features, telemetry, hostEnvironment, templateNuGetConfigService); var projectFactory = serviceProvider.GetRequiredService(); var cliFactory = new CliTemplateFactory(languageDiscovery, projectFactory, scaffoldingService, prompter, executionContext, interactionService, hostEnvironment, templateNuGetConfigService, cliTemplateLogger); return new TemplateProvider([dotNetFactory, cliFactory]); From 5b1dfc8536a7450138bb008e53930f114baaab78 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 1 May 2026 13:13:37 -0700 Subject: [PATCH 2/2] Address PR review feedback (#16672) Multi-model code review caught the following issues: 1. Restore original order of operations in DotNetTemplateFactory.ApplyTemplateAsync. The first refactor moved extraArgsCallback ahead of template package resolution, which changed prompt/error precedence for `aspire new` (extra-args prompts like Redis-cache, test-framework, xUnit-version would now run before channel lookup, and answers would be discarded if resolution failed afterward). Restored the BEFORE order from release/13.3: ResolveTemplatePackageAsync first, then extraArgsCallback, then InstallTemplatePackageAsync. Updated the in-source comment to be accurate. 2. Catch NuGetPackageCacheException in InitCommand.DropCSharpProjectSkeletonAsync. The pre-extraction init code went straight to `dotnet new install` and never invoked a NuGet search, so feed search failures (offline, inaccessible feed, etc.) couldn't bubble up. After the extraction init now performs the search and was missing the catch, surfacing the failure as an unhandled "unexpected error". Added the catch with the same friendly-error treatment as ChannelNotFoundException / EmptyChoicesException. 3. Use TemplatingStrings.TemplateInstallationFailed in InitCommand for parity with `aspire new`. The previous ad-hoc string omitted the log file path, making post-mortem diagnosis harder. 4. Added a comment in InstallTemplatePackageAsync clarifying that the temporary NuGet config is intentionally disposed at the end of the install (only `dotnet new install` consumes it; the subsequent `dotnet new