diff --git a/src/Tools/CLI/FSH.CLI.csproj b/src/Tools/CLI/FSH.CLI.csproj
index 2dac6585d..1875ed08b 100644
--- a/src/Tools/CLI/FSH.CLI.csproj
+++ b/src/Tools/CLI/FSH.CLI.csproj
@@ -30,6 +30,7 @@
+
diff --git a/src/Tools/CLI/Scaffolding/ITemplateCache.cs b/src/Tools/CLI/Scaffolding/ITemplateCache.cs
new file mode 100644
index 000000000..0a8e47b2d
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/ITemplateCache.cs
@@ -0,0 +1,42 @@
+namespace FSH.CLI.Scaffolding;
+
+///
+/// Caching layer for templates
+///
+internal interface ITemplateCache
+{
+ ///
+ /// Gets a cached template by key
+ ///
+ string? GetTemplate(string key);
+
+ ///
+ /// Stores a template in the cache
+ ///
+ void SetTemplate(string key, string template);
+
+ ///
+ /// Checks if a template is cached
+ ///
+ bool ContainsTemplate(string key);
+
+ ///
+ /// Removes a template from the cache
+ ///
+ void RemoveTemplate(string key);
+
+ ///
+ /// Clears all cached templates
+ ///
+ void ClearCache();
+
+ ///
+ /// Gets cache statistics
+ ///
+ CacheStatistics GetStatistics();
+}
+
+///
+/// Cache performance statistics
+///
+internal record CacheStatistics(int TotalEntries, int HitCount, int MissCount, double HitRatio);
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/ITemplateLoader.cs b/src/Tools/CLI/Scaffolding/ITemplateLoader.cs
new file mode 100644
index 000000000..ea28a5da9
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/ITemplateLoader.cs
@@ -0,0 +1,24 @@
+using FSH.CLI.Models;
+
+namespace FSH.CLI.Scaffolding;
+
+///
+/// Loads templates from various sources (embedded resources, disk, etc.)
+///
+internal interface ITemplateLoader
+{
+ ///
+ /// Gets the framework version for package references
+ ///
+ string GetFrameworkVersion();
+
+ ///
+ /// Gets a static template by name
+ ///
+ string GetStaticTemplate(string templateName);
+
+ ///
+ /// Checks if a template exists
+ ///
+ bool TemplateExists(string templateName);
+}
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/ITemplateParser.cs b/src/Tools/CLI/Scaffolding/ITemplateParser.cs
new file mode 100644
index 000000000..4854f6692
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/ITemplateParser.cs
@@ -0,0 +1,36 @@
+using FSH.CLI.Models;
+
+namespace FSH.CLI.Scaffolding;
+
+///
+/// Parses template syntax and extracts variables
+///
+internal interface ITemplateParser
+{
+ ///
+ /// Extracts variables from a template string
+ ///
+ IEnumerable ExtractVariables(string template);
+
+ ///
+ /// Validates template syntax
+ ///
+ bool IsValidTemplate(string template);
+
+ ///
+ /// Normalizes project names for different contexts (lowercase, safe characters, etc.)
+ ///
+ string NormalizeProjectName(string projectName, NameContext context);
+}
+
+///
+/// Context for project name normalization
+///
+internal enum NameContext
+{
+ Default,
+ LowerCase,
+ SafeIdentifier,
+ DockerImage,
+ DatabaseName
+}
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/ITemplateRenderer.cs b/src/Tools/CLI/Scaffolding/ITemplateRenderer.cs
new file mode 100644
index 000000000..da7f10876
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/ITemplateRenderer.cs
@@ -0,0 +1,54 @@
+using FSH.CLI.Models;
+
+namespace FSH.CLI.Scaffolding;
+
+///
+/// Renders templates with variable substitution
+///
+internal interface ITemplateRenderer
+{
+ // Solution and Project Templates
+ string RenderSolution(ProjectOptions options);
+ string RenderApiCsproj(ProjectOptions options);
+ string RenderApiProgram(ProjectOptions options);
+ string RenderMigrationsCsproj(ProjectOptions options);
+ string RenderBlazorCsproj();
+ string RenderBlazorProgram(ProjectOptions options);
+ string RenderAppHostCsproj(ProjectOptions options);
+ string RenderAppHostProgram(ProjectOptions options);
+
+ // Configuration Templates
+ string RenderAppSettings(ProjectOptions options);
+ string RenderAppSettingsDevelopment();
+ string RenderApiLaunchSettings(ProjectOptions options);
+ string RenderAppHostLaunchSettings(ProjectOptions options);
+
+ // Blazor Templates
+ string RenderBlazorApp();
+ string RenderBlazorImports(ProjectOptions options);
+ string RenderBlazorIndexPage(ProjectOptions options);
+ string RenderBlazorMainLayout(ProjectOptions options);
+
+ // Infrastructure Templates
+ string RenderDockerfile(ProjectOptions options);
+ string RenderDockerCompose(ProjectOptions options);
+ string RenderDockerComposeOverride();
+ string RenderTerraformMain(ProjectOptions options);
+ string RenderTerraformVariables(ProjectOptions options);
+ string RenderTerraformOutputs(ProjectOptions options);
+ string RenderGitHubActionsCI(ProjectOptions options);
+
+ // Module Templates
+ string RenderCatalogModule(ProjectOptions options);
+ string RenderCatalogModuleCsproj(ProjectOptions options);
+ string RenderCatalogContractsCsproj();
+ string RenderGetProductsEndpoint(ProjectOptions options);
+
+ // Static Content Templates
+ string RenderReadme(ProjectOptions options);
+ string RenderGitignore();
+ string RenderDirectoryBuildProps(ProjectOptions options);
+ string RenderDirectoryPackagesProps(ProjectOptions options);
+ string RenderEditorConfig();
+ string RenderGlobalJson();
+}
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/ITemplateValidator.cs b/src/Tools/CLI/Scaffolding/ITemplateValidator.cs
new file mode 100644
index 000000000..aab013311
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/ITemplateValidator.cs
@@ -0,0 +1,41 @@
+using FSH.CLI.Models;
+
+namespace FSH.CLI.Scaffolding;
+
+///
+/// Validates template structure and configuration
+///
+internal interface ITemplateValidator
+{
+ ///
+ /// Validates project options for template generation
+ ///
+ ValidationResult ValidateProjectOptions(ProjectOptions options);
+
+ ///
+ /// Validates that required templates are available
+ ///
+ ValidationResult ValidateTemplateAvailability(ProjectOptions options);
+
+ ///
+ /// Validates generated template content
+ ///
+ ValidationResult ValidateGeneratedContent(string content, string templateType);
+
+ ///
+ /// Validates project structure compatibility
+ ///
+ ValidationResult ValidateProjectStructure(ProjectOptions options);
+}
+
+///
+/// Result of a validation operation
+///
+internal record ValidationResult(bool IsValid, IEnumerable Errors, IEnumerable Warnings)
+{
+ public static ValidationResult Success() => new(true, Enumerable.Empty(), Enumerable.Empty());
+
+ public static ValidationResult Failure(params string[] errors) => new(false, errors, Enumerable.Empty());
+
+ public static ValidationResult Warning(params string[] warnings) => new(true, Enumerable.Empty(), warnings);
+}
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/REFACTORING.md b/src/Tools/CLI/Scaffolding/REFACTORING.md
new file mode 100644
index 000000000..0c4375771
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/REFACTORING.md
@@ -0,0 +1,91 @@
+# TemplateEngine Refactoring
+
+This document outlines the refactoring of the TemplateEngine.cs god class into focused, single-responsibility components.
+
+## Overview
+
+The original `TemplateEngine.cs` was a 1645-line god class that handled everything from template loading to rendering and validation. This refactoring splits it into focused services while maintaining backward compatibility.
+
+## New Architecture
+
+### 1. ITemplateLoader / TemplateLoader
+**Responsibility:** Load templates from embedded resources and static sources
+- `GetFrameworkVersion()` - Gets the current framework version
+- `GetStaticTemplate(name)` - Gets static template content by name
+- `TemplateExists(name)` - Checks if a template exists
+
+### 2. ITemplateParser / TemplateParser
+**Responsibility:** Parse template syntax and normalize project names
+- `ExtractVariables(template)` - Extracts interpolation variables
+- `IsValidTemplate(template)` - Validates template syntax
+- `NormalizeProjectName(name, context)` - Normalizes names for different contexts (Docker, database, etc.)
+
+### 3. ITemplateRenderer / TemplateRenderer
+**Responsibility:** Render templates with variable substitution
+- All the `Render*()` methods that generate specific templates
+- Handles the actual template interpolation logic
+- Uses other services for loading, parsing, and validation
+
+### 4. ITemplateValidator / TemplateValidator
+**Responsibility:** Validate template structure and project configuration
+- `ValidateProjectOptions()` - Validates input parameters
+- `ValidateTemplateAvailability()` - Ensures required templates exist
+- `ValidateGeneratedContent()` - Validates output quality
+- `ValidateProjectStructure()` - Checks for logical issues
+
+### 5. ITemplateCache / TemplateCache
+**Responsibility:** Cache frequently used templates
+- In-memory caching with statistics
+- Thread-safe operations
+- Cache hit/miss metrics
+
+### 6. TemplateServices
+**Responsibility:** Dependency injection container
+- Factory pattern for creating services
+- Manages service lifetimes
+- Provides easy access to all services
+
+## Backward Compatibility
+
+The refactored `TemplateEngine` class maintains the exact same public API as before. All existing code calling `TemplateEngine.GenerateXxx()` methods will continue to work without changes.
+
+## Benefits
+
+1. **Single Responsibility Principle** - Each class has one clear purpose
+2. **Testability** - Smaller, focused classes are easier to unit test
+3. **Maintainability** - Changes are isolated to specific areas
+4. **Extensibility** - New template types or sources are easier to add
+5. **Performance** - Template caching reduces repeated work
+6. **Validation** - Built-in validation catches issues early
+
+## Files Changed
+
+- `TemplateEngine.cs` - Refactored to delegate to services (maintains API)
+- `ITemplateLoader.cs` + `TemplateLoader.cs` - Template loading
+- `ITemplateParser.cs` + `TemplateParser.cs` - Template parsing
+- `ITemplateRenderer.cs` + `TemplateRenderer.cs` - Template rendering
+- `ITemplateValidator.cs` + `TemplateValidator.cs` - Validation logic
+- `ITemplateCache.cs` + `TemplateCache.cs` - Caching layer
+- `TemplateServices.cs` - DI container
+- `TemplateEngineTests.cs` - Basic validation tests
+- `FSH.CLI.csproj` - Added Microsoft.Extensions.DependencyInjection
+
+## Testing
+
+Run `TemplateEngineTests.RunValidationTests()` to verify the refactoring works correctly.
+
+## Migration Notes
+
+- No breaking changes - existing code continues to work
+- The original `TemplateEngine.cs` is backed up as `TemplateEngine.cs.backup`
+- All template generation logic has been moved but preserved
+- Validation now provides better error messages and warnings
+
+## Future Enhancements
+
+1. **Template Hot Reloading** - Watch template files and reload automatically
+2. **Custom Template Sources** - Load from databases, HTTP, etc.
+3. **Template Composition** - Combine multiple templates
+4. **Async Operations** - For I/O heavy template operations
+5. **Template Versioning** - Support multiple template versions
+6. **Plugin Architecture** - Allow external template providers
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/TemplateCache.cs b/src/Tools/CLI/Scaffolding/TemplateCache.cs
new file mode 100644
index 000000000..71c8a9107
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/TemplateCache.cs
@@ -0,0 +1,64 @@
+using System.Collections.Concurrent;
+
+namespace FSH.CLI.Scaffolding;
+
+///
+/// In-memory cache for templates
+///
+internal sealed class TemplateCache : ITemplateCache
+{
+ private readonly ConcurrentDictionary _cache = new();
+ private int _hitCount;
+ private int _missCount;
+
+ public string? GetTemplate(string key)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(key);
+
+ if (_cache.TryGetValue(key, out var template))
+ {
+ Interlocked.Increment(ref _hitCount);
+ return template;
+ }
+
+ Interlocked.Increment(ref _missCount);
+ return null;
+ }
+
+ public void SetTemplate(string key, string template)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(key);
+ ArgumentNullException.ThrowIfNull(template);
+
+ _cache[key] = template;
+ }
+
+ public bool ContainsTemplate(string key)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(key);
+
+ return _cache.ContainsKey(key);
+ }
+
+ public void RemoveTemplate(string key)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(key);
+
+ _cache.TryRemove(key, out _);
+ }
+
+ public void ClearCache()
+ {
+ _cache.Clear();
+ Interlocked.Exchange(ref _hitCount, 0);
+ Interlocked.Exchange(ref _missCount, 0);
+ }
+
+ public CacheStatistics GetStatistics()
+ {
+ var totalRequests = _hitCount + _missCount;
+ var hitRatio = totalRequests > 0 ? (double)_hitCount / totalRequests : 0.0;
+
+ return new CacheStatistics(_cache.Count, _hitCount, _missCount, hitRatio);
+ }
+}
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/TemplateEngine.cs b/src/Tools/CLI/Scaffolding/TemplateEngine.cs
index 51e7a55a5..53f4b6b42 100644
--- a/src/Tools/CLI/Scaffolding/TemplateEngine.cs
+++ b/src/Tools/CLI/Scaffolding/TemplateEngine.cs
@@ -1,1645 +1,252 @@
using System.Diagnostics.CodeAnalysis;
-using System.Reflection;
using FSH.CLI.Models;
namespace FSH.CLI.Scaffolding;
+///
+/// Refactored TemplateEngine that delegates to focused services
+/// This maintains the original public API while using the new architecture
+///
[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Lowercase is required for Docker, Terraform, and GitHub Actions naming conventions")]
internal static class TemplateEngine
{
- private static readonly string FrameworkVersion = GetFrameworkVersion();
+ private static readonly ITemplateRenderer _renderer = TemplateServices.GetRenderer();
+ private static readonly ITemplateValidator _validator = TemplateServices.GetValidator();
- private static string GetFrameworkVersion()
- {
- var assembly = Assembly.GetExecutingAssembly();
- var version = assembly.GetCustomAttribute()?.InformationalVersion
- ?? assembly.GetName().Version?.ToString()
- ?? "10.0.0";
-
- // Remove any +buildmetadata suffix (e.g., "10.0.0-rc.1+abc123" -> "10.0.0-rc.1")
- var plusIndex = version.IndexOf('+', StringComparison.Ordinal);
- return plusIndex > 0 ? version[..plusIndex] : version;
- }
+ #region Solution and Project Templates
public static string GenerateSolution(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- var projects = new List
- {
- $""" """,
- $""" """
- };
-
- if (options.Type == ProjectType.ApiBlazor)
- {
- projects.Add($""" """);
- }
-
- if (options.IncludeAspire)
- {
- projects.Add($""" """);
- }
-
- if (options.IncludeSampleModule)
- {
- projects.Add($""" """);
- projects.Add($""" """);
- }
-
- return $$"""
-
-
- {{string.Join(Environment.NewLine, projects)}}
-
-
-
-
-
-
-
- """;
+ ValidateOptions(options);
+ return _renderer.RenderSolution(options);
}
public static string GenerateApiCsproj(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- var serverless = options.Architecture == ArchitectureStyle.Serverless;
-
- var sampleModuleRef = options.IncludeSampleModule
- ? $"""
-
-
-
-
-
- """
- : string.Empty;
-
- return $$"""
-
-
-
- net10.0
- enable
- enable
- {{(serverless ? " Library" : "")}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
- {{(serverless ? """
-
-
-
-
-
- """ : "")}}{{sampleModuleRef}}
-
- """;
+ ValidateOptions(options);
+ return _renderer.RenderApiCsproj(options);
}
public static string GenerateApiProgram(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- var serverless = options.Architecture == ArchitectureStyle.Serverless;
-
- if (serverless)
- {
- var serverlessModuleUsing = options.IncludeSampleModule
- ? $"using {options.Name}.Catalog;\n"
- : string.Empty;
-
- var serverlessModuleAssembly = options.IncludeSampleModule
- ? $",\n typeof(CatalogModule).Assembly"
- : string.Empty;
-
- return $$"""
- {{serverlessModuleUsing}}using FSH.Framework.Web;
- using FSH.Framework.Web.Modules;
- using System.Reflection;
-
- var builder = WebApplication.CreateBuilder(args);
-
- // Add AWS Lambda hosting
- builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi);
-
- // Add FSH Platform
- builder.AddHeroPlatform(platform =>
- {
- platform.EnableOpenApi = true;
- platform.EnableCaching = true;
- });
-
- // Add modules
- var moduleAssemblies = new Assembly[]
- {
- typeof(Program).Assembly{{serverlessModuleAssembly}}
- };
- builder.AddModules(moduleAssemblies);
-
- var app = builder.Build();
-
- // Use FSH Platform
- app.UseHeroPlatform(platform =>
- {
- platform.MapModules = true;
- });
-
- await app.RunAsync();
- """;
- }
-
- var sampleModuleUsing = options.IncludeSampleModule
- ? $"using {options.Name}.Catalog;\n"
- : string.Empty;
-
- var sampleModuleAssembly = options.IncludeSampleModule
- ? ",\n typeof(CatalogModule).Assembly"
- : string.Empty;
-
- return $$"""
- {{sampleModuleUsing}}using FSH.Framework.Web;
- using FSH.Framework.Web.Modules;
- using FSH.Modules.Auditing;
- using FSH.Modules.Identity;
- using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration;
- using FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration;
- using FSH.Modules.Multitenancy;
- using FSH.Modules.Multitenancy.Contracts.v1.GetTenantStatus;
- using FSH.Modules.Multitenancy.Features.v1.GetTenantStatus;
- using System.Reflection;
-
- var builder = WebApplication.CreateBuilder(args);
-
- // Configure Mediator with required assemblies
- builder.Services.AddMediator(o =>
- {
- o.ServiceLifetime = ServiceLifetime.Scoped;
- o.Assemblies = [
- typeof(GenerateTokenCommand),
- typeof(GenerateTokenCommandHandler),
- typeof(GetTenantStatusQuery),
- typeof(GetTenantStatusQueryHandler),
- typeof(FSH.Modules.Auditing.Contracts.AuditEnvelope),
- typeof(FSH.Modules.Auditing.Persistence.AuditDbContext)];
- });
-
- // FSH Module assemblies
- var moduleAssemblies = new Assembly[]
- {
- typeof(IdentityModule).Assembly,
- typeof(MultitenancyModule).Assembly,
- typeof(AuditingModule).Assembly{{sampleModuleAssembly}}
- };
-
- // Add FSH Platform
- builder.AddHeroPlatform(platform =>
- {
- platform.EnableOpenApi = true;
- platform.EnableCaching = true;
- platform.EnableJobs = true;
- platform.EnableMailing = true;
- });
-
- // Add modules
- builder.AddModules(moduleAssemblies);
-
- var app = builder.Build();
-
- // Apply tenant database migrations
- app.UseHeroMultiTenantDatabases();
-
- // Use FSH Platform
- app.UseHeroPlatform(platform =>
- {
- platform.MapModules = true;
- });
-
- await app.RunAsync();
- """;
+ ValidateOptions(options);
+ return _renderer.RenderApiProgram(options);
}
- public static string GenerateAppSettings(ProjectOptions options)
+ public static string GenerateMigrationsCsproj(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- var connectionString = options.Database switch
- {
- DatabaseProvider.PostgreSQL => $"Server=localhost;Database={options.Name.ToLowerInvariant()};User Id=postgres;Password=password",
- DatabaseProvider.SqlServer => $"Server=localhost;Database={options.Name};Trusted_Connection=True;TrustServerCertificate=True",
- DatabaseProvider.SQLite => $"Data Source={options.Name}.db",
- _ => string.Empty
- };
-
- var dbProvider = options.Database switch
- {
- DatabaseProvider.PostgreSQL => "POSTGRESQL",
- DatabaseProvider.SqlServer => "MSSQL",
- DatabaseProvider.SQLite => "SQLITE",
- _ => "POSTGRESQL"
- };
-
- var migrationsAssembly = $"{options.Name}.Migrations";
- var projectNameLower = options.Name.ToLowerInvariant();
-
- return $$"""
- {
- "OpenTelemetryOptions": {
- "Enabled": true,
- "Tracing": {
- "Enabled": true
- },
- "Metrics": {
- "Enabled": true,
- "MeterNames": []
- },
- "Exporter": {
- "Otlp": {
- "Enabled": true,
- "Endpoint": "http://localhost:4317",
- "Protocol": "grpc"
- }
- },
- "Jobs": { "Enabled": true },
- "Mediator": { "Enabled": true },
- "Http": {
- "Histograms": {
- "Enabled": true
- }
- },
- "Data": {
- "FilterEfStatements": true,
- "FilterRedisCommands": true
- }
- },
- "Serilog": {
- "Using": [
- "Serilog.Sinks.Console",
- "Serilog.Sinks.OpenTelemetry"
- ],
- "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId", "WithCorrelationId", "WithProcessId", "WithProcessName" ],
- "MinimumLevel": {
- "Default": "Debug"
- },
- "WriteTo": [
- {
- "Name": "Console",
- "Args": {
- "restrictedToMinimumLevel": "Information"
- }
- },
- {
- "Name": "OpenTelemetry",
- "Args": {
- "endpoint": "http://localhost:4317",
- "protocol": "grpc",
- "resourceAttributes": {
- "service.name": "{{options.Name}}.Api"
- }
- }
- }
- ]
- },
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning",
- "Hangfire": "Warning",
- "Microsoft.EntityFrameworkCore": "Warning"
- }
- },
- "DatabaseOptions": {
- "Provider": "{{dbProvider}}",
- "ConnectionString": "{{connectionString}}",
- "MigrationsAssembly": "{{migrationsAssembly}}"
- },
- "OriginOptions": {
- "OriginUrl": "https://localhost:7030"
- },
- "CachingOptions": {
- "Redis": ""
- },
- "HangfireOptions": {
- "Username": "admin",
- "Password": "Secure1234!Me",
- "Route": "/jobs"
- },
- "AllowedHosts": "*",
- "OpenApiOptions": {
- "Enabled": true,
- "Title": "{{options.Name}} API",
- "Version": "v1",
- "Description": "{{options.Name}} API built with FullStackHero .NET Starter Kit.",
- "Contact": {
- "Name": "Your Name",
- "Url": "https://yourwebsite.com",
- "Email": "your@email.com"
- },
- "License": {
- "Name": "MIT License",
- "Url": "https://opensource.org/licenses/MIT"
- }
- },
- "CorsOptions": {
- "AllowAll": false,
- "AllowedOrigins": [
- "https://localhost:4200",
- "https://localhost:7140"
- ],
- "AllowedHeaders": [ "content-type", "authorization" ],
- "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ]
- },
- "JwtOptions": {
- "Issuer": "{{projectNameLower}}.local",
- "Audience": "{{projectNameLower}}.clients",
- "SigningKey": "replace-with-256-bit-secret-min-32-chars",
- "AccessTokenMinutes": 2,
- "RefreshTokenDays": 7
- },
- "SecurityHeadersOptions": {
- "Enabled": true,
- "ExcludedPaths": [ "/scalar", "/openapi" ],
- "AllowInlineStyles": true,
- "ScriptSources": [],
- "StyleSources": []
- },
- "MailOptions": {
- "From": "noreply@{{projectNameLower}}.com",
- "Host": "smtp.ethereal.email",
- "Port": 587,
- "UserName": "your-smtp-user",
- "Password": "your-smtp-password",
- "DisplayName": "{{options.Name}}"
- },
- "RateLimitingOptions": {
- "Enabled": false,
- "Global": {
- "PermitLimit": 100,
- "WindowSeconds": 60,
- "QueueLimit": 0
- },
- "Auth": {
- "PermitLimit": 10,
- "WindowSeconds": 60,
- "QueueLimit": 0
- }
- },
- "MultitenancyOptions": {
- "RunTenantMigrationsOnStartup": true
- },
- "Storage": {
- "Provider": "local"
- }
- }
- """;
+ ValidateOptions(options);
+ return _renderer.RenderMigrationsCsproj(options);
}
- private const string AppSettingsDevelopmentTemplate = """
- {
- "Logging": {
- "LogLevel": {
- "Default": "Debug",
- "Microsoft.AspNetCore": "Information",
- "Microsoft.EntityFrameworkCore": "Warning"
- }
- }
- }
- """;
-
- private const string BlazorCsprojTemplate = """
-
-
-
- net10.0
- enable
- enable
-
-
-
-
-
-
-
-
-
-
- """;
-
- public static string GenerateAppSettingsDevelopment() => AppSettingsDevelopmentTemplate;
-
- public static string GenerateBlazorCsproj() => BlazorCsprojTemplate;
+ public static string GenerateBlazorCsproj()
+ {
+ return _renderer.RenderBlazorCsproj();
+ }
public static string GenerateBlazorProgram(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- return $$"""
- using Microsoft.AspNetCore.Components.Web;
- using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
- using MudBlazor.Services;
- using {{options.Name}}.Blazor;
-
- var builder = WebAssemblyHostBuilder.CreateDefault(args);
- builder.RootComponents.Add("#app");
- builder.RootComponents.Add("head::after");
-
- builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
- builder.Services.AddMudServices();
-
- await builder.Build().RunAsync();
- """;
+ ValidateOptions(options);
+ return _renderer.RenderBlazorProgram(options);
}
- public static string GenerateBlazorImports(ProjectOptions options)
+ public static string GenerateAppHostCsproj(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- return $$"""
- @using System.Net.Http
- @using System.Net.Http.Json
- @using Microsoft.AspNetCore.Components.Forms
- @using Microsoft.AspNetCore.Components.Routing
- @using Microsoft.AspNetCore.Components.Web
- @using Microsoft.AspNetCore.Components.Web.Virtualization
- @using Microsoft.AspNetCore.Components.WebAssembly.Http
- @using Microsoft.JSInterop
- @using MudBlazor
- @using {{options.Name}}.Blazor
- """;
+ ValidateOptions(options);
+ return _renderer.RenderAppHostCsproj(options);
}
- private const string BlazorAppTemplate = """
-
-
-
-
-
-
-
-
-
-
-
- Not found
-
- Sorry, there's nothing at this address.
-
-
-
- """;
-
- public static string GenerateBlazorApp() => BlazorAppTemplate;
-
- public static string GenerateBlazorIndexPage(ProjectOptions options)
+ public static string GenerateAppHostProgram(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
+ ValidateOptions(options);
+ return _renderer.RenderAppHostProgram(options);
+ }
- return $$"""
- @page "/"
+ #endregion
- {{options.Name}}
+ #region Configuration Templates
-
- Welcome to {{options.Name}}
-
- Built with FullStackHero .NET Starter Kit
-
-
- """;
+ public static string GenerateAppSettings(ProjectOptions options)
+ {
+ ValidateOptions(options);
+ return _renderer.RenderAppSettings(options);
}
- public static string GenerateMigrationsCsproj(ProjectOptions options)
+ public static string GenerateAppSettingsDevelopment()
{
- ArgumentNullException.ThrowIfNull(options);
-
- var dbPackage = options.Database switch
- {
- DatabaseProvider.PostgreSQL => "",
- DatabaseProvider.SqlServer => "",
- DatabaseProvider.SQLite => "",
- _ => string.Empty
- };
-
- return $$"""
-
-
-
- net10.0
- enable
- enable
-
-
-
-
- {{dbPackage}}
-
-
-
-
-
-
-
- """;
+ return _renderer.RenderAppSettingsDevelopment();
}
- public static string GenerateAppHostCsproj(ProjectOptions options)
+ public static string GenerateApiLaunchSettings(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- var dbPackage = options.Database switch
- {
- DatabaseProvider.PostgreSQL => "",
- DatabaseProvider.SqlServer => "",
- _ => string.Empty // SQLite doesn't need a hosting package
- };
-
- return $$"""
-
-
-
- Exe
- net10.0
- enable
- enable
- false
-
-
-
- {{dbPackage}}
-
-
-
-
-
- {{(options.Type == ProjectType.ApiBlazor ? $" " : "")}}
-
-
-
- """;
+ ValidateOptions(options);
+ return _renderer.RenderApiLaunchSettings(options);
}
- public static string GenerateAppHostProgram(ProjectOptions options)
+ public static string GenerateAppHostLaunchSettings(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- var projectNameLower = options.Name.ToLowerInvariant();
- var projectNameSafe = options.Name.Replace(".", "_", StringComparison.Ordinal);
-
- var (dbSetup, dbProvider, dbRef, dbWait, migrationsAssembly) = options.Database switch
- {
- DatabaseProvider.PostgreSQL => (
- $"""
- // Postgres container + database
- var postgres = builder.AddPostgres("postgres").WithDataVolume("{projectNameLower}-postgres-data").AddDatabase("{projectNameLower}");
- """,
- "POSTGRESQL",
- ".WithReference(postgres)",
- ".WaitFor(postgres)",
- $"{options.Name}.Migrations"),
- DatabaseProvider.SqlServer => (
- $"""
- // SQL Server container + database
- var sqlserver = builder.AddSqlServer("sqlserver").WithDataVolume("{projectNameLower}-sqlserver-data").AddDatabase("{projectNameLower}");
- """,
- "MSSQL",
- ".WithReference(sqlserver)",
- ".WaitFor(sqlserver)",
- $"{options.Name}.Migrations"),
- DatabaseProvider.SQLite => (
- "// SQLite runs embedded - no container needed",
- "SQLITE",
- string.Empty,
- string.Empty,
- $"{options.Name}.Migrations"),
- _ => ("// Database configured externally", "POSTGRESQL", string.Empty, string.Empty, $"{options.Name}.Migrations")
- };
-
- var redisSetup = $"""
- var redis = builder.AddRedis("redis").WithDataVolume("{projectNameLower}-redis-data");
- """;
-
- // Build database environment variables
- var dbResourceName = options.Database == DatabaseProvider.PostgreSQL ? "postgres" : "sqlserver";
- var dbEnvVars = options.Database != DatabaseProvider.SQLite
- ? $$"""
- .WithEnvironment("DatabaseOptions__Provider", "{{dbProvider}}")
- .WithEnvironment("DatabaseOptions__ConnectionString", {{dbResourceName}}.Resource.ConnectionStringExpression)
- .WithEnvironment("DatabaseOptions__MigrationsAssembly", "{{migrationsAssembly}}")
- {{dbWait}}
- """
- : """
- .WithEnvironment("DatabaseOptions__Provider", "SQLITE")
- """;
-
- // When Blazor is included, api variable is referenced; otherwise suppress unused warning
- var (apiDeclaration, blazorProject) = options.Type == ProjectType.ApiBlazor
- ? ($"var api = builder.AddProject(\"{projectNameLower}-api\")",
- $"""
-
- builder.AddProject("{projectNameLower}-blazor");
- """)
- : ($"builder.AddProject(\"{projectNameLower}-api\")", string.Empty);
-
- return $$"""
- var builder = DistributedApplication.CreateBuilder(args);
-
- {{dbSetup}}
+ ValidateOptions(options);
+ return _renderer.RenderAppHostLaunchSettings(options);
+ }
- {{redisSetup}}
+ #endregion
- {{apiDeclaration}}
- {{dbRef}}
- .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
- {{dbEnvVars}}
- .WithReference(redis)
- .WithEnvironment("CachingOptions__Redis", redis.Resource.ConnectionStringExpression)
- .WaitFor(redis);
- {{blazorProject}}
+ #region Blazor Templates
- await builder.Build().RunAsync();
- """;
+ public static string GenerateBlazorApp()
+ {
+ return _renderer.RenderBlazorApp();
}
- public static string GenerateDockerCompose(ProjectOptions options)
+ public static string GenerateBlazorImports(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- var projectNameLower = options.Name.ToUpperInvariant().ToLowerInvariant();
-
- var dbService = options.Database switch
- {
- DatabaseProvider.PostgreSQL => $"""
- postgres:
- image: postgres:16-alpine
- container_name: postgres
- environment:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: {projectNameLower}
- ports:
- - "5432:5432"
- volumes:
- - postgres_data:/var/lib/postgresql/data
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U postgres"]
- interval: 10s
- timeout: 5s
- retries: 5
- """,
- DatabaseProvider.SqlServer => """
- sqlserver:
- image: mcr.microsoft.com/mssql/server:2022-latest
- container_name: sqlserver
- environment:
- ACCEPT_EULA: "Y"
- SA_PASSWORD: "Your_password123"
- ports:
- - "1433:1433"
- volumes:
- - sqlserver_data:/var/opt/mssql
- """,
- _ => string.Empty
- };
-
- var volumes = options.Database switch
- {
- DatabaseProvider.PostgreSQL => """
- volumes:
- postgres_data:
- redis_data:
- """,
- DatabaseProvider.SqlServer => """
- volumes:
- sqlserver_data:
- redis_data:
- """,
- _ => """
- volumes:
- redis_data:
- """
- };
-
- return $$"""
- version: '3.8'
-
- services:
- {{dbService}}
-
- redis:
- image: redis:7-alpine
- container_name: redis
- ports:
- - "6379:6379"
- volumes:
- - redis_data:/data
- healthcheck:
- test: ["CMD", "redis-cli", "ping"]
- interval: 10s
- timeout: 5s
- retries: 5
-
- {{volumes}}
- """;
+ ValidateOptions(options);
+ return _renderer.RenderBlazorImports(options);
}
- private const string DockerComposeOverrideTemplate = """
- version: '3.8'
-
- # Development overrides
- services:
- redis:
- command: redis-server --appendonly yes
- """;
-
- private const string CatalogContractsCsprojTemplate = """
-
-
-
- net10.0
- enable
- enable
-
-
-
- """;
-
- public static string GenerateDockerComposeOverride() => DockerComposeOverrideTemplate;
-
- public static string GenerateCatalogContractsCsproj() => CatalogContractsCsprojTemplate;
-
- public static string GenerateCatalogModuleCsproj(ProjectOptions options)
+ public static string GenerateBlazorIndexPage(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- return $$"""
-
-
-
- net10.0
- enable
- enable
-
-
-
-
-
-
-
-
-
-
-
-
-
- """;
+ ValidateOptions(options);
+ return _renderer.RenderBlazorIndexPage(options);
}
- public static string GenerateCatalogModule(ProjectOptions options)
+ public static string GenerateBlazorMainLayout(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- return $$"""
- using {{options.Name}}.Catalog.Features.v1.Products;
- using FSH.Framework.Web.Modules;
- using Microsoft.AspNetCore.Builder;
- using Microsoft.AspNetCore.Http;
- using Microsoft.AspNetCore.Routing;
- using Microsoft.Extensions.Hosting;
-
- namespace {{options.Name}}.Catalog;
+ ValidateOptions(options);
+ return _renderer.RenderBlazorMainLayout(options);
+ }
- public sealed class CatalogModule : IModule
- {
- public void ConfigureServices(IHostApplicationBuilder builder)
- {
- // Register services
- }
+ #endregion
- public void MapEndpoints(IEndpointRouteBuilder endpoints)
- {
- var group = endpoints.MapGroup("/api/v1/catalog")
- .WithTags("Catalog");
+ #region Infrastructure Templates
- group.MapGetProductsEndpoint();
- }
- }
- """;
+ public static string GenerateDockerfile(ProjectOptions options)
+ {
+ ValidateOptions(options);
+ return _renderer.RenderDockerfile(options);
}
- public static string GenerateGetProductsEndpoint(ProjectOptions options)
+ public static string GenerateDockerCompose(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- return $$"""
- using Microsoft.AspNetCore.Builder;
- using Microsoft.AspNetCore.Http;
- using Microsoft.AspNetCore.Routing;
-
- namespace {{options.Name}}.Catalog.Features.v1.Products;
-
- public static class GetProductsEndpoint
- {
- public static RouteHandlerBuilder MapGetProductsEndpoint(this IEndpointRouteBuilder endpoints)
- {
- return endpoints.MapGet("/products", () =>
- {
- var products = new[]
- {
- new { Id = 1, Name = "Product 1", Price = 9.99m },
- new { Id = 2, Name = "Product 2", Price = 19.99m },
- new { Id = 3, Name = "Product 3", Price = 29.99m }
- };
+ ValidateOptions(options);
+ return _renderer.RenderDockerCompose(options);
+ }
- return TypedResults.Ok(products);
- })
- .WithName("GetProducts")
- .WithSummary("Get all products")
- .Produces(StatusCodes.Status200OK);
- }
- }
- """;
+ public static string GenerateDockerComposeOverride()
+ {
+ return _renderer.RenderDockerComposeOverride();
}
public static string GenerateTerraformMain(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- var serverless = options.Architecture == ArchitectureStyle.Serverless;
- var projectNameLower = options.Name.ToUpperInvariant().ToLowerInvariant();
-
- if (serverless)
- {
- return $$"""
- terraform {
- required_version = ">= 1.0"
-
- required_providers {
- aws = {
- source = "hashicorp/aws"
- version = "~> 5.0"
- }
- }
-
- backend "s3" {
- bucket = "{{projectNameLower}}-terraform-state"
- key = "state/terraform.tfstate"
- region = var.aws_region
- }
- }
-
- provider "aws" {
- region = var.aws_region
- }
-
- # Lambda function
- resource "aws_lambda_function" "api" {
- function_name = "${var.project_name}-api"
- runtime = "dotnet8"
- handler = "{{options.Name}}.Api"
- memory_size = 512
- timeout = 30
-
- filename = var.lambda_zip_path
- source_code_hash = filebase64sha256(var.lambda_zip_path)
-
- role = aws_iam_role.lambda_role.arn
-
- environment {
- variables = {
- ASPNETCORE_ENVIRONMENT = var.environment
- }
- }
- }
-
- # API Gateway
- resource "aws_apigatewayv2_api" "api" {
- name = "${var.project_name}-api"
- protocol_type = "HTTP"
- }
-
- resource "aws_apigatewayv2_integration" "lambda" {
- api_id = aws_apigatewayv2_api.api.id
- integration_type = "AWS_PROXY"
- integration_uri = aws_lambda_function.api.invoke_arn
- integration_method = "POST"
- }
-
- resource "aws_apigatewayv2_route" "default" {
- api_id = aws_apigatewayv2_api.api.id
- route_key = "$default"
- target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
- }
-
- resource "aws_apigatewayv2_stage" "default" {
- api_id = aws_apigatewayv2_api.api.id
- name = "$default"
- auto_deploy = true
- }
-
- # Lambda IAM role
- resource "aws_iam_role" "lambda_role" {
- name = "${var.project_name}-lambda-role"
-
- assume_role_policy = jsonencode({
- Version = "2012-10-17"
- Statement = [{
- Action = "sts:AssumeRole"
- Effect = "Allow"
- Principal = {
- Service = "lambda.amazonaws.com"
- }
- }]
- })
- }
-
- resource "aws_iam_role_policy_attachment" "lambda_basic" {
- role = aws_iam_role.lambda_role.name
- policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
- }
- """;
- }
-
- return $$"""
- terraform {
- required_version = ">= 1.0"
-
- required_providers {
- aws = {
- source = "hashicorp/aws"
- version = "~> 5.0"
- }
- }
-
- backend "s3" {
- bucket = "{{projectNameLower}}-terraform-state"
- key = "state/terraform.tfstate"
- region = var.aws_region
- }
- }
-
- provider "aws" {
- region = var.aws_region
- }
-
- # VPC
- module "vpc" {
- source = "terraform-aws-modules/vpc/aws"
-
- name = "${var.project_name}-vpc"
- cidr = "10.0.0.0/16"
-
- azs = ["${var.aws_region}a", "${var.aws_region}b"]
- private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
- public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
-
- enable_nat_gateway = true
- single_nat_gateway = var.environment != "prod"
- }
-
- # RDS PostgreSQL
- module "rds" {
- source = "terraform-aws-modules/rds/aws"
-
- identifier = "${var.project_name}-db"
-
- engine = "postgres"
- engine_version = "16"
- instance_class = var.db_instance_class
- allocated_storage = 20
-
- db_name = var.project_name
- username = "postgres"
- port = 5432
-
- vpc_security_group_ids = [module.vpc.default_security_group_id]
- subnet_ids = module.vpc.private_subnets
-
- family = "postgres16"
- }
-
- # ElastiCache Redis
- module "elasticache" {
- source = "terraform-aws-modules/elasticache/aws"
-
- cluster_id = "${var.project_name}-redis"
- engine = "redis"
- node_type = var.redis_node_type
- num_cache_nodes = 1
- parameter_group_name = "default.redis7"
-
- subnet_ids = module.vpc.private_subnets
- security_group_ids = [module.vpc.default_security_group_id]
- }
-
- # ECS Cluster
- module "ecs" {
- source = "terraform-aws-modules/ecs/aws"
-
- cluster_name = "${var.project_name}-cluster"
-
- fargate_capacity_providers = {
- FARGATE = {
- default_capacity_provider_strategy = {
- weight = 100
- }
- }
- }
- }
- """;
+ ValidateOptions(options);
+ return _renderer.RenderTerraformMain(options);
}
public static string GenerateTerraformVariables(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- var projectNameLower = options.Name.ToUpperInvariant().ToLowerInvariant();
-
- return $$"""
- variable "aws_region" {
- description = "AWS region"
- type = string
- default = "us-east-1"
- }
-
- variable "project_name" {
- description = "Project name"
- type = string
- default = "{{projectNameLower}}"
- }
-
- variable "environment" {
- description = "Environment (dev, staging, prod)"
- type = string
- default = "dev"
- }
-
- variable "db_instance_class" {
- description = "RDS instance class"
- type = string
- default = "db.t3.micro"
- }
-
- variable "redis_node_type" {
- description = "ElastiCache node type"
- type = string
- default = "cache.t3.micro"
- }
- {{(options.Architecture == ArchitectureStyle.Serverless ? """
-
- variable "lambda_zip_path" {
- description = "Path to Lambda deployment package"
- type = string
- default = "../publish/api.zip"
- }
- """ : "")}}
- """;
+ ValidateOptions(options);
+ return _renderer.RenderTerraformVariables(options);
}
public static string GenerateTerraformOutputs(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- if (options.Architecture == ArchitectureStyle.Serverless)
- {
- return """
- output "api_endpoint" {
- description = "API Gateway endpoint URL"
- value = aws_apigatewayv2_api.api.api_endpoint
- }
-
- output "lambda_function_name" {
- description = "Lambda function name"
- value = aws_lambda_function.api.function_name
- }
- """;
- }
-
- return """
- output "vpc_id" {
- description = "VPC ID"
- value = module.vpc.vpc_id
- }
-
- output "rds_endpoint" {
- description = "RDS endpoint"
- value = module.rds.db_instance_endpoint
- }
-
- output "redis_endpoint" {
- description = "ElastiCache endpoint"
- value = module.elasticache.cluster_address
- }
-
- output "ecs_cluster_name" {
- description = "ECS cluster name"
- value = module.ecs.cluster_name
- }
- """;
+ ValidateOptions(options);
+ return _renderer.RenderTerraformOutputs(options);
}
public static string GenerateGitHubActionsCI(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- var projectNameLower = options.Name.ToUpperInvariant().ToLowerInvariant();
-
- return $@"name: CI
-
-on:
- push:
- branches: [main, develop]
- pull_request:
- branches: [main]
-
-env:
- DOTNET_VERSION: '10.0.x'
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v4
-
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: ${{{{ env.DOTNET_VERSION }}}}
-
- - name: Restore dependencies
- run: dotnet restore src/{options.Name}.slnx
-
- - name: Build
- run: dotnet build src/{options.Name}.slnx --no-restore --configuration Release
-
- - name: Test
- run: dotnet test src/{options.Name}.slnx --no-build --configuration Release --verbosity normal
-
- docker:
- runs-on: ubuntu-latest
- needs: build
- if: github.event_name == 'push' && github.ref == 'refs/heads/main'
-
- steps:
- - uses: actions/checkout@v4
-
- - name: Build Docker image
- run: |
- docker build -t {projectNameLower}:${{{{ github.sha }}}} -f src/{options.Name}.Api/Dockerfile .
-";
+ ValidateOptions(options);
+ return _renderer.RenderGitHubActionsCI(options);
}
- private const string GitignoreTemplate = """
- ## .NET
- bin/
- obj/
- *.user
- *.userosscache
- *.suo
- *.cache
- *.nupkg
-
- ## IDE
- .vs/
- .vscode/
- .idea/
- *.swp
- *.swo
-
- ## Build
- publish/
- artifacts/
- TestResults/
+ #endregion
- ## Secrets
- appsettings.*.json
- !appsettings.json
- !appsettings.Development.json
- *.pfx
- *.p12
+ #region Module Templates
- ## Terraform
- .terraform/
- *.tfstate
- *.tfstate.*
- .terraform.lock.hcl
-
- ## OS
- .DS_Store
- Thumbs.db
-
- ## Logs
- *.log
- logs/
- """;
-
- public static string GenerateGitignore() => GitignoreTemplate;
-
- public static string GenerateDirectoryBuildProps(ProjectOptions options)
+ public static string GenerateCatalogModule(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- return $$"""
-
-
- net10.0
- latest
- enable
- enable
- false
- true
-
-
-
- {{options.Name}}
- {{options.Name}}
- 1.0.0
-
-
-
-
- all
- runtime; build; native; contentfiles; analyzers
-
-
-
- """;
+ ValidateOptions(options);
+ return _renderer.RenderCatalogModule(options);
}
- public static string GenerateDirectoryPackagesProps(ProjectOptions options)
+ public static string GenerateCatalogModuleCsproj(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- // Use custom version from options, or fall back to CLI's version
- var version = options.FrameworkVersion ?? FrameworkVersion;
-
- return $$"""
-
-
- true
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- """;
+ ValidateOptions(options);
+ return _renderer.RenderCatalogModuleCsproj(options);
}
- public static string GenerateReadme(ProjectOptions options)
+ public static string GenerateCatalogContractsCsproj()
{
- ArgumentNullException.ThrowIfNull(options);
-
- var archDescription = options.Architecture switch
- {
- ArchitectureStyle.Monolith => "monolithic",
- ArchitectureStyle.Microservices => "microservices",
- ArchitectureStyle.Serverless => "serverless (AWS Lambda)",
- _ => string.Empty
- };
-
- return $$"""
- # {{options.Name}}
-
- A {{archDescription}} application built with [FullStackHero .NET Starter Kit](https://fullstackhero.net).
-
- ## Getting Started
-
- ### Prerequisites
-
- - [.NET 10 SDK](https://dotnet.microsoft.com/download)
- - [Docker](https://www.docker.com/) (optional, for infrastructure)
- {{(options.Database == DatabaseProvider.PostgreSQL ? "- PostgreSQL 16+" : "")}}
- {{(options.Database == DatabaseProvider.SqlServer ? "- SQL Server 2022+" : "")}}
- - Redis
-
- ### Running the Application
-
- {{(options.IncludeDocker ? """
- #### Start Infrastructure (Docker)
-
- ```bash
- docker-compose up -d
- ```
- """ : "")}}
-
- {{(options.IncludeAspire ? $"""
- #### Run with Aspire
-
- ```bash
- dotnet run --project src/{options.Name}.AppHost
- ```
- """ : $"""
- #### Run the API
-
- ```bash
- dotnet run --project src/{options.Name}.Api
- ```
- """)}}
-
- ### Project Structure
-
- ```
- src/
- ├── {{options.Name}}.Api/ # Web API project
- ├── {{options.Name}}.Migrations/ # Database migrations
- {{(options.Type == ProjectType.ApiBlazor ? $"├── {options.Name}.Blazor/ # Blazor WebAssembly UI" : "")}}
- {{(options.IncludeAspire ? $"├── {options.Name}.AppHost/ # Aspire orchestrator" : "")}}
- {{(options.IncludeSampleModule ? "└── Modules/ # Feature modules" : "")}}
- ```
-
- ## Configuration
-
- Update `appsettings.json` with your settings:
-
- - `DatabaseOptions:ConnectionString` - Database connection
- - `CachingOptions:Redis` - Redis connection
- - `JwtOptions:SigningKey` - JWT signing key (change in production!)
-
- ## License
-
- MIT
- """;
+ return _renderer.RenderCatalogContractsCsproj();
}
- public static string GenerateBlazorMainLayout(ProjectOptions options)
+ public static string GenerateGetProductsEndpoint(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- return $$"""
- @inherits LayoutComponentBase
-
-
-
-
-
+ ValidateOptions(options);
+ return _renderer.RenderGetProductsEndpoint(options);
+ }
-
-
-
- {{options.Name}}
-
-
-
-
-
- Home
-
-
-
- @Body
-
-
+ #endregion
- @code {
- private bool _drawerOpen = true;
+ #region Static Content Templates
- private void ToggleDrawer()
- {
- _drawerOpen = !_drawerOpen;
- }
- }
- """;
+ public static string GenerateReadme(ProjectOptions options)
+ {
+ ValidateOptions(options);
+ return _renderer.RenderReadme(options);
}
- public static string GenerateDockerfile(ProjectOptions options)
+ public static string GenerateGitignore()
{
- ArgumentNullException.ThrowIfNull(options);
-
- return $$"""
- FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base
- WORKDIR /app
- EXPOSE 8080
- EXPOSE 8081
-
- FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
- ARG BUILD_CONFIGURATION=Release
- WORKDIR /src
- COPY ["src/{{options.Name}}.Api/{{options.Name}}.Api.csproj", "{{options.Name}}.Api/"]
- RUN dotnet restore "{{options.Name}}.Api/{{options.Name}}.Api.csproj"
- COPY src/ .
- WORKDIR "/src/{{options.Name}}.Api"
- RUN dotnet build "{{options.Name}}.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
-
- FROM build AS publish
- ARG BUILD_CONFIGURATION=Release
- RUN dotnet publish "{{options.Name}}.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
-
- FROM base AS final
- WORKDIR /app
- COPY --from=publish /app/publish .
- ENTRYPOINT ["dotnet", "{{options.Name}}.Api.dll"]
- """;
+ return _renderer.RenderGitignore();
}
- public static string GenerateApiLaunchSettings(ProjectOptions options)
+ public static string GenerateDirectoryBuildProps(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- return $$"""
- {
- "$schema": "https://json.schemastore.org/launchsettings.json",
- "profiles": {
- "http": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "launchUrl": "openapi",
- "applicationUrl": "http://localhost:5000",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- },
- "https": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "launchUrl": "openapi",
- "applicationUrl": "https://localhost:7000;http://localhost:5000",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- }
- }
- }
- """;
+ ValidateOptions(options);
+ return _renderer.RenderDirectoryBuildProps(options);
}
- public static string GenerateAppHostLaunchSettings(ProjectOptions options)
+ public static string GenerateDirectoryPackagesProps(ProjectOptions options)
{
- ArgumentNullException.ThrowIfNull(options);
-
- return $$"""
- {
- "$schema": "https://json.schemastore.org/launchsettings.json",
- "profiles": {
- "https": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "applicationUrl": "https://localhost:17000;http://localhost:15000",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development",
- "DOTNET_ENVIRONMENT": "Development",
- "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000",
- "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000"
- }
- }
- }
- }
- """;
+ ValidateOptions(options);
+ return _renderer.RenderDirectoryPackagesProps(options);
}
- private const string GlobalJsonTemplate = """
- {
- "sdk": {
- "version": "10.0.100",
- "rollForward": "latestFeature"
- }
- }
- """;
-
- public static string GenerateGlobalJson() => GlobalJsonTemplate;
-
- private const string EditorConfigTemplate = """
- # EditorConfig is awesome: https://EditorConfig.org
-
- root = true
-
- [*]
- indent_style = space
- indent_size = 4
- end_of_line = lf
- charset = utf-8
- trim_trailing_whitespace = true
- insert_final_newline = true
-
- [*.{cs,csx}]
- indent_size = 4
-
- [*.{json,yml,yaml}]
- indent_size = 2
-
- [*.md]
- trim_trailing_whitespace = false
-
- [*.razor]
- indent_size = 4
-
- # C# files
- [*.cs]
-
- # Sort using and Import directives with System.* appearing first
- dotnet_sort_system_directives_first = true
- dotnet_separate_import_directive_groups = false
-
- # Avoid "this." for fields, properties, methods, events
- dotnet_style_qualification_for_field = false:suggestion
- dotnet_style_qualification_for_property = false:suggestion
- dotnet_style_qualification_for_method = false:suggestion
- dotnet_style_qualification_for_event = false:suggestion
-
- # Use language keywords instead of framework type names
- dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
- dotnet_style_predefined_type_for_member_access = true:suggestion
+ public static string GenerateEditorConfig()
+ {
+ return _renderer.RenderEditorConfig();
+ }
- # Prefer var
- csharp_style_var_for_built_in_types = true:suggestion
- csharp_style_var_when_type_is_apparent = true:suggestion
- csharp_style_var_elsewhere = true:suggestion
+ public static string GenerateGlobalJson()
+ {
+ return _renderer.RenderGlobalJson();
+ }
- # Prefer expression-bodied members
- csharp_style_expression_bodied_methods = when_on_single_line:suggestion
- csharp_style_expression_bodied_constructors = when_on_single_line:suggestion
- csharp_style_expression_bodied_properties = when_on_single_line:suggestion
+ #endregion
- # Prefer pattern matching
- csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
- csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+ #region Validation
- # Namespace preferences
- csharp_style_namespace_declarations = file_scoped:suggestion
+ ///
+ /// Validates project options before template generation
+ ///
+ private static void ValidateOptions(ProjectOptions options)
+ {
+ var validationResult = _validator.ValidateProjectOptions(options);
+
+ if (!validationResult.IsValid)
+ {
+ throw new ArgumentException($"Invalid project options: {string.Join(", ", validationResult.Errors)}", nameof(options));
+ }
- # Newline preferences
- csharp_new_line_before_open_brace = all
- csharp_new_line_before_else = true
- csharp_new_line_before_catch = true
- csharp_new_line_before_finally = true
- """;
+ // Log warnings if any
+ foreach (var warning in validationResult.Warnings)
+ {
+ Console.WriteLine($"Warning: {warning}");
+ }
+ }
- public static string GenerateEditorConfig() => EditorConfigTemplate;
-}
+ #endregion
+}
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/TemplateEngine.cs.backup b/src/Tools/CLI/Scaffolding/TemplateEngine.cs.backup
new file mode 100644
index 000000000..51e7a55a5
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/TemplateEngine.cs.backup
@@ -0,0 +1,1645 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using FSH.CLI.Models;
+
+namespace FSH.CLI.Scaffolding;
+
+[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Lowercase is required for Docker, Terraform, and GitHub Actions naming conventions")]
+internal static class TemplateEngine
+{
+ private static readonly string FrameworkVersion = GetFrameworkVersion();
+
+ private static string GetFrameworkVersion()
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ var version = assembly.GetCustomAttribute()?.InformationalVersion
+ ?? assembly.GetName().Version?.ToString()
+ ?? "10.0.0";
+
+ // Remove any +buildmetadata suffix (e.g., "10.0.0-rc.1+abc123" -> "10.0.0-rc.1")
+ var plusIndex = version.IndexOf('+', StringComparison.Ordinal);
+ return plusIndex > 0 ? version[..plusIndex] : version;
+ }
+
+ public static string GenerateSolution(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var projects = new List
+ {
+ $""" """,
+ $""" """
+ };
+
+ if (options.Type == ProjectType.ApiBlazor)
+ {
+ projects.Add($""" """);
+ }
+
+ if (options.IncludeAspire)
+ {
+ projects.Add($""" """);
+ }
+
+ if (options.IncludeSampleModule)
+ {
+ projects.Add($""" """);
+ projects.Add($""" """);
+ }
+
+ return $$"""
+
+
+ {{string.Join(Environment.NewLine, projects)}}
+
+
+
+
+
+
+
+ """;
+ }
+
+ public static string GenerateApiCsproj(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var serverless = options.Architecture == ArchitectureStyle.Serverless;
+
+ var sampleModuleRef = options.IncludeSampleModule
+ ? $"""
+
+
+
+
+
+ """
+ : string.Empty;
+
+ return $$"""
+
+
+
+ net10.0
+ enable
+ enable
+ {{(serverless ? " Library" : "")}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ {{(serverless ? """
+
+
+
+
+
+ """ : "")}}{{sampleModuleRef}}
+
+ """;
+ }
+
+ public static string GenerateApiProgram(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var serverless = options.Architecture == ArchitectureStyle.Serverless;
+
+ if (serverless)
+ {
+ var serverlessModuleUsing = options.IncludeSampleModule
+ ? $"using {options.Name}.Catalog;\n"
+ : string.Empty;
+
+ var serverlessModuleAssembly = options.IncludeSampleModule
+ ? $",\n typeof(CatalogModule).Assembly"
+ : string.Empty;
+
+ return $$"""
+ {{serverlessModuleUsing}}using FSH.Framework.Web;
+ using FSH.Framework.Web.Modules;
+ using System.Reflection;
+
+ var builder = WebApplication.CreateBuilder(args);
+
+ // Add AWS Lambda hosting
+ builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi);
+
+ // Add FSH Platform
+ builder.AddHeroPlatform(platform =>
+ {
+ platform.EnableOpenApi = true;
+ platform.EnableCaching = true;
+ });
+
+ // Add modules
+ var moduleAssemblies = new Assembly[]
+ {
+ typeof(Program).Assembly{{serverlessModuleAssembly}}
+ };
+ builder.AddModules(moduleAssemblies);
+
+ var app = builder.Build();
+
+ // Use FSH Platform
+ app.UseHeroPlatform(platform =>
+ {
+ platform.MapModules = true;
+ });
+
+ await app.RunAsync();
+ """;
+ }
+
+ var sampleModuleUsing = options.IncludeSampleModule
+ ? $"using {options.Name}.Catalog;\n"
+ : string.Empty;
+
+ var sampleModuleAssembly = options.IncludeSampleModule
+ ? ",\n typeof(CatalogModule).Assembly"
+ : string.Empty;
+
+ return $$"""
+ {{sampleModuleUsing}}using FSH.Framework.Web;
+ using FSH.Framework.Web.Modules;
+ using FSH.Modules.Auditing;
+ using FSH.Modules.Identity;
+ using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration;
+ using FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration;
+ using FSH.Modules.Multitenancy;
+ using FSH.Modules.Multitenancy.Contracts.v1.GetTenantStatus;
+ using FSH.Modules.Multitenancy.Features.v1.GetTenantStatus;
+ using System.Reflection;
+
+ var builder = WebApplication.CreateBuilder(args);
+
+ // Configure Mediator with required assemblies
+ builder.Services.AddMediator(o =>
+ {
+ o.ServiceLifetime = ServiceLifetime.Scoped;
+ o.Assemblies = [
+ typeof(GenerateTokenCommand),
+ typeof(GenerateTokenCommandHandler),
+ typeof(GetTenantStatusQuery),
+ typeof(GetTenantStatusQueryHandler),
+ typeof(FSH.Modules.Auditing.Contracts.AuditEnvelope),
+ typeof(FSH.Modules.Auditing.Persistence.AuditDbContext)];
+ });
+
+ // FSH Module assemblies
+ var moduleAssemblies = new Assembly[]
+ {
+ typeof(IdentityModule).Assembly,
+ typeof(MultitenancyModule).Assembly,
+ typeof(AuditingModule).Assembly{{sampleModuleAssembly}}
+ };
+
+ // Add FSH Platform
+ builder.AddHeroPlatform(platform =>
+ {
+ platform.EnableOpenApi = true;
+ platform.EnableCaching = true;
+ platform.EnableJobs = true;
+ platform.EnableMailing = true;
+ });
+
+ // Add modules
+ builder.AddModules(moduleAssemblies);
+
+ var app = builder.Build();
+
+ // Apply tenant database migrations
+ app.UseHeroMultiTenantDatabases();
+
+ // Use FSH Platform
+ app.UseHeroPlatform(platform =>
+ {
+ platform.MapModules = true;
+ });
+
+ await app.RunAsync();
+ """;
+ }
+
+ public static string GenerateAppSettings(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var connectionString = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => $"Server=localhost;Database={options.Name.ToLowerInvariant()};User Id=postgres;Password=password",
+ DatabaseProvider.SqlServer => $"Server=localhost;Database={options.Name};Trusted_Connection=True;TrustServerCertificate=True",
+ DatabaseProvider.SQLite => $"Data Source={options.Name}.db",
+ _ => string.Empty
+ };
+
+ var dbProvider = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => "POSTGRESQL",
+ DatabaseProvider.SqlServer => "MSSQL",
+ DatabaseProvider.SQLite => "SQLITE",
+ _ => "POSTGRESQL"
+ };
+
+ var migrationsAssembly = $"{options.Name}.Migrations";
+ var projectNameLower = options.Name.ToLowerInvariant();
+
+ return $$"""
+ {
+ "OpenTelemetryOptions": {
+ "Enabled": true,
+ "Tracing": {
+ "Enabled": true
+ },
+ "Metrics": {
+ "Enabled": true,
+ "MeterNames": []
+ },
+ "Exporter": {
+ "Otlp": {
+ "Enabled": true,
+ "Endpoint": "http://localhost:4317",
+ "Protocol": "grpc"
+ }
+ },
+ "Jobs": { "Enabled": true },
+ "Mediator": { "Enabled": true },
+ "Http": {
+ "Histograms": {
+ "Enabled": true
+ }
+ },
+ "Data": {
+ "FilterEfStatements": true,
+ "FilterRedisCommands": true
+ }
+ },
+ "Serilog": {
+ "Using": [
+ "Serilog.Sinks.Console",
+ "Serilog.Sinks.OpenTelemetry"
+ ],
+ "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId", "WithCorrelationId", "WithProcessId", "WithProcessName" ],
+ "MinimumLevel": {
+ "Default": "Debug"
+ },
+ "WriteTo": [
+ {
+ "Name": "Console",
+ "Args": {
+ "restrictedToMinimumLevel": "Information"
+ }
+ },
+ {
+ "Name": "OpenTelemetry",
+ "Args": {
+ "endpoint": "http://localhost:4317",
+ "protocol": "grpc",
+ "resourceAttributes": {
+ "service.name": "{{options.Name}}.Api"
+ }
+ }
+ }
+ ]
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Hangfire": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ },
+ "DatabaseOptions": {
+ "Provider": "{{dbProvider}}",
+ "ConnectionString": "{{connectionString}}",
+ "MigrationsAssembly": "{{migrationsAssembly}}"
+ },
+ "OriginOptions": {
+ "OriginUrl": "https://localhost:7030"
+ },
+ "CachingOptions": {
+ "Redis": ""
+ },
+ "HangfireOptions": {
+ "Username": "admin",
+ "Password": "Secure1234!Me",
+ "Route": "/jobs"
+ },
+ "AllowedHosts": "*",
+ "OpenApiOptions": {
+ "Enabled": true,
+ "Title": "{{options.Name}} API",
+ "Version": "v1",
+ "Description": "{{options.Name}} API built with FullStackHero .NET Starter Kit.",
+ "Contact": {
+ "Name": "Your Name",
+ "Url": "https://yourwebsite.com",
+ "Email": "your@email.com"
+ },
+ "License": {
+ "Name": "MIT License",
+ "Url": "https://opensource.org/licenses/MIT"
+ }
+ },
+ "CorsOptions": {
+ "AllowAll": false,
+ "AllowedOrigins": [
+ "https://localhost:4200",
+ "https://localhost:7140"
+ ],
+ "AllowedHeaders": [ "content-type", "authorization" ],
+ "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ]
+ },
+ "JwtOptions": {
+ "Issuer": "{{projectNameLower}}.local",
+ "Audience": "{{projectNameLower}}.clients",
+ "SigningKey": "replace-with-256-bit-secret-min-32-chars",
+ "AccessTokenMinutes": 2,
+ "RefreshTokenDays": 7
+ },
+ "SecurityHeadersOptions": {
+ "Enabled": true,
+ "ExcludedPaths": [ "/scalar", "/openapi" ],
+ "AllowInlineStyles": true,
+ "ScriptSources": [],
+ "StyleSources": []
+ },
+ "MailOptions": {
+ "From": "noreply@{{projectNameLower}}.com",
+ "Host": "smtp.ethereal.email",
+ "Port": 587,
+ "UserName": "your-smtp-user",
+ "Password": "your-smtp-password",
+ "DisplayName": "{{options.Name}}"
+ },
+ "RateLimitingOptions": {
+ "Enabled": false,
+ "Global": {
+ "PermitLimit": 100,
+ "WindowSeconds": 60,
+ "QueueLimit": 0
+ },
+ "Auth": {
+ "PermitLimit": 10,
+ "WindowSeconds": 60,
+ "QueueLimit": 0
+ }
+ },
+ "MultitenancyOptions": {
+ "RunTenantMigrationsOnStartup": true
+ },
+ "Storage": {
+ "Provider": "local"
+ }
+ }
+ """;
+ }
+
+ private const string AppSettingsDevelopmentTemplate = """
+ {
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "Microsoft.AspNetCore": "Information",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ }
+ }
+ """;
+
+ private const string BlazorCsprojTemplate = """
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ public static string GenerateAppSettingsDevelopment() => AppSettingsDevelopmentTemplate;
+
+ public static string GenerateBlazorCsproj() => BlazorCsprojTemplate;
+
+ public static string GenerateBlazorProgram(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ using Microsoft.AspNetCore.Components.Web;
+ using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
+ using MudBlazor.Services;
+ using {{options.Name}}.Blazor;
+
+ var builder = WebAssemblyHostBuilder.CreateDefault(args);
+ builder.RootComponents.Add("#app");
+ builder.RootComponents.Add("head::after");
+
+ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+ builder.Services.AddMudServices();
+
+ await builder.Build().RunAsync();
+ """;
+ }
+
+ public static string GenerateBlazorImports(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ @using System.Net.Http
+ @using System.Net.Http.Json
+ @using Microsoft.AspNetCore.Components.Forms
+ @using Microsoft.AspNetCore.Components.Routing
+ @using Microsoft.AspNetCore.Components.Web
+ @using Microsoft.AspNetCore.Components.Web.Virtualization
+ @using Microsoft.AspNetCore.Components.WebAssembly.Http
+ @using Microsoft.JSInterop
+ @using MudBlazor
+ @using {{options.Name}}.Blazor
+ """;
+ }
+
+ private const string BlazorAppTemplate = """
+
+
+
+
+
+
+
+
+
+
+
+ Not found
+
+ Sorry, there's nothing at this address.
+
+
+
+ """;
+
+ public static string GenerateBlazorApp() => BlazorAppTemplate;
+
+ public static string GenerateBlazorIndexPage(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ @page "/"
+
+ {{options.Name}}
+
+
+ Welcome to {{options.Name}}
+
+ Built with FullStackHero .NET Starter Kit
+
+
+ """;
+ }
+
+ public static string GenerateMigrationsCsproj(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var dbPackage = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => "",
+ DatabaseProvider.SqlServer => "",
+ DatabaseProvider.SQLite => "",
+ _ => string.Empty
+ };
+
+ return $$"""
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+ {{dbPackage}}
+
+
+
+
+
+
+
+ """;
+ }
+
+ public static string GenerateAppHostCsproj(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var dbPackage = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => "",
+ DatabaseProvider.SqlServer => "",
+ _ => string.Empty // SQLite doesn't need a hosting package
+ };
+
+ return $$"""
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+
+
+
+ {{dbPackage}}
+
+
+
+
+
+ {{(options.Type == ProjectType.ApiBlazor ? $" " : "")}}
+
+
+
+ """;
+ }
+
+ public static string GenerateAppHostProgram(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var projectNameLower = options.Name.ToLowerInvariant();
+ var projectNameSafe = options.Name.Replace(".", "_", StringComparison.Ordinal);
+
+ var (dbSetup, dbProvider, dbRef, dbWait, migrationsAssembly) = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => (
+ $"""
+ // Postgres container + database
+ var postgres = builder.AddPostgres("postgres").WithDataVolume("{projectNameLower}-postgres-data").AddDatabase("{projectNameLower}");
+ """,
+ "POSTGRESQL",
+ ".WithReference(postgres)",
+ ".WaitFor(postgres)",
+ $"{options.Name}.Migrations"),
+ DatabaseProvider.SqlServer => (
+ $"""
+ // SQL Server container + database
+ var sqlserver = builder.AddSqlServer("sqlserver").WithDataVolume("{projectNameLower}-sqlserver-data").AddDatabase("{projectNameLower}");
+ """,
+ "MSSQL",
+ ".WithReference(sqlserver)",
+ ".WaitFor(sqlserver)",
+ $"{options.Name}.Migrations"),
+ DatabaseProvider.SQLite => (
+ "// SQLite runs embedded - no container needed",
+ "SQLITE",
+ string.Empty,
+ string.Empty,
+ $"{options.Name}.Migrations"),
+ _ => ("// Database configured externally", "POSTGRESQL", string.Empty, string.Empty, $"{options.Name}.Migrations")
+ };
+
+ var redisSetup = $"""
+ var redis = builder.AddRedis("redis").WithDataVolume("{projectNameLower}-redis-data");
+ """;
+
+ // Build database environment variables
+ var dbResourceName = options.Database == DatabaseProvider.PostgreSQL ? "postgres" : "sqlserver";
+ var dbEnvVars = options.Database != DatabaseProvider.SQLite
+ ? $$"""
+ .WithEnvironment("DatabaseOptions__Provider", "{{dbProvider}}")
+ .WithEnvironment("DatabaseOptions__ConnectionString", {{dbResourceName}}.Resource.ConnectionStringExpression)
+ .WithEnvironment("DatabaseOptions__MigrationsAssembly", "{{migrationsAssembly}}")
+ {{dbWait}}
+ """
+ : """
+ .WithEnvironment("DatabaseOptions__Provider", "SQLITE")
+ """;
+
+ // When Blazor is included, api variable is referenced; otherwise suppress unused warning
+ var (apiDeclaration, blazorProject) = options.Type == ProjectType.ApiBlazor
+ ? ($"var api = builder.AddProject(\"{projectNameLower}-api\")",
+ $"""
+
+ builder.AddProject("{projectNameLower}-blazor");
+ """)
+ : ($"builder.AddProject(\"{projectNameLower}-api\")", string.Empty);
+
+ return $$"""
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ {{dbSetup}}
+
+ {{redisSetup}}
+
+ {{apiDeclaration}}
+ {{dbRef}}
+ .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
+ {{dbEnvVars}}
+ .WithReference(redis)
+ .WithEnvironment("CachingOptions__Redis", redis.Resource.ConnectionStringExpression)
+ .WaitFor(redis);
+ {{blazorProject}}
+
+ await builder.Build().RunAsync();
+ """;
+ }
+
+ public static string GenerateDockerCompose(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var projectNameLower = options.Name.ToUpperInvariant().ToLowerInvariant();
+
+ var dbService = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => $"""
+ postgres:
+ image: postgres:16-alpine
+ container_name: postgres
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: {projectNameLower}
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ """,
+ DatabaseProvider.SqlServer => """
+ sqlserver:
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ container_name: sqlserver
+ environment:
+ ACCEPT_EULA: "Y"
+ SA_PASSWORD: "Your_password123"
+ ports:
+ - "1433:1433"
+ volumes:
+ - sqlserver_data:/var/opt/mssql
+ """,
+ _ => string.Empty
+ };
+
+ var volumes = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => """
+ volumes:
+ postgres_data:
+ redis_data:
+ """,
+ DatabaseProvider.SqlServer => """
+ volumes:
+ sqlserver_data:
+ redis_data:
+ """,
+ _ => """
+ volumes:
+ redis_data:
+ """
+ };
+
+ return $$"""
+ version: '3.8'
+
+ services:
+ {{dbService}}
+
+ redis:
+ image: redis:7-alpine
+ container_name: redis
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ {{volumes}}
+ """;
+ }
+
+ private const string DockerComposeOverrideTemplate = """
+ version: '3.8'
+
+ # Development overrides
+ services:
+ redis:
+ command: redis-server --appendonly yes
+ """;
+
+ private const string CatalogContractsCsprojTemplate = """
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+ """;
+
+ public static string GenerateDockerComposeOverride() => DockerComposeOverrideTemplate;
+
+ public static string GenerateCatalogContractsCsproj() => CatalogContractsCsprojTemplate;
+
+ public static string GenerateCatalogModuleCsproj(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+ }
+
+ public static string GenerateCatalogModule(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ using {{options.Name}}.Catalog.Features.v1.Products;
+ using FSH.Framework.Web.Modules;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Routing;
+ using Microsoft.Extensions.Hosting;
+
+ namespace {{options.Name}}.Catalog;
+
+ public sealed class CatalogModule : IModule
+ {
+ public void ConfigureServices(IHostApplicationBuilder builder)
+ {
+ // Register services
+ }
+
+ public void MapEndpoints(IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup("/api/v1/catalog")
+ .WithTags("Catalog");
+
+ group.MapGetProductsEndpoint();
+ }
+ }
+ """;
+ }
+
+ public static string GenerateGetProductsEndpoint(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Routing;
+
+ namespace {{options.Name}}.Catalog.Features.v1.Products;
+
+ public static class GetProductsEndpoint
+ {
+ public static RouteHandlerBuilder MapGetProductsEndpoint(this IEndpointRouteBuilder endpoints)
+ {
+ return endpoints.MapGet("/products", () =>
+ {
+ var products = new[]
+ {
+ new { Id = 1, Name = "Product 1", Price = 9.99m },
+ new { Id = 2, Name = "Product 2", Price = 19.99m },
+ new { Id = 3, Name = "Product 3", Price = 29.99m }
+ };
+
+ return TypedResults.Ok(products);
+ })
+ .WithName("GetProducts")
+ .WithSummary("Get all products")
+ .Produces(StatusCodes.Status200OK);
+ }
+ }
+ """;
+ }
+
+ public static string GenerateTerraformMain(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var serverless = options.Architecture == ArchitectureStyle.Serverless;
+ var projectNameLower = options.Name.ToUpperInvariant().ToLowerInvariant();
+
+ if (serverless)
+ {
+ return $$"""
+ terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 5.0"
+ }
+ }
+
+ backend "s3" {
+ bucket = "{{projectNameLower}}-terraform-state"
+ key = "state/terraform.tfstate"
+ region = var.aws_region
+ }
+ }
+
+ provider "aws" {
+ region = var.aws_region
+ }
+
+ # Lambda function
+ resource "aws_lambda_function" "api" {
+ function_name = "${var.project_name}-api"
+ runtime = "dotnet8"
+ handler = "{{options.Name}}.Api"
+ memory_size = 512
+ timeout = 30
+
+ filename = var.lambda_zip_path
+ source_code_hash = filebase64sha256(var.lambda_zip_path)
+
+ role = aws_iam_role.lambda_role.arn
+
+ environment {
+ variables = {
+ ASPNETCORE_ENVIRONMENT = var.environment
+ }
+ }
+ }
+
+ # API Gateway
+ resource "aws_apigatewayv2_api" "api" {
+ name = "${var.project_name}-api"
+ protocol_type = "HTTP"
+ }
+
+ resource "aws_apigatewayv2_integration" "lambda" {
+ api_id = aws_apigatewayv2_api.api.id
+ integration_type = "AWS_PROXY"
+ integration_uri = aws_lambda_function.api.invoke_arn
+ integration_method = "POST"
+ }
+
+ resource "aws_apigatewayv2_route" "default" {
+ api_id = aws_apigatewayv2_api.api.id
+ route_key = "$default"
+ target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
+ }
+
+ resource "aws_apigatewayv2_stage" "default" {
+ api_id = aws_apigatewayv2_api.api.id
+ name = "$default"
+ auto_deploy = true
+ }
+
+ # Lambda IAM role
+ resource "aws_iam_role" "lambda_role" {
+ name = "${var.project_name}-lambda-role"
+
+ assume_role_policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Action = "sts:AssumeRole"
+ Effect = "Allow"
+ Principal = {
+ Service = "lambda.amazonaws.com"
+ }
+ }]
+ })
+ }
+
+ resource "aws_iam_role_policy_attachment" "lambda_basic" {
+ role = aws_iam_role.lambda_role.name
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ }
+ """;
+ }
+
+ return $$"""
+ terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 5.0"
+ }
+ }
+
+ backend "s3" {
+ bucket = "{{projectNameLower}}-terraform-state"
+ key = "state/terraform.tfstate"
+ region = var.aws_region
+ }
+ }
+
+ provider "aws" {
+ region = var.aws_region
+ }
+
+ # VPC
+ module "vpc" {
+ source = "terraform-aws-modules/vpc/aws"
+
+ name = "${var.project_name}-vpc"
+ cidr = "10.0.0.0/16"
+
+ azs = ["${var.aws_region}a", "${var.aws_region}b"]
+ private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
+ public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
+
+ enable_nat_gateway = true
+ single_nat_gateway = var.environment != "prod"
+ }
+
+ # RDS PostgreSQL
+ module "rds" {
+ source = "terraform-aws-modules/rds/aws"
+
+ identifier = "${var.project_name}-db"
+
+ engine = "postgres"
+ engine_version = "16"
+ instance_class = var.db_instance_class
+ allocated_storage = 20
+
+ db_name = var.project_name
+ username = "postgres"
+ port = 5432
+
+ vpc_security_group_ids = [module.vpc.default_security_group_id]
+ subnet_ids = module.vpc.private_subnets
+
+ family = "postgres16"
+ }
+
+ # ElastiCache Redis
+ module "elasticache" {
+ source = "terraform-aws-modules/elasticache/aws"
+
+ cluster_id = "${var.project_name}-redis"
+ engine = "redis"
+ node_type = var.redis_node_type
+ num_cache_nodes = 1
+ parameter_group_name = "default.redis7"
+
+ subnet_ids = module.vpc.private_subnets
+ security_group_ids = [module.vpc.default_security_group_id]
+ }
+
+ # ECS Cluster
+ module "ecs" {
+ source = "terraform-aws-modules/ecs/aws"
+
+ cluster_name = "${var.project_name}-cluster"
+
+ fargate_capacity_providers = {
+ FARGATE = {
+ default_capacity_provider_strategy = {
+ weight = 100
+ }
+ }
+ }
+ }
+ """;
+ }
+
+ public static string GenerateTerraformVariables(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var projectNameLower = options.Name.ToUpperInvariant().ToLowerInvariant();
+
+ return $$"""
+ variable "aws_region" {
+ description = "AWS region"
+ type = string
+ default = "us-east-1"
+ }
+
+ variable "project_name" {
+ description = "Project name"
+ type = string
+ default = "{{projectNameLower}}"
+ }
+
+ variable "environment" {
+ description = "Environment (dev, staging, prod)"
+ type = string
+ default = "dev"
+ }
+
+ variable "db_instance_class" {
+ description = "RDS instance class"
+ type = string
+ default = "db.t3.micro"
+ }
+
+ variable "redis_node_type" {
+ description = "ElastiCache node type"
+ type = string
+ default = "cache.t3.micro"
+ }
+ {{(options.Architecture == ArchitectureStyle.Serverless ? """
+
+ variable "lambda_zip_path" {
+ description = "Path to Lambda deployment package"
+ type = string
+ default = "../publish/api.zip"
+ }
+ """ : "")}}
+ """;
+ }
+
+ public static string GenerateTerraformOutputs(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (options.Architecture == ArchitectureStyle.Serverless)
+ {
+ return """
+ output "api_endpoint" {
+ description = "API Gateway endpoint URL"
+ value = aws_apigatewayv2_api.api.api_endpoint
+ }
+
+ output "lambda_function_name" {
+ description = "Lambda function name"
+ value = aws_lambda_function.api.function_name
+ }
+ """;
+ }
+
+ return """
+ output "vpc_id" {
+ description = "VPC ID"
+ value = module.vpc.vpc_id
+ }
+
+ output "rds_endpoint" {
+ description = "RDS endpoint"
+ value = module.rds.db_instance_endpoint
+ }
+
+ output "redis_endpoint" {
+ description = "ElastiCache endpoint"
+ value = module.elasticache.cluster_address
+ }
+
+ output "ecs_cluster_name" {
+ description = "ECS cluster name"
+ value = module.ecs.cluster_name
+ }
+ """;
+ }
+
+ public static string GenerateGitHubActionsCI(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var projectNameLower = options.Name.ToUpperInvariant().ToLowerInvariant();
+
+ return $@"name: CI
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main]
+
+env:
+ DOTNET_VERSION: '10.0.x'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{{{ env.DOTNET_VERSION }}}}
+
+ - name: Restore dependencies
+ run: dotnet restore src/{options.Name}.slnx
+
+ - name: Build
+ run: dotnet build src/{options.Name}.slnx --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test src/{options.Name}.slnx --no-build --configuration Release --verbosity normal
+
+ docker:
+ runs-on: ubuntu-latest
+ needs: build
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build Docker image
+ run: |
+ docker build -t {projectNameLower}:${{{{ github.sha }}}} -f src/{options.Name}.Api/Dockerfile .
+";
+ }
+
+ private const string GitignoreTemplate = """
+ ## .NET
+ bin/
+ obj/
+ *.user
+ *.userosscache
+ *.suo
+ *.cache
+ *.nupkg
+
+ ## IDE
+ .vs/
+ .vscode/
+ .idea/
+ *.swp
+ *.swo
+
+ ## Build
+ publish/
+ artifacts/
+ TestResults/
+
+ ## Secrets
+ appsettings.*.json
+ !appsettings.json
+ !appsettings.Development.json
+ *.pfx
+ *.p12
+
+ ## Terraform
+ .terraform/
+ *.tfstate
+ *.tfstate.*
+ .terraform.lock.hcl
+
+ ## OS
+ .DS_Store
+ Thumbs.db
+
+ ## Logs
+ *.log
+ logs/
+ """;
+
+ public static string GenerateGitignore() => GitignoreTemplate;
+
+ public static string GenerateDirectoryBuildProps(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+
+
+ net10.0
+ latest
+ enable
+ enable
+ false
+ true
+
+
+
+ {{options.Name}}
+ {{options.Name}}
+ 1.0.0
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+
+ """;
+ }
+
+ public static string GenerateDirectoryPackagesProps(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ // Use custom version from options, or fall back to CLI's version
+ var version = options.FrameworkVersion ?? FrameworkVersion;
+
+ return $$"""
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+ }
+
+ public static string GenerateReadme(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var archDescription = options.Architecture switch
+ {
+ ArchitectureStyle.Monolith => "monolithic",
+ ArchitectureStyle.Microservices => "microservices",
+ ArchitectureStyle.Serverless => "serverless (AWS Lambda)",
+ _ => string.Empty
+ };
+
+ return $$"""
+ # {{options.Name}}
+
+ A {{archDescription}} application built with [FullStackHero .NET Starter Kit](https://fullstackhero.net).
+
+ ## Getting Started
+
+ ### Prerequisites
+
+ - [.NET 10 SDK](https://dotnet.microsoft.com/download)
+ - [Docker](https://www.docker.com/) (optional, for infrastructure)
+ {{(options.Database == DatabaseProvider.PostgreSQL ? "- PostgreSQL 16+" : "")}}
+ {{(options.Database == DatabaseProvider.SqlServer ? "- SQL Server 2022+" : "")}}
+ - Redis
+
+ ### Running the Application
+
+ {{(options.IncludeDocker ? """
+ #### Start Infrastructure (Docker)
+
+ ```bash
+ docker-compose up -d
+ ```
+ """ : "")}}
+
+ {{(options.IncludeAspire ? $"""
+ #### Run with Aspire
+
+ ```bash
+ dotnet run --project src/{options.Name}.AppHost
+ ```
+ """ : $"""
+ #### Run the API
+
+ ```bash
+ dotnet run --project src/{options.Name}.Api
+ ```
+ """)}}
+
+ ### Project Structure
+
+ ```
+ src/
+ ├── {{options.Name}}.Api/ # Web API project
+ ├── {{options.Name}}.Migrations/ # Database migrations
+ {{(options.Type == ProjectType.ApiBlazor ? $"├── {options.Name}.Blazor/ # Blazor WebAssembly UI" : "")}}
+ {{(options.IncludeAspire ? $"├── {options.Name}.AppHost/ # Aspire orchestrator" : "")}}
+ {{(options.IncludeSampleModule ? "└── Modules/ # Feature modules" : "")}}
+ ```
+
+ ## Configuration
+
+ Update `appsettings.json` with your settings:
+
+ - `DatabaseOptions:ConnectionString` - Database connection
+ - `CachingOptions:Redis` - Redis connection
+ - `JwtOptions:SigningKey` - JWT signing key (change in production!)
+
+ ## License
+
+ MIT
+ """;
+ }
+
+ public static string GenerateBlazorMainLayout(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ @inherits LayoutComponentBase
+
+
+
+
+
+
+
+
+
+ {{options.Name}}
+
+
+
+
+
+ Home
+
+
+
+ @Body
+
+
+
+ @code {
+ private bool _drawerOpen = true;
+
+ private void ToggleDrawer()
+ {
+ _drawerOpen = !_drawerOpen;
+ }
+ }
+ """;
+ }
+
+ public static string GenerateDockerfile(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base
+ WORKDIR /app
+ EXPOSE 8080
+ EXPOSE 8081
+
+ FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
+ ARG BUILD_CONFIGURATION=Release
+ WORKDIR /src
+ COPY ["src/{{options.Name}}.Api/{{options.Name}}.Api.csproj", "{{options.Name}}.Api/"]
+ RUN dotnet restore "{{options.Name}}.Api/{{options.Name}}.Api.csproj"
+ COPY src/ .
+ WORKDIR "/src/{{options.Name}}.Api"
+ RUN dotnet build "{{options.Name}}.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
+
+ FROM build AS publish
+ ARG BUILD_CONFIGURATION=Release
+ RUN dotnet publish "{{options.Name}}.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+ FROM base AS final
+ WORKDIR /app
+ COPY --from=publish /app/publish .
+ ENTRYPOINT ["dotnet", "{{options.Name}}.Api.dll"]
+ """;
+ }
+
+ public static string GenerateApiLaunchSettings(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ {
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "openapi",
+ "applicationUrl": "http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "openapi",
+ "applicationUrl": "https://localhost:7000;http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+ }
+ """;
+ }
+
+ public static string GenerateAppHostLaunchSettings(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ {
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17000;http://localhost:15000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000"
+ }
+ }
+ }
+ }
+ """;
+ }
+
+ private const string GlobalJsonTemplate = """
+ {
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestFeature"
+ }
+ }
+ """;
+
+ public static string GenerateGlobalJson() => GlobalJsonTemplate;
+
+ private const string EditorConfigTemplate = """
+ # EditorConfig is awesome: https://EditorConfig.org
+
+ root = true
+
+ [*]
+ indent_style = space
+ indent_size = 4
+ end_of_line = lf
+ charset = utf-8
+ trim_trailing_whitespace = true
+ insert_final_newline = true
+
+ [*.{cs,csx}]
+ indent_size = 4
+
+ [*.{json,yml,yaml}]
+ indent_size = 2
+
+ [*.md]
+ trim_trailing_whitespace = false
+
+ [*.razor]
+ indent_size = 4
+
+ # C# files
+ [*.cs]
+
+ # Sort using and Import directives with System.* appearing first
+ dotnet_sort_system_directives_first = true
+ dotnet_separate_import_directive_groups = false
+
+ # Avoid "this." for fields, properties, methods, events
+ dotnet_style_qualification_for_field = false:suggestion
+ dotnet_style_qualification_for_property = false:suggestion
+ dotnet_style_qualification_for_method = false:suggestion
+ dotnet_style_qualification_for_event = false:suggestion
+
+ # Use language keywords instead of framework type names
+ dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+ dotnet_style_predefined_type_for_member_access = true:suggestion
+
+ # Prefer var
+ csharp_style_var_for_built_in_types = true:suggestion
+ csharp_style_var_when_type_is_apparent = true:suggestion
+ csharp_style_var_elsewhere = true:suggestion
+
+ # Prefer expression-bodied members
+ csharp_style_expression_bodied_methods = when_on_single_line:suggestion
+ csharp_style_expression_bodied_constructors = when_on_single_line:suggestion
+ csharp_style_expression_bodied_properties = when_on_single_line:suggestion
+
+ # Prefer pattern matching
+ csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+ csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+
+ # Namespace preferences
+ csharp_style_namespace_declarations = file_scoped:suggestion
+
+ # Newline preferences
+ csharp_new_line_before_open_brace = all
+ csharp_new_line_before_else = true
+ csharp_new_line_before_catch = true
+ csharp_new_line_before_finally = true
+ """;
+
+ public static string GenerateEditorConfig() => EditorConfigTemplate;
+}
diff --git a/src/Tools/CLI/Scaffolding/TemplateEngineTests.cs b/src/Tools/CLI/Scaffolding/TemplateEngineTests.cs
new file mode 100644
index 000000000..c4b7a27e3
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/TemplateEngineTests.cs
@@ -0,0 +1,82 @@
+using FSH.CLI.Models;
+
+namespace FSH.CLI.Scaffolding;
+
+///
+/// Simple validation tests for the refactored TemplateEngine
+///
+internal static class TemplateEngineTests
+{
+ public static void RunValidationTests()
+ {
+ Console.WriteLine("Running TemplateEngine validation tests...");
+
+ var testOptions = new ProjectOptions
+ {
+ Name = "TestProject",
+ OutputPath = "/tmp/test",
+ Type = ProjectType.Api,
+ Architecture = ArchitectureStyle.Monolith,
+ Database = DatabaseProvider.PostgreSQL,
+ IncludeAspire = false,
+ IncludeDocker = true,
+ IncludeSampleModule = false
+ };
+
+ try
+ {
+ // Test basic template generation
+ Console.Write("Testing solution generation... ");
+ var solution = TemplateEngine.GenerateSolution(testOptions);
+ if (string.IsNullOrEmpty(solution)) throw new InvalidOperationException("Solution generation failed");
+ Console.WriteLine("✓");
+
+ Console.Write("Testing API csproj generation... ");
+ var apiCsproj = TemplateEngine.GenerateApiCsproj(testOptions);
+ if (string.IsNullOrEmpty(apiCsproj)) throw new InvalidOperationException("API csproj generation failed");
+ Console.WriteLine("✓");
+
+ Console.Write("Testing AppSettings generation... ");
+ var appSettings = TemplateEngine.GenerateAppSettings(testOptions);
+ if (string.IsNullOrEmpty(appSettings)) throw new InvalidOperationException("AppSettings generation failed");
+ Console.WriteLine("✓");
+
+ Console.Write("Testing static template generation... ");
+ var gitignore = TemplateEngine.GenerateGitignore();
+ if (string.IsNullOrEmpty(gitignore)) throw new InvalidOperationException("Gitignore generation failed");
+ Console.WriteLine("✓");
+
+ Console.Write("Testing template services initialization... ");
+ var renderer = TemplateServices.GetRenderer();
+ var validator = TemplateServices.GetValidator();
+ var cache = TemplateServices.GetCache();
+ var loader = TemplateServices.GetLoader();
+ var parser = TemplateServices.GetParser();
+ if (renderer == null || validator == null || cache == null || loader == null || parser == null)
+ throw new InvalidOperationException("Service initialization failed");
+ Console.WriteLine("✓");
+
+ Console.WriteLine("All validation tests passed! ✅");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"❌ Test failed: {ex.Message}");
+ throw;
+ }
+ }
+}
+
+#if DEBUG
+// Uncomment the following to run validation tests during debug builds
+// This can be called from Program.cs during development
+/*
+public static class TemplateEngineTestRunner
+{
+ [System.Diagnostics.Conditional("DEBUG")]
+ public static void RunTests()
+ {
+ TemplateEngineTests.RunValidationTests();
+ }
+}
+*/
+#endif
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/TemplateLoader.cs b/src/Tools/CLI/Scaffolding/TemplateLoader.cs
new file mode 100644
index 000000000..7595ad508
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/TemplateLoader.cs
@@ -0,0 +1,234 @@
+using System.Reflection;
+
+namespace FSH.CLI.Scaffolding;
+
+///
+/// Loads templates from embedded resources and static sources
+///
+internal sealed class TemplateLoader : ITemplateLoader
+{
+ private static readonly Lazy _frameworkVersion = new(GetFrameworkVersionInternal);
+
+ private static readonly Dictionary _staticTemplates = new()
+ {
+ ["AppSettingsDevelopment"] = """
+ {
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "Microsoft.AspNetCore": "Information",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ }
+ }
+ """,
+
+ ["BlazorCsproj"] = """
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+ """,
+
+ ["BlazorApp"] = """
+
+
+
+
+
+
+
+
+
+
+
+ Not found
+
+ Sorry, there's nothing at this address.
+
+
+
+ """,
+
+ ["DockerComposeOverride"] = """
+ version: '3.8'
+
+ # Development overrides
+ services:
+ redis:
+ command: redis-server --appendonly yes
+ """,
+
+ ["CatalogContractsCsproj"] = """
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+ """,
+
+ ["GlobalJson"] = """
+ {
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestFeature"
+ }
+ }
+ """,
+
+ ["Gitignore"] = """
+ ## .NET
+ bin/
+ obj/
+ *.user
+ *.userosscache
+ *.suo
+ *.cache
+ *.nupkg
+
+ ## IDE
+ .vs/
+ .vscode/
+ .idea/
+ *.swp
+ *.swo
+
+ ## Build
+ publish/
+ artifacts/
+ TestResults/
+
+ ## Secrets
+ appsettings.*.json
+ !appsettings.json
+ !appsettings.Development.json
+ *.pfx
+ *.p12
+
+ ## Terraform
+ .terraform/
+ *.tfstate
+ *.tfstate.*
+ .terraform.lock.hcl
+
+ ## OS
+ .DS_Store
+ Thumbs.db
+
+ ## Logs
+ *.log
+ logs/
+ """,
+
+ ["EditorConfig"] = """
+ # EditorConfig is awesome: https://EditorConfig.org
+
+ root = true
+
+ [*]
+ indent_style = space
+ indent_size = 4
+ end_of_line = lf
+ charset = utf-8
+ trim_trailing_whitespace = true
+ insert_final_newline = true
+
+ [*.{cs,csx}]
+ indent_size = 4
+
+ [*.{json,yml,yaml}]
+ indent_size = 2
+
+ [*.md]
+ trim_trailing_whitespace = false
+
+ [*.razor]
+ indent_size = 4
+
+ # C# files
+ [*.cs]
+
+ # Sort using and Import directives with System.* appearing first
+ dotnet_sort_system_directives_first = true
+ dotnet_separate_import_directive_groups = false
+
+ # Avoid "this." for fields, properties, methods, events
+ dotnet_style_qualification_for_field = false:suggestion
+ dotnet_style_qualification_for_property = false:suggestion
+ dotnet_style_qualification_for_method = false:suggestion
+ dotnet_style_qualification_for_event = false:suggestion
+
+ # Use language keywords instead of framework type names
+ dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+ dotnet_style_predefined_type_for_member_access = true:suggestion
+
+ # Prefer var
+ csharp_style_var_for_built_in_types = true:suggestion
+ csharp_style_var_when_type_is_apparent = true:suggestion
+ csharp_style_var_elsewhere = true:suggestion
+
+ # Prefer expression-bodied members
+ csharp_style_expression_bodied_methods = when_on_single_line:suggestion
+ csharp_style_expression_bodied_constructors = when_on_single_line:suggestion
+ csharp_style_expression_bodied_properties = when_on_single_line:suggestion
+
+ # Prefer pattern matching
+ csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+ csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+
+ # Namespace preferences
+ csharp_style_namespace_declarations = file_scoped:suggestion
+
+ # Newline preferences
+ csharp_new_line_before_open_brace = all
+ csharp_new_line_before_else = true
+ csharp_new_line_before_catch = true
+ csharp_new_line_before_finally = true
+ """
+ };
+
+ public string GetFrameworkVersion() => _frameworkVersion.Value;
+
+ public string GetStaticTemplate(string templateName)
+ {
+ if (!_staticTemplates.TryGetValue(templateName, out var template))
+ {
+ throw new InvalidOperationException($"Static template '{templateName}' not found.");
+ }
+
+ return template;
+ }
+
+ public bool TemplateExists(string templateName)
+ {
+ return _staticTemplates.ContainsKey(templateName);
+ }
+
+ private static string GetFrameworkVersionInternal()
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ var version = assembly.GetCustomAttribute()?.InformationalVersion
+ ?? assembly.GetName().Version?.ToString()
+ ?? "10.0.0";
+
+ // Remove any +buildmetadata suffix (e.g., "10.0.0-rc.1+abc123" -> "10.0.0-rc.1")
+ var plusIndex = version.IndexOf('+', StringComparison.Ordinal);
+ return plusIndex > 0 ? version[..plusIndex] : version;
+ }
+}
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/TemplateParser.cs b/src/Tools/CLI/Scaffolding/TemplateParser.cs
new file mode 100644
index 000000000..0807e4ed8
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/TemplateParser.cs
@@ -0,0 +1,65 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.RegularExpressions;
+
+namespace FSH.CLI.Scaffolding;
+
+///
+/// Parses template syntax and normalizes project names
+///
+[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Lowercase is required for Docker, Terraform, and GitHub Actions naming conventions")]
+internal sealed class TemplateParser : ITemplateParser
+{
+ private static readonly Regex VariablePattern = new(@"\{\{([^}]+)\}\}", RegexOptions.Compiled);
+ private static readonly Regex ValidTemplatePattern = new(@"^\s*(\{\{[^}]+\}\}|[^{])*\s*$", RegexOptions.Compiled);
+
+ public IEnumerable ExtractVariables(string template)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(template);
+
+ var matches = VariablePattern.Matches(template);
+ return matches.Select(m => m.Groups[1].Value.Trim()).Distinct();
+ }
+
+ public bool IsValidTemplate(string template)
+ {
+ if (string.IsNullOrEmpty(template))
+ {
+ return false;
+ }
+
+ return ValidTemplatePattern.IsMatch(template);
+ }
+
+ public string NormalizeProjectName(string projectName, NameContext context)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(projectName);
+
+ return context switch
+ {
+ NameContext.LowerCase => projectName.ToLowerInvariant(),
+ NameContext.SafeIdentifier => MakeSafeIdentifier(projectName),
+ NameContext.DockerImage => MakeDockerImageName(projectName),
+ NameContext.DatabaseName => MakeDatabaseName(projectName),
+ NameContext.Default => projectName,
+ _ => throw new ArgumentOutOfRangeException(nameof(context), context, "Unknown name context")
+ };
+ }
+
+ private static string MakeSafeIdentifier(string name)
+ {
+ // Replace dots with underscores for safe C# identifiers
+ return name.Replace(".", "_", StringComparison.Ordinal);
+ }
+
+ private static string MakeDockerImageName(string name)
+ {
+ // Docker image names must be lowercase
+ return name.ToUpperInvariant().ToLowerInvariant();
+ }
+
+ private static string MakeDatabaseName(string name)
+ {
+ // Database names should be lowercase and use underscores
+ return name.ToLowerInvariant().Replace(".", "_", StringComparison.Ordinal);
+ }
+}
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/TemplateRenderer.cs b/src/Tools/CLI/Scaffolding/TemplateRenderer.cs
new file mode 100644
index 000000000..04b399d97
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/TemplateRenderer.cs
@@ -0,0 +1,1477 @@
+using System.Diagnostics.CodeAnalysis;
+using FSH.CLI.Models;
+
+namespace FSH.CLI.Scaffolding;
+
+///
+/// Renders templates with variable substitution
+///
+[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Lowercase is required for Docker, Terraform, and GitHub Actions naming conventions")]
+internal sealed class TemplateRenderer : ITemplateRenderer
+{
+ private readonly ITemplateLoader _templateLoader;
+ private readonly ITemplateParser _templateParser;
+ private readonly ITemplateCache _templateCache;
+
+ public TemplateRenderer(ITemplateLoader templateLoader, ITemplateParser templateParser, ITemplateCache templateCache)
+ {
+ _templateLoader = templateLoader ?? throw new ArgumentNullException(nameof(templateLoader));
+ _templateParser = templateParser ?? throw new ArgumentNullException(nameof(templateParser));
+ _templateCache = templateCache ?? throw new ArgumentNullException(nameof(templateCache));
+ }
+
+ #region Solution and Project Templates
+
+ public string RenderSolution(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var projects = new List
+ {
+ $""" """,
+ $""" """
+ };
+
+ if (options.Type == ProjectType.ApiBlazor)
+ {
+ projects.Add($""" """);
+ }
+
+ if (options.IncludeAspire)
+ {
+ projects.Add($""" """);
+ }
+
+ if (options.IncludeSampleModule)
+ {
+ projects.Add($""" """);
+ projects.Add($""" """);
+ }
+
+ return $$"""
+
+
+ {{string.Join(Environment.NewLine, projects)}}
+
+
+
+
+
+
+
+ """;
+ }
+
+ public string RenderApiCsproj(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var serverless = options.Architecture == ArchitectureStyle.Serverless;
+
+ var sampleModuleRef = options.IncludeSampleModule
+ ? $"""
+
+
+
+
+
+ """
+ : string.Empty;
+
+ return $$"""
+
+
+
+ net10.0
+ enable
+ enable
+ {{(serverless ? " Library" : "")}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ {{(serverless ? """
+
+
+
+
+
+ """ : "")}}{{sampleModuleRef}}
+
+ """;
+ }
+
+ public string RenderApiProgram(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var serverless = options.Architecture == ArchitectureStyle.Serverless;
+
+ if (serverless)
+ {
+ var serverlessModuleUsing = options.IncludeSampleModule
+ ? $"using {options.Name}.Catalog;\n"
+ : string.Empty;
+
+ var serverlessModuleAssembly = options.IncludeSampleModule
+ ? $",\n typeof(CatalogModule).Assembly"
+ : string.Empty;
+
+ return $$"""
+ {{serverlessModuleUsing}}using FSH.Framework.Web;
+ using FSH.Framework.Web.Modules;
+ using System.Reflection;
+
+ var builder = WebApplication.CreateBuilder(args);
+
+ // Add AWS Lambda hosting
+ builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi);
+
+ // Add FSH Platform
+ builder.AddHeroPlatform(platform =>
+ {
+ platform.EnableOpenApi = true;
+ platform.EnableCaching = true;
+ });
+
+ // Add modules
+ var moduleAssemblies = new Assembly[]
+ {
+ typeof(Program).Assembly{{serverlessModuleAssembly}}
+ };
+ builder.AddModules(moduleAssemblies);
+
+ var app = builder.Build();
+
+ // Use FSH Platform
+ app.UseHeroPlatform(platform =>
+ {
+ platform.MapModules = true;
+ });
+
+ await app.RunAsync();
+ """;
+ }
+
+ var sampleModuleUsing = options.IncludeSampleModule
+ ? $"using {options.Name}.Catalog;\n"
+ : string.Empty;
+
+ var sampleModuleAssembly = options.IncludeSampleModule
+ ? ",\n typeof(CatalogModule).Assembly"
+ : string.Empty;
+
+ return $$"""
+ {{sampleModuleUsing}}using FSH.Framework.Web;
+ using FSH.Framework.Web.Modules;
+ using FSH.Modules.Auditing;
+ using FSH.Modules.Identity;
+ using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration;
+ using FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration;
+ using FSH.Modules.Multitenancy;
+ using FSH.Modules.Multitenancy.Contracts.v1.GetTenantStatus;
+ using FSH.Modules.Multitenancy.Features.v1.GetTenantStatus;
+ using System.Reflection;
+
+ var builder = WebApplication.CreateBuilder(args);
+
+ // Configure Mediator with required assemblies
+ builder.Services.AddMediator(o =>
+ {
+ o.ServiceLifetime = ServiceLifetime.Scoped;
+ o.Assemblies = [
+ typeof(GenerateTokenCommand),
+ typeof(GenerateTokenCommandHandler),
+ typeof(GetTenantStatusQuery),
+ typeof(GetTenantStatusQueryHandler),
+ typeof(FSH.Modules.Auditing.Contracts.AuditEnvelope),
+ typeof(FSH.Modules.Auditing.Persistence.AuditDbContext)];
+ });
+
+ // FSH Module assemblies
+ var moduleAssemblies = new Assembly[]
+ {
+ typeof(IdentityModule).Assembly,
+ typeof(MultitenancyModule).Assembly,
+ typeof(AuditingModule).Assembly{{sampleModuleAssembly}}
+ };
+
+ // Add FSH Platform
+ builder.AddHeroPlatform(platform =>
+ {
+ platform.EnableOpenApi = true;
+ platform.EnableCaching = true;
+ platform.EnableJobs = true;
+ platform.EnableMailing = true;
+ });
+
+ // Add modules
+ builder.AddModules(moduleAssemblies);
+
+ var app = builder.Build();
+
+ // Apply tenant database migrations
+ app.UseHeroMultiTenantDatabases();
+
+ // Use FSH Platform
+ app.UseHeroPlatform(platform =>
+ {
+ platform.MapModules = true;
+ });
+
+ await app.RunAsync();
+ """;
+ }
+
+ public string RenderMigrationsCsproj(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var dbPackage = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => "",
+ DatabaseProvider.SqlServer => "",
+ DatabaseProvider.SQLite => "",
+ _ => string.Empty
+ };
+
+ return $$"""
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+ {{dbPackage}}
+
+
+
+
+
+
+
+ """;
+ }
+
+ public string RenderBlazorCsproj() => _templateLoader.GetStaticTemplate("BlazorCsproj");
+
+ public string RenderBlazorProgram(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ using Microsoft.AspNetCore.Components.Web;
+ using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
+ using MudBlazor.Services;
+ using {{options.Name}}.Blazor;
+
+ var builder = WebAssemblyHostBuilder.CreateDefault(args);
+ builder.RootComponents.Add("#app");
+ builder.RootComponents.Add("head::after");
+
+ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+ builder.Services.AddMudServices();
+
+ await builder.Build().RunAsync();
+ """;
+ }
+
+ public string RenderAppHostCsproj(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var dbPackage = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => "",
+ DatabaseProvider.SqlServer => "",
+ _ => string.Empty // SQLite doesn't need a hosting package
+ };
+
+ return $$"""
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+
+
+
+ {{dbPackage}}
+
+
+
+
+
+ {{(options.Type == ProjectType.ApiBlazor ? $" " : "")}}
+
+
+
+ """;
+ }
+
+ public string RenderAppHostProgram(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var projectNameLower = _templateParser.NormalizeProjectName(options.Name, NameContext.LowerCase);
+ var projectNameSafe = _templateParser.NormalizeProjectName(options.Name, NameContext.SafeIdentifier);
+
+ var (dbSetup, dbProvider, dbRef, dbWait, migrationsAssembly) = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => (
+ $"""
+ // Postgres container + database
+ var postgres = builder.AddPostgres("postgres").WithDataVolume("{projectNameLower}-postgres-data").AddDatabase("{projectNameLower}");
+ """,
+ "POSTGRESQL",
+ ".WithReference(postgres)",
+ ".WaitFor(postgres)",
+ $"{options.Name}.Migrations"),
+ DatabaseProvider.SqlServer => (
+ $"""
+ // SQL Server container + database
+ var sqlserver = builder.AddSqlServer("sqlserver").WithDataVolume("{projectNameLower}-sqlserver-data").AddDatabase("{projectNameLower}");
+ """,
+ "MSSQL",
+ ".WithReference(sqlserver)",
+ ".WaitFor(sqlserver)",
+ $"{options.Name}.Migrations"),
+ DatabaseProvider.SQLite => (
+ "// SQLite runs embedded - no container needed",
+ "SQLITE",
+ string.Empty,
+ string.Empty,
+ $"{options.Name}.Migrations"),
+ _ => ("// Database configured externally", "POSTGRESQL", string.Empty, string.Empty, $"{options.Name}.Migrations")
+ };
+
+ var redisSetup = $"""
+ var redis = builder.AddRedis("redis").WithDataVolume("{projectNameLower}-redis-data");
+ """;
+
+ // Build database environment variables
+ var dbResourceName = options.Database == DatabaseProvider.PostgreSQL ? "postgres" : "sqlserver";
+ var dbEnvVars = options.Database != DatabaseProvider.SQLite
+ ? $$"""
+ .WithEnvironment("DatabaseOptions__Provider", "{{dbProvider}}")
+ .WithEnvironment("DatabaseOptions__ConnectionString", {{dbResourceName}}.Resource.ConnectionStringExpression)
+ .WithEnvironment("DatabaseOptions__MigrationsAssembly", "{{migrationsAssembly}}")
+ {{dbWait}}
+ """
+ : """
+ .WithEnvironment("DatabaseOptions__Provider", "SQLITE")
+ """;
+
+ // When Blazor is included, api variable is referenced; otherwise suppress unused warning
+ var (apiDeclaration, blazorProject) = options.Type == ProjectType.ApiBlazor
+ ? ($"var api = builder.AddProject(\"{projectNameLower}-api\")",
+ $"""
+
+ builder.AddProject("{projectNameLower}-blazor");
+ """)
+ : ($"builder.AddProject(\"{projectNameLower}-api\")", string.Empty);
+
+ return $$"""
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ {{dbSetup}}
+
+ {{redisSetup}}
+
+ {{apiDeclaration}}
+ {{dbRef}}
+ .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
+ {{dbEnvVars}}
+ .WithReference(redis)
+ .WithEnvironment("CachingOptions__Redis", redis.Resource.ConnectionStringExpression)
+ .WaitFor(redis);
+ {{blazorProject}}
+
+ await builder.Build().RunAsync();
+ """;
+ }
+
+ #endregion
+
+ #region Configuration Templates
+
+ public string RenderAppSettings(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var connectionString = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => $"Server=localhost;Database={_templateParser.NormalizeProjectName(options.Name, NameContext.LowerCase)};User Id=postgres;Password=password",
+ DatabaseProvider.SqlServer => $"Server=localhost;Database={options.Name};Trusted_Connection=True;TrustServerCertificate=True",
+ DatabaseProvider.SQLite => $"Data Source={options.Name}.db",
+ _ => string.Empty
+ };
+
+ var dbProvider = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => "POSTGRESQL",
+ DatabaseProvider.SqlServer => "MSSQL",
+ DatabaseProvider.SQLite => "SQLITE",
+ _ => "POSTGRESQL"
+ };
+
+ var migrationsAssembly = $"{options.Name}.Migrations";
+ var projectNameLower = _templateParser.NormalizeProjectName(options.Name, NameContext.LowerCase);
+
+ return $$"""
+ {
+ "OpenTelemetryOptions": {
+ "Enabled": true,
+ "Tracing": {
+ "Enabled": true
+ },
+ "Metrics": {
+ "Enabled": true,
+ "MeterNames": []
+ },
+ "Exporter": {
+ "Otlp": {
+ "Enabled": true,
+ "Endpoint": "http://localhost:4317",
+ "Protocol": "grpc"
+ }
+ },
+ "Jobs": { "Enabled": true },
+ "Mediator": { "Enabled": true },
+ "Http": {
+ "Histograms": {
+ "Enabled": true
+ }
+ },
+ "Data": {
+ "FilterEfStatements": true,
+ "FilterRedisCommands": true
+ }
+ },
+ "Serilog": {
+ "Using": [
+ "Serilog.Sinks.Console",
+ "Serilog.Sinks.OpenTelemetry"
+ ],
+ "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId", "WithCorrelationId", "WithProcessId", "WithProcessName" ],
+ "MinimumLevel": {
+ "Default": "Debug"
+ },
+ "WriteTo": [
+ {
+ "Name": "Console",
+ "Args": {
+ "restrictedToMinimumLevel": "Information"
+ }
+ },
+ {
+ "Name": "OpenTelemetry",
+ "Args": {
+ "endpoint": "http://localhost:4317",
+ "protocol": "grpc",
+ "resourceAttributes": {
+ "service.name": "{{options.Name}}.Api"
+ }
+ }
+ }
+ ]
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Hangfire": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ },
+ "DatabaseOptions": {
+ "Provider": "{{dbProvider}}",
+ "ConnectionString": "{{connectionString}}",
+ "MigrationsAssembly": "{{migrationsAssembly}}"
+ },
+ "OriginOptions": {
+ "OriginUrl": "https://localhost:7030"
+ },
+ "CachingOptions": {
+ "Redis": ""
+ },
+ "HangfireOptions": {
+ "Username": "admin",
+ "Password": "Secure1234!Me",
+ "Route": "/jobs"
+ },
+ "AllowedHosts": "*",
+ "OpenApiOptions": {
+ "Enabled": true,
+ "Title": "{{options.Name}} API",
+ "Version": "v1",
+ "Description": "{{options.Name}} API built with FullStackHero .NET Starter Kit.",
+ "Contact": {
+ "Name": "Your Name",
+ "Url": "https://yourwebsite.com",
+ "Email": "your@email.com"
+ },
+ "License": {
+ "Name": "MIT License",
+ "Url": "https://opensource.org/licenses/MIT"
+ }
+ },
+ "CorsOptions": {
+ "AllowAll": false,
+ "AllowedOrigins": [
+ "https://localhost:4200",
+ "https://localhost:7140"
+ ],
+ "AllowedHeaders": [ "content-type", "authorization" ],
+ "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ]
+ },
+ "JwtOptions": {
+ "Issuer": "{{projectNameLower}}.local",
+ "Audience": "{{projectNameLower}}.clients",
+ "SigningKey": "replace-with-256-bit-secret-min-32-chars",
+ "AccessTokenMinutes": 2,
+ "RefreshTokenDays": 7
+ },
+ "SecurityHeadersOptions": {
+ "Enabled": true,
+ "ExcludedPaths": [ "/scalar", "/openapi" ],
+ "AllowInlineStyles": true,
+ "ScriptSources": [],
+ "StyleSources": []
+ },
+ "MailOptions": {
+ "From": "noreply@{{projectNameLower}}.com",
+ "Host": "smtp.ethereal.email",
+ "Port": 587,
+ "UserName": "your-smtp-user",
+ "Password": "your-smtp-password",
+ "DisplayName": "{{options.Name}}"
+ },
+ "RateLimitingOptions": {
+ "Enabled": false,
+ "Global": {
+ "PermitLimit": 100,
+ "WindowSeconds": 60,
+ "QueueLimit": 0
+ },
+ "Auth": {
+ "PermitLimit": 10,
+ "WindowSeconds": 60,
+ "QueueLimit": 0
+ }
+ },
+ "MultitenancyOptions": {
+ "RunTenantMigrationsOnStartup": true
+ },
+ "Storage": {
+ "Provider": "local"
+ }
+ }
+ """;
+ }
+
+ public string RenderAppSettingsDevelopment() => _templateLoader.GetStaticTemplate("AppSettingsDevelopment");
+
+ public string RenderApiLaunchSettings(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ {
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "openapi",
+ "applicationUrl": "http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "openapi",
+ "applicationUrl": "https://localhost:7000;http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+ }
+ """;
+ }
+
+ public string RenderAppHostLaunchSettings(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ {
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17000;http://localhost:15000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000"
+ }
+ }
+ }
+ }
+ """;
+ }
+
+ #endregion
+
+ #region Blazor Templates
+
+ public string RenderBlazorApp() => _templateLoader.GetStaticTemplate("BlazorApp");
+
+ public string RenderBlazorImports(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ @using System.Net.Http
+ @using System.Net.Http.Json
+ @using Microsoft.AspNetCore.Components.Forms
+ @using Microsoft.AspNetCore.Components.Routing
+ @using Microsoft.AspNetCore.Components.Web
+ @using Microsoft.AspNetCore.Components.Web.Virtualization
+ @using Microsoft.AspNetCore.Components.WebAssembly.Http
+ @using Microsoft.JSInterop
+ @using MudBlazor
+ @using {{options.Name}}.Blazor
+ """;
+ }
+
+ public string RenderBlazorIndexPage(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ @page "/"
+
+ {{options.Name}}
+
+
+ Welcome to {{options.Name}}
+
+ Built with FullStackHero .NET Starter Kit
+
+
+ """;
+ }
+
+ public string RenderBlazorMainLayout(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ @inherits LayoutComponentBase
+
+
+
+
+
+
+
+
+
+ {{options.Name}}
+
+
+
+
+
+ Home
+
+
+
+ @Body
+
+
+
+ @code {
+ private bool _drawerOpen = true;
+
+ private void ToggleDrawer()
+ {
+ _drawerOpen = !_drawerOpen;
+ }
+ }
+ """;
+ }
+
+ #endregion
+
+ #region Infrastructure Templates
+
+ public string RenderDockerfile(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base
+ WORKDIR /app
+ EXPOSE 8080
+ EXPOSE 8081
+
+ FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
+ ARG BUILD_CONFIGURATION=Release
+ WORKDIR /src
+ COPY ["src/{{options.Name}}.Api/{{options.Name}}.Api.csproj", "{{options.Name}}.Api/"]
+ RUN dotnet restore "{{options.Name}}.Api/{{options.Name}}.Api.csproj"
+ COPY src/ .
+ WORKDIR "/src/{{options.Name}}.Api"
+ RUN dotnet build "{{options.Name}}.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
+
+ FROM build AS publish
+ ARG BUILD_CONFIGURATION=Release
+ RUN dotnet publish "{{options.Name}}.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+ FROM base AS final
+ WORKDIR /app
+ COPY --from=publish /app/publish .
+ ENTRYPOINT ["dotnet", "{{options.Name}}.Api.dll"]
+ """;
+ }
+
+ public string RenderDockerCompose(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var projectNameLower = _templateParser.NormalizeProjectName(options.Name, NameContext.DockerImage);
+
+ var dbService = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => $"""
+ postgres:
+ image: postgres:16-alpine
+ container_name: postgres
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: {projectNameLower}
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ """,
+ DatabaseProvider.SqlServer => """
+ sqlserver:
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ container_name: sqlserver
+ environment:
+ ACCEPT_EULA: "Y"
+ SA_PASSWORD: "Your_password123"
+ ports:
+ - "1433:1433"
+ volumes:
+ - sqlserver_data:/var/opt/mssql
+ """,
+ _ => string.Empty
+ };
+
+ var volumes = options.Database switch
+ {
+ DatabaseProvider.PostgreSQL => """
+ volumes:
+ postgres_data:
+ redis_data:
+ """,
+ DatabaseProvider.SqlServer => """
+ volumes:
+ sqlserver_data:
+ redis_data:
+ """,
+ _ => """
+ volumes:
+ redis_data:
+ """
+ };
+
+ return $$"""
+ version: '3.8'
+
+ services:
+ {{dbService}}
+
+ redis:
+ image: redis:7-alpine
+ container_name: redis
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ {{volumes}}
+ """;
+ }
+
+ public string RenderDockerComposeOverride() => _templateLoader.GetStaticTemplate("DockerComposeOverride");
+
+ public string RenderTerraformMain(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var serverless = options.Architecture == ArchitectureStyle.Serverless;
+ var projectNameLower = _templateParser.NormalizeProjectName(options.Name, NameContext.DockerImage);
+
+ if (serverless)
+ {
+ return $$"""
+ terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 5.0"
+ }
+ }
+
+ backend "s3" {
+ bucket = "{{projectNameLower}}-terraform-state"
+ key = "state/terraform.tfstate"
+ region = var.aws_region
+ }
+ }
+
+ provider "aws" {
+ region = var.aws_region
+ }
+
+ # Lambda function
+ resource "aws_lambda_function" "api" {
+ function_name = "${var.project_name}-api"
+ runtime = "dotnet8"
+ handler = "{{options.Name}}.Api"
+ memory_size = 512
+ timeout = 30
+
+ filename = var.lambda_zip_path
+ source_code_hash = filebase64sha256(var.lambda_zip_path)
+
+ role = aws_iam_role.lambda_role.arn
+
+ environment {
+ variables = {
+ ASPNETCORE_ENVIRONMENT = var.environment
+ }
+ }
+ }
+
+ # API Gateway
+ resource "aws_apigatewayv2_api" "api" {
+ name = "${var.project_name}-api"
+ protocol_type = "HTTP"
+ }
+
+ resource "aws_apigatewayv2_integration" "lambda" {
+ api_id = aws_apigatewayv2_api.api.id
+ integration_type = "AWS_PROXY"
+ integration_uri = aws_lambda_function.api.invoke_arn
+ integration_method = "POST"
+ }
+
+ resource "aws_apigatewayv2_route" "default" {
+ api_id = aws_apigatewayv2_api.api.id
+ route_key = "$default"
+ target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
+ }
+
+ resource "aws_apigatewayv2_stage" "default" {
+ api_id = aws_apigatewayv2_api.api.id
+ name = "$default"
+ auto_deploy = true
+ }
+
+ # Lambda IAM role
+ resource "aws_iam_role" "lambda_role" {
+ name = "${var.project_name}-lambda-role"
+
+ assume_role_policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Action = "sts:AssumeRole"
+ Effect = "Allow"
+ Principal = {
+ Service = "lambda.amazonaws.com"
+ }
+ }]
+ })
+ }
+
+ resource "aws_iam_role_policy_attachment" "lambda_basic" {
+ role = aws_iam_role.lambda_role.name
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ }
+ """;
+ }
+
+ return $$"""
+ terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 5.0"
+ }
+ }
+
+ backend "s3" {
+ bucket = "{{projectNameLower}}-terraform-state"
+ key = "state/terraform.tfstate"
+ region = var.aws_region
+ }
+ }
+
+ provider "aws" {
+ region = var.aws_region
+ }
+
+ # VPC
+ module "vpc" {
+ source = "terraform-aws-modules/vpc/aws"
+
+ name = "${var.project_name}-vpc"
+ cidr = "10.0.0.0/16"
+
+ azs = ["${var.aws_region}a", "${var.aws_region}b"]
+ private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
+ public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
+
+ enable_nat_gateway = true
+ single_nat_gateway = var.environment != "prod"
+ }
+
+ # RDS PostgreSQL
+ module "rds" {
+ source = "terraform-aws-modules/rds/aws"
+
+ identifier = "${var.project_name}-db"
+
+ engine = "postgres"
+ engine_version = "16"
+ instance_class = var.db_instance_class
+ allocated_storage = 20
+
+ db_name = var.project_name
+ username = "postgres"
+ port = 5432
+
+ vpc_security_group_ids = [module.vpc.default_security_group_id]
+ subnet_ids = module.vpc.private_subnets
+
+ family = "postgres16"
+ }
+
+ # ElastiCache Redis
+ module "elasticache" {
+ source = "terraform-aws-modules/elasticache/aws"
+
+ cluster_id = "${var.project_name}-redis"
+ engine = "redis"
+ node_type = var.redis_node_type
+ num_cache_nodes = 1
+ parameter_group_name = "default.redis7"
+
+ subnet_ids = module.vpc.private_subnets
+ security_group_ids = [module.vpc.default_security_group_id]
+ }
+
+ # ECS Cluster
+ module "ecs" {
+ source = "terraform-aws-modules/ecs/aws"
+
+ cluster_name = "${var.project_name}-cluster"
+
+ fargate_capacity_providers = {
+ FARGATE = {
+ default_capacity_provider_strategy = {
+ weight = 100
+ }
+ }
+ }
+ }
+ """;
+ }
+
+ public string RenderTerraformVariables(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var projectNameLower = _templateParser.NormalizeProjectName(options.Name, NameContext.DockerImage);
+
+ return $$"""
+ variable "aws_region" {
+ description = "AWS region"
+ type = string
+ default = "us-east-1"
+ }
+
+ variable "project_name" {
+ description = "Project name"
+ type = string
+ default = "{{projectNameLower}}"
+ }
+
+ variable "environment" {
+ description = "Environment (dev, staging, prod)"
+ type = string
+ default = "dev"
+ }
+
+ variable "db_instance_class" {
+ description = "RDS instance class"
+ type = string
+ default = "db.t3.micro"
+ }
+
+ variable "redis_node_type" {
+ description = "ElastiCache node type"
+ type = string
+ default = "cache.t3.micro"
+ }
+ {{(options.Architecture == ArchitectureStyle.Serverless ? """
+
+ variable "lambda_zip_path" {
+ description = "Path to Lambda deployment package"
+ type = string
+ default = "../publish/api.zip"
+ }
+ """ : "")}}
+ """;
+ }
+
+ public string RenderTerraformOutputs(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (options.Architecture == ArchitectureStyle.Serverless)
+ {
+ return """
+ output "api_endpoint" {
+ description = "API Gateway endpoint URL"
+ value = aws_apigatewayv2_api.api.api_endpoint
+ }
+
+ output "lambda_function_name" {
+ description = "Lambda function name"
+ value = aws_lambda_function.api.function_name
+ }
+ """;
+ }
+
+ return """
+ output "vpc_id" {
+ description = "VPC ID"
+ value = module.vpc.vpc_id
+ }
+
+ output "rds_endpoint" {
+ description = "RDS endpoint"
+ value = module.rds.db_instance_endpoint
+ }
+
+ output "redis_endpoint" {
+ description = "ElastiCache endpoint"
+ value = module.elasticache.cluster_address
+ }
+
+ output "ecs_cluster_name" {
+ description = "ECS cluster name"
+ value = module.ecs.cluster_name
+ }
+ """;
+ }
+
+ public string RenderGitHubActionsCI(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var projectNameLower = _templateParser.NormalizeProjectName(options.Name, NameContext.DockerImage);
+
+ return $@"name: CI
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main]
+
+env:
+ DOTNET_VERSION: '10.0.x'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{{{ env.DOTNET_VERSION }}}}
+
+ - name: Restore dependencies
+ run: dotnet restore src/{options.Name}.slnx
+
+ - name: Build
+ run: dotnet build src/{options.Name}.slnx --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test src/{options.Name}.slnx --no-build --configuration Release --verbosity normal
+
+ docker:
+ runs-on: ubuntu-latest
+ needs: build
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build Docker image
+ run: |
+ docker build -t {projectNameLower}:${{{{ github.sha }}}} -f src/{options.Name}.Api/Dockerfile .
+";
+ }
+
+ #endregion
+
+ #region Module Templates
+
+ public string RenderCatalogModule(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ using {{options.Name}}.Catalog.Features.v1.Products;
+ using FSH.Framework.Web.Modules;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Routing;
+ using Microsoft.Extensions.Hosting;
+
+ namespace {{options.Name}}.Catalog;
+
+ public sealed class CatalogModule : IModule
+ {
+ public void ConfigureServices(IHostApplicationBuilder builder)
+ {
+ // Register services
+ }
+
+ public void MapEndpoints(IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup("/api/v1/catalog")
+ .WithTags("Catalog");
+
+ group.MapGetProductsEndpoint();
+ }
+ }
+ """;
+ }
+
+ public string RenderCatalogModuleCsproj(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+ }
+
+ public string RenderCatalogContractsCsproj() => _templateLoader.GetStaticTemplate("CatalogContractsCsproj");
+
+ public string RenderGetProductsEndpoint(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Routing;
+
+ namespace {{options.Name}}.Catalog.Features.v1.Products;
+
+ public static class GetProductsEndpoint
+ {
+ public static RouteHandlerBuilder MapGetProductsEndpoint(this IEndpointRouteBuilder endpoints)
+ {
+ return endpoints.MapGet("/products", () =>
+ {
+ var products = new[]
+ {
+ new { Id = 1, Name = "Product 1", Price = 9.99m },
+ new { Id = 2, Name = "Product 2", Price = 19.99m },
+ new { Id = 3, Name = "Product 3", Price = 29.99m }
+ };
+
+ return TypedResults.Ok(products);
+ })
+ .WithName("GetProducts")
+ .WithSummary("Get all products")
+ .Produces(StatusCodes.Status200OK);
+ }
+ }
+ """;
+ }
+
+ #endregion
+
+ #region Static Content Templates
+
+ public string RenderReadme(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var archDescription = options.Architecture switch
+ {
+ ArchitectureStyle.Monolith => "monolithic",
+ ArchitectureStyle.Microservices => "microservices",
+ ArchitectureStyle.Serverless => "serverless (AWS Lambda)",
+ _ => string.Empty
+ };
+
+ return $$"""
+ # {{options.Name}}
+
+ A {{archDescription}} application built with [FullStackHero .NET Starter Kit](https://fullstackhero.net).
+
+ ## Getting Started
+
+ ### Prerequisites
+
+ - [.NET 10 SDK](https://dotnet.microsoft.com/download)
+ - [Docker](https://www.docker.com/) (optional, for infrastructure)
+ {{(options.Database == DatabaseProvider.PostgreSQL ? "- PostgreSQL 16+" : "")}}
+ {{(options.Database == DatabaseProvider.SqlServer ? "- SQL Server 2022+" : "")}}
+ - Redis
+
+ ### Running the Application
+
+ {{(options.IncludeDocker ? """
+ #### Start Infrastructure (Docker)
+
+ ```bash
+ docker-compose up -d
+ ```
+ """ : "")}}
+
+ {{(options.IncludeAspire ? $"""
+ #### Run with Aspire
+
+ ```bash
+ dotnet run --project src/{options.Name}.AppHost
+ ```
+ """ : $"""
+ #### Run the API
+
+ ```bash
+ dotnet run --project src/{options.Name}.Api
+ ```
+ """)}}
+
+ ### Project Structure
+
+ ```
+ src/
+ ├── {{options.Name}}.Api/ # Web API project
+ ├── {{options.Name}}.Migrations/ # Database migrations
+ {{(options.Type == ProjectType.ApiBlazor ? $"├── {options.Name}.Blazor/ # Blazor WebAssembly UI" : "")}}
+ {{(options.IncludeAspire ? $"├── {options.Name}.AppHost/ # Aspire orchestrator" : "")}}
+ {{(options.IncludeSampleModule ? "└── Modules/ # Feature modules" : "")}}
+ ```
+
+ ## Configuration
+
+ Update `appsettings.json` with your settings:
+
+ - `DatabaseOptions:ConnectionString` - Database connection
+ - `CachingOptions:Redis` - Redis connection
+ - `JwtOptions:SigningKey` - JWT signing key (change in production!)
+
+ ## License
+
+ MIT
+ """;
+ }
+
+ public string RenderGitignore() => _templateLoader.GetStaticTemplate("Gitignore");
+
+ public string RenderDirectoryBuildProps(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return $$"""
+
+
+ net10.0
+ latest
+ enable
+ enable
+ false
+ true
+
+
+
+ {{options.Name}}
+ {{options.Name}}
+ 1.0.0
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+
+ """;
+ }
+
+ public string RenderDirectoryPackagesProps(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ // Use custom version from options, or fall back to framework version
+ var version = options.FrameworkVersion ?? _templateLoader.GetFrameworkVersion();
+
+ return $$"""
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+ }
+
+ public string RenderEditorConfig() => _templateLoader.GetStaticTemplate("EditorConfig");
+
+ public string RenderGlobalJson() => _templateLoader.GetStaticTemplate("GlobalJson");
+
+ #endregion
+}
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/TemplateServices.cs b/src/Tools/CLI/Scaffolding/TemplateServices.cs
new file mode 100644
index 000000000..d4d5a2c99
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/TemplateServices.cs
@@ -0,0 +1,35 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FSH.CLI.Scaffolding;
+
+///
+/// Factory for creating template services with dependency injection
+///
+internal static class TemplateServices
+{
+ private static readonly Lazy _serviceProvider = new(CreateServiceProvider);
+
+ public static ITemplateRenderer GetRenderer() => _serviceProvider.Value.GetRequiredService();
+
+ public static ITemplateValidator GetValidator() => _serviceProvider.Value.GetRequiredService();
+
+ public static ITemplateCache GetCache() => _serviceProvider.Value.GetRequiredService();
+
+ public static ITemplateLoader GetLoader() => _serviceProvider.Value.GetRequiredService();
+
+ public static ITemplateParser GetParser() => _serviceProvider.Value.GetRequiredService();
+
+ private static IServiceProvider CreateServiceProvider()
+ {
+ var services = new ServiceCollection();
+
+ // Register template services
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+
+ return services.BuildServiceProvider();
+ }
+}
\ No newline at end of file
diff --git a/src/Tools/CLI/Scaffolding/TemplateValidator.cs b/src/Tools/CLI/Scaffolding/TemplateValidator.cs
new file mode 100644
index 000000000..fbb5a229b
--- /dev/null
+++ b/src/Tools/CLI/Scaffolding/TemplateValidator.cs
@@ -0,0 +1,235 @@
+using FSH.CLI.Models;
+
+namespace FSH.CLI.Scaffolding;
+
+///
+/// Validates template structure and project configuration
+///
+internal sealed class TemplateValidator : ITemplateValidator
+{
+ private readonly ITemplateLoader _templateLoader;
+ private readonly ITemplateParser _templateParser;
+
+ public TemplateValidator(ITemplateLoader templateLoader, ITemplateParser templateParser)
+ {
+ _templateLoader = templateLoader ?? throw new ArgumentNullException(nameof(templateLoader));
+ _templateParser = templateParser ?? throw new ArgumentNullException(nameof(templateParser));
+ }
+
+ public ValidationResult ValidateProjectOptions(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var errors = new List();
+ var warnings = new List();
+
+ // Validate project name
+ if (string.IsNullOrWhiteSpace(options.Name))
+ {
+ errors.Add("Project name cannot be empty");
+ }
+ else if (options.Name.Length < 2)
+ {
+ errors.Add("Project name must be at least 2 characters long");
+ }
+ else if (options.Name.Length > 100)
+ {
+ errors.Add("Project name cannot exceed 100 characters");
+ }
+
+ // Validate project name characters
+ if (!string.IsNullOrEmpty(options.Name) &&
+ options.Name.Any(c => !char.IsLetterOrDigit(c) && c != '.' && c != '-' && c != '_'))
+ {
+ errors.Add("Project name can only contain letters, digits, dots, hyphens, and underscores");
+ }
+
+ // Validate output path
+ if (string.IsNullOrWhiteSpace(options.OutputPath))
+ {
+ errors.Add("Output path cannot be empty");
+ }
+ else
+ {
+ try
+ {
+ _ = Path.GetFullPath(options.OutputPath);
+ }
+ catch (Exception)
+ {
+ errors.Add("Output path is not valid");
+ }
+ }
+
+ // Validate architecture and project type combinations
+ if (options.Architecture == ArchitectureStyle.Serverless && options.Type == ProjectType.ApiBlazor)
+ {
+ warnings.Add("Blazor WebAssembly with serverless architecture may require additional configuration for static file hosting");
+ }
+
+ // Validate database and architecture combinations
+ if (options.Architecture == ArchitectureStyle.Serverless && options.Database == DatabaseProvider.SQLite)
+ {
+ warnings.Add("SQLite with serverless architecture may have limitations in multi-instance scenarios");
+ }
+
+ // Validate framework version if specified
+ if (!string.IsNullOrEmpty(options.FrameworkVersion))
+ {
+ if (!IsValidFrameworkVersion(options.FrameworkVersion))
+ {
+ errors.Add($"Framework version '{options.FrameworkVersion}' is not in a valid format");
+ }
+ }
+
+ return new ValidationResult(errors.Count == 0, errors, warnings);
+ }
+
+ public ValidationResult ValidateTemplateAvailability(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var errors = new List();
+
+ // Check required static templates
+ var requiredTemplates = new[]
+ {
+ "GlobalJson",
+ "Gitignore",
+ "EditorConfig"
+ };
+
+ foreach (var template in requiredTemplates)
+ {
+ if (!_templateLoader.TemplateExists(template))
+ {
+ errors.Add($"Required template '{template}' is not available");
+ }
+ }
+
+ // Check Blazor-specific templates if needed
+ if (options.Type == ProjectType.ApiBlazor)
+ {
+ var blazorTemplates = new[] { "BlazorCsproj", "BlazorApp" };
+ foreach (var template in blazorTemplates)
+ {
+ if (!_templateLoader.TemplateExists(template))
+ {
+ errors.Add($"Required Blazor template '{template}' is not available");
+ }
+ }
+ }
+
+ return new ValidationResult(errors.Count == 0, errors, Enumerable.Empty());
+ }
+
+ public ValidationResult ValidateGeneratedContent(string content, string templateType)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(content);
+ ArgumentException.ThrowIfNullOrEmpty(templateType);
+
+ var errors = new List();
+ var warnings = new List();
+
+ // Basic content validation
+ if (content.Length == 0)
+ {
+ errors.Add("Generated content is empty");
+ return new ValidationResult(false, errors, warnings);
+ }
+
+ // Template-specific validations
+ switch (templateType.ToUpperInvariant())
+ {
+ case "JSON":
+ if (!IsValidJson(content))
+ {
+ errors.Add("Generated JSON content is not valid");
+ }
+ break;
+
+ case "XML":
+ case "CSPROJ":
+ if (!IsValidXml(content))
+ {
+ errors.Add("Generated XML content is not valid");
+ }
+ break;
+
+ case "DOCKERFILE":
+ if (!content.Contains("FROM ", StringComparison.OrdinalIgnoreCase))
+ {
+ warnings.Add("Dockerfile doesn't contain a FROM instruction");
+ }
+ break;
+ }
+
+ // Check for unresolved template variables
+ var unresolvedVariables = _templateParser.ExtractVariables(content);
+ if (unresolvedVariables.Any())
+ {
+ warnings.Add($"Content contains unresolved variables: {string.Join(", ", unresolvedVariables)}");
+ }
+
+ return new ValidationResult(errors.Count == 0, errors, warnings);
+ }
+
+ public ValidationResult ValidateProjectStructure(ProjectOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var warnings = new List();
+
+ // Validate Docker compatibility
+ if (options.IncludeDocker && options.Database == DatabaseProvider.SQLite)
+ {
+ warnings.Add("SQLite with Docker may require volume mounting for data persistence");
+ }
+
+ // Validate Aspire compatibility
+ if (options.IncludeAspire && options.Architecture == ArchitectureStyle.Serverless)
+ {
+ warnings.Add("Aspire orchestration may not be suitable for serverless deployments");
+ }
+
+ // Validate module structure
+ if (options.IncludeSampleModule && options.Architecture == ArchitectureStyle.Microservices)
+ {
+ warnings.Add("Sample module in microservices architecture should be moved to separate service");
+ }
+
+ return new ValidationResult(true, Enumerable.Empty(), warnings);
+ }
+
+ private static bool IsValidFrameworkVersion(string version)
+ {
+ return Version.TryParse(version.Split('-')[0], out _);
+ }
+
+ private static bool IsValidJson(string json)
+ {
+ try
+ {
+ System.Text.Json.JsonDocument.Parse(json);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool IsValidXml(string xml)
+ {
+ try
+ {
+ var doc = new System.Xml.XmlDocument();
+ doc.LoadXml(xml);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
\ No newline at end of file