From 2472be3a6d5f09fb70a01c5a172a11762de75ba6 Mon Sep 17 00:00:00 2001 From: JuansesDev Date: Mon, 20 Jan 2025 10:30:00 -0500 Subject: [PATCH 1/9] fix(security): update Scriban to 7.2.6 Closes 14 known vulnerabilities (1 critical, 8 high severity). No API changes; all templates render correctly. --- MTC/MTC.csproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/MTC/MTC.csproj b/MTC/MTC.csproj index dad6a36..5065bbd 100644 --- a/MTC/MTC.csproj +++ b/MTC/MTC.csproj @@ -2,24 +2,24 @@ Exe - net9.0 + net10.0 enable enable true mtc MTC - 1.0.2 + 1.1.0 Juanse Modular Template CLI for .NET README.md - - - - + + + + From 253ad45eb437fdfa8c836593b5f588fd5e9431db Mon Sep 17 00:00:00 2001 From: JuansesDev Date: Mon, 20 Jan 2025 14:45:00 -0500 Subject: [PATCH 2/9] build: target .NET 10 across projects and templates - net9.0 -> net10.0 in MTC, tests, and all 7 template projects - Update framework-coupled packages: Spectre.Console 0.54.0 -> 0.57.2 - CI: dotnet-version 9.0.x -> 10.0.x - Integration tests locate binary dynamically, not hardcoded framework --- .github/workflows/build-and-test.yml | 2 +- MTC.IntegrationTests/MTC.IntegrationTests.csproj | 8 +++++++- MTC.Tests/MTC.Tests.csproj | 2 +- Templates/CleanArch/{{Name}}.API/{{Name}}.API.csproj | 7 +++++-- .../{{Name}}.Application/{{Name}}.Application.csproj | 6 +++--- .../CleanArch/{{Name}}.Domain/{{Name}}.Domain.csproj | 2 +- .../{{Name}}.Infrastructure.csproj | 4 ++-- Templates/ConsoleApp/{{Name}}/{{Name}}.csproj | 2 +- Templates/MvcMonolith/{{Name}}/{{Name}}.csproj | 2 +- Templates/VerticalSlice/{{Name}}/{{Name}}.csproj | 11 +++++++---- 10 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 3fdaa42..77305f1 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -17,7 +17,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore diff --git a/MTC.IntegrationTests/MTC.IntegrationTests.csproj b/MTC.IntegrationTests/MTC.IntegrationTests.csproj index 627a60e..99a06dc 100644 --- a/MTC.IntegrationTests/MTC.IntegrationTests.csproj +++ b/MTC.IntegrationTests/MTC.IntegrationTests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable false @@ -18,4 +18,10 @@ + + + + + diff --git a/MTC.Tests/MTC.Tests.csproj b/MTC.Tests/MTC.Tests.csproj index 7c39c4d..c2d1d30 100644 --- a/MTC.Tests/MTC.Tests.csproj +++ b/MTC.Tests/MTC.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable false diff --git a/Templates/CleanArch/{{Name}}.API/{{Name}}.API.csproj b/Templates/CleanArch/{{Name}}.API/{{Name}}.API.csproj index f0cb609..6bca1cf 100644 --- a/Templates/CleanArch/{{Name}}.API/{{Name}}.API.csproj +++ b/Templates/CleanArch/{{Name}}.API/{{Name}}.API.csproj @@ -1,13 +1,16 @@ - net9.0 + net10.0 enable enable - + + + diff --git a/Templates/CleanArch/{{Name}}.Application/{{Name}}.Application.csproj b/Templates/CleanArch/{{Name}}.Application/{{Name}}.Application.csproj index 47bb59a..db9d9ba 100644 --- a/Templates/CleanArch/{{Name}}.Application/{{Name}}.Application.csproj +++ b/Templates/CleanArch/{{Name}}.Application/{{Name}}.Application.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable @@ -11,8 +11,8 @@ - - + + diff --git a/Templates/CleanArch/{{Name}}.Domain/{{Name}}.Domain.csproj b/Templates/CleanArch/{{Name}}.Domain/{{Name}}.Domain.csproj index 0d2df0d..9ed914b 100644 --- a/Templates/CleanArch/{{Name}}.Domain/{{Name}}.Domain.csproj +++ b/Templates/CleanArch/{{Name}}.Domain/{{Name}}.Domain.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable diff --git a/Templates/CleanArch/{{Name}}.Infrastructure/{{Name}}.Infrastructure.csproj b/Templates/CleanArch/{{Name}}.Infrastructure/{{Name}}.Infrastructure.csproj index a3e1665..c39b006 100644 --- a/Templates/CleanArch/{{Name}}.Infrastructure/{{Name}}.Infrastructure.csproj +++ b/Templates/CleanArch/{{Name}}.Infrastructure/{{Name}}.Infrastructure.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable @@ -11,7 +11,7 @@ - + diff --git a/Templates/ConsoleApp/{{Name}}/{{Name}}.csproj b/Templates/ConsoleApp/{{Name}}/{{Name}}.csproj index 694035b..dfb40ca 100644 --- a/Templates/ConsoleApp/{{Name}}/{{Name}}.csproj +++ b/Templates/ConsoleApp/{{Name}}/{{Name}}.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/Templates/MvcMonolith/{{Name}}/{{Name}}.csproj b/Templates/MvcMonolith/{{Name}}/{{Name}}.csproj index 6568b3d..a3a34b6 100644 --- a/Templates/MvcMonolith/{{Name}}/{{Name}}.csproj +++ b/Templates/MvcMonolith/{{Name}}/{{Name}}.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable diff --git a/Templates/VerticalSlice/{{Name}}/{{Name}}.csproj b/Templates/VerticalSlice/{{Name}}/{{Name}}.csproj index 2dafa7f..f8294d3 100644 --- a/Templates/VerticalSlice/{{Name}}/{{Name}}.csproj +++ b/Templates/VerticalSlice/{{Name}}/{{Name}}.csproj @@ -1,15 +1,18 @@ - net9.0 + net10.0 enable enable - - - + + + + + From b87b2256d9fd70ffb70fb9f5731979754a951d50 Mon Sep 17 00:00:00 2001 From: JuansesDev Date: Mon, 10 Feb 2025 09:00:00 -0500 Subject: [PATCH 3/9] fix(cli): correct mtc new arguments and exit codes - mtc new ConsoleApp MyApp: name now positional argument - Default name resolves correctly (no literal '.') - Commands exit 1 on error, not 0 - Don't share Argument/Option instances across commands - Remove debug output from Program.cs --- MTC/Models/Property.cs | 7 + MTC/Program.cs | 536 ++++++++++++++++++++++-------------- MTC/Services/FieldParser.cs | 31 ++- 3 files changed, 365 insertions(+), 209 deletions(-) diff --git a/MTC/Models/Property.cs b/MTC/Models/Property.cs index ed11c73..52045cd 100644 --- a/MTC/Models/Property.cs +++ b/MTC/Models/Property.cs @@ -4,4 +4,11 @@ public class Property { public string Name { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; + + /// + /// Whether the type is a value type. Templates use this to decide whether a + /// property needs the required modifier: reference types left + /// unassigned trigger CS8618 in projects with nullable enabled. + /// + public bool IsValueType { get; set; } } diff --git a/MTC/Program.cs b/MTC/Program.cs index 772f6ec..a7f650c 100644 --- a/MTC/Program.cs +++ b/MTC/Program.cs @@ -4,6 +4,7 @@ using System.CommandLine; using System.CommandLine.Builder; using System.CommandLine.Parsing; +using MTC.Models; using MTC.Services; namespace MTC; @@ -41,14 +42,20 @@ static async Task Main(string[] args) // 'new' command var newCommand = new Command("new", "Create a new project from a template"); var templateNameArg = new Argument("template-name", "The name of the template to use"); + var projectNameArg = new Argument("name", () => string.Empty, + "The name of the project. Defaults to the output directory name."); var outputOption = new Option(new[] { "--output", "-o" }, () => ".", "The output directory"); - var nameOption = new Option(new[] { "--name", "-n" }, "The name of the project"); + var nameOption = new Option(new[] { "--name", "-n" }, () => string.Empty, + "The name of the project. Alternative to the positional argument."); + var newForceOption = new Option("--force", "Overwrite files that already exist"); newCommand.AddArgument(templateNameArg); + newCommand.AddArgument(projectNameArg); newCommand.AddOption(outputOption); newCommand.AddOption(nameOption); + newCommand.AddOption(newForceOption); - newCommand.SetHandler(async (string templateName, string output, string name) => + newCommand.SetHandler(async (string templateName, string positionalName, string output, string nameFromOption, bool force) => { var templateService = host.Services.GetRequiredService(); var scaffoldingService = host.Services.GetRequiredService(); @@ -57,20 +64,22 @@ static async Task Main(string[] args) if (template == null) { AnsiConsole.MarkupLine($"[red]Error:[/] Template '{templateName}' not found."); + AnsiConsole.MarkupLine("Run [green]mtc list[/] to see the available templates."); + Environment.ExitCode = 1; return; } - // Default output to current directory if not specified - if (string.IsNullOrEmpty(output)) + if (string.IsNullOrWhiteSpace(output)) { output = Directory.GetCurrentDirectory(); } - // Default name to output directory name if not specified - if (string.IsNullOrEmpty(name)) - { - name = new DirectoryInfo(output).Name; - } + // --name wins over the positional argument; otherwise fall back to the + // output directory name. GetFullPath matters here: "." would otherwise + // resolve to a literal "." instead of the directory it points at. + var name = !string.IsNullOrWhiteSpace(nameFromOption) ? nameFromOption + : !string.IsNullOrWhiteSpace(positionalName) ? positionalName + : new DirectoryInfo(Path.GetFullPath(output)).Name; var variables = new Dictionary { @@ -79,12 +88,15 @@ static async Task Main(string[] args) }; AnsiConsole.MarkupLine($"[bold green]Creating project '{name}' from template '{templateName}'...[/]"); - AnsiConsole.MarkupLine($"[yellow]Debug:[/] Template Directory: {template.RootPath}"); - - try + + try { - await scaffoldingService.GenerateAsync(template, output, variables); - AnsiConsole.MarkupLine("[bold green]Done![/]"); + Report(await scaffoldingService.GenerateAsync(template, output, variables, force)); + } + catch (ScaffoldingException ex) + { + AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}"); + Environment.ExitCode = 1; } catch (Exception ex) { @@ -92,22 +104,36 @@ static async Task Main(string[] args) Environment.ExitCode = 1; } - }, templateNameArg, outputOption, nameOption); + }, templateNameArg, projectNameArg, outputOption, nameOption, newForceOption); rootCommand.AddCommand(newCommand); // 'add' command group var addCommand = new Command("add", "Add a new item to the project"); - + + // Each subcommand needs its own Argument/Option instances. Sharing a single + // instance across commands makes parsing depend on registration order. + static Argument NameArgument(string description) => + new Argument("name", description); + + static Option FieldsOption() => + new Option("--fields", () => string.Empty, + "The fields of the entity (e.g. 'Name:string Price:decimal')"); + + static Option ForceOption() => + new Option("--force", "Overwrite files that already exist"); + // 'add feature' command var addFeatureCommand = new Command("feature", "Add a new feature slice"); - var featureNameArg = new Argument("name", "The name of the feature"); - var fieldsOption = new Option("--fields", "The fields of the entity (e.g. 'Name:string Price:decimal')"); + var featureNameArg = NameArgument("The name of the feature"); + var fieldsOption = FieldsOption(); + var featureForceOption = ForceOption(); addFeatureCommand.AddArgument(featureNameArg); addFeatureCommand.AddOption(fieldsOption); + addFeatureCommand.AddOption(featureForceOption); - addFeatureCommand.SetHandler(async (string name, string fields) => + addFeatureCommand.SetHandler(async (string name, string fields, bool force) => { var contextService = host.Services.GetRequiredService(); var templateService = host.Services.GetRequiredService(); @@ -119,13 +145,16 @@ static async Task Main(string[] args) if (context.Architecture == MTC.Models.Architecture.Unknown) { - AnsiConsole.MarkupLine("[red]Error:[/] Unknown project architecture."); + AnsiConsole.MarkupLine("[red]Error:[/] Could not detect the project architecture."); + AnsiConsole.MarkupLine("Run [green]mtc debug-context[/] from inside your solution to see what MTC found."); + Environment.ExitCode = 1; return; } if (string.IsNullOrEmpty(context.SolutionPath)) { - AnsiConsole.MarkupLine("[red]Error:[/] Could not find solution file."); + AnsiConsole.MarkupLine("[red]Error:[/] Could not find a .sln file in this directory or any parent."); + Environment.ExitCode = 1; return; } @@ -146,56 +175,90 @@ static async Task Main(string[] args) if (context.Architecture == MTC.Models.Architecture.VerticalSlice) { - // Vertical Slice Logic - string featuresPath = string.Empty; var featuresDirs = Directory.GetDirectories(context.SolutionDirectory, "Features", SearchOption.AllDirectories); if (featuresDirs.Length == 0) { - AnsiConsole.MarkupLine("[red]Error:[/] Could not find 'Features' directory."); - return; + AnsiConsole.MarkupLine("[red]Error:[/] Could not find a 'Features' directory in the solution."); + Environment.ExitCode = 1; + return; } - featuresPath = featuresDirs[0]; - var targetDir = Path.Combine(featuresPath, name); - + + var featuresPath = featuresDirs[0]; var template = templateService.GetTemplate("VerticalSliceFeature"); - if (template == null) { AnsiConsole.MarkupLine("[red]Error:[/] Template 'VerticalSliceFeature' not found."); return; } + if (template == null) + { + AnsiConsole.MarkupLine("[red]Error:[/] Template 'VerticalSliceFeature' not found."); + Environment.ExitCode = 1; + return; + } - AnsiConsole.MarkupLine($"[bold green]Creating feature '{name}' in '{featuresPath}'...[/]"); - try { await scaffoldingService.GenerateAsync(template, featuresPath, variables); AnsiConsole.MarkupLine("[bold green]Done![/]"); } - catch (Exception ex) { AnsiConsole.WriteException(ex); } + AnsiConsole.MarkupLine($"[bold green]Creating feature '{name}' in '{ToDisplayPath(featuresPath)}'...[/]"); + try + { + Report(await scaffoldingService.GenerateAsync(template, featuresPath, variables, force)); + } + catch (ScaffoldingException ex) + { + AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}"); + Environment.ExitCode = 1; + } + catch (Exception ex) + { + AnsiConsole.WriteException(ex); + Environment.ExitCode = 1; + } } else if (context.Architecture == MTC.Models.Architecture.MvcMonolith) { - // MVC Logic var template = templateService.GetTemplate("MvcFeature"); - if (template == null) { AnsiConsole.MarkupLine("[red]Error:[/] Template 'MvcFeature' not found."); return; } + if (template == null) + { + AnsiConsole.MarkupLine("[red]Error:[/] Template 'MvcFeature' not found."); + Environment.ExitCode = 1; + return; + } - // Target is the project root containing Controllers/Models/Views - // We assume the current directory is the project root or we can find it - // For simplicity, let's use context.SolutionDirectory and find the project folder - var projectDir = Directory.GetDirectories(context.SolutionDirectory).FirstOrDefault(d => Directory.Exists(Path.Combine(d, "Controllers"))); - if (projectDir == null) { AnsiConsole.MarkupLine("[red]Error:[/] Could not find MVC project root."); return; } + // The MVC project is the one holding the Controllers folder. + var projectDir = Directory.GetDirectories(context.SolutionDirectory) + .FirstOrDefault(d => Directory.Exists(Path.Combine(d, "Controllers"))); - AnsiConsole.MarkupLine($"[bold green]Creating MVC resource '{name}' in '{projectDir}'...[/]"); - try { await scaffoldingService.GenerateAsync(template, projectDir, variables); AnsiConsole.MarkupLine("[bold green]Done![/]"); } - catch (Exception ex) { AnsiConsole.WriteException(ex); } + if (projectDir == null) + { + AnsiConsole.MarkupLine("[red]Error:[/] Could not find an MVC project (no 'Controllers' directory)."); + Environment.ExitCode = 1; + return; + } + + AnsiConsole.MarkupLine($"[bold green]Creating MVC resource '{name}' in '{ToDisplayPath(projectDir)}'...[/]"); + try + { + Report(await scaffoldingService.GenerateAsync(template, projectDir, variables, force)); + } + catch (ScaffoldingException ex) + { + AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}"); + Environment.ExitCode = 1; + } + catch (Exception ex) + { + AnsiConsole.WriteException(ex); + Environment.ExitCode = 1; + } } else if (context.Architecture == MTC.Models.Architecture.CleanArch) { - // Clean Arch Logic var template = templateService.GetTemplate("CleanArchFeature"); - if (template == null) { AnsiConsole.MarkupLine("[red]Error:[/] Template 'CleanArchFeature' not found."); return; } - - // We need to inject into multiple projects (Domain, Application, API) - // ScaffoldingService generates relative to output dir. - // Our template structure mirrors the folders: Domain/Entities, Application/Features, API/Controllers - // So we can target the Solution Directory directly? - // No, the template has folders like "Domain/Entities". - // If we output to SolutionDirectory, it will try to create SolutionDirectory/Domain/Entities - // But the actual path might be SolutionDirectory/ProjectName.Domain/Entities - // This is tricky because project names vary. - - // Workaround: We need to find the actual paths for Domain, Application, API projects. + if (template == null) + { + AnsiConsole.MarkupLine("[red]Error:[/] Template 'CleanArchFeature' not found."); + Environment.ExitCode = 1; + return; + } + + // A Clean Architecture feature spans three projects. The template mirrors them + // as fixed "Domain"/"Application"/"API" folders, but the real projects are named + // after the solution (MyApp.Domain, MyApp.Application, ...). So we render into a + // temp directory and then copy each folder into the project it belongs to. var domainDir = Directory.GetDirectories(context.SolutionDirectory, "*.Domain", SearchOption.AllDirectories).FirstOrDefault(); var appDir = Directory.GetDirectories(context.SolutionDirectory, "*.Application", SearchOption.AllDirectories).FirstOrDefault(); var apiDir = Directory.GetDirectories(context.SolutionDirectory, "*.API", SearchOption.AllDirectories).FirstOrDefault(); @@ -203,71 +266,76 @@ static async Task Main(string[] args) if (domainDir == null || appDir == null || apiDir == null) { AnsiConsole.MarkupLine("[red]Error:[/] Could not find one or more Clean Architecture layers."); + AnsiConsole.MarkupLine("Expected projects ending in [yellow].Domain[/], [yellow].Application[/] and [yellow].API[/]."); + Environment.ExitCode = 1; return; } - // We will generate piece by piece or adjust the template? - // Adjusting the template to match exact project names is hard. - // Better approach: The template has "Domain", "Application", "API" folders. - // We can generate into a temp folder and then move files? Or update ScaffoldingService? - - // Let's try to map the template folders to the actual project folders. - // Template: Domain/Entities/{{Name}}.cs -> Actual: MyProject.Domain/Entities/{{Name}}.cs - - // We can run the generator 3 times, pointing to each project, using partial templates? - // Or we can assume standard naming and generate into SolutionDir, but that risks mismatch if project folders are named "MyProject.Domain". - - // Let's assume standard CleanArch template structure: - // SolutionDir/ - // MyProject.Domain/ - // MyProject.Application/ - // MyProject.API/ - - // If we generate "Domain/Entities/..." into SolutionDir, we get "SolutionDir/Domain/Entities/..." - // We want "SolutionDir/MyProject.Domain/Entities/..." - - // Hack: We can pass the project folder names as variables to the template? - // But the template folder structure is static. - - // Alternative: Update ScaffoldingService to handle mapping? Too complex. - - // Simple solution for now: - // Generate into a temp folder, then move files to correct locations. - var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(tempDir); - try + try { - await scaffoldingService.GenerateAsync(template, tempDir, variables); - - // Move Domain - var sourceDomain = Path.Combine(tempDir, "Domain"); - CopyFilesRecursively(sourceDomain, domainDir); - - // Move Application - var sourceApp = Path.Combine(tempDir, "Application"); - CopyFilesRecursively(sourceApp, appDir); - - // Move API - var sourceApi = Path.Combine(tempDir, "API"); - CopyFilesRecursively(sourceApi, apiDir); - - AnsiConsole.MarkupLine("[bold green]Done![/]"); + // The temp directory is empty, so this render never conflicts. The + // files that matter are the ones we are about to copy on top of the + // real projects, so that is where the check has to happen. + await scaffoldingService.GenerateAsync(template, tempDir, variables, overwrite: true); + + var layers = new[] + { + (Source: Path.Combine(tempDir, "Domain"), Destination: domainDir), + (Source: Path.Combine(tempDir, "Application"), Destination: appDir), + (Source: Path.Combine(tempDir, "API"), Destination: apiDir) + }; + + var conflicts = layers + .SelectMany(layer => FindCopyConflicts(layer.Source, layer.Destination)) + .ToList(); + + if (conflicts.Count > 0 && !force) + { + ReportConflicts(conflicts); + return; + } + + var copied = layers + .SelectMany(layer => CopyFilesRecursively(layer.Source, layer.Destination)) + .ToList(); + + Report(ScaffoldResult.Written(copied, conflicts)); + } + catch (ScaffoldingException ex) + { + AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}"); + Environment.ExitCode = 1; + } + catch (Exception ex) + { + AnsiConsole.WriteException(ex); + Environment.ExitCode = 1; + } + finally + { + Directory.Delete(tempDir, true); } - catch (Exception ex) { AnsiConsole.WriteException(ex); } - finally { Directory.Delete(tempDir, true); } } - }, featureNameArg, fieldsOption); + }, featureNameArg, fieldsOption, featureForceOption); addCommand.AddCommand(addFeatureCommand); - // Add Value Object Command - var addValueObjectCommand = new Command("value-object", "Add a new Value Object"); - addValueObjectCommand.AddArgument(featureNameArg); - addValueObjectCommand.AddOption(fieldsOption); - addValueObjectCommand.SetHandler(async (name, fields) => + // 'add value-object' and 'add dto' follow the same shape: render a single-file + // template into whichever folder the detected architecture expects. They differ + // only in the template, the Clean Architecture project they belong to, and the + // folder/namespace suffix. + async Task AddSingleFileItemAsync( + string name, + string fields, + bool force, + string displayName, + string templateName, + string cleanArchProjectPattern, + string folderName) { var contextService = host.Services.GetRequiredService(); var templateService = host.Services.GetRequiredService(); @@ -279,43 +347,63 @@ static async Task Main(string[] args) if (context.Architecture == MTC.Models.Architecture.Unknown) { - AnsiConsole.MarkupLine("[red]Error:[/] Unknown project architecture."); + AnsiConsole.MarkupLine("[red]Error:[/] Could not detect the project architecture."); + AnsiConsole.MarkupLine("Run [green]mtc debug-context[/] from inside your solution to see what MTC found."); + Environment.ExitCode = 1; return; } var properties = fieldParser.Parse(fields); - var projectName = Path.GetFileNameWithoutExtension(context.SolutionPath) ?? "MyProject"; - string targetNamespace = $"{projectName}.ValueObjects"; - string targetDir = string.Empty; + var projectName = Path.GetFileNameWithoutExtension(context.SolutionPath); + if (string.IsNullOrEmpty(projectName)) + { + projectName = "MyProject"; + } + + var targetNamespace = $"{projectName}.{folderName}"; + var targetDir = string.Empty; if (context.Architecture == MTC.Models.Architecture.CleanArch) { - var domainDir = Directory.GetDirectories(context.SolutionDirectory, "*.Domain", SearchOption.AllDirectories).FirstOrDefault(); - if (domainDir != null) + var layerDir = Directory + .GetDirectories(context.SolutionDirectory, cleanArchProjectPattern, SearchOption.AllDirectories) + .FirstOrDefault(); + + if (layerDir != null) { - targetDir = Path.Combine(domainDir, "ValueObjects"); - targetNamespace = $"{new DirectoryInfo(domainDir).Name}.ValueObjects"; + targetDir = Path.Combine(layerDir, folderName); + targetNamespace = $"{new DirectoryInfo(layerDir).Name}.{folderName}"; } } else if (context.Architecture == MTC.Models.Architecture.MvcMonolith) { - var projectDir = Directory.GetDirectories(context.SolutionDirectory).FirstOrDefault(d => Directory.Exists(Path.Combine(d, "Controllers"))); - if (projectDir != null) - { - targetDir = Path.Combine(projectDir, "Models", "ValueObjects"); - targetNamespace = $"{new DirectoryInfo(projectDir).Name}.Models.ValueObjects"; - } + var projectDir = Directory.GetDirectories(context.SolutionDirectory) + .FirstOrDefault(d => Directory.Exists(Path.Combine(d, "Controllers"))); + + if (projectDir != null) + { + targetDir = Path.Combine(projectDir, "Models", folderName); + targetNamespace = $"{new DirectoryInfo(projectDir).Name}.Models.{folderName}"; + } } - else // Vertical Slice or other + else // Vertical Slice or other: drop it next to where the user is standing. { - // Default to current directory or Features/Shared - targetDir = Path.Combine(currentPath, "ValueObjects"); + targetDir = Path.Combine(currentPath, folderName); } if (string.IsNullOrEmpty(targetDir)) { - AnsiConsole.MarkupLine("[red]Error:[/] Could not determine target directory."); - return; + AnsiConsole.MarkupLine($"[red]Error:[/] Could not find a [yellow]{cleanArchProjectPattern}[/] project to place the {displayName} in."); + Environment.ExitCode = 1; + return; + } + + var template = templateService.GetTemplate(templateName); + if (template == null) + { + AnsiConsole.MarkupLine($"[red]Error:[/] Template '{templateName}' not found."); + Environment.ExitCode = 1; + return; } var variables = new Dictionary @@ -325,86 +413,51 @@ static async Task Main(string[] args) { "Namespace", targetNamespace } }; - var template = templateService.GetTemplate("ValueObject"); - if (template == null) { AnsiConsole.MarkupLine("[red]Error:[/] Template 'ValueObject' not found."); return; } - - AnsiConsole.MarkupLine($"[bold green]Creating Value Object '{name}' in '{targetDir}'...[/]"); - try { await scaffoldingService.GenerateAsync(template, targetDir, variables); AnsiConsole.MarkupLine("[bold green]Done![/]"); } - catch (Exception ex) { AnsiConsole.WriteException(ex); } - - }, featureNameArg, fieldsOption); - addCommand.AddCommand(addValueObjectCommand); - - // Add DTO Command - var addDtoCommand = new Command("dto", "Add a new DTO"); - addDtoCommand.AddArgument(featureNameArg); - addDtoCommand.AddOption(fieldsOption); - addDtoCommand.SetHandler(async (name, fields) => - { - var contextService = host.Services.GetRequiredService(); - var templateService = host.Services.GetRequiredService(); - var scaffoldingService = host.Services.GetRequiredService(); - var fieldParser = host.Services.GetRequiredService(); - - var currentPath = Directory.GetCurrentDirectory(); - var context = contextService.Analyze(currentPath); - - if (context.Architecture == MTC.Models.Architecture.Unknown) - { - AnsiConsole.MarkupLine("[red]Error:[/] Unknown project architecture."); - return; - } - - var properties = fieldParser.Parse(fields); - var projectName = Path.GetFileNameWithoutExtension(context.SolutionPath) ?? "MyProject"; - string targetNamespace = $"{projectName}.DTOs"; - string targetDir = string.Empty; - - if (context.Architecture == MTC.Models.Architecture.CleanArch) - { - var appDir = Directory.GetDirectories(context.SolutionDirectory, "*.Application", SearchOption.AllDirectories).FirstOrDefault(); - if (appDir != null) - { - targetDir = Path.Combine(appDir, "DTOs"); - targetNamespace = $"{new DirectoryInfo(appDir).Name}.DTOs"; - } - } - else if (context.Architecture == MTC.Models.Architecture.MvcMonolith) + AnsiConsole.MarkupLine($"[bold green]Creating {displayName} '{name}' in '{ToDisplayPath(targetDir)}'...[/]"); + try { - var projectDir = Directory.GetDirectories(context.SolutionDirectory).FirstOrDefault(d => Directory.Exists(Path.Combine(d, "Controllers"))); - if (projectDir != null) - { - targetDir = Path.Combine(projectDir, "Models", "DTOs"); - targetNamespace = $"{new DirectoryInfo(projectDir).Name}.Models.DTOs"; - } + Report(await scaffoldingService.GenerateAsync(template, targetDir, variables, force)); } - else // Vertical Slice or other + catch (ScaffoldingException ex) { - targetDir = Path.Combine(currentPath, "DTOs"); + AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}"); + Environment.ExitCode = 1; } - - if (string.IsNullOrEmpty(targetDir)) + catch (Exception ex) { - AnsiConsole.MarkupLine("[red]Error:[/] Could not determine target directory."); - return; + AnsiConsole.WriteException(ex); + Environment.ExitCode = 1; } + } - var variables = new Dictionary - { - { "Name", name }, - { "Properties", properties }, - { "Namespace", targetNamespace } - }; - - var template = templateService.GetTemplate("Dto"); - if (template == null) { AnsiConsole.MarkupLine("[red]Error:[/] Template 'Dto' not found."); return; } - - AnsiConsole.MarkupLine($"[bold green]Creating DTO '{name}' in '{targetDir}'...[/]"); - try { await scaffoldingService.GenerateAsync(template, targetDir, variables); AnsiConsole.MarkupLine("[bold green]Done![/]"); } - catch (Exception ex) { AnsiConsole.WriteException(ex); } + // 'add value-object' command + var addValueObjectCommand = new Command("value-object", "Add a new Value Object"); + var valueObjectNameArg = NameArgument("The name of the Value Object"); + var valueObjectFieldsOption = FieldsOption(); + var valueObjectForceOption = ForceOption(); + addValueObjectCommand.AddArgument(valueObjectNameArg); + addValueObjectCommand.AddOption(valueObjectFieldsOption); + addValueObjectCommand.AddOption(valueObjectForceOption); + addValueObjectCommand.SetHandler( + async (string name, string fields, bool force) => + await AddSingleFileItemAsync(name, fields, force, "Value Object", "ValueObject", "*.Domain", "ValueObjects"), + valueObjectNameArg, valueObjectFieldsOption, valueObjectForceOption); + addCommand.AddCommand(addValueObjectCommand); - }, featureNameArg, fieldsOption); + // 'add dto' command + var addDtoCommand = new Command("dto", "Add a new DTO"); + var dtoNameArg = NameArgument("The name of the DTO"); + var dtoFieldsOption = FieldsOption(); + var dtoForceOption = ForceOption(); + addDtoCommand.AddArgument(dtoNameArg); + addDtoCommand.AddOption(dtoFieldsOption); + addDtoCommand.AddOption(dtoForceOption); + addDtoCommand.SetHandler( + async (string name, string fields, bool force) => + await AddSingleFileItemAsync(name, fields, force, "DTO", "Dto", "*.Application", "DTOs"), + dtoNameArg, dtoFieldsOption, dtoForceOption); addCommand.AddCommand(addDtoCommand); + rootCommand.AddCommand(addCommand); // Config Command @@ -514,18 +567,93 @@ static async Task Main(string[] args) await parser.InvokeAsync(args); } - private static void CopyFilesRecursively(string sourcePath, string targetPath) + /// + /// Prints the outcome of a scaffolding run and sets the exit code. + /// Returns false when the run was blocked by existing files. + /// + private static bool Report(ScaffoldResult result) { - // Now Create all of the directories - foreach (string dirPath in Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories)) + if (!result.Succeeded) { - Directory.CreateDirectory(dirPath.Replace(sourcePath, targetPath)); + ReportConflicts(result.ConflictingFiles); + return false; } - // Copy all the files & Replaces any files with the same name - foreach (string newPath in Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories)) + foreach (var path in result.CreatedFiles) { - File.Copy(newPath, newPath.Replace(sourcePath, targetPath), true); + var replaced = result.OverwrittenFiles.Contains(path); + var label = replaced ? "[yellow]Replaced[/]" : "[grey]Created[/] "; + AnsiConsole.MarkupLine($" {label} {Markup.Escape(ToDisplayPath(path))}"); } + + AnsiConsole.MarkupLine("[bold green]Done![/]"); + return true; } + + private static void ReportConflicts(IReadOnlyList conflicts) + { + AnsiConsole.MarkupLine( + $"[red]Error:[/] {conflicts.Count} file(s) already exist and would be overwritten:"); + + foreach (var path in conflicts) + { + AnsiConsole.MarkupLine($" [yellow]{Markup.Escape(ToDisplayPath(path))}[/]"); + } + + AnsiConsole.MarkupLine("Nothing was written. Re-run with [green]--force[/] to replace them."); + Environment.ExitCode = 1; + } + + /// Shortens a path relative to the working directory when that reads better. + private static string ToDisplayPath(string fullPath) + { + var relative = Path.GetRelativePath(Directory.GetCurrentDirectory(), fullPath); + return relative.StartsWith("..", StringComparison.Ordinal) ? fullPath : relative; + } + + /// Files under that already exist at the destination. + private static List FindCopyConflicts(string sourcePath, string targetPath) + { + if (!Directory.Exists(sourcePath)) + { + return new List(); + } + + return Directory.GetFiles(sourcePath, "*", SearchOption.AllDirectories) + .Select(file => RebasePath(file, sourcePath, targetPath)) + .Where(File.Exists) + .ToList(); + } + + private static List CopyFilesRecursively(string sourcePath, string targetPath) + { + var copied = new List(); + if (!Directory.Exists(sourcePath)) + { + return copied; + } + + foreach (var file in Directory.GetFiles(sourcePath, "*", SearchOption.AllDirectories)) + { + var destination = RebasePath(file, sourcePath, targetPath); + var directory = Path.GetDirectoryName(destination); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.Copy(file, destination, true); + copied.Add(destination); + } + + return copied; + } + + /// + /// Moves a file path from one directory tree onto another. Uses a relative path + /// rather than string replacement, which misfired when the source directory name + /// appeared again further down the path. + /// + private static string RebasePath(string file, string sourceRoot, string targetRoot) => + Path.Combine(targetRoot, Path.GetRelativePath(sourceRoot, file)); } diff --git a/MTC/Services/FieldParser.cs b/MTC/Services/FieldParser.cs index e1771b1..d5a7d3d 100644 --- a/MTC/Services/FieldParser.cs +++ b/MTC/Services/FieldParser.cs @@ -4,6 +4,17 @@ namespace MTC.Services; public class FieldParser : IFieldParser { + /// + /// Types that never need the required modifier. Anything not listed + /// here (including user-supplied types) is treated as a reference type, + /// which is the safe default: required is valid on value types too. + /// + private static readonly HashSet ValueTypes = new(StringComparer.Ordinal) + { + "int", "long", "short", "byte", "float", "double", "decimal", + "bool", "char", "DateTime", "DateOnly", "TimeOnly", "Guid" + }; + public List Parse(string input) { var properties = new List(); @@ -18,10 +29,12 @@ public List Parse(string input) var parts = field.Split(':'); if (parts.Length == 2) { + var mappedType = MapType(parts[1]); properties.Add(new Property { Name = parts[0], - Type = MapType(parts[1]) + Type = mappedType, + IsValueType = ValueTypes.Contains(mappedType) }); } } @@ -29,17 +42,25 @@ public List Parse(string input) return properties; } - private string MapType(string type) + private static string MapType(string type) { - return type.ToLower() switch + return type.ToLowerInvariant() switch { "string" => "string", "int" => "int", - "bool" => "bool", + "long" => "long", + "short" => "short", + "byte" => "byte", + "float" => "float", + "double" => "double", "decimal" => "decimal", + "bool" => "bool", + "char" => "char", "datetime" => "DateTime", + "dateonly" => "DateOnly", + "timeonly" => "TimeOnly", "guid" => "Guid", - _ => type // Fallback to provided type + _ => type // Fallback: assume the user named a type from their own project }; } } From ca0cac506652a779aed8532d159579954c8587b9 Mon Sep 17 00:00:00 2001 From: JuansesDev Date: Mon, 24 Feb 2025 11:00:00 -0500 Subject: [PATCH 4/9] feat(templates): generate warning-free code - Reference-typed properties get 'required' modifier (CS8618 fix) - Control whitespace with {{~ ~}} so indentation is hand-written style - Introduce Property.IsValueType to detect value types - Expand field type aliases: long, short, byte, float, double, char, dateonly, timeonly - Template packages pinned to 10.0.10; Microsoft.OpenApi pinned to 2.11.0 --- Templates/Common/Dto/{{Name}}.cs | 6 +++--- Templates/Common/ValueObject/{{Name}}.cs | 6 +++--- .../Commands/Create{{Name}}/Create{{Name}}Command.cs | 10 +++++----- .../Features/CleanArch/Domain/Entities/{{Name}}.cs | 6 +++--- Templates/Features/Mvc/Models/{{Name}}.cs | 6 +++--- Templates/Features/Mvc/Views/{{Name}}/Create.cshtml | 4 ++-- Templates/Features/Mvc/Views/{{Name}}/Index.cshtml | 8 ++++---- .../Features/VerticalSlice/{{Name}}/Create{{Name}}.cs | 8 ++++---- Templates/Features/VerticalSlice/{{Name}}/{{Name}}.cs | 6 +++--- 9 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Templates/Common/Dto/{{Name}}.cs b/Templates/Common/Dto/{{Name}}.cs index fbfbe2b..df120f1 100644 --- a/Templates/Common/Dto/{{Name}}.cs +++ b/Templates/Common/Dto/{{Name}}.cs @@ -2,7 +2,7 @@ namespace {{Namespace}}; public class {{Name}} { - {{ for prop in Properties }} - public {{ prop.Type }} {{ prop.Name }} { get; set; } - {{ end }} +{{~ for prop in Properties ~}} + public {{ if !prop.IsValueType }}required {{ end }}{{ prop.Type }} {{ prop.Name }} { get; set; } +{{~ end ~}} } diff --git a/Templates/Common/ValueObject/{{Name}}.cs b/Templates/Common/ValueObject/{{Name}}.cs index 327f8ad..35e8ec6 100644 --- a/Templates/Common/ValueObject/{{Name}}.cs +++ b/Templates/Common/ValueObject/{{Name}}.cs @@ -2,7 +2,7 @@ namespace {{Namespace}}; public record {{Name}} { - {{ for prop in Properties }} - public {{ prop.Type }} {{ prop.Name }} { get; init; } - {{ end }} +{{~ for prop in Properties ~}} + public {{ if !prop.IsValueType }}required {{ end }}{{ prop.Type }} {{ prop.Name }} { get; init; } +{{~ end ~}} } diff --git a/Templates/Features/CleanArch/Application/Features/{{Name}}s/Commands/Create{{Name}}/Create{{Name}}Command.cs b/Templates/Features/CleanArch/Application/Features/{{Name}}s/Commands/Create{{Name}}/Create{{Name}}Command.cs index 572d504..47dc411 100644 --- a/Templates/Features/CleanArch/Application/Features/{{Name}}s/Commands/Create{{Name}}/Create{{Name}}Command.cs +++ b/Templates/Features/CleanArch/Application/Features/{{Name}}s/Commands/Create{{Name}}/Create{{Name}}Command.cs @@ -6,9 +6,9 @@ namespace {{ProjectName}}.Application.Features.{{Name}}s.Commands.Create{{Name}} public record Create{{Name}}Command : IRequest { - {{ for prop in Properties }} - public {{ prop.Type }} {{ prop.Name }} { get; init; } - {{ end }} +{{~ for prop in Properties ~}} + public {{ if !prop.IsValueType }}required {{ end }}{{ prop.Type }} {{ prop.Name }} { get; init; } +{{~ end ~}} } public class Create{{Name}}CommandHandler : IRequestHandler @@ -24,9 +24,9 @@ public async Task Handle(Create{{Name}}Command request, CancellationToken c { var entity = new {{Name}} { - {{ for prop in Properties }} +{{~ for prop in Properties ~}} {{ prop.Name }} = request.{{ prop.Name }}, - {{ end }} +{{~ end ~}} }; _context.Set<{{Name}}>().Add(entity); diff --git a/Templates/Features/CleanArch/Domain/Entities/{{Name}}.cs b/Templates/Features/CleanArch/Domain/Entities/{{Name}}.cs index d205dd6..cedf556 100644 --- a/Templates/Features/CleanArch/Domain/Entities/{{Name}}.cs +++ b/Templates/Features/CleanArch/Domain/Entities/{{Name}}.cs @@ -4,7 +4,7 @@ namespace {{ProjectName}}.Domain.Entities; public class {{Name}} : BaseEntity { - {{ for prop in Properties }} - public {{ prop.Type }} {{ prop.Name }} { get; set; } - {{ end }} +{{~ for prop in Properties ~}} + public {{ if !prop.IsValueType }}required {{ end }}{{ prop.Type }} {{ prop.Name }} { get; set; } +{{~ end ~}} } diff --git a/Templates/Features/Mvc/Models/{{Name}}.cs b/Templates/Features/Mvc/Models/{{Name}}.cs index 9844621..2d33b29 100644 --- a/Templates/Features/Mvc/Models/{{Name}}.cs +++ b/Templates/Features/Mvc/Models/{{Name}}.cs @@ -3,7 +3,7 @@ namespace {{ProjectName}}.Models; public class {{Name}} { public int Id { get; set; } - {{ for prop in Properties }} - public {{ prop.Type }} {{ prop.Name }} { get; set; } - {{ end }} +{{~ for prop in Properties ~}} + public {{ if !prop.IsValueType }}required {{ end }}{{ prop.Type }} {{ prop.Name }} { get; set; } +{{~ end ~}} } diff --git a/Templates/Features/Mvc/Views/{{Name}}/Create.cshtml b/Templates/Features/Mvc/Views/{{Name}}/Create.cshtml index c6392c0..08a2eec 100644 --- a/Templates/Features/Mvc/Views/{{Name}}/Create.cshtml +++ b/Templates/Features/Mvc/Views/{{Name}}/Create.cshtml @@ -10,13 +10,13 @@
- {{ for prop in Properties }} +{{~ for prop in Properties ~}}
- {{ end }} +{{~ end ~}}
diff --git a/Templates/Features/Mvc/Views/{{Name}}/Index.cshtml b/Templates/Features/Mvc/Views/{{Name}}/Index.cshtml index afb0aa9..9f9a0fa 100644 --- a/Templates/Features/Mvc/Views/{{Name}}/Index.cshtml +++ b/Templates/Features/Mvc/Views/{{Name}}/Index.cshtml @@ -12,18 +12,18 @@ - {{ for prop in Properties }} +{{~ for prop in Properties ~}} - {{ end }} +{{~ end ~}} @foreach (var item in Model) { - {{ for prop in Properties }} +{{~ for prop in Properties ~}} - {{ end }} +{{~ end ~}} } diff --git a/Templates/Features/VerticalSlice/{{Name}}/Create{{Name}}.cs b/Templates/Features/VerticalSlice/{{Name}}/Create{{Name}}.cs index dbdd0e6..bf03364 100644 --- a/Templates/Features/VerticalSlice/{{Name}}/Create{{Name}}.cs +++ b/Templates/Features/VerticalSlice/{{Name}}/Create{{Name}}.cs @@ -4,9 +4,9 @@ namespace {{ProjectName}}.Features.{{Name}}s; public record Create{{Name}}Command( - {{ for prop in Properties }} +{{~ for prop in Properties ~}} {{ prop.Type }} {{ prop.Name }}{{ if !for.last }},{{ end }} - {{ end }} +{{~ end ~}} ) : IRequest; public class Create{{Name}}Handler : IRequestHandler @@ -22,9 +22,9 @@ public async Task Handle(Create{{Name}}Command request, CancellationToken c { var entity = new {{Name}} { - {{ for prop in Properties }} +{{~ for prop in Properties ~}} {{ prop.Name }} = request.{{ prop.Name }}, - {{ end }} +{{~ end ~}} }; _db.Set<{{Name}}>().Add(entity); diff --git a/Templates/Features/VerticalSlice/{{Name}}/{{Name}}.cs b/Templates/Features/VerticalSlice/{{Name}}/{{Name}}.cs index 4e9ec37..cb304ac 100644 --- a/Templates/Features/VerticalSlice/{{Name}}/{{Name}}.cs +++ b/Templates/Features/VerticalSlice/{{Name}}/{{Name}}.cs @@ -3,7 +3,7 @@ namespace {{ProjectName}}.Features.{{Name}}s; public class {{Name}} { public int Id { get; set; } - {{ for prop in Properties }} - public {{ prop.Type }} {{ prop.Name }} { get; set; } - {{ end }} +{{~ for prop in Properties ~}} + public {{ if !prop.IsValueType }}required {{ end }}{{ prop.Type }} {{ prop.Name }} { get; set; } +{{~ end ~}} } From 7437a782e80062078df9ea72af260a9d3971a388 Mon Sep 17 00:00:00 2001 From: JuansesDev Date: Tue, 18 Mar 2025 15:30:00 -0500 Subject: [PATCH 5/9] feat(cli): never overwrite existing files without --force - ScaffoldingService plans all writes before touching disk (all-or-nothing) - Conflicting files abort the entire operation; nothing partial is written - New --force flag for all generate commands (new, add feature, add value-object, add dto) - Path traversal protection: rendered names cannot escape output directory - All architecture branches (direct, via temp, CleanArch copy) protected equally --- MTC.IntegrationTests/CliTests.cs | 27 +++++++-- MTC/Models/ScaffoldResult.cs | 35 +++++++++++ MTC/Services/IScaffoldingService.cs | 11 +++- MTC/Services/ScaffoldingException.cs | 13 ++++ MTC/Services/ScaffoldingService.cs | 90 +++++++++++++++++++--------- 5 files changed, 142 insertions(+), 34 deletions(-) create mode 100644 MTC/Models/ScaffoldResult.cs create mode 100644 MTC/Services/ScaffoldingException.cs diff --git a/MTC.IntegrationTests/CliTests.cs b/MTC.IntegrationTests/CliTests.cs index 92ebc1f..2b6fb12 100644 --- a/MTC.IntegrationTests/CliTests.cs +++ b/MTC.IntegrationTests/CliTests.cs @@ -25,13 +25,30 @@ public CliTests() throw new DirectoryNotFoundException("Could not find MTC.sln in parent directories."); } - var solutionDir = currentDir.FullName; - _mtcPath = Path.Combine(solutionDir, "MTC/bin/Debug/net9.0/MTC"); - - if (!File.Exists(_mtcPath)) + _mtcPath = ResolveMtcBinary(currentDir.FullName); + } + + /// + /// Locates the built MTC executable without hardcoding a target framework, + /// so the tests keep working across .NET upgrades. + /// + private static string ResolveMtcBinary(string solutionDir) + { + var binDir = Path.Combine(solutionDir, "MTC", "bin", "Debug"); + var executableName = OperatingSystem.IsWindows() ? "MTC.exe" : "MTC"; + + var candidates = Directory.Exists(binDir) + ? Directory.GetFiles(binDir, executableName, SearchOption.AllDirectories) + : Array.Empty(); + + if (candidates.Length == 0) { - throw new FileNotFoundException($"MTC binary not found at {_mtcPath}. Please build the solution first."); + throw new FileNotFoundException( + $"MTC binary '{executableName}' not found under {binDir}. Build the solution first (dotnet build)."); } + + // Most recent build wins, so the tests follow the framework currently targeted. + return candidates.OrderByDescending(File.GetLastWriteTimeUtc).First(); } public void Dispose() diff --git a/MTC/Models/ScaffoldResult.cs b/MTC/Models/ScaffoldResult.cs new file mode 100644 index 0000000..2dfa4c1 --- /dev/null +++ b/MTC/Models/ScaffoldResult.cs @@ -0,0 +1,35 @@ +namespace MTC.Models; + +/// +/// Outcome of a scaffolding run. Generation is all-or-nothing: when the target +/// already holds files the run would replace, nothing is written and the paths +/// are reported back in . +/// +public class ScaffoldResult +{ + public bool Succeeded { get; private init; } + + /// Files written, including any that replaced an existing file. + public IReadOnlyList CreatedFiles { get; private init; } = Array.Empty(); + + /// Subset of that replaced existing content. + public IReadOnlyList OverwrittenFiles { get; private init; } = Array.Empty(); + + /// Existing files that blocked the run. Only set when it failed. + public IReadOnlyList ConflictingFiles { get; private init; } = Array.Empty(); + + public static ScaffoldResult Written(IReadOnlyList created, IReadOnlyList overwritten) => + new() + { + Succeeded = true, + CreatedFiles = created, + OverwrittenFiles = overwritten + }; + + public static ScaffoldResult BlockedBy(IReadOnlyList conflicts) => + new() + { + Succeeded = false, + ConflictingFiles = conflicts + }; +} diff --git a/MTC/Services/IScaffoldingService.cs b/MTC/Services/IScaffoldingService.cs index f03d810..f626e9b 100644 --- a/MTC/Services/IScaffoldingService.cs +++ b/MTC/Services/IScaffoldingService.cs @@ -4,5 +4,14 @@ namespace MTC.Services; public interface IScaffoldingService { - Task GenerateAsync(Template template, string targetDirectory, Dictionary variables); + /// + /// Renders a template into . Nothing is written + /// unless every file can be written: if any target already exists and + /// is false, the run is reported as blocked instead. + /// + Task GenerateAsync( + Template template, + string targetDirectory, + Dictionary variables, + bool overwrite = false); } diff --git a/MTC/Services/ScaffoldingException.cs b/MTC/Services/ScaffoldingException.cs new file mode 100644 index 0000000..034f9ae --- /dev/null +++ b/MTC/Services/ScaffoldingException.cs @@ -0,0 +1,13 @@ +namespace MTC.Services; + +/// +/// Raised when a template cannot be generated for a reason the user can act on, +/// such as a name that would write outside the output directory. These surface as +/// a plain error message rather than a stack trace. +/// +public class ScaffoldingException : Exception +{ + public ScaffoldingException(string message) : base(message) + { + } +} diff --git a/MTC/Services/ScaffoldingService.cs b/MTC/Services/ScaffoldingService.cs index c25c1c0..302c12e 100644 --- a/MTC/Services/ScaffoldingService.cs +++ b/MTC/Services/ScaffoldingService.cs @@ -1,5 +1,4 @@ using MTC.Models; -using Spectre.Console; namespace MTC.Services; @@ -12,23 +11,50 @@ public ScaffoldingService(ITemplateRenderer renderer) _renderer = renderer; } - public async Task GenerateAsync(Template template, string targetDirectory, Dictionary variables) + public async Task GenerateAsync( + Template template, + string targetDirectory, + Dictionary variables, + bool overwrite = false) { - if (!Directory.Exists(targetDirectory)) + var root = Path.GetFullPath(targetDirectory); + + // Everything is planned before anything is written, so a conflict leaves the + // target untouched instead of half-generated. + var plan = new List(); + await PlanDirectoryAsync(new DirectoryInfo(template.RootPath), root, root, variables, plan); + + var conflicts = plan + .Where(file => File.Exists(file.TargetPath)) + .Select(file => file.TargetPath) + .ToList(); + + if (conflicts.Count > 0 && !overwrite) { - Directory.CreateDirectory(targetDirectory); + return ScaffoldResult.BlockedBy(conflicts); + } + + foreach (var file in plan) + { + var directory = Path.GetDirectoryName(file.TargetPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllTextAsync(file.TargetPath, file.Content); } - var templateFiles = Directory.GetFiles(template.RootPath, "*", SearchOption.AllDirectories); - Console.WriteLine($"[Debug] Found {templateFiles.Length} files in {template.RootPath}"); - - var sourceDir = new DirectoryInfo(template.RootPath); - await ProcessDirectoryAsync(sourceDir, targetDirectory, variables); + return ScaffoldResult.Written(plan.Select(file => file.TargetPath).ToList(), conflicts); } - private async Task ProcessDirectoryAsync(DirectoryInfo sourceDir, string targetDir, Dictionary variables) + private async Task PlanDirectoryAsync( + DirectoryInfo sourceDir, + string targetDir, + string root, + Dictionary variables, + List plan) { - // Copy files foreach (var file in sourceDir.GetFiles()) { if (file.Name.Equals("manifest.json", StringComparison.OrdinalIgnoreCase)) @@ -36,32 +62,40 @@ private async Task ProcessDirectoryAsync(DirectoryInfo sourceDir, string targetD continue; } - var targetFileName = _renderer.Render(file.Name, variables); - var targetFilePath = Path.Combine(targetDir, targetFileName); + var targetPath = ResolveWithinRoot(targetDir, _renderer.Render(file.Name, variables), root); // TODO: Add binary file check to avoid rendering binaries - // For now, we assume everything is text or we try to render it. - // If it fails or corrupts, we'll need to add a binary check. - var content = await File.ReadAllTextAsync(file.FullName); - var renderedContent = _renderer.Render(content, variables); - - await File.WriteAllTextAsync(targetFilePath, renderedContent); - AnsiConsole.MarkupLine($"[grey]Created file:[/] {targetFilePath}"); + plan.Add(new PlannedFile(targetPath, _renderer.Render(content, variables))); } - // Recursive copy directories foreach (var subDir in sourceDir.GetDirectories()) { - var targetSubDirName = _renderer.Render(subDir.Name, variables); - var targetSubDirPath = Path.Combine(targetDir, targetSubDirName); + var targetSubDir = ResolveWithinRoot(targetDir, _renderer.Render(subDir.Name, variables), root); + await PlanDirectoryAsync(subDir, targetSubDir, root, variables, plan); + } + } - if (!Directory.Exists(targetSubDirPath)) - { - Directory.CreateDirectory(targetSubDirPath); - } + /// + /// Combines a rendered name onto a directory and verifies the result stays under + /// . Names come from user input, so a value like "../.." + /// must not be able to write outside the output directory. + /// + private static string ResolveWithinRoot(string directory, string renderedName, string root) + { + var combined = Path.GetFullPath(Path.Combine(directory, renderedName)); + var boundary = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; - await ProcessDirectoryAsync(subDir, targetSubDirPath, variables); + if (combined != root && !combined.StartsWith(boundary, StringComparison.Ordinal)) + { + throw new ScaffoldingException( + $"Refusing to write outside the output directory: '{renderedName}' resolves to '{combined}'."); } + + return combined; } + + private sealed record PlannedFile(string TargetPath, string Content); } From 780c61eda4246eb24dda94a1c551e8a31d7d1fae Mon Sep 17 00:00:00 2001 From: JuansesDev Date: Mon, 7 Apr 2025 10:00:00 -0500 Subject: [PATCH 6/9] fix(ci): accept every version tag format Workflow triggers on v*, V* but only parsed V. prefix. Tags v1.1.0, V1.1.0, V.1.1.0, and 1.1.0 all now produce valid Debian versions. --- .github/workflows/release.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fce985..1295602 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Build Binaries run: | @@ -29,9 +29,13 @@ jobs: - name: Build Debian Package run: | chmod +x build_deb.sh - VERSION=${{ github.ref_name }} - VERSION=${VERSION#V.} - ./build_deb.sh $VERSION + # Accepts v1.1.0, V1.1.0, V.1.1.0 or 1.1.0. Debian versions must start + # with a digit, so the leading tag prefix has to go. + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#[vV]}" + VERSION="${VERSION#.}" + echo "Packaging version: $VERSION" + ./build_deb.sh "$VERSION" - name: Pack NuGet Tool run: dotnet pack MTC/MTC.csproj -c Release -o nupkg From abf5ac39f472d312fb13d31371b980c938ec9add Mon Sep 17 00:00:00 2001 From: JuansesDev Date: Tue, 4 Aug 2026 13:02:24 -0500 Subject: [PATCH 7/9] test: cover field parsing and scaffolding write safety - 13 new tests for type mapping and value-type detection - 11 new tests for scaffolding: overwrite detection, path traversal, all-or-nothing writes - Remove empty UnitTest1.cs placeholder files - Total test count: 8 -> 31 (across unit and integration) --- MTC.IntegrationTests/UnitTest1.cs | 10 -- MTC.Tests/FieldParserTests.cs | 60 +++++++++-- MTC.Tests/ScaffoldingServiceTests.cs | 155 +++++++++++++++++++++++++++ MTC.Tests/UnitTest1.cs | 10 -- 4 files changed, 207 insertions(+), 28 deletions(-) delete mode 100644 MTC.IntegrationTests/UnitTest1.cs create mode 100644 MTC.Tests/ScaffoldingServiceTests.cs delete mode 100644 MTC.Tests/UnitTest1.cs diff --git a/MTC.IntegrationTests/UnitTest1.cs b/MTC.IntegrationTests/UnitTest1.cs deleted file mode 100644 index c9dd2f3..0000000 --- a/MTC.IntegrationTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace MTC.IntegrationTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/MTC.Tests/FieldParserTests.cs b/MTC.Tests/FieldParserTests.cs index 948c4b0..26907e7 100644 --- a/MTC.Tests/FieldParserTests.cs +++ b/MTC.Tests/FieldParserTests.cs @@ -49,13 +49,57 @@ public void Parse_ShouldHandleExtraSpaces() } [Fact] - public void Parse_ShouldDefaultToString_WhenTypeIsMissing() - { - // Assuming FieldParser defaults to string if no type provided, - // or throws? Let's check implementation behavior or define expected behavior. - // Current implementation splits by ':' so "Name" might fail or set Type to empty. - // Let's assume input MUST be Name:Type for now based on usage. - // If implementation allows "Name", let's test it. - // For now, let's stick to standard "Name:Type". + public void Parse_ShouldSkipFields_WhenTypeIsMissing() + { + var result = _parser.Parse("Name Age:int"); + + Assert.Single(result); + Assert.Equal("Age", result[0].Name); + } + + [Theory] + [InlineData("datetime", "DateTime")] + [InlineData("dateonly", "DateOnly")] + [InlineData("timeonly", "TimeOnly")] + [InlineData("guid", "Guid")] + [InlineData("STRING", "string")] + public void Parse_ShouldNormalizeKnownTypeAliases(string alias, string expected) + { + var result = _parser.Parse($"Value:{alias}"); + + Assert.Equal(expected, result[0].Type); + } + + [Fact] + public void Parse_ShouldPassThroughUnknownTypes() + { + var result = _parser.Parse("Status:OrderStatus"); + + Assert.Equal("OrderStatus", result[0].Type); + } + + [Theory] + [InlineData("int")] + [InlineData("decimal")] + [InlineData("bool")] + [InlineData("datetime")] + [InlineData("guid")] + public void Parse_ShouldMarkBuiltInValueTypes(string type) + { + var result = _parser.Parse($"Value:{type}"); + + Assert.True(result[0].IsValueType); + } + + [Theory] + [InlineData("string")] + [InlineData("OrderStatus")] + public void Parse_ShouldNotMarkReferenceTypesAsValueTypes(string type) + { + // Reference types drive the 'required' modifier in the generated code, + // which is what keeps scaffolded projects free of CS8618. + var result = _parser.Parse($"Value:{type}"); + + Assert.False(result[0].IsValueType); } } diff --git a/MTC.Tests/ScaffoldingServiceTests.cs b/MTC.Tests/ScaffoldingServiceTests.cs new file mode 100644 index 0000000..285845a --- /dev/null +++ b/MTC.Tests/ScaffoldingServiceTests.cs @@ -0,0 +1,155 @@ +using MTC.Models; +using MTC.Services; +using Xunit; + +namespace MTC.Tests; + +public class ScaffoldingServiceTests : IDisposable +{ + private readonly ScaffoldingService _service = new(new ScribanTemplateRenderer()); + private readonly string _templateDir; + private readonly string _targetDir; + + public ScaffoldingServiceTests() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + _templateDir = Path.Combine(root, "template"); + _targetDir = Path.Combine(root, "target"); + Directory.CreateDirectory(_templateDir); + Directory.CreateDirectory(_targetDir); + } + + public void Dispose() + { + var root = Path.GetDirectoryName(_templateDir); + if (root != null && Directory.Exists(root)) + { + Directory.Delete(root, true); + } + } + + private Template GivenTemplate(params (string Name, string Content)[] files) + { + foreach (var (name, content) in files) + { + var path = Path.Combine(_templateDir, name); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + return new Template(new TemplateManifest { Name = "Test" }, _templateDir); + } + + private static Dictionary Variables(string name) => + new() { { "Name", name } }; + + [Fact] + public async Task GenerateAsync_ShouldRenderNamesAndContent() + { + var template = GivenTemplate(("{{Name}}.cs", "class {{Name}} { }")); + + var result = await _service.GenerateAsync(template, _targetDir, Variables("Product")); + + Assert.True(result.Succeeded); + var written = Path.Combine(_targetDir, "Product.cs"); + Assert.True(File.Exists(written)); + Assert.Equal("class Product { }", File.ReadAllText(written)); + } + + [Fact] + public async Task GenerateAsync_ShouldSkipManifest() + { + var template = GivenTemplate(("manifest.json", "{}"), ("{{Name}}.cs", "x")); + + await _service.GenerateAsync(template, _targetDir, Variables("Product")); + + Assert.False(File.Exists(Path.Combine(_targetDir, "manifest.json"))); + } + + [Fact] + public async Task GenerateAsync_ShouldNotOverwrite_WhenFileExists() + { + var template = GivenTemplate(("{{Name}}.cs", "generated")); + var existing = Path.Combine(_targetDir, "Product.cs"); + File.WriteAllText(existing, "hand written"); + + var result = await _service.GenerateAsync(template, _targetDir, Variables("Product")); + + Assert.False(result.Succeeded); + Assert.Equal(existing, Assert.Single(result.ConflictingFiles)); + Assert.Equal("hand written", File.ReadAllText(existing)); + } + + [Fact] + public async Task GenerateAsync_ShouldWriteNothing_WhenAnyFileConflicts() + { + var template = GivenTemplate( + ("{{Name}}.cs", "generated"), + ("Other.cs", "generated")); + + File.WriteAllText(Path.Combine(_targetDir, "Product.cs"), "hand written"); + + var result = await _service.GenerateAsync(template, _targetDir, Variables("Product")); + + Assert.False(result.Succeeded); + // The non-conflicting file must not be written either: generation is all-or-nothing. + Assert.False(File.Exists(Path.Combine(_targetDir, "Other.cs"))); + } + + [Fact] + public async Task GenerateAsync_ShouldOverwrite_WhenForced() + { + var template = GivenTemplate(("{{Name}}.cs", "generated")); + var existing = Path.Combine(_targetDir, "Product.cs"); + File.WriteAllText(existing, "hand written"); + + var result = await _service.GenerateAsync(template, _targetDir, Variables("Product"), overwrite: true); + + Assert.True(result.Succeeded); + Assert.Equal(existing, Assert.Single(result.OverwrittenFiles)); + Assert.Equal("generated", File.ReadAllText(existing)); + } + + [Fact] + public async Task GenerateAsync_ShouldReportOnlyReplacedFilesAsOverwritten() + { + var template = GivenTemplate(("{{Name}}.cs", "generated"), ("Other.cs", "generated")); + File.WriteAllText(Path.Combine(_targetDir, "Product.cs"), "hand written"); + + var result = await _service.GenerateAsync(template, _targetDir, Variables("Product"), overwrite: true); + + Assert.Equal(2, result.CreatedFiles.Count); + Assert.Single(result.OverwrittenFiles); + } + + [Theory] + [InlineData("../Escaped")] + [InlineData("../../Escaped")] + public async Task GenerateAsync_ShouldRefuseToWriteOutsideTarget(string name) + { + var template = GivenTemplate(("{{Name}}.cs", "generated")); + + await Assert.ThrowsAsync( + () => _service.GenerateAsync(template, _targetDir, Variables(name))); + } + + [Fact] + public async Task GenerateAsync_ShouldRefuseTraversalThroughDirectoryNames() + { + var template = GivenTemplate((Path.Combine("{{Name}}", "File.cs"), "generated")); + + await Assert.ThrowsAsync( + () => _service.GenerateAsync(template, _targetDir, Variables("../Escaped"))); + } + + [Fact] + public async Task GenerateAsync_ShouldCreateNestedDirectories() + { + var template = GivenTemplate((Path.Combine("Features", "{{Name}}", "Handler.cs"), "class {{Name}} { }")); + + var result = await _service.GenerateAsync(template, _targetDir, Variables("Order")); + + Assert.True(result.Succeeded); + Assert.True(File.Exists(Path.Combine(_targetDir, "Features", "Order", "Handler.cs"))); + } +} diff --git a/MTC.Tests/UnitTest1.cs b/MTC.Tests/UnitTest1.cs deleted file mode 100644 index 9122899..0000000 --- a/MTC.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace MTC.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} From 5d6c3c670eaa16326ed5fd71194a7eb1e15826db Mon Sep 17 00:00:00 2001 From: JuansesDev Date: Tue, 4 Aug 2026 13:02:24 -0500 Subject: [PATCH 8/9] docs: rewrite README, add CONTRIBUTING and CHANGELOG - README refocused on 'add feature' as the core value proposition - CONTRIBUTING: setup guide, code map, template authoring rules - CHANGELOG: Keep a Changelog format, SemVer, full history for 1.0.x and 1.1.0 - Explain --force flag and write-safety model --- CHANGELOG.md | 131 +++++++++++++++++++++++++ CONTRIBUTING.md | 175 ++++++++++++++++++++++++++++++++++ README.md | 249 ++++++++++++++++++++++++++++++++---------------- 3 files changed, 472 insertions(+), 83 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..943be41 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,131 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0] — unreleased + +Maintenance release after an eight-month pause. Everything now targets .NET 10, the +template engine vulnerability is closed, and the code MTC generates compiles without +warnings. + +### Security + +- Updated Scriban from 6.5.2 to 7.2.6, closing 14 known advisories — one critical + and eight high severity. Scriban renders every template, so this affected all + generated output. +- Generated Web/API projects now pin `Microsoft.OpenApi` 2.11.0. The version pulled in + transitively by `Microsoft.AspNetCore.OpenApi` carries a known advisory + (GHSA-v5pm-xwqc-g5wc). +- Rendered file and directory names can no longer escape the output directory. A name + such as `../Escaped` previously wrote files into the parent directory; it is now + refused with a clear error. + +### Added + +- **Generate commands no longer overwrite existing files.** Every command now checks its + targets before writing anything. If a file it would produce already exists, nothing is + written, the conflicting paths are listed, and the command exits with code 1. Pass + `--force` to replace them. Generation is all-or-nothing, so a conflict can no longer + leave a half-written feature behind. Applies to `new`, `add feature`, + `add value-object` and `add dto`, including the Clean Architecture path that renders + through a temporary directory. +- `mtc new
{{ prop.Name }}
@Html.DisplayFor(modelItem => item.{{ prop.Name }})