Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 51 additions & 14 deletions src/Aspire.Cli/Commands/InitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
using Aspire.Cli.Certificates;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Exceptions;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
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;

Expand All @@ -37,6 +40,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<string?> s_sourceOption = new("--source", "-s")
{
Expand Down Expand Up @@ -67,7 +71,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;
Expand All @@ -78,6 +83,7 @@ public InitCommand(
_certificateService = certificateService;
_scaffoldingService = scaffoldingService;
_languageDiscovery = languageDiscovery;
_templateNuGetConfigService = templateNuGetConfigService;

_channelOption = new Option<string?>("--channel")
{
Expand Down Expand Up @@ -272,24 +278,55 @@ private async Task<int> 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;
}
catch (NuGetPackageCacheException ex)
{
// Surface NuGet feed search failures (offline, inaccessible feed, etc.) with a friendly error
// instead of letting them bubble up to the top-level "unexpected error" handler. The pre-extraction
// init code went straight to `dotnet new install` and never invoked a NuGet search, so this catch
// restores parity with the prior init failure mode for these scenarios.
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(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.TemplateInstallationFailed, installOutcome.ExitCode, _executionContext.LogFilePath));
return ExitCodeConstants.FailedToInstallTemplates;
}

Expand Down
157 changes: 19 additions & 138 deletions src/Aspire.Cli/Templating/DotNetTemplateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,21 @@
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;

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)
Expand Down Expand Up @@ -457,50 +450,36 @@ private async Task<TemplateResult> ApplyTemplateAsync(CallbackTemplate template,
{
try
{
var source = inputs.Source;
var selectedTemplateDetails = await GetProjectTemplatesVersionAsync(inputs, cancellationToken: cancellationToken);
// Resolve the template package first, matching the pre-extraction order in
// release/13.3. Surfacing channel/version errors before prompting for extra args
// avoids discarding answers the user just gave.
var query = new TemplatePackageQuery(
ChannelOverride: inputs.Channel,
VersionOverride: inputs.Version,
SourceOverride: inputs.Source,
IncludePrHives: true);

var selectedTemplateDetails = await templateNuGetConfigService.ResolveTemplatePackageAsync(query, 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.
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)>(
var installOutcome = await templateNuGetConfigService.InstallTemplatePackageAsync(
selectedTemplateDetails,
runner,
TemplatingStrings.GettingTemplates,
async () =>
{
var options = new ProcessInvocationOptions()
{
StandardOutputCallback = templateInstallCollector.AppendOutput,
StandardErrorCallback = templateInstallCollector.AppendOutput,
};
statusEmoji: KnownEmojis.Ice,
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.

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);

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(
Expand Down Expand Up @@ -614,102 +593,4 @@ private async Task<string> GetOutputPathAsync(TemplateInputs inputs, Func<string

return outputPath;
}

private async Task<(NuGetPackage Package, PackageChannel Channel)> 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<PackageChannel> 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;
}

}
Loading
Loading