File: Templating\TemplateEngineFactory.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.CommandLine;
using System.Globalization;
using System.IO.Hashing;
using System.Text;
using Aspire.Cli.Commands;
using Aspire.Cli.Git;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
using Microsoft.TemplateEngine.Abstractions;
using Microsoft.TemplateEngine.Abstractions.Installer;
using Microsoft.TemplateEngine.Edge;
using Microsoft.TemplateEngine.Edge.Settings;
using Microsoft.TemplateEngine.Edge.Template;
using Microsoft.TemplateEngine.Utils;
using Spectre.Console;
 
namespace Aspire.Cli.Templating;
 
internal sealed partial class TemplateEngineFactory(
    CliExecutionContext executionContext,
    IInteractionService interactionService,
    INewCommandPrompter prompter,
    ICliHostEnvironment hostEnvironment,
    IGitClient git,
    TimeProvider timeProvider,
    IPackagingService packagingService,
    TemplateNuGetConfigService templateNuGetConfigService,
    INuGetClient nuGetClient,
    ILogger<TemplateEngineFactory> logger) : ITemplateFactory, IDisposable
{
    private static readonly TimeSpan s_gitOperationTimeout = TimeSpan.FromMinutes(2);
    private readonly List<IDisposable> _resources = [];
    private bool _disposed;
 
    public IEnumerable<ITemplate> GetTemplates() => [];
 
    public Task<IEnumerable<ITemplate>> GetInitTemplatesAsync(CancellationToken cancellationToken = default) =>
        Task.FromResult<IEnumerable<ITemplate>>([]);
 
    public async Task<IEnumerable<ITemplate>> GetTemplatesAsync(TemplateSource? source, CancellationToken cancellationToken = default)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        if (source is null)
        {
            return [];
        }
 
        cancellationToken.ThrowIfCancellationRequested();
        var fullPath = source.Repository is null
            ? Path.GetFullPath(source.Path, executionContext.WorkingDirectory.FullName)
            : await GetRepositoryTemplateAsync(source.Repository, source.Path, source.RepositoryRef, cancellationToken);
        if (!Directory.Exists(fullPath))
        {
            throw new TemplateDiscoveryException(CliExitCodes.InvalidCommand,
                string.Format(CultureInfo.CurrentCulture, NewCommandStrings.TemplateDirectoryNotFound, fullPath));
        }
 
        // Register concrete built-ins instead of loading engine extensions dynamically, which
        // Native AOT cannot support. A virtual hive keeps these templates out of dotnet new's state.
        // https://github.com/dotnet/sdk/blob/main/src/TemplateEngine/Microsoft.TemplateEngine.Edge/DefaultTemplateEngineHost.cs
        var components = Microsoft.TemplateEngine.Edge.Components.AllComponents
            .Concat(Microsoft.TemplateEngine.Orchestrator.RunnableProjects.Components.AllComponents).ToList();
        var host = new TemplateEngineHost(components);
        _resources.Add(host);
        var settings = new EngineEnvironmentSettings(host, virtualizeSettings: true);
        _resources.Add(settings);
        var manager = new TemplatePackageManager(settings);
        _resources.Add(manager);
        var installed = await manager.GetBuiltInManagedProvider().InstallAsync([new InstallRequest(fullPath)], cancellationToken);
        cancellationToken.ThrowIfCancellationRequested();
        var failures = installed.Where(result => !result.Success).ToArray();
        if (failures.Length > 0)
        {
            throw new TemplateDiscoveryException(CliExitCodes.FailedToInstallTemplates,
                string.Format(CultureInfo.CurrentCulture, NewCommandStrings.LocalTemplateLoadFailed,
                    string.Join(", ", failures.Select(result => result.Error))));
        }
 
        var creator = new TemplateCreator(settings);
        var templates = (await manager.GetTemplatesAsync(cancellationToken))
            .OrderBy(template => template.Name, StringComparer.OrdinalIgnoreCase)
            .Select(template => new CallbackTemplate(
                GetTemplateName(template),
                template.Name,
                (context, name) => OutputPathHelper.GetUniqueDefaultOutputPath(name, context.WorkingDirectory.FullName),
                _ => { },
                (_, templateInputs, result, token) => ApplyAsync(settings, creator, template, templateInputs, result, token)))
            .ToArray();
        cancellationToken.ThrowIfCancellationRequested();
        if (templates.Length == 0)
        {
            throw new TemplateDiscoveryException(CliExitCodes.InvalidCommand,
                string.Format(CultureInfo.CurrentCulture, NewCommandStrings.NoLocalTemplatesFound, fullPath));
        }
 
        // Returned callbacks still use the engine's mounts. Keep them alive through generation;
        // immutable repository checkouts are retained in the SHA-keyed cache across invocations.
        return templates;
    }
 
    private async Task<string> GetRepositoryTemplateAsync(string repository, string templatePath, string? reference, CancellationToken cancellationToken)
    {
        var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "git");
        var pathRoot = Path.Combine(cacheRoot, "repository");
        var relativePath = Path.GetRelativePath(pathRoot, Path.GetFullPath(templatePath, pathRoot));
        if (Path.IsPathRooted(templatePath) || relativePath == ".." ||
            relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
        {
            throw new TemplateDiscoveryException(CliExitCodes.InvalidCommand, NewCommandStrings.TemplateRepositoryPathInvalid);
        }
 
        try
        {
            var sha = await interactionService.ShowStatusAsync(NewCommandStrings.ResolvingTemplateRepository,
                () => WithGitTimeoutAsync(
                    token => git.ResolveReferenceAsync(repository, reference, executionContext.WorkingDirectory, GitCommandMode.Captured, token), cancellationToken));
            cancellationToken.ThrowIfCancellationRequested();
            // Different paths and refs at the same commit share the complete repository snapshot.
            // NUL separates the repository address and SHA without ambiguous delimiters.
            var identity = $"{repository}\0{sha}";
            var key = Convert.ToHexString(XxHash3.Hash(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant();
            var cacheDirectory = Path.Combine(cacheRoot, key);
            DirectoryHelper.CreateWithOwnerOnlyPermissions(cacheRoot);
            if (Directory.Exists(cacheDirectory))
            {
                return GetRepositoryTemplatePath(cacheDirectory, relativePath);
            }
 
            using var checkout = TemporaryCacheDirectory.Create(cacheRoot, "repo", DeleteCheckout, DeleteLease);
            var repositoryRoot = new DirectoryInfo(Path.Combine(checkout.FullName, "repository"));
            await interactionService.ShowStatusAsync(NewCommandStrings.CloningTemplateRepository, async () =>
            {
                await CheckoutRepositoryAsync(repository, repositoryRoot, sha, cancellationToken);
                return true;
            });
            cancellationToken.ThrowIfCancellationRequested();
 
            // Root templates must not copy Git history/configuration into generated projects.
            Directory.Move(Path.Combine(repositoryRoot.FullName, ".git"), Path.Combine(checkout.FullName, ".git"));
            GetRepositoryTemplatePath(checkout.FullName, relativePath);
            cancellationToken.ThrowIfCancellationRequested();
            try
            {
                // Publish only complete checkouts. Concurrent invocations can safely use the winner;
                // the losing temporary checkout is removed by its lease owner on disposal.
                checkout.MoveTo(cacheDirectory);
            }
            catch (IOException) when (Directory.Exists(cacheDirectory))
            {
                logger.LogDebug("Template checkout was already cached at {Path}.", cacheDirectory);
            }
 
            return GetRepositoryTemplatePath(cacheDirectory, relativePath);
        }
        catch (GitReferenceException ex)
        {
            throw new TemplateDiscoveryException(CliExitCodes.FailedToInstallTemplates,
                string.Format(CultureInfo.CurrentCulture, NewCommandStrings.TemplateRepositoryRefNotFound, ex.Reference));
        }
        catch (GitCommandException ex)
        {
            var message = ex.ExitCode is int exitCode
                ? string.Format(CultureInfo.CurrentCulture, NewCommandStrings.TemplateRepositoryGitExited, exitCode, ex.Message)
                : ex.Message;
            throw new TemplateDiscoveryException(CliExitCodes.FailedToInstallTemplates, message);
        }
    }
 
    private Task CheckoutRepositoryAsync(string repository, DirectoryInfo destination, string commitSha, CancellationToken cancellationToken) =>
        WithGitTimeoutAsync(async token =>
        {
            if (!GitClient.IsCommitSha(commitSha))
            {
                throw new ArgumentException("A full commit SHA is required.", nameof(commitSha));
            }
            if (!Path.IsPathRooted(repository) && !repository.Contains(':'))
            {
                var localPath = Path.GetFullPath(repository, executionContext.WorkingDirectory.FullName);
                if (Directory.Exists(localPath))
                {
                    repository = localPath;
                }
            }
 
            // Fetch the resolved SHA rather than a movable ref. Depth-one fetches also avoid
            // local clone hard links, so cleanup cannot change source object attributes.
            // https://git-scm.com/docs/git-fetch
            var location = new GitRepositoryLocation(destination, IsBare: false);
            await git.InitializeAsync(executionContext.WorkingDirectory, location,
                commitSha.Length == 64 ? "sha256" : "sha1", GitCommandMode.Captured, token);
            await git.FetchAsync(location, repository, commitSha, GitCommandMode.Captured, token);
            await git.CheckoutAsync(location, GitCommandMode.Captured, token);
            var actualSha = await git.GetCommitAsync(location, "HEAD", GitCommandMode.Captured, token);
            if (!string.Equals(actualSha, commitSha, StringComparison.OrdinalIgnoreCase))
            {
                throw new GitReferenceException(commitSha);
            }
 
            return true;
        }, cancellationToken);
 
    private async Task<T> WithGitTimeoutAsync<T>(Func<CancellationToken, Task<T>> operation, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        using var deadline = new CancellationTokenSource(s_gitOperationTimeout, timeProvider);
        using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token);
        try
        {
            return await operation(linked.Token);
        }
        catch (OperationCanceledException) when (deadline.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
        {
            throw new GitCommandException(GitFailure.TimedOut, null);
        }
    }
 
    private static string GetRepositoryTemplatePath(string checkout, string relativePath)
    {
        var repositoryRoot = Path.Combine(checkout, "repository");
        var fullPath = Path.GetFullPath(relativePath, repositoryRoot);
        // A repository-relative path must not escape through a checked-out symbolic link.
        for (var directory = new DirectoryInfo(fullPath); directory is not null && directory.FullName != repositoryRoot; directory = directory.Parent)
        {
            if (directory.LinkTarget is not null)
            {
                throw new TemplateDiscoveryException(CliExitCodes.InvalidCommand, NewCommandStrings.TemplateRepositoryPathInvalid);
            }
        }
 
        if (!Directory.Exists(fullPath))
        {
            throw new TemplateDiscoveryException(CliExitCodes.InvalidCommand,
                string.Format(CultureInfo.CurrentCulture, NewCommandStrings.TemplateDirectoryNotFound, fullPath));
        }
 
        return fullPath;
    }
 
    private void DeleteCheckout(string path)
    {
        try
        {
            DeleteTemplateDirectory(path);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            logger.LogWarning(ex, "Could not delete template checkout {Path}.", path);
        }
    }
 
    private static void DeleteTemplateDirectory(string path)
    {
        if (Directory.Exists(path))
        {
            // Git object files can be read-only on Windows. Do not follow repository
            // symlinks while clearing that bit or touch files outside this directory.
            foreach (var file in Directory.EnumerateFiles(path, "*", new EnumerationOptions
            {
                RecurseSubdirectories = true,
                AttributesToSkip = FileAttributes.ReparsePoint
            }))
            {
                var attributes = File.GetAttributes(file);
                if ((attributes & FileAttributes.ReadOnly) != 0)
                {
                    File.SetAttributes(file, attributes & ~FileAttributes.ReadOnly);
                }
            }
            Directory.Delete(path, recursive: true);
        }
    }
 
    private void DeleteLease(string path)
    {
        try
        {
            File.Delete(path);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            logger.LogWarning(ex, "Could not delete template checkout lease {Path}.", path);
        }
    }
 
    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }
 
        _disposed = true;
        for (var i = _resources.Count - 1; i >= 0; i--)
        {
            _resources[i].Dispose();
        }
 
        _resources.Clear();
    }
 
    private async Task<TemplateResult> ApplyAsync(IEngineEnvironmentSettings settings, TemplateCreator creator, ITemplateInfo template, TemplateInputs inputs, ParseResult parseResult, CancellationToken cancellationToken)
    {
        var hostConfiguration = await TemplateHostConfiguration.LoadAsync(settings, template, cancellationToken);
        var name = inputs.Name is { } explicitName && ProjectNameValidator.IsProjectNameValid(explicitName)
            ? explicitName
            : await prompter.PromptForProjectNameAsync(
                string.IsNullOrWhiteSpace(template.DefaultName) ? GetTemplateName(template) : template.DefaultName, parseResult, cancellationToken);
        var isExtensionHost = ExtensionHelper.IsExtensionHost(interactionService, out _, out _);
        var createSubdirectory = await OutputPathHelper.PromptExtensionCreateProjectNameSubdirectoryAsync(
            interactionService, isExtensionHost, inputs.Output is not null, name, cancellationToken);
        var outputPath = await OutputPathHelper.ResolveOutputPathAsync(
            inputs.Output,
            executionContext.WorkingDirectory.FullName,
            () => prompter.PromptForOutputPath(
                OutputPathHelper.GetUniqueDefaultOutputPath(name, executionContext.WorkingDirectory.FullName),
                parseResult,
                OutputPathHelper.CreateOutputPathValidator(executionContext.WorkingDirectory.FullName),
                OutputPathHelper.CreateProjectNameSubdirectoryOutputPathResolver(createSubdirectory, name),
                cancellationToken),
            interactionService);
        if (outputPath is null)
        {
            return new TemplateResult(CliExitCodes.FailedToCreateNewProject);
        }
 
        var parameters = new Dictionary<string, string?>(StringComparer.Ordinal);
        var computedParameters = hostConfiguration.ValueSources.Keys.ToHashSet(StringComparer.Ordinal);
        PackageChannel? identityChannel = null;
        PackageChannel? templateChannel = null;
        // Root NuGet policy is explicitly requested by the selected host file, independently
        // of language tags and AspireVersion. A polyglot template can pin its SDK version
        // without needing a root config; a template with .NET consumers can opt in.
        // Resolve required policy before generation so an unavailable channel cannot leave files.
        if (hostConfiguration.CreateNuGetConfig)
        {
            templateChannel = await GetIdentityPackageChannelAsync(cancellationToken);
            if (templateChannel is null)
            {
                return new TemplateResult(CliExitCodes.InvalidCommand);
            }
            identityChannel = templateChannel;
        }
        try
        {
            var promptState = (hostEnvironment.SupportsInteractiveInput || computedParameters.Count > 0) && template.ParameterDefinitions.Any(parameter =>
                !string.IsNullOrEmpty(parameter.Precedence.IsEnabledCondition) || !string.IsNullOrEmpty(parameter.Precedence.IsRequiredCondition))
                ? new TemplateParameterPromptState(settings, template, name, logger, computedParameters)
                : null;
            foreach (var parameter in promptState?.Parameters ?? template.ParameterDefinitions)
            {
                if (parameter.IsName || parameter.Type != "parameter" ||
                    parameter.Precedence.PrecedenceDefinition is PrecedenceDefinition.Implicit or PrecedenceDefinition.Disabled)
                {
                    continue;
                }
 
                cancellationToken.ThrowIfCancellationRequested();
                if (promptState is not null && !promptState.IsEnabled(parameter))
                {
                    continue;
                }
 
                string? value;
                if (computedParameters.Contains(parameter.Name))
                {
                    // Render valid version-shaped placeholders only in disposable staging.
                    // The final render always receives the resolved package version instead.
                    value = "0.0.0";
                }
                else if (parameter.Name.Equals("AspireVersion", StringComparison.OrdinalIgnoreCase))
                {
                    value = executionContext.IdentitySdkVersion;
                }
                else if (parameter.Name.Equals("AspireNuGetSource", StringComparison.OrdinalIgnoreCase))
                {
                    identityChannel ??= await GetIdentityPackageChannelAsync(cancellationToken);
                    if (identityChannel is null)
                    {
                        return new TemplateResult(CliExitCodes.InvalidCommand);
                    }
 
                    value = identityChannel.SourceDetails;
                }
                else if (!hostEnvironment.SupportsInteractiveInput || hostConfiguration.IsHidden(parameter.Name))
                {
                    // Leave hidden values unset, not explicitly defaulted: coalesce symbols use
                    // whether a value was supplied to choose generated fallbacks such as random ports.
                    continue;
                }
                else
                {
                    value = await PromptForParameterAsync(
                        parameter, promptState?.IsRequired(parameter) ?? parameter.Precedence.IsRequired,
                        hostConfiguration.GetDisplayName(parameter.Name), cancellationToken);
                }
 
                if (value is null)
                {
                    continue;
                }
 
                if (promptState is not null && !promptState.TrySetValue(parameter, value))
                {
                    return CreationFailed(CreationResultStatus.InvalidParamValues, parameter.Name);
                }
 
                parameters[parameter.Name] = value;
            }
        }
        catch (TemplateAuthoringException ex)
        {
            return CreationFailed(CreationResultStatus.TemplateIssueDetected, ex.Message);
        }
 
        if (computedParameters.Any(parameters.ContainsKey))
        {
            return await CreateWithComputedValuesAsync(
                creator, template, name, outputPath, parameters, hostConfiguration, templateChannel, cancellationToken);
        }
 
        // Validate through the engine before it creates the output directory. TemplateCreator
        // returns post-actions as metadata; this flow deliberately does not execute them.
        var preview = await creator.InstantiateAsync(template, name, null, outputPath, parameters, dryRun: true, cancellationToken: cancellationToken);
        cancellationToken.ThrowIfCancellationRequested();
        if (preview.Status != CreationResultStatus.Success)
        {
            return CreationFailed(preview);
        }
 
        var result = await interactionService.ShowStatusAsync(
            TemplatingStrings.CreatingNewProject,
            () => creator.InstantiateAsync(template, name, null, outputPath, parameters, cancellationToken: cancellationToken),
            emoji: KnownEmojis.Rocket);
        cancellationToken.ThrowIfCancellationRequested();
        if (result.Status != CreationResultStatus.Success)
        {
            return CreationFailed(result);
        }
 
        if (templateChannel is not null)
        {
            await templateNuGetConfigService.CreateNuGetConfigIfMissingAsync(templateChannel, outputPath, cancellationToken);
        }
 
        interactionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.ProjectCreatedSuccessfully, outputPath));
        return new TemplateResult(CliExitCodes.Success, outputPath);
    }
 
    private async Task<PackageChannel?> GetIdentityPackageChannelAsync(CancellationToken cancellationToken)
    {
        // Resolve source projection and explicitly requested NuGet policy against the same CLI
        // identity used for version injection, without querying feeds. PackagingService also
        // handles PR hives and explicit local/source overrides.
        var channels = (await packagingService.GetChannelsAsync(cancellationToken, executionContext.IdentityChannel)).ToArray();
        var channel = channels.FirstOrDefault(channel =>
            string.Equals(channel.Name, executionContext.IdentityChannel, StringComparisons.ChannelName) &&
            channel.Mappings is { Length: > 0 });
        if (channel is null)
        {
            interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture,
                NewCommandStrings.NoChannelFoundMatching,
                executionContext.IdentityChannel,
                string.Join(", ", channels.Select(channel => channel.Name))));
        }
 
        return channel;
    }
 
    private async Task<string?> PromptForParameterAsync(ITemplateParameter parameter, bool required, string? displayName, CancellationToken cancellationToken)
    {
        var prompt = (!string.IsNullOrWhiteSpace(displayName) ? displayName
            : !string.IsNullOrWhiteSpace(parameter.DisplayName)
            ? parameter.DisplayName
            : !string.IsNullOrWhiteSpace(parameter.Description) ? parameter.Description : parameter.Name).EscapeMarkup();
        if (string.Equals(parameter.DataType, "bool", StringComparison.OrdinalIgnoreCase))
        {
            var value = await interactionService.PromptConfirmAsync(
                prompt,
                binding: PromptBinding.CreateDefault(bool.TryParse(parameter.DefaultValue, out var defaultValue) && defaultValue),
                cancellationToken: cancellationToken);
            return value ? "true" : "false";
        }
 
        if (parameter.Choices is { Count: > 0 } choices)
        {
            // Multi-choice defaults accept "web|worker" or "web,worker"; single-choice keys are literal.
            var defaults = parameter.DefaultValue is { } defaultValue
                ? parameter.AllowMultipleValues ? defaultValue.Split(MultiValueParameter.MultiValueSeparators) : [defaultValue]
                : [];
            var keys = choices.Keys.OrderByDescending(key => defaults.Contains(key, StringComparer.OrdinalIgnoreCase)).ToArray();
            string FormatChoice(string key) => (string.IsNullOrWhiteSpace(choices[key].DisplayName) ? key : choices[key].DisplayName).EscapeMarkup();
            if (parameter.AllowMultipleValues)
            {
                var selected = await interactionService.PromptForSelectionsAsync(
                    prompt, keys, FormatChoice,
                    preSelected: keys.Where(key => defaults.Contains(key, StringComparer.OrdinalIgnoreCase)),
                    optional: !required,
                    cancellationToken: cancellationToken);
                return string.Join('|', selected);
            }
 
            if (!required && string.IsNullOrEmpty(parameter.DefaultValue))
            {
                // The extension identifies choices by display text, so the skip label must also be unique.
                var skipLabel = TemplatingStrings.Skip.EscapeMarkup();
                while (keys.Any(key => FormatChoice(key) == skipLabel))
                {
                    skipLabel = $"({skipLabel})";
                }
 
                // Omission is distinct from an empty value or a template choice whose key is "Skip".
                var optionalChoices = keys.Select(key => (Value: (string?)key, DisplayName: FormatChoice(key)))
                    .Prepend((Value: (string?)null, DisplayName: skipLabel));
                var selected = await interactionService.PromptForSelectionAsync(
                    prompt, optionalChoices, choice => choice.DisplayName, cancellationToken: cancellationToken);
                return selected.Value;
            }
 
            return await interactionService.PromptForSelectionAsync(prompt, keys, FormatChoice, cancellationToken: cancellationToken);
        }
 
        return await interactionService.PromptForStringAsync(
            prompt,
            required: required,
            binding: PromptBinding.CreateDefault<string?>(parameter.DefaultValue ?? string.Empty),
            cancellationToken: cancellationToken);
    }
 
    private TemplateResult CreationFailed(ITemplateCreationResult result) => CreationFailed(result.Status, result.ErrorMessage);
 
    private TemplateResult CreationFailed(CreationResultStatus status, string? errorMessage)
    {
        interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, NewCommandStrings.LocalTemplateCreationFailed, status, errorMessage));
        return new TemplateResult(CliExitCodes.FailedToCreateNewProject);
    }
 
    private static string GetTemplateName(ITemplateInfo template) =>
        template.ShortNameList.Count > 0 ? template.ShortNameList[0] : template.Identity;
}