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