File: Templating\CliTemplateFactory.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.Diagnostics;
using System.Globalization;
using System.Text;
using Aspire.Cli.Commands;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Scaffolding;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Templating;
 
internal sealed partial class CliTemplateFactory : ITemplateFactory
{
    private static readonly HashSet<string> s_binaryTemplateExtensions =
    [
        ".png",
        ".jpg",
        ".jpeg",
        ".gif",
        ".ico",
        ".bmp",
        ".webp",
        ".svg",
        ".woff",
        ".woff2",
        ".ttf",
        ".otf"
    ];
 
    private static readonly Option<bool?> s_localhostTldOption = new("--localhost-tld")
    {
        Description = TemplatingStrings.UseLocalhostTld_Description
    };
 
    private readonly ILanguageDiscovery _languageDiscovery;
    private readonly IScaffoldingService _scaffoldingService;
    private readonly INewCommandPrompter _prompter;
    private readonly CliExecutionContext _executionContext;
    private readonly IInteractionService _interactionService;
    private readonly ICliHostEnvironment _hostEnvironment;
    private readonly TemplateNuGetConfigService _templateNuGetConfigService;
    private readonly ILogger<CliTemplateFactory> _logger;
 
    public CliTemplateFactory(
        ILanguageDiscovery languageDiscovery,
        IScaffoldingService scaffoldingService,
        INewCommandPrompter prompter,
        CliExecutionContext executionContext,
        IInteractionService interactionService,
        ICliHostEnvironment hostEnvironment,
        TemplateNuGetConfigService templateNuGetConfigService,
        ILogger<CliTemplateFactory> logger)
    {
        _languageDiscovery = languageDiscovery;
        _scaffoldingService = scaffoldingService;
        _prompter = prompter;
        _executionContext = executionContext;
        _interactionService = interactionService;
        _hostEnvironment = hostEnvironment;
        _templateNuGetConfigService = templateNuGetConfigService;
        _logger = logger;
    }
 
    public Task<IEnumerable<ITemplate>> GetTemplatesAsync(CancellationToken cancellationToken = default)
    {
        IEnumerable<ITemplate> templates =
        [
            new CallbackTemplate(
                KnownTemplateId.TypeScriptStarter,
                "Starter App (Express/React)",
                projectName => $"./{projectName}",
                static cmd => AddOptionIfMissing(cmd, s_localhostTldOption),
                ApplyTypeScriptStarterTemplateAsync,
                runtime: TemplateRuntime.Cli,
                supportsLanguageCallback: static languageId =>
                    languageId.Equals(KnownLanguageId.TypeScript, StringComparison.OrdinalIgnoreCase) ||
                    languageId.Equals(KnownLanguageId.TypeScriptAlias, StringComparison.OrdinalIgnoreCase)),
 
            new CallbackTemplate(
                KnownTemplateId.EmptyAppHost,
                "Empty AppHost",
                projectName => $"./{projectName}",
                static cmd => AddOptionIfMissing(cmd, s_localhostTldOption),
                ApplyEmptyAppHostTemplateAsync,
                runtime: TemplateRuntime.Cli,
                supportsLanguageCallback: static languageId =>
                    languageId.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase) ||
                    languageId.Equals(KnownLanguageId.TypeScript, StringComparison.OrdinalIgnoreCase) ||
                    languageId.Equals(KnownLanguageId.TypeScriptAlias, StringComparison.OrdinalIgnoreCase),
                selectableAppHostLanguages: [KnownLanguageId.CSharp, KnownLanguageId.TypeScript])
        ];
 
        return Task.FromResult(templates);
    }
 
    public Task<IEnumerable<ITemplate>> GetInitTemplatesAsync(CancellationToken cancellationToken = default)
    {
        return Task.FromResult<IEnumerable<ITemplate>>([]);
    }
 
    private static string ApplyTokens(string content, string projectName, string projectNameLower, string aspireVersion, TemplatePorts ports, string hostName = "localhost")
    {
        return content
            .Replace("{{projectName}}", projectName)
            .Replace("{{projectNameLower}}", projectNameLower)
            .Replace("{{aspireVersion}}", aspireVersion)
            .Replace("{{hostName}}", hostName)
            .Replace("{{httpPort}}", ports.HttpPort.ToString(CultureInfo.InvariantCulture))
            .Replace("{{httpsPort}}", ports.HttpsPort.ToString(CultureInfo.InvariantCulture))
            .Replace("{{otlpHttpPort}}", ports.OtlpHttpPort.ToString(CultureInfo.InvariantCulture))
            .Replace("{{otlpHttpsPort}}", ports.OtlpHttpsPort.ToString(CultureInfo.InvariantCulture))
            .Replace("{{mcpHttpPort}}", ports.McpHttpPort.ToString(CultureInfo.InvariantCulture))
            .Replace("{{mcpHttpsPort}}", ports.McpHttpsPort.ToString(CultureInfo.InvariantCulture))
            .Replace("{{resourceHttpPort}}", ports.ResourceHttpPort.ToString(CultureInfo.InvariantCulture))
            .Replace("{{resourceHttpsPort}}", ports.ResourceHttpsPort.ToString(CultureInfo.InvariantCulture));
    }
 
    private static TemplatePorts GenerateRandomPorts()
    {
        return new TemplatePorts(
            HttpPort: Random.Shared.Next(15000, 15300),
            HttpsPort: Random.Shared.Next(17000, 17300),
            OtlpHttpPort: Random.Shared.Next(19000, 19300),
            OtlpHttpsPort: Random.Shared.Next(21000, 21300),
            McpHttpPort: Random.Shared.Next(18000, 18300),
            McpHttpsPort: Random.Shared.Next(23000, 23300),
            ResourceHttpPort: Random.Shared.Next(20000, 20300),
            ResourceHttpsPort: Random.Shared.Next(22000, 22300));
    }
 
    private sealed record TemplatePorts(
        int HttpPort, int HttpsPort,
        int OtlpHttpPort, int OtlpHttpsPort,
        int McpHttpPort, int McpHttpsPort,
        int ResourceHttpPort, int ResourceHttpsPort);
 
    private static void AddOptionIfMissing(System.CommandLine.Command command, System.CommandLine.Option option)
    {
        if (!command.Options.Contains(option))
        {
            command.Options.Add(option);
        }
    }
 
    private async Task CopyTemplateTreeToDiskAsync(string templateRoot, string outputPath, Func<string, string> tokenReplacer, CancellationToken cancellationToken)
    {
        var assembly = typeof(CliTemplateFactory).Assembly;
        _logger.LogDebug("Copying embedded template tree '{TemplateRoot}' to '{OutputPath}'.", templateRoot, outputPath);
 
        var allResourceNames = assembly.GetManifestResourceNames();
        var resourcePrefix = $"{templateRoot}.";
        var resourceNames = allResourceNames
            .Where(name => name.StartsWith(resourcePrefix, StringComparison.Ordinal))
            .OrderBy(static name => name, StringComparer.Ordinal)
            .ToArray();
 
        if (resourceNames.Length == 0)
        {
            _logger.LogDebug("No embedded resources found for template root '{TemplateRoot}'. Available manifest resources: {ManifestResources}", templateRoot, string.Join(", ", allResourceNames));
            throw new InvalidOperationException($"No embedded template resources found for '{templateRoot}'.");
        }
 
        _logger.LogDebug("Found {ResourceCount} embedded resources for template root '{TemplateRoot}': {TemplateResources}", resourceNames.Length, templateRoot, string.Join(", ", resourceNames));
 
        foreach (var resourceName in resourceNames)
        {
            var relativePath = resourceName[resourcePrefix.Length..].Replace('/', Path.DirectorySeparatorChar);
            var filePath = Path.Combine(outputPath, relativePath);
            var fileDirectory = Path.GetDirectoryName(filePath);
            if (!string.IsNullOrEmpty(fileDirectory))
            {
                Directory.CreateDirectory(fileDirectory);
            }
 
            using var stream = assembly.GetManifestResourceStream(resourceName)
                ?? throw new InvalidOperationException($"Embedded template resource not found: {resourceName}");
 
            _logger.LogDebug("Writing embedded template resource '{ResourceName}' to '{FilePath}'.", resourceName, filePath);
            if (s_binaryTemplateExtensions.Contains(Path.GetExtension(filePath)))
            {
                await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
                await stream.CopyToAsync(fileStream, cancellationToken);
            }
            else
            {
                using var reader = new StreamReader(stream);
                var content = await reader.ReadToEndAsync(cancellationToken);
                var transformedContent = tokenReplacer(content);
                await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
                await using var writer = new StreamWriter(fileStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
                await writer.WriteAsync(transformedContent.AsMemory(), cancellationToken);
                await writer.FlushAsync(cancellationToken);
            }
        }
    }
 
    private void DisplayProcessOutput(ProcessExecutionResult result, bool treatStandardErrorAsError)
    {
        if (!string.IsNullOrWhiteSpace(result.StandardOutput))
        {
            _interactionService.DisplaySubtleMessage(result.StandardOutput.TrimEnd());
        }
 
        if (!string.IsNullOrWhiteSpace(result.StandardError))
        {
            var message = result.StandardError.TrimEnd();
            if (treatStandardErrorAsError)
            {
                _interactionService.DisplayError(message);
            }
            else
            {
                _interactionService.DisplaySubtleMessage(message);
            }
        }
    }
 
    private static async Task<ProcessExecutionResult> RunProcessAsync(string fileName, string arguments, string workingDirectory, CancellationToken cancellationToken)
    {
        var startInfo = new ProcessStartInfo(fileName, arguments)
        {
            RedirectStandardInput = true,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
            WorkingDirectory = workingDirectory
        };
 
        using var process = new Process { StartInfo = startInfo };
        process.Start();
        process.StandardInput.Close(); // Prevent hanging on prompts
 
        // Drain output streams to prevent deadlocks
        var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
        var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
 
        try
        {
            await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
            await Task.WhenAll(outputTask, errorTask).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            try
            {
                if (!process.HasExited)
                {
                    process.Kill(entireProcessTree: true);
                }
            }
            catch (InvalidOperationException)
            {
            }
 
            throw;
        }
 
        return new ProcessExecutionResult(
            process.ExitCode,
            await outputTask.ConfigureAwait(false),
            await errorTask.ConfigureAwait(false));
    }
 
    private sealed record ProcessExecutionResult(int ExitCode, string StandardOutput, string StandardError);
}