// 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.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 Aspire.Shared;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Templating;
internal sealed partial class CliTemplateFactory : ITemplateFactory
{
private static readonly string[] s_emptyAppHostLanguages =
[
KnownLanguageId.CSharp,
KnownLanguageId.TypeScript,
KnownLanguageId.Python,
KnownLanguageId.Go,
KnownLanguageId.Java,
KnownLanguageId.Rust
];
private static readonly HashSet<string> s_binaryTemplateExtensions =
[
".png",
".jpg",
".jpeg",
".gif",
".ico",
".bmp",
".webp",
".svg",
".woff",
".woff2",
".ttf",
".otf"
];
// Embedded resources carry no file mode, so every scaffolded file lands with the default
// 0644. Build wrappers are invoked as "./mvnw" / "./gradlew" and fail with "Permission
// denied" on Unix unless the execute bit is restored here.
private static readonly HashSet<string> s_executableTemplateFileNames =
[
"mvnw",
"gradlew"
];
private readonly Option<bool?> _localhostTldOption = new("--localhost-tld")
{
Description = TemplatingStrings.UseLocalhostTld_Description
};
private readonly Option<bool?> _useRedisCacheOption = new("--use-redis-cache")
{
Description = TemplatingStrings.UseRedisCache_Description
};
private readonly ILanguageDiscovery _languageDiscovery;
private readonly IAppHostProjectFactory _projectFactory;
private readonly IScaffoldingService _scaffoldingService;
private readonly INewCommandPrompter _prompter;
private readonly CliExecutionContext _executionContext;
private readonly IInteractionService _interactionService;
private readonly ICliHostEnvironment _hostEnvironment;
private readonly IEnvironment _environment;
private readonly TemplateNuGetConfigService _templateNuGetConfigService;
private readonly ILogger<CliTemplateFactory> _logger;
public CliTemplateFactory(
ILanguageDiscovery languageDiscovery,
IAppHostProjectFactory projectFactory,
IScaffoldingService scaffoldingService,
INewCommandPrompter prompter,
CliExecutionContext executionContext,
IInteractionService interactionService,
ICliHostEnvironment hostEnvironment,
IEnvironment environment,
TemplateNuGetConfigService templateNuGetConfigService,
ILogger<CliTemplateFactory> logger)
{
_languageDiscovery = languageDiscovery;
_projectFactory = projectFactory;
_scaffoldingService = scaffoldingService;
_prompter = prompter;
_executionContext = executionContext;
_interactionService = interactionService;
_hostEnvironment = hostEnvironment;
_environment = environment;
_templateNuGetConfigService = templateNuGetConfigService;
_logger = logger;
}
public IEnumerable<ITemplate> GetTemplates()
{
return GetTemplateDefinitions();
}
public Task<IEnumerable<ITemplate>> GetTemplatesAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(GetTemplateDefinitions());
}
public Task<IEnumerable<ITemplate>> GetInitTemplatesAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult<IEnumerable<ITemplate>>(Array.Empty<ITemplate>());
}
private IEnumerable<ITemplate> GetTemplateDefinitions()
{
ITemplate[] templates =
[
new CallbackTemplate(
KnownTemplateId.TypeScriptStarter,
"Starter App (Express/React, TypeScript AppHost)",
(ctx, projectName) => OutputPathHelper.GetUniqueDefaultOutputPath(projectName, ctx.WorkingDirectory.FullName),
cmd => AddOptionIfMissing(cmd, _localhostTldOption),
ApplyTypeScriptStarterTemplateAsync,
runtime: TemplateRuntime.Cli,
languageId: KnownLanguageId.TypeScript),
new CallbackTemplate(
KnownTemplateId.CSharpEmptyAppHost,
"Empty AppHost (Choose language...)",
(ctx, projectName) => OutputPathHelper.GetUniqueDefaultOutputPath(projectName, ctx.WorkingDirectory.FullName),
cmd => AddOptionIfMissing(cmd, _localhostTldOption),
ApplyEmptyAppHostTemplateAsync,
runtime: TemplateRuntime.Cli,
supportsLanguageCallback: IsSelectableEmptyAppHostLanguage,
selectableAppHostLanguages: GetSelectableEmptyAppHostLanguages(),
isEmpty: true),
new CallbackTemplate(
KnownTemplateId.TypeScriptEmptyAppHost,
"Empty (TypeScript AppHost)",
(ctx, projectName) => OutputPathHelper.GetUniqueDefaultOutputPath(projectName, ctx.WorkingDirectory.FullName),
cmd => AddOptionIfMissing(cmd, _localhostTldOption),
ApplyEmptyAppHostTemplateAsync,
runtime: TemplateRuntime.Cli,
languageId: KnownLanguageId.TypeScript,
isEmpty: true,
showInPrompt: false),
new CallbackTemplate(
KnownTemplateId.PythonEmptyAppHost,
"Empty (Python AppHost)",
(ctx, projectName) => OutputPathHelper.GetUniqueDefaultOutputPath(projectName, ctx.WorkingDirectory.FullName),
cmd => AddOptionIfMissing(cmd, _localhostTldOption),
ApplyEmptyAppHostTemplateAsync,
runtime: TemplateRuntime.Cli,
languageId: KnownLanguageId.Python,
isEmpty: true,
showInPrompt: false),
new CallbackTemplate(
KnownTemplateId.JavaEmptyAppHost,
"Empty (Java AppHost)",
(ctx, projectName) => OutputPathHelper.GetUniqueDefaultOutputPath(projectName, ctx.WorkingDirectory.FullName),
cmd => AddOptionIfMissing(cmd, _localhostTldOption),
ApplyEmptyAppHostTemplateAsync,
runtime: TemplateRuntime.Cli,
languageId: KnownLanguageId.Java,
isEmpty: true,
showInPrompt: false),
new CallbackTemplate(
KnownTemplateId.GoEmptyAppHost,
"Empty (Go AppHost)",
(ctx, projectName) => OutputPathHelper.GetUniqueDefaultOutputPath(projectName, ctx.WorkingDirectory.FullName),
cmd => AddOptionIfMissing(cmd, _localhostTldOption),
ApplyEmptyAppHostTemplateAsync,
runtime: TemplateRuntime.Cli,
languageId: KnownLanguageId.Go,
isEmpty: true,
showInPrompt: false),
new CallbackTemplate(
KnownTemplateId.RustEmptyAppHost,
"Empty (Rust AppHost)",
(ctx, projectName) => OutputPathHelper.GetUniqueDefaultOutputPath(projectName, ctx.WorkingDirectory.FullName),
cmd => AddOptionIfMissing(cmd, _localhostTldOption),
ApplyEmptyAppHostTemplateAsync,
runtime: TemplateRuntime.Cli,
languageId: KnownLanguageId.Rust,
isEmpty: true,
showInPrompt: false),
new CallbackTemplate(
KnownTemplateId.PythonStarter,
"Starter App (FastAPI/React, TypeScript AppHost)",
(ctx, projectName) => OutputPathHelper.GetUniqueDefaultOutputPath(projectName, ctx.WorkingDirectory.FullName),
cmd =>
{
AddOptionIfMissing(cmd, _localhostTldOption);
AddOptionIfMissing(cmd, _useRedisCacheOption);
},
ApplyPythonStarterTemplateAsync,
runtime: TemplateRuntime.Cli,
languageId: KnownLanguageId.TypeScript),
new CallbackTemplate(
KnownTemplateId.GoStarter,
"Starter App (Go API + Redis, Go AppHost)",
(ctx, projectName) => OutputPathHelper.GetUniqueDefaultOutputPath(projectName, ctx.WorkingDirectory.FullName),
cmd => AddOptionIfMissing(cmd, _localhostTldOption),
ApplyGoStarterTemplateAsync,
runtime: TemplateRuntime.Cli,
languageId: KnownLanguageId.Go),
new CallbackTemplate(
KnownTemplateId.JavaStarter,
"Starter App (Spring Boot/React, Java AppHost)",
(ctx, projectName) => OutputPathHelper.GetUniqueDefaultOutputPath(projectName, ctx.WorkingDirectory.FullName),
cmd => AddOptionIfMissing(cmd, _localhostTldOption),
ApplyJavaStarterTemplateAsync,
runtime: TemplateRuntime.Cli,
languageId: KnownLanguageId.Java)
];
return templates.Where(IsTemplateAvailable);
}
private IReadOnlyList<string> GetSelectableEmptyAppHostLanguages()
{
return s_emptyAppHostLanguages
.Where(IsSelectableEmptyAppHostLanguage)
.ToArray();
}
private bool IsSelectableEmptyAppHostLanguage(string languageId)
{
return _languageDiscovery.GetLanguageById(new LanguageId(languageId)) is not null;
}
private bool IsTemplateAvailable(ITemplate template)
{
if (string.IsNullOrWhiteSpace(template.LanguageId))
{
return true;
}
return _languageDiscovery.GetLanguageById(new LanguageId(template.LanguageId)) is not null;
}
private async Task<string?> ResolveOutputPathAsync(TemplateInputs inputs, Func<CliExecutionContext, string, string> pathDeriver, string projectName, System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken)
{
var isExtensionHost = ExtensionHelper.IsExtensionHost(_interactionService, out _, out _);
var createProjectNameSubdirectory = await OutputPathHelper.PromptExtensionCreateProjectNameSubdirectoryAsync(
_interactionService,
isExtensionHost,
inputs.Output is not null,
projectName,
cancellationToken);
var outputPathResolver = OutputPathHelper.CreateProjectNameSubdirectoryOutputPathResolver(createProjectNameSubdirectory, projectName);
return await OutputPathHelper.ResolveOutputPathAsync(
inputs.Output,
_executionContext.WorkingDirectory.FullName,
async () =>
{
var defaultOutputPath = pathDeriver(_executionContext, projectName);
var outputPathValidator = OutputPathHelper.CreateOutputPathValidator(_executionContext.WorkingDirectory.FullName);
return await _prompter.PromptForOutputPath(defaultOutputPath, parseResult, outputPathValidator, outputPathResolver, cancellationToken);
},
_interactionService);
}
private static string ApplyTokens(string content, string projectName, string projectNameLower, string aspireVersion, AppHostProfilePorts ports, string hostName = "localhost")
{
return content
.Replace("{{projectName}}", projectName)
.Replace("{{projectNameLower}}", projectNameLower)
.Replace("{{aspireVersion}}", aspireVersion)
.Replace("{{hostName}}", hostName)
.Replace("{{httpPort}}", ports.DashboardHttpPort.ToString(CultureInfo.InvariantCulture))
.Replace("{{httpsPort}}", ports.DashboardHttpsPort.ToString(CultureInfo.InvariantCulture))
.Replace("{{otlpHttpPort}}", ports.OtlpHttpPort.ToString(CultureInfo.InvariantCulture))
.Replace("{{otlpHttpsPort}}", ports.OtlpHttpsPort.ToString(CultureInfo.InvariantCulture))
.Replace("{{resourceHttpPort}}", ports.ResourceServiceHttpPort.ToString(CultureInfo.InvariantCulture))
.Replace("{{resourceHttpsPort}}", ports.ResourceServiceHttpsPort.ToString(CultureInfo.InvariantCulture));
}
private static AppHostProfilePorts GenerateRandomPorts()
{
return AppHostProfilePortGenerator.Generate(Random.Shared);
}
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);
}
MakeExecutableIfBuildWrapper(filePath);
}
}
private void MakeExecutableIfBuildWrapper(string filePath)
{
if (_environment.IsWindows() || !s_executableTemplateFileNames.Contains(Path.GetFileName(filePath)))
{
return;
}
try
{
#pragma warning disable CA1416 // Guarded by the IsWindows check above, which the analyzer cannot see through.
var mode = File.GetUnixFileMode(filePath);
File.SetUnixFileMode(filePath, mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
#pragma warning restore CA1416
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
// A wrapper that is not executable still runs through "sh ./mvnw", so a file system that
// rejects mode changes should degrade rather than fail the whole scaffold.
_logger.LogDebug(ex, "Unable to set the execute bit on scaffolded build wrapper '{FilePath}'.", filePath);
}
}
private void DisplayPostCreationInstructions(string outputPath)
{
var currentDir = _executionContext.WorkingDirectory.FullName;
var relativePath = Path.GetRelativePath(currentDir, outputPath);
var pathComparison = _environment.IsWindows() || _environment.IsMacOS()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
if (!string.Equals(Path.GetFullPath(currentDir), Path.GetFullPath(outputPath), pathComparison))
{
_interactionService.DisplayMessage(KnownEmojis.Information, string.Format(CultureInfo.CurrentCulture, TemplatingStrings.RunCdThenAspireRun, relativePath));
}
else
{
_interactionService.DisplayMessage(KnownEmojis.Information, TemplatingStrings.RunAspireRun);
}
}
}