From df7a3eb37314d5918d725a98ef05d93a783148f1 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 17:54:38 +0000 Subject: [PATCH 1/5] feat: standalone project scaffolding with published NuGet packages The `sm new project` command can now create projects that reference published SimpleModule packages from nuget.org and npmjs.org, instead of requiring local package sources. Adds NuGet version auto-detection, fallback templates for all host files, and an integration test that verifies the scaffolded project builds successfully. --- .../Commands/New/NewProjectCommand.cs | 275 +++++++++------- .../Commands/New/NewProjectSettings.cs | 4 + .../Infrastructure/NuGetVersionResolver.cs | 107 ++++++ .../Templates/HostTemplates.cs | 309 +++++++++++++++++- .../Templates/ModuleTemplates.cs | 34 +- .../Templates/ProjectTemplates.cs | 122 +++++-- .../NewProjectScaffoldTests.cs | 150 +++++++++ 7 files changed, 831 insertions(+), 170 deletions(-) create mode 100644 cli/SimpleModule.Cli/Infrastructure/NuGetVersionResolver.cs create mode 100644 tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.cs diff --git a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs index 94df179b..091c1391 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs @@ -10,11 +10,6 @@ public sealed class NewProjectCommand : Command public override int Execute(CommandContext context, NewProjectSettings settings) { var solution = SolutionContext.Discover(); - if (solution is null) - { - AnsiConsole.MarkupLine("[red]No .slnx file found. The 'sm new project' command must be run from inside the SimpleModule framework repository.[/]"); - return 1; - } var projectName = settings.ResolveName(); var outputDir = settings.ResolveOutputDir(); @@ -26,23 +21,170 @@ public override int Execute(CommandContext context, NewProjectSettings settings) return 1; } - var projectTemplates = new ProjectTemplates(solution); + var frameworkVersion = NuGetVersionResolver.ResolveVersion( + settings.FrameworkVersion, + solution + ); + + if (settings.DryRun) + { + var ops = PlanFiles(projectName, rootDir); + RenderDryRunTree(projectName, ops, rootDir); + return 0; + } + + AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .Start($"Creating project '{projectName}'...", ctx => + { + ctx.Status("Scaffolding project files..."); + ScaffoldProject(projectName, rootDir, solution, frameworkVersion); + }); + + var allOps = PlanFiles(projectName, rootDir); + RenderFileTree(projectName, allOps, rootDir); + + AnsiConsole.MarkupLine($"\n[green]Project '{Markup.Escape(projectName)}' created![/]"); + AnsiConsole.MarkupLine("[dim]Next steps:[/]"); + AnsiConsole.MarkupLine($"[dim] cd {Markup.Escape(projectName)}[/]"); + AnsiConsole.MarkupLine("[dim] npm install[/]"); + AnsiConsole.MarkupLine("[dim] npm run build[/]"); + AnsiConsole.MarkupLine("[dim] dotnet build[/]"); + AnsiConsole.MarkupLine($"[dim] dotnet run --project src/{Markup.Escape(projectName)}.Host[/]"); + return 0; + } + + public static void ScaffoldProject( + string projectName, + string rootDir, + SolutionContext? solution, + string frameworkVersion) + { + var projectTemplates = new ProjectTemplates(solution, frameworkVersion); var hostTemplates = new HostTemplates(solution); var moduleTemplates = new ModuleTemplates(solution); const string moduleName = "Items"; var singularName = ModuleTemplates.GetSingularName(moduleName); - // ── Plan all files ──────────────────────────────────────────── - var ops = new List<(string Path, FileAction Action)>(); + var hostDir = Path.Combine(rootDir, "src", $"{projectName}.Host"); + var modulesDir = Path.Combine(rootDir, "src", "modules"); + var testsSharedDir = Path.Combine(rootDir, "tests", $"{projectName}.Tests.Shared"); + + var contractsDir = Path.Combine(modulesDir, moduleName, "src", $"{moduleName}.Contracts"); + var moduleDir = Path.Combine(modulesDir, moduleName, "src", moduleName); + var eventsDir = Path.Combine(contractsDir, "Events"); + var endpointsDir = Path.Combine(moduleDir, "Endpoints", moduleName); + var moduleTestDir = Path.Combine(modulesDir, moduleName, "tests", $"{moduleName}.Tests"); + + // ── Create directories ──────────────────────────────── + Directory.CreateDirectory(Path.Combine(hostDir, "Components")); + Directory.CreateDirectory(Path.Combine(hostDir, "ClientApp")); + Directory.CreateDirectory(Path.Combine(hostDir, "Styles")); + Directory.CreateDirectory(Path.Combine(hostDir, "Properties")); + Directory.CreateDirectory(Path.Combine(hostDir, "wwwroot", "css")); + Directory.CreateDirectory(eventsDir); + Directory.CreateDirectory(endpointsDir); + Directory.CreateDirectory(Path.Combine(moduleDir, "Pages")); + Directory.CreateDirectory(Path.Combine(moduleDir, "Views")); + Directory.CreateDirectory(Path.Combine(moduleTestDir, "Unit")); + Directory.CreateDirectory(Path.Combine(moduleTestDir, "Integration")); + Directory.CreateDirectory(testsSharedDir); + + // ── Root config files ───────────────────────────────── + File.WriteAllText(Path.Combine(rootDir, $"{projectName}.slnx"), GenerateSlnx(projectName)); + File.WriteAllText(Path.Combine(rootDir, "Directory.Build.props"), projectTemplates.DirectoryBuildProps()); + File.WriteAllText(Path.Combine(rootDir, "Directory.Packages.props"), projectTemplates.DirectoryPackagesProps()); + File.WriteAllText(Path.Combine(rootDir, "global.json"), projectTemplates.GlobalJson()); + File.WriteAllText( + Path.Combine(rootDir, "package.json"), + projectTemplates.RootPackageJson( + projectName, + solution is not null ? Path.Combine(solution.RootPath, "packages") : null + ) + ); + File.WriteAllText(Path.Combine(rootDir, "biome.json"), projectTemplates.BiomeJson()); + File.WriteAllText(Path.Combine(rootDir, "tsconfig.json"), projectTemplates.TsconfigJson()); + var editorConfig = projectTemplates.EditorConfig(); + if (!string.IsNullOrEmpty(editorConfig)) + { + File.WriteAllText(Path.Combine(rootDir, ".editorconfig"), editorConfig); + } + File.WriteAllText( + Path.Combine(rootDir, "nuget.config"), + ProjectTemplates.NugetConfig(solution?.RootPath) + ); + + // ── Host project ────────────────────────────────────── + File.WriteAllText(Path.Combine(hostDir, $"{projectName}.Host.csproj"), hostTemplates.HostCsproj(projectName)); + File.WriteAllText(Path.Combine(hostDir, "Program.cs"), hostTemplates.ProgramCs()); + File.WriteAllText(Path.Combine(hostDir, "Components", "App.razor"), hostTemplates.AppRazor(projectName)); + File.WriteAllText(Path.Combine(hostDir, "Components", "InertiaShell.razor"), hostTemplates.InertiaShellRazor(projectName)); + File.WriteAllText(Path.Combine(hostDir, "Components", "Routes.razor"), HostTemplates.RoutesRazor()); + File.WriteAllText(Path.Combine(hostDir, "Components", "_Imports.razor"), hostTemplates.ImportsRazor(projectName)); + File.WriteAllText(Path.Combine(hostDir, "ClientApp", "app.tsx"), hostTemplates.AppTsx()); + File.WriteAllText(Path.Combine(hostDir, "ClientApp", "vite.config.ts"), hostTemplates.ViteConfig()); + File.WriteAllText(Path.Combine(hostDir, "ClientApp", "validate-pages.mjs"), hostTemplates.ValidatePages()); + File.WriteAllText(Path.Combine(hostDir, "ClientApp", "package.json"), hostTemplates.ClientAppPackageJson(projectName)); + File.WriteAllText(Path.Combine(hostDir, "Styles", "app.css"), HostTemplates.AppCss()); + File.WriteAllText(Path.Combine(hostDir, "wwwroot", "css", "app.css"), MinimalAppCss()); + File.WriteAllText(Path.Combine(hostDir, "appsettings.json"), hostTemplates.AppSettings()); + File.WriteAllText(Path.Combine(hostDir, "appsettings.Development.json"), hostTemplates.AppSettingsDevelopment()); + File.WriteAllText(Path.Combine(hostDir, "Properties", "launchSettings.json"), hostTemplates.LaunchSettings(projectName)); + + // ── Home module ─────────────────────────────────────── + File.WriteAllText(Path.Combine(contractsDir, $"{moduleName}.Contracts.csproj"), moduleTemplates.ContractsCsproj(moduleName)); + File.WriteAllText(Path.Combine(contractsDir, $"I{singularName}Contracts.cs"), moduleTemplates.ContractsInterface(moduleName, singularName)); + File.WriteAllText(Path.Combine(contractsDir, $"{singularName}.cs"), moduleTemplates.DtoClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(eventsDir, $"{singularName}CreatedEvent.cs"), moduleTemplates.EventClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}.csproj"), moduleTemplates.ModuleCsproj(moduleName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}Module.cs"), StarterModuleClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}Constants.cs"), moduleTemplates.ConstantsClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}DbContext.cs"), moduleTemplates.DbContextClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{singularName}Service.cs"), moduleTemplates.ServiceClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(endpointsDir, "GetAllEndpoint.cs"), moduleTemplates.GetAllEndpoint(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, "Views", "WelcomeEndpoint.cs"), StarterWelcomeEndpoint(moduleName)); + File.WriteAllText(Path.Combine(moduleDir, "Pages", "index.ts"), $$""" + export const pages: Record = { + '{{moduleName}}/Welcome': () => import('./Welcome'), + }; + """); + File.WriteAllText(Path.Combine(moduleDir, "Pages", "Welcome.tsx"), StarterWelcomePage(projectName)); + File.WriteAllText(Path.Combine(moduleDir, "vite.config.ts"), StarterViteConfig()); + File.WriteAllText(Path.Combine(moduleDir, "package.json"), StarterPackageJson(projectName)); + File.WriteAllText(Path.Combine(moduleTestDir, $"{moduleName}.Tests.csproj"), moduleTemplates.TestCsproj(moduleName)); + File.WriteAllText(Path.Combine(moduleTestDir, "GlobalUsings.cs"), moduleTemplates.GlobalUsings()); + File.WriteAllText(Path.Combine(moduleTestDir, "Unit", $"{singularName}ServiceTests.cs"), moduleTemplates.UnitTestSkeleton(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleTestDir, "Integration", $"{moduleName}EndpointTests.cs"), moduleTemplates.IntegrationTestSkeleton(moduleName, singularName)); + + // ── Tests.Shared ────────────────────────────────────── + File.WriteAllText(Path.Combine(testsSharedDir, $"{projectName}.Tests.Shared.csproj"), projectTemplates.TestsSharedCsproj(projectName)); + + // ── Wire up solution ────────────────────────────────── + var slnxPath = Path.Combine(rootDir, $"{projectName}.slnx"); + SlnxManipulator.AddModuleEntries(slnxPath, moduleName); + + var hostCsprojPath = Path.Combine(hostDir, $"{projectName}.Host.csproj"); + ProjectManipulator.AddProjectReference( + hostCsprojPath, + $@"..\modules\{moduleName}\src\{moduleName}\{moduleName}.csproj" + ); + } + + private static List<(string Path, FileAction Action)> PlanFiles(string projectName, string rootDir) + { + var ops = new List<(string Path, FileAction Action)>(); void Plan(string path) => ops.Add((path, FileAction.Create)); - // Root config files var hostDir = Path.Combine(rootDir, "src", $"{projectName}.Host"); var modulesDir = Path.Combine(rootDir, "src", "modules"); var testsSharedDir = Path.Combine(rootDir, "tests", $"{projectName}.Tests.Shared"); + const string moduleName = "Items"; + var singularName = ModuleTemplates.GetSingularName(moduleName); + + // Root config files Plan(Path.Combine(rootDir, $"{projectName}.slnx")); Plan(Path.Combine(rootDir, "Directory.Build.props")); Plan(Path.Combine(rootDir, "Directory.Packages.props")); @@ -99,118 +241,7 @@ public override int Execute(CommandContext context, NewProjectSettings settings) // Tests.Shared Plan(Path.Combine(testsSharedDir, $"{projectName}.Tests.Shared.csproj")); - if (settings.DryRun) - { - RenderDryRunTree(projectName, ops, rootDir); - return 0; - } - - AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .Start($"Creating project '{projectName}'...", ctx => - { - // ── Create directories ──────────────────────────────── - Directory.CreateDirectory(Path.Combine(hostDir, "Components")); - Directory.CreateDirectory(Path.Combine(hostDir, "ClientApp")); - Directory.CreateDirectory(Path.Combine(hostDir, "Styles")); - Directory.CreateDirectory(Path.Combine(hostDir, "Properties")); - Directory.CreateDirectory(Path.Combine(hostDir, "wwwroot", "css")); - Directory.CreateDirectory(eventsDir); - Directory.CreateDirectory(endpointsDir); - Directory.CreateDirectory(Path.Combine(moduleDir, "Pages")); - Directory.CreateDirectory(Path.Combine(moduleDir, "Views")); - Directory.CreateDirectory(Path.Combine(moduleTestDir, "Unit")); - Directory.CreateDirectory(Path.Combine(moduleTestDir, "Integration")); - Directory.CreateDirectory(testsSharedDir); - - // ── Root config files ───────────────────────────────── - ctx.Status("Writing root configuration files..."); - File.WriteAllText(Path.Combine(rootDir, $"{projectName}.slnx"), GenerateSlnx(projectName)); - File.WriteAllText(Path.Combine(rootDir, "Directory.Build.props"), projectTemplates.DirectoryBuildProps()); - File.WriteAllText(Path.Combine(rootDir, "Directory.Packages.props"), projectTemplates.DirectoryPackagesProps()); - File.WriteAllText(Path.Combine(rootDir, "global.json"), projectTemplates.GlobalJson()); - File.WriteAllText(Path.Combine(rootDir, "package.json"), ProjectTemplates.RootPackageJson(projectName, Path.Combine(solution.RootPath, "packages"))); - File.WriteAllText(Path.Combine(rootDir, "biome.json"), projectTemplates.BiomeJson()); - File.WriteAllText(Path.Combine(rootDir, "tsconfig.json"), projectTemplates.TsconfigJson()); - var editorConfig = projectTemplates.EditorConfig(); - if (!string.IsNullOrEmpty(editorConfig)) - { - File.WriteAllText(Path.Combine(rootDir, ".editorconfig"), editorConfig); - } - - File.WriteAllText(Path.Combine(rootDir, "nuget.config"), ProjectTemplates.NugetConfig(solution.RootPath)); - - // ── Host project ────────────────────────────────────── - ctx.Status("Scaffolding Host project..."); - File.WriteAllText(Path.Combine(hostDir, $"{projectName}.Host.csproj"), hostTemplates.HostCsproj(projectName)); - File.WriteAllText(Path.Combine(hostDir, "Program.cs"), hostTemplates.ProgramCs()); - File.WriteAllText(Path.Combine(hostDir, "Components", "App.razor"), hostTemplates.AppRazor(projectName)); - File.WriteAllText(Path.Combine(hostDir, "Components", "InertiaShell.razor"), hostTemplates.InertiaShellRazor(projectName)); - File.WriteAllText(Path.Combine(hostDir, "Components", "Routes.razor"), HostTemplates.RoutesRazor()); - File.WriteAllText(Path.Combine(hostDir, "Components", "_Imports.razor"), hostTemplates.ImportsRazor(projectName)); - File.WriteAllText(Path.Combine(hostDir, "ClientApp", "app.tsx"), hostTemplates.AppTsx()); - File.WriteAllText(Path.Combine(hostDir, "ClientApp", "vite.config.ts"), hostTemplates.ViteConfig()); - File.WriteAllText(Path.Combine(hostDir, "ClientApp", "validate-pages.mjs"), hostTemplates.ValidatePages()); - File.WriteAllText(Path.Combine(hostDir, "ClientApp", "package.json"), hostTemplates.ClientAppPackageJson(projectName)); - File.WriteAllText(Path.Combine(hostDir, "Styles", "app.css"), HostTemplates.AppCss()); - // Minimal compiled CSS so the Blazor layout looks decent before a full Tailwind build - File.WriteAllText(Path.Combine(hostDir, "wwwroot", "css", "app.css"), MinimalAppCss()); - File.WriteAllText(Path.Combine(hostDir, "appsettings.json"), hostTemplates.AppSettings()); - File.WriteAllText(Path.Combine(hostDir, "appsettings.Development.json"), hostTemplates.AppSettingsDevelopment()); - File.WriteAllText(Path.Combine(hostDir, "Properties", "launchSettings.json"), hostTemplates.LaunchSettings(projectName)); - - // ── Home module ─────────────────────────────────────── - ctx.Status("Creating Home starter module..."); - File.WriteAllText(Path.Combine(contractsDir, $"{moduleName}.Contracts.csproj"), moduleTemplates.ContractsCsproj(moduleName)); - File.WriteAllText(Path.Combine(contractsDir, $"I{singularName}Contracts.cs"), moduleTemplates.ContractsInterface(moduleName, singularName)); - File.WriteAllText(Path.Combine(contractsDir, $"{singularName}.cs"), moduleTemplates.DtoClass(moduleName, singularName)); - File.WriteAllText(Path.Combine(eventsDir, $"{singularName}CreatedEvent.cs"), moduleTemplates.EventClass(moduleName, singularName)); - File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}.csproj"), moduleTemplates.ModuleCsproj(moduleName)); - File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}Module.cs"), StarterModuleClass(moduleName, singularName)); - File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}Constants.cs"), moduleTemplates.ConstantsClass(moduleName, singularName)); - File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}DbContext.cs"), moduleTemplates.DbContextClass(moduleName, singularName)); - File.WriteAllText(Path.Combine(moduleDir, $"{singularName}Service.cs"), moduleTemplates.ServiceClass(moduleName, singularName)); - File.WriteAllText(Path.Combine(endpointsDir, "GetAllEndpoint.cs"), moduleTemplates.GetAllEndpoint(moduleName, singularName)); - File.WriteAllText(Path.Combine(moduleDir, "Views", "WelcomeEndpoint.cs"), StarterWelcomeEndpoint(moduleName)); - File.WriteAllText(Path.Combine(moduleDir, "Pages", "index.ts"), $$""" - export const pages: Record = { - '{{moduleName}}/Welcome': () => import('./Welcome'), - }; - """); - File.WriteAllText(Path.Combine(moduleDir, "Pages", "Welcome.tsx"), StarterWelcomePage(projectName)); - File.WriteAllText(Path.Combine(moduleDir, "vite.config.ts"), StarterViteConfig()); - File.WriteAllText(Path.Combine(moduleDir, "package.json"), StarterPackageJson(projectName)); - File.WriteAllText(Path.Combine(moduleTestDir, $"{moduleName}.Tests.csproj"), moduleTemplates.TestCsproj(moduleName)); - File.WriteAllText(Path.Combine(moduleTestDir, "GlobalUsings.cs"), moduleTemplates.GlobalUsings()); - File.WriteAllText(Path.Combine(moduleTestDir, "Unit", $"{singularName}ServiceTests.cs"), moduleTemplates.UnitTestSkeleton(moduleName, singularName)); - File.WriteAllText(Path.Combine(moduleTestDir, "Integration", $"{moduleName}EndpointTests.cs"), moduleTemplates.IntegrationTestSkeleton(moduleName, singularName)); - - // ── Tests.Shared ────────────────────────────────────── - ctx.Status("Creating Tests.Shared project..."); - File.WriteAllText(Path.Combine(testsSharedDir, $"{projectName}.Tests.Shared.csproj"), projectTemplates.TestsSharedCsproj(projectName)); - - // ── Wire up solution ────────────────────────────────── - ctx.Status("Updating solution references..."); - var slnxPath = Path.Combine(rootDir, $"{projectName}.slnx"); - SlnxManipulator.AddModuleEntries(slnxPath, moduleName); - - var hostCsprojPath = Path.Combine(hostDir, $"{projectName}.Host.csproj"); - ProjectManipulator.AddProjectReference( - hostCsprojPath, - $@"..\modules\{moduleName}\src\{moduleName}\{moduleName}.csproj" - ); - }); - - RenderFileTree(projectName, ops, rootDir); - - AnsiConsole.MarkupLine($"\n[green]Project '{Markup.Escape(projectName)}' created![/]"); - AnsiConsole.MarkupLine("[dim]Next steps:[/]"); - AnsiConsole.MarkupLine($"[dim] cd {Markup.Escape(projectName)}[/]"); - AnsiConsole.MarkupLine("[dim] npm install[/]"); - AnsiConsole.MarkupLine("[dim] npm run build[/]"); - AnsiConsole.MarkupLine("[dim] dotnet build[/]"); - AnsiConsole.MarkupLine($"[dim] dotnet run --project src/{Markup.Escape(projectName)}.Host[/]"); - return 0; + return ops; } private static string GenerateSlnx(string projectName) @@ -249,7 +280,7 @@ private static string StarterModuleClass(string moduleName, string singularName) namespace SimpleModule.{{moduleName}}; - [Module({{moduleName}}Constants.ModuleName, RoutePrefix = {{moduleName}}Constants.RoutePrefix, ViewPrefix = "")] + [Module({{moduleName}}Constants.ModuleName, RoutePrefix = {{moduleName}}Constants.RoutePrefix, ViewPrefix = "/")] public class {{moduleName}}Module : IModule { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) diff --git a/cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs b/cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs index 6d62f5bc..7ab027c3 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs @@ -18,6 +18,10 @@ public sealed class NewProjectSettings : CommandSettings [Description("Show what would be created without writing any files")] public bool DryRun { get; set; } + [CommandOption("--framework-version")] + [Description("SimpleModule framework version (auto-detected from nuget.org if omitted)")] + public string? FrameworkVersion { get; set; } + public string ResolveName() { if (string.IsNullOrWhiteSpace(Name)) diff --git a/cli/SimpleModule.Cli/Infrastructure/NuGetVersionResolver.cs b/cli/SimpleModule.Cli/Infrastructure/NuGetVersionResolver.cs new file mode 100644 index 00000000..2da44780 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/NuGetVersionResolver.cs @@ -0,0 +1,107 @@ +using System.Text.Json; + +namespace SimpleModule.Cli.Infrastructure; + +public static class NuGetVersionResolver +{ + private const string FallbackVersion = "0.0.15"; + private const string NuGetIndexUrl = + "https://api.nuget.org/v3-flatcontainer/simplemodule.core/index.json"; + + /// + /// Resolves the SimpleModule framework version to use for a new project. + /// Priority: explicit version > NuGet API > version.json > hardcoded fallback. + /// + public static string ResolveVersion( + string? explicitVersion = null, + SolutionContext? solution = null + ) + { + if (!string.IsNullOrWhiteSpace(explicitVersion)) + { + return explicitVersion; + } + + var nugetVersion = QueryNuGetLatestVersion(); + if (nugetVersion is not null) + { + return nugetVersion; + } + + if (solution is not null) + { + var versionFromFile = ReadVersionJson(solution.RootPath); + if (versionFromFile is not null) + { + return versionFromFile; + } + } + + return FallbackVersion; + } + + private static string? QueryNuGetLatestVersion() + { + try + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var json = client.GetStringAsync(new Uri(NuGetIndexUrl)).GetAwaiter().GetResult(); + using var doc = JsonDocument.Parse(json); + + if ( + doc.RootElement.TryGetProperty("versions", out var versions) + && versions.ValueKind == JsonValueKind.Array + ) + { + var count = versions.GetArrayLength(); + if (count > 0) + { + return versions[count - 1].GetString(); + } + } + } + catch (HttpRequestException) + { + // Network errors — fall through + } + catch (TaskCanceledException) + { + // Timeout — fall through + } + catch (JsonException) + { + // Parse errors — fall through + } + + return null; + } + + private static string? ReadVersionJson(string rootPath) + { + try + { + var path = Path.Combine(rootPath, "version.json"); + if (!File.Exists(path)) + { + return null; + } + + var json = File.ReadAllText(path); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("version", out var version)) + { + return version.GetString(); + } + } + catch (JsonException) + { + // Parse errors — fall through + } + catch (IOException) + { + // File read errors — fall through + } + + return null; + } +} diff --git a/cli/SimpleModule.Cli/Templates/HostTemplates.cs b/cli/SimpleModule.Cli/Templates/HostTemplates.cs index ce984aa2..2574e180 100644 --- a/cli/SimpleModule.Cli/Templates/HostTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/HostTemplates.cs @@ -24,11 +24,13 @@ public sealed class HostTemplates "ReferenceOutputAssembly=", ]; - private readonly string _templateHostDir; + private readonly string? _templateHostDir; - public HostTemplates(SolutionContext solution) + public HostTemplates(SolutionContext? solution) { - _templateHostDir = Path.Combine(solution.RootPath, "template", "SimpleModule.Host"); + _templateHostDir = solution is not null + ? Path.Combine(solution.RootPath, "template", "SimpleModule.Host") + : null; } /// @@ -39,7 +41,17 @@ public HostTemplates(SolutionContext solution) /// public string HostCsproj(string projectName) { + if (_templateHostDir is null) + { + return FallbackHostCsproj(projectName); + } + var path = Path.Combine(_templateHostDir, "SimpleModule.Host.csproj"); + if (!File.Exists(path)) + { + return FallbackHostCsproj(projectName); + } + var lines = File.ReadAllLines(path).ToList(); // Strip lines containing patterns that should not appear in a new project @@ -88,12 +100,24 @@ public string HostCsproj(string projectName) /// public string ProgramCs() { + if (_templateHostDir is null) + { + return FallbackProgramCs(); + } + var path = Path.Combine(_templateHostDir, "Program.cs"); + if (!File.Exists(path)) + { + return FallbackProgramCs(); + } + var lines = File.ReadAllLines(path).ToList(); lines.RemoveAll(line => line.Contains("ServiceDefaults", StringComparison.Ordinal) || line.Contains("MapDefaultEndpoints", StringComparison.Ordinal) + || line.Contains("Storage.Local", StringComparison.Ordinal) + || line.Contains("AddLocalStorage", StringComparison.Ordinal) ); lines = TemplateExtractor.CollapseBlankLines(lines); @@ -106,7 +130,17 @@ public string ProgramCs() /// public string AppRazor(string projectName) { + if (_templateHostDir is null) + { + return FallbackAppRazor(projectName); + } + var path = Path.Combine(_templateHostDir, "Components", "App.razor"); + if (!File.Exists(path)) + { + return FallbackAppRazor(projectName); + } + var content = File.ReadAllText(path); return ReplaceProjectName(content, projectName); } @@ -116,7 +150,17 @@ public string AppRazor(string projectName) /// public string InertiaShellRazor(string projectName) { + if (_templateHostDir is null) + { + return FallbackInertiaShellRazor(projectName); + } + var path = Path.Combine(_templateHostDir, "Components", "InertiaShell.razor"); + if (!File.Exists(path)) + { + return FallbackInertiaShellRazor(projectName); + } + var content = File.ReadAllText(path); return ReplaceProjectName(content, projectName); } @@ -140,7 +184,17 @@ public static string RoutesRazor() /// public string ImportsRazor(string projectName) { + if (_templateHostDir is null) + { + return FallbackImportsRazor(projectName); + } + var path = Path.Combine(_templateHostDir, "Components", "_Imports.razor"); + if (!File.Exists(path)) + { + return FallbackImportsRazor(projectName); + } + var content = File.ReadAllText(path); return ReplaceProjectName(content, projectName); } @@ -150,8 +204,13 @@ public string ImportsRazor(string projectName) /// public string AppTsx() { + if (_templateHostDir is null) + { + return FallbackAppTsx(); + } + var path = Path.Combine(_templateHostDir, "ClientApp", "app.tsx"); - return File.ReadAllText(path); + return File.Exists(path) ? File.ReadAllText(path) : FallbackAppTsx(); } /// @@ -159,8 +218,13 @@ public string AppTsx() /// public string ViteConfig() { + if (_templateHostDir is null) + { + return FallbackViteConfig(); + } + var path = Path.Combine(_templateHostDir, "ClientApp", "vite.config.ts"); - return File.ReadAllText(path); + return File.Exists(path) ? File.ReadAllText(path) : FallbackViteConfig(); } /// @@ -168,8 +232,13 @@ public string ViteConfig() /// public string ValidatePages() { + if (_templateHostDir is null) + { + return FallbackValidatePages(); + } + var path = Path.Combine(_templateHostDir, "ClientApp", "validate-pages.mjs"); - return File.ReadAllText(path); + return File.Exists(path) ? File.ReadAllText(path) : FallbackValidatePages(); } /// @@ -177,7 +246,17 @@ public string ValidatePages() /// public string ClientAppPackageJson(string projectName) { + if (_templateHostDir is null) + { + return FallbackClientAppPackageJson(projectName); + } + var path = Path.Combine(_templateHostDir, "ClientApp", "package.json"); + if (!File.Exists(path)) + { + return FallbackClientAppPackageJson(projectName); + } + var content = File.ReadAllText(path); return content.Replace( "@simplemodule/app", @@ -206,8 +285,13 @@ public static string AppCss() /// public string AppSettings() { + if (_templateHostDir is null) + { + return FallbackAppSettings(); + } + var path = Path.Combine(_templateHostDir, "appsettings.json"); - return File.ReadAllText(path); + return File.Exists(path) ? File.ReadAllText(path) : FallbackAppSettings(); } /// @@ -215,8 +299,13 @@ public string AppSettings() /// public string AppSettingsDevelopment() { + if (_templateHostDir is null) + { + return FallbackAppSettingsDevelopment(); + } + var path = Path.Combine(_templateHostDir, "appsettings.Development.json"); - return File.ReadAllText(path); + return File.Exists(path) ? File.ReadAllText(path) : FallbackAppSettingsDevelopment(); } /// @@ -224,7 +313,17 @@ public string AppSettingsDevelopment() /// public string LaunchSettings(string projectName) { + if (_templateHostDir is null) + { + return FallbackLaunchSettings(projectName); + } + var path = Path.Combine(_templateHostDir, "Properties", "launchSettings.json"); + if (!File.Exists(path)) + { + return FallbackLaunchSettings(projectName); + } + var content = File.ReadAllText(path); return ReplaceProjectName(content, projectName); } @@ -325,4 +424,198 @@ private static List RemoveEmptyXmlBlocks(List lines, string elem return result; } + + // ── Fallback templates ──────────────────────────────────────────── + + private static string FallbackHostCsproj(string projectName) => + $""" + + + net10.0 + + + + + + + + + + + + """; + + private static string FallbackProgramCs() => + """ + using SimpleModule.Hosting; + + var builder = WebApplication.CreateBuilder(args); + + builder.AddSimpleModule(); + + var app = builder.Build(); + + await app.UseSimpleModule(); + + await app.RunAsync(); + """; + + private static string FallbackAppRazor(string projectName) => + $$""" + + + + + + {{projectName}} + + + + + + + + + """; + + private static string FallbackInertiaShellRazor(string projectName) => + $$""" + @using Microsoft.AspNetCore.Components + @using Microsoft.AspNetCore.Components.Web + +
+ + @code { + [Parameter] public string PageJson { get; set; } = string.Empty; + } + """; + + private static string FallbackImportsRazor(string projectName) => + $$""" + @using Microsoft.AspNetCore.Components.Routing + @using Microsoft.AspNetCore.Components.Web + @using {{projectName}}.Host.Components + """; + + private static string FallbackAppTsx() => + """ + import { createInertiaApp } from '@inertiajs/react'; + import { createRoot } from 'react-dom/client'; + import { resolvePage } from '@simplemodule/client/resolve-page'; + + createInertiaApp({ + resolve: resolvePage, + setup({ el, App, props }) { + createRoot(el).render(); + }, + }); + """; + + private static string FallbackViteConfig() => + """ + import { defineConfig } from 'vite'; + import react from '@vitejs/plugin-react'; + import { simpleModuleVendor } from '@simplemodule/client/vite'; + + export default defineConfig({ + plugins: [simpleModuleVendor(), react()], + build: { + rollupOptions: { + input: 'app.tsx', + output: { + entryFileNames: 'js/app.js', + dir: '../wwwroot', + }, + }, + sourcemap: process.env.VITE_MODE !== 'prod', + minify: process.env.VITE_MODE === 'prod', + }, + }); + """; + + private static string FallbackValidatePages() => + """ + // Placeholder: validate-pages script + // Run this to check that all C# IViewEndpoints have matching page entries + console.log('validate-pages: OK'); + """; + + private static string FallbackClientAppPackageJson(string projectName) + { + var name = projectName.ToLowerInvariant(); + return $$""" + { + "private": true, + "name": "@{{name}}/app", + "version": "0.0.0", + "scripts": { + "build": "vite build", + "build:dev": "cross-env VITE_MODE=dev vite build", + "watch": "cross-env VITE_MODE=dev vite build --watch", + "validate-pages": "node validate-pages.mjs" + }, + "dependencies": { + "@inertiajs/react": "^2.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + } + """; + } + + private static string FallbackAppSettings() => + """ + { + "Database": { + "DefaultConnection": "Data Source=app.db" + }, + "Storage": { + "Provider": "Local", + "Local": { + "Path": "./storage" + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } + } + """; + + private static string FallbackAppSettingsDevelopment() => + """ + { + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "TimestampFormat": "HH:mm:ss ", + "SingleLine": true + } + } + } + } + """; + + private static string FallbackLaunchSettings(string projectName) => + $$""" + { + "profiles": { + "{{projectName}}.Host": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } + """; } diff --git a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs index 07ceaf6a..c710ed9f 100644 --- a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs @@ -4,17 +4,19 @@ namespace SimpleModule.Cli.Templates; public sealed class ModuleTemplates { - private readonly SolutionContext _solution; + private readonly SolutionContext? _solution; private readonly string? _refModule; private readonly string? _refSingular; private readonly IReadOnlyList _otherModuleNames; - public ModuleTemplates(SolutionContext solution) + public ModuleTemplates(SolutionContext? solution) { _solution = solution; - _refModule = solution.ExistingModules.Count > 0 ? solution.ExistingModules[0] : null; + _refModule = solution is not null && solution.ExistingModules.Count > 0 + ? solution.ExistingModules[0] + : null; _refSingular = _refModule is not null ? GetSingularName(_refModule) : null; - _otherModuleNames = _refModule is not null + _otherModuleNames = _refModule is not null && solution is not null ? solution .ExistingModules.Where(m => !string.Equals(m, _refModule, StringComparison.OrdinalIgnoreCase) @@ -701,7 +703,7 @@ public static string GetSingularName(string pluralName) private string? RefContractsPath(string relativePath) { - if (_refModule is null) + if (_refModule is null || _solution is null) { return null; } @@ -712,7 +714,7 @@ public static string GetSingularName(string pluralName) private string? RefModulePath(string relativePath) { - if (_refModule is null) + if (_refModule is null || _solution is null) { return null; } @@ -723,7 +725,7 @@ public static string GetSingularName(string pluralName) private string? RefTestPath(string relativePath) { - if (_refModule is null) + if (_refModule is null || _solution is null) { return null; } @@ -1002,16 +1004,8 @@ public static class {{moduleName}}Constants { public const string ModuleName = "{{moduleName}}"; public const string RoutePrefix = "/api/{{moduleName.ToLowerInvariant()}}"; - - public static class Fields - { - public const string Name = "Name"; - } - - public static class ValidationMessages - { - public const string NameRequired = "Name is required."; - } + public const string FieldName = "Name"; + public const string NameRequired = "Name is required."; } """; @@ -1033,6 +1027,8 @@ IOptions dbOptions protected override void OnModelCreating(ModelBuilder modelBuilder) { + ArgumentNullException.ThrowIfNull(modelBuilder); + modelBuilder.Entity<{{singularName}}>(entity => { entity.HasKey(e => e.Id); @@ -1053,7 +1049,7 @@ namespace SimpleModule.{{moduleName}}; public class {{singularName}}Service({{moduleName}}DbContext db) : I{{singularName}}Contracts { public async Task> GetAll{{moduleName}}Async() => - await db.{{moduleName}}.ToListAsync(); + await db.{{moduleName}}.ToListAsync().ConfigureAwait(false); } """; @@ -1075,7 +1071,7 @@ public void Map(IEndpointRouteBuilder app) "/", async (I{{singularName}}Contracts contracts) => { - var items = await contracts.GetAll{{moduleName}}Async(); + var items = await contracts.GetAll{{moduleName}}Async().ConfigureAwait(false); return TypedResults.Ok(items); } ).AllowAnonymous(); diff --git a/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs b/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs index be3c3525..2c69a18f 100644 --- a/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs @@ -8,10 +8,12 @@ public sealed class ProjectTemplates { private const string BaseProjectName = "SimpleModule"; private readonly SolutionContext? _solution; + private readonly string _frameworkVersion; - public ProjectTemplates(SolutionContext? solution) + public ProjectTemplates(SolutionContext? solution, string frameworkVersion) { _solution = solution; + _frameworkVersion = frameworkVersion; } public string Slnx(string projectName) @@ -174,10 +176,10 @@ nextContentLine is not null var frameworkPackages = new[] { " ", - " ", - " ", - " ", - " ", + $" ", + $" ", + $" ", + $" ", }; // Find the last and insert before it @@ -191,8 +193,20 @@ nextContentLine is not null return string.Join(Environment.NewLine, TemplateExtractor.CollapseBlankLines(cleaned)); } - public static string NugetConfig(string simpleModuleRepoPath) + public static string NugetConfig(string? simpleModuleRepoPath = null) { + if (simpleModuleRepoPath is null) + { + return """ + + + + + + + """; + } + var nupkgPath = Path.Combine(simpleModuleRepoPath, "nupkg").Replace('\\', '/'); return $""" @@ -216,9 +230,9 @@ public string GlobalJson() return File.Exists(path) ? File.ReadAllText(path) : FallbackGlobalJson(); } - public static string RootPackageJson(string projectName, string frameworkPackagesPath) + public string RootPackageJson(string projectName, string? frameworkPackagesPath) { - return FallbackRootPackageJson(projectName, frameworkPackagesPath); + return FallbackRootPackageJson(projectName, frameworkPackagesPath, _frameworkVersion); } public string BiomeJson() @@ -285,13 +299,13 @@ public string EditorConfig() { if (_solution is null) { - return string.Empty; + return FallbackEditorConfig(); } var path = Path.Combine(_solution.RootPath, ".editorconfig"); if (!File.Exists(path)) { - return string.Empty; + return FallbackEditorConfig(); } var content = File.ReadAllText(path); @@ -304,6 +318,49 @@ public string EditorConfig() return content; } + private static string FallbackEditorConfig() => + """ + root = true + + [*.cs] + # File-scoped namespaces + csharp_style_namespace_declarations = file_scoped:error + + # Naming: private fields _camelCase + dotnet_naming_rule.private_fields_underscore.symbols = private_fields + dotnet_naming_rule.private_fields_underscore.style = underscore_prefix + dotnet_naming_rule.private_fields_underscore.severity = error + dotnet_naming_symbol.private_fields.applicable_kinds = field + dotnet_naming_symbol.private_fields.applicable_accessibilities = private + dotnet_naming_style.underscore_prefix.required_prefix = _ + dotnet_naming_style.underscore_prefix.capitalization = camel_case + + # Suppressions for modular monolith patterns + dotnet_diagnostic.IDE0058.severity = none + dotnet_diagnostic.IDE0130.severity = none + dotnet_diagnostic.CA1062.severity = none + dotnet_diagnostic.CA1848.severity = none + dotnet_diagnostic.CA1034.severity = none + dotnet_diagnostic.CA1515.severity = none + dotnet_diagnostic.CA1050.severity = none + dotnet_diagnostic.CA1724.severity = none + dotnet_diagnostic.CA2007.severity = none + dotnet_diagnostic.CA1716.severity = none + dotnet_diagnostic.CA2227.severity = none + dotnet_diagnostic.CA1002.severity = none + dotnet_diagnostic.IDE0046.severity = none + dotnet_diagnostic.IDE0010.severity = none + dotnet_diagnostic.IDE0072.severity = none + dotnet_diagnostic.CA1812.severity = none + dotnet_diagnostic.CA1707.severity = none + + [{tests,src/modules/*/tests}/**/*.cs] + # Allow underscore method names in tests (Method_Scenario_Expected) + dotnet_diagnostic.CA1707.severity = none + dotnet_diagnostic.CA2234.severity = none + """; + + public string ApiCsproj(string projectName) { if (_solution is null) @@ -514,18 +571,18 @@ private static string FallbackDirectoryBuildProps() => """; - private static string FallbackDirectoryPackagesProps() => - """ + private string FallbackDirectoryPackagesProps() => + $""" true - - - - + + + + @@ -541,6 +598,7 @@ private static string FallbackDirectoryPackagesProps() => + """; @@ -638,10 +696,32 @@ private static string FallbackTestsSharedCsproj(string projectName) => """; - private static string FallbackRootPackageJson(string projectName, string frameworkPackagesPath) + private static string FallbackRootPackageJson( + string projectName, + string? frameworkPackagesPath, + string frameworkVersion + ) { var name = projectName.ToLowerInvariant(); - var pkgPath = frameworkPackagesPath.Replace('\\', '/'); + + string clientDep; + string uiDep; + string themeDep; + + if (frameworkPackagesPath is not null) + { + var pkgPath = frameworkPackagesPath.Replace('\\', '/'); + clientDep = $"\"file:{pkgPath}/SimpleModule.Client\""; + uiDep = $"\"file:{pkgPath}/SimpleModule.UI\""; + themeDep = $"\"file:{pkgPath}/SimpleModule.Theme.Default\""; + } + else + { + clientDep = $"\"^{frameworkVersion}\""; + uiDep = $"\"^{frameworkVersion}\""; + themeDep = $"\"^{frameworkVersion}\""; + } + return $$""" { "private": true, @@ -671,9 +751,9 @@ private static string FallbackRootPackageJson(string projectName, string framewo }, "dependencies": { "@inertiajs/react": "^2.0.0", - "@simplemodule/client": "file:{{pkgPath}}/SimpleModule.Client", - "@simplemodule/ui": "file:{{pkgPath}}/SimpleModule.UI", - "@simplemodule/theme-default": "file:{{pkgPath}}/SimpleModule.Theme.Default", + "@simplemodule/client": {{clientDep}}, + "@simplemodule/ui": {{uiDep}}, + "@simplemodule/theme-default": {{themeDep}}, "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.2.1" diff --git a/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.cs b/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.cs new file mode 100644 index 00000000..4d0391d0 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.cs @@ -0,0 +1,150 @@ +using System.Diagnostics; +using FluentAssertions; +using SimpleModule.Cli.Commands.New; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +[Trait("Category", "Integration")] +public sealed class NewProjectScaffoldTests : IDisposable +{ + private readonly string _tempDir; + + public NewProjectScaffoldTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-scaffold-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch (IOException) + { + // Best effort cleanup + } + } + + [Fact] + public void Scaffold_CreatesExpectedFiles() + { + const string projectName = "TestApp"; + var rootDir = Path.Combine(_tempDir, projectName); + + NewProjectCommand.ScaffoldProject(projectName, rootDir, solution: null, frameworkVersion: "0.0.15"); + + // Verify key files exist + File.Exists(Path.Combine(rootDir, $"{projectName}.slnx")).Should().BeTrue(); + File.Exists(Path.Combine(rootDir, "Directory.Build.props")).Should().BeTrue(); + File.Exists(Path.Combine(rootDir, "Directory.Packages.props")).Should().BeTrue(); + File.Exists(Path.Combine(rootDir, "nuget.config")).Should().BeTrue(); + File.Exists(Path.Combine(rootDir, "global.json")).Should().BeTrue(); + File.Exists(Path.Combine(rootDir, "src", $"{projectName}.Host", $"{projectName}.Host.csproj")).Should().BeTrue(); + File.Exists(Path.Combine(rootDir, "src", $"{projectName}.Host", "Program.cs")).Should().BeTrue(); + File.Exists(Path.Combine(rootDir, "src", "modules", "Items", "src", "Items", "Items.csproj")).Should().BeTrue(); + File.Exists(Path.Combine(rootDir, "src", "modules", "Items", "src", "Items.Contracts", "Items.Contracts.csproj")).Should().BeTrue(); + } + + [Fact] + public void Scaffold_DirectoryPackagesProps_UsesPublishedVersion() + { + const string projectName = "TestApp"; + var rootDir = Path.Combine(_tempDir, projectName); + + NewProjectCommand.ScaffoldProject(projectName, rootDir, solution: null, frameworkVersion: "0.0.15"); + + var content = File.ReadAllText(Path.Combine(rootDir, "Directory.Packages.props")); + content.Should().Contain("Version=\"0.0.15\""); + content.Should().NotContain("0.1.0-local"); + } + + [Fact] + public void Scaffold_NugetConfig_OnlyContainsNuGetOrg() + { + const string projectName = "TestApp"; + var rootDir = Path.Combine(_tempDir, projectName); + + NewProjectCommand.ScaffoldProject(projectName, rootDir, solution: null, frameworkVersion: "0.0.15"); + + var content = File.ReadAllText(Path.Combine(rootDir, "nuget.config")); + content.Should().Contain("nuget.org"); + content.Should().NotContain("SimpleModule-Local"); + content.Should().NotContain("nupkg"); + } + + [Fact] + public void Scaffold_PackageJson_UsesPublishedNpmPackages() + { + const string projectName = "TestApp"; + var rootDir = Path.Combine(_tempDir, projectName); + + NewProjectCommand.ScaffoldProject(projectName, rootDir, solution: null, frameworkVersion: "0.0.15"); + + var content = File.ReadAllText(Path.Combine(rootDir, "package.json")); + content.Should().Contain("\"@simplemodule/client\": \"^0.0.15\""); + content.Should().Contain("\"@simplemodule/ui\": \"^0.0.15\""); + content.Should().Contain("\"@simplemodule/theme-default\": \"^0.0.15\""); + content.Should().NotContain("file:"); + } + + [Fact] + public void Scaffold_WithSolution_UsesLocalPackages() + { + // When run from the framework repo, it should still use file: references for npm + var solution = SolutionContext.Discover(); + if (solution is null) + { + // Skip if not running from framework repo + return; + } + + const string projectName = "TestApp"; + var rootDir = Path.Combine(_tempDir, projectName); + + NewProjectCommand.ScaffoldProject(projectName, rootDir, solution, frameworkVersion: "0.0.15"); + + var content = File.ReadAllText(Path.Combine(rootDir, "package.json")); + content.Should().Contain("file:"); + } + + [Fact] + public void ScaffoldedProject_DotnetBuildSucceeds() + { + const string projectName = "TestApp"; + var rootDir = Path.Combine(_tempDir, projectName); + + var frameworkVersion = NuGetVersionResolver.ResolveVersion(); + + NewProjectCommand.ScaffoldProject(projectName, rootDir, solution: null, frameworkVersion: frameworkVersion); + + // Run dotnet build on the scaffolded project + var slnxPath = Path.Combine(rootDir, $"{projectName}.slnx"); + var psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"build \"{slnxPath}\"", + WorkingDirectory = rootDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi)!; + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(TimeSpan.FromSeconds(120)); + + var buildOutput = $""" + === STDOUT === + {stdout} + === STDERR === + {stderr} + """; + + process.ExitCode.Should().Be(0, $"dotnet build should succeed.\n{buildOutput}"); + } +} From c179d40d6f1542c2180d0d22f0afbe71805fd4dc Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 17:55:18 +0000 Subject: [PATCH 2/5] chore: regenerated TypeScript type definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build artifact from dotnet build — trailing newline normalization. --- modules/Admin/src/SimpleModule.Admin/types.ts | 4 +++- modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts | 1 + modules/FileStorage/src/SimpleModule.FileStorage/types.ts | 1 + modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts | 4 +++- modules/Orders/src/SimpleModule.Orders/types.ts | 1 + modules/PageBuilder/src/SimpleModule.PageBuilder/types.ts | 1 + modules/Products/src/SimpleModule.Products/types.ts | 1 + modules/Settings/src/SimpleModule.Settings/types.ts | 1 + modules/Users/src/SimpleModule.Users/types.ts | 1 + 9 files changed, 13 insertions(+), 2 deletions(-) diff --git a/modules/Admin/src/SimpleModule.Admin/types.ts b/modules/Admin/src/SimpleModule.Admin/types.ts index a7ed2736..140b48c4 100644 --- a/modules/Admin/src/SimpleModule.Admin/types.ts +++ b/modules/Admin/src/SimpleModule.Admin/types.ts @@ -1,5 +1,6 @@ // Auto-generated from [Dto] types — do not edit -export type AdminPermissions = {}; +export interface AdminPermissions { +} export interface AuditLogEntryDto { id: number; @@ -10,3 +11,4 @@ export interface AuditLogEntryDto { details: string; timestamp: string; } + diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts b/modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts index af16cb67..c6232099 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts @@ -83,3 +83,4 @@ export interface AuditStats { byAction: Record; byStatusCode: Record; } + diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/types.ts b/modules/FileStorage/src/SimpleModule.FileStorage/types.ts index 89ade8f2..97621843 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/types.ts +++ b/modules/FileStorage/src/SimpleModule.FileStorage/types.ts @@ -8,3 +8,4 @@ export interface StoredFile { folder: string; createdAt: string; } + diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts b/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts index 7d5dadaa..294e1680 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts @@ -1,2 +1,4 @@ // Auto-generated from [Dto] types — do not edit -export type OpenIddictPermissions = {}; +export interface OpenIddictPermissions { +} + diff --git a/modules/Orders/src/SimpleModule.Orders/types.ts b/modules/Orders/src/SimpleModule.Orders/types.ts index c2131e36..1e9249d7 100644 --- a/modules/Orders/src/SimpleModule.Orders/types.ts +++ b/modules/Orders/src/SimpleModule.Orders/types.ts @@ -21,3 +21,4 @@ export interface UpdateOrderRequest { userId: string; items: OrderItem[]; } + diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/types.ts b/modules/PageBuilder/src/SimpleModule.PageBuilder/types.ts index 38d8729f..715aae9b 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/types.ts +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/types.ts @@ -68,3 +68,4 @@ export interface UpdatePageRequest { metaKeywords: string; ogImage: string; } + diff --git a/modules/Products/src/SimpleModule.Products/types.ts b/modules/Products/src/SimpleModule.Products/types.ts index 8b0b9646..bd271339 100644 --- a/modules/Products/src/SimpleModule.Products/types.ts +++ b/modules/Products/src/SimpleModule.Products/types.ts @@ -14,3 +14,4 @@ export interface UpdateProductRequest { name: string; price: number; } + diff --git a/modules/Settings/src/SimpleModule.Settings/types.ts b/modules/Settings/src/SimpleModule.Settings/types.ts index 2e9c4a0a..955564a0 100644 --- a/modules/Settings/src/SimpleModule.Settings/types.ts +++ b/modules/Settings/src/SimpleModule.Settings/types.ts @@ -65,3 +65,4 @@ export interface UpdateSettingRequest { value: string; scope: any; } + diff --git a/modules/Users/src/SimpleModule.Users/types.ts b/modules/Users/src/SimpleModule.Users/types.ts index 715c10a2..4ccbbccd 100644 --- a/modules/Users/src/SimpleModule.Users/types.ts +++ b/modules/Users/src/SimpleModule.Users/types.ts @@ -62,3 +62,4 @@ export interface UserDto { emailConfirmed: boolean; twoFactorEnabled: boolean; } + From 5670574af8c72a0affd1852dea6731ea426da8c9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 18:05:17 +0000 Subject: [PATCH 3/5] refactor: embed host templates as assembly resources, remove fallbacks The template/SimpleModule.Host/ files are now embedded as resources in the CLI assembly. HostTemplates reads from embedded resources instead of the filesystem, eliminating both the filesystem dependency and all hardcoded fallback templates. The CLI is now fully self-contained for host project scaffolding. --- .../Commands/New/NewProjectCommand.cs | 25 +- .../Infrastructure/EmbeddedResourceReader.cs | 39 ++ cli/SimpleModule.Cli/SimpleModule.Cli.csproj | 16 + .../Templates/HostTemplates.cs | 413 +++--------------- 4 files changed, 129 insertions(+), 364 deletions(-) create mode 100644 cli/SimpleModule.Cli/Infrastructure/EmbeddedResourceReader.cs diff --git a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs index 091c1391..5fffbc42 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs @@ -61,7 +61,6 @@ public static void ScaffoldProject( string frameworkVersion) { var projectTemplates = new ProjectTemplates(solution, frameworkVersion); - var hostTemplates = new HostTemplates(solution); var moduleTemplates = new ModuleTemplates(solution); const string moduleName = "Items"; @@ -117,21 +116,21 @@ public static void ScaffoldProject( ); // ── Host project ────────────────────────────────────── - File.WriteAllText(Path.Combine(hostDir, $"{projectName}.Host.csproj"), hostTemplates.HostCsproj(projectName)); - File.WriteAllText(Path.Combine(hostDir, "Program.cs"), hostTemplates.ProgramCs()); - File.WriteAllText(Path.Combine(hostDir, "Components", "App.razor"), hostTemplates.AppRazor(projectName)); - File.WriteAllText(Path.Combine(hostDir, "Components", "InertiaShell.razor"), hostTemplates.InertiaShellRazor(projectName)); + File.WriteAllText(Path.Combine(hostDir, $"{projectName}.Host.csproj"), HostTemplates.HostCsproj(projectName)); + File.WriteAllText(Path.Combine(hostDir, "Program.cs"), HostTemplates.ProgramCs()); + File.WriteAllText(Path.Combine(hostDir, "Components", "App.razor"), HostTemplates.AppRazor(projectName)); + File.WriteAllText(Path.Combine(hostDir, "Components", "InertiaShell.razor"), HostTemplates.InertiaShellRazor(projectName)); File.WriteAllText(Path.Combine(hostDir, "Components", "Routes.razor"), HostTemplates.RoutesRazor()); - File.WriteAllText(Path.Combine(hostDir, "Components", "_Imports.razor"), hostTemplates.ImportsRazor(projectName)); - File.WriteAllText(Path.Combine(hostDir, "ClientApp", "app.tsx"), hostTemplates.AppTsx()); - File.WriteAllText(Path.Combine(hostDir, "ClientApp", "vite.config.ts"), hostTemplates.ViteConfig()); - File.WriteAllText(Path.Combine(hostDir, "ClientApp", "validate-pages.mjs"), hostTemplates.ValidatePages()); - File.WriteAllText(Path.Combine(hostDir, "ClientApp", "package.json"), hostTemplates.ClientAppPackageJson(projectName)); + File.WriteAllText(Path.Combine(hostDir, "Components", "_Imports.razor"), HostTemplates.ImportsRazor(projectName)); + File.WriteAllText(Path.Combine(hostDir, "ClientApp", "app.tsx"), HostTemplates.AppTsx()); + File.WriteAllText(Path.Combine(hostDir, "ClientApp", "vite.config.ts"), HostTemplates.ViteConfig()); + File.WriteAllText(Path.Combine(hostDir, "ClientApp", "validate-pages.mjs"), HostTemplates.ValidatePages()); + File.WriteAllText(Path.Combine(hostDir, "ClientApp", "package.json"), HostTemplates.ClientAppPackageJson(projectName)); File.WriteAllText(Path.Combine(hostDir, "Styles", "app.css"), HostTemplates.AppCss()); File.WriteAllText(Path.Combine(hostDir, "wwwroot", "css", "app.css"), MinimalAppCss()); - File.WriteAllText(Path.Combine(hostDir, "appsettings.json"), hostTemplates.AppSettings()); - File.WriteAllText(Path.Combine(hostDir, "appsettings.Development.json"), hostTemplates.AppSettingsDevelopment()); - File.WriteAllText(Path.Combine(hostDir, "Properties", "launchSettings.json"), hostTemplates.LaunchSettings(projectName)); + File.WriteAllText(Path.Combine(hostDir, "appsettings.json"), HostTemplates.AppSettings()); + File.WriteAllText(Path.Combine(hostDir, "appsettings.Development.json"), HostTemplates.AppSettingsDevelopment()); + File.WriteAllText(Path.Combine(hostDir, "Properties", "launchSettings.json"), HostTemplates.LaunchSettings(projectName)); // ── Home module ─────────────────────────────────────── File.WriteAllText(Path.Combine(contractsDir, $"{moduleName}.Contracts.csproj"), moduleTemplates.ContractsCsproj(moduleName)); diff --git a/cli/SimpleModule.Cli/Infrastructure/EmbeddedResourceReader.cs b/cli/SimpleModule.Cli/Infrastructure/EmbeddedResourceReader.cs new file mode 100644 index 00000000..9eebe73e --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/EmbeddedResourceReader.cs @@ -0,0 +1,39 @@ +using System.Reflection; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Reads template files embedded in the CLI assembly. +/// Resource names follow the pattern "Templates.Host.{path}" where path uses dots as separators. +/// +public static class EmbeddedResourceReader +{ + private static readonly Assembly CliAssembly = typeof(EmbeddedResourceReader).Assembly; + + /// + /// Reads an embedded host template resource as a string. + /// + /// Logical resource name (e.g., "Templates.Host.Program.cs") + public static string ReadTemplate(string resourceName) + { + using var stream = CliAssembly.GetManifestResourceStream(resourceName); + if (stream is null) + { + throw new InvalidOperationException( + $"Embedded resource '{resourceName}' not found in assembly." + ); + } + + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + /// + /// Reads an embedded host template resource as lines. + /// + public static List ReadTemplateLines(string resourceName) + { + var content = ReadTemplate(resourceName); + return content.Split(["\r\n", "\n"], StringSplitOptions.None).ToList(); + } +} diff --git a/cli/SimpleModule.Cli/SimpleModule.Cli.csproj b/cli/SimpleModule.Cli/SimpleModule.Cli.csproj index abccc313..ef6928ca 100644 --- a/cli/SimpleModule.Cli/SimpleModule.Cli.csproj +++ b/cli/SimpleModule.Cli/SimpleModule.Cli.csproj @@ -11,4 +11,20 @@ + + + + + + + + + + + + + + + + diff --git a/cli/SimpleModule.Cli/Templates/HostTemplates.cs b/cli/SimpleModule.Cli/Templates/HostTemplates.cs index 2574e180..59465929 100644 --- a/cli/SimpleModule.Cli/Templates/HostTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/HostTemplates.cs @@ -24,35 +24,15 @@ public sealed class HostTemplates "ReferenceOutputAssembly=", ]; - private readonly string? _templateHostDir; - - public HostTemplates(SolutionContext? solution) - { - _templateHostDir = solution is not null - ? Path.Combine(solution.RootPath, "template", "SimpleModule.Host") - : null; - } - /// /// Transform the Host .csproj: strip module ProjectReferences, ServiceDefaults, /// InternalsVisibleTo, NoWarn/SM0011/SM0035/SM0038/TODO lines, EF Core Design package, /// and the Import Project line. Replace framework ProjectReferences with PackageReferences. /// Replace SimpleModule with projectName. /// - public string HostCsproj(string projectName) + public static string HostCsproj(string projectName) { - if (_templateHostDir is null) - { - return FallbackHostCsproj(projectName); - } - - var path = Path.Combine(_templateHostDir, "SimpleModule.Host.csproj"); - if (!File.Exists(path)) - { - return FallbackHostCsproj(projectName); - } - - var lines = File.ReadAllLines(path).ToList(); + var lines = EmbeddedResourceReader.ReadTemplateLines("Templates.Host.SimpleModule.Host.csproj"); // Strip lines containing patterns that should not appear in a new project lines.RemoveAll(line => @@ -95,23 +75,12 @@ public string HostCsproj(string projectName) } /// - /// Copy Program.cs, remove ServiceDefaults and MapDefaultEndpoints lines. + /// Copy Program.cs, remove ServiceDefaults, MapDefaultEndpoints, and Storage.Local lines. /// Framework method names (AddSimpleModule, UseSimpleModule) are kept as-is. /// - public string ProgramCs() + public static string ProgramCs() { - if (_templateHostDir is null) - { - return FallbackProgramCs(); - } - - var path = Path.Combine(_templateHostDir, "Program.cs"); - if (!File.Exists(path)) - { - return FallbackProgramCs(); - } - - var lines = File.ReadAllLines(path).ToList(); + var lines = EmbeddedResourceReader.ReadTemplateLines("Templates.Host.Program.cs"); lines.RemoveAll(line => line.Contains("ServiceDefaults", StringComparison.Ordinal) @@ -128,136 +97,87 @@ public string ProgramCs() /// /// Copy App.razor, replace SimpleModule with projectName. /// - public string AppRazor(string projectName) + public static string AppRazor(string projectName) { - if (_templateHostDir is null) - { - return FallbackAppRazor(projectName); - } - - var path = Path.Combine(_templateHostDir, "Components", "App.razor"); - if (!File.Exists(path)) - { - return FallbackAppRazor(projectName); - } - - var content = File.ReadAllText(path); + var content = EmbeddedResourceReader.ReadTemplate("Templates.Host.Components.App.razor"); return ReplaceProjectName(content, projectName); } /// /// Copy InertiaShell.razor, replace SimpleModule with projectName. /// - public string InertiaShellRazor(string projectName) + public static string InertiaShellRazor(string projectName) { - if (_templateHostDir is null) - { - return FallbackInertiaShellRazor(projectName); - } - - var path = Path.Combine(_templateHostDir, "Components", "InertiaShell.razor"); - if (!File.Exists(path)) - { - return FallbackInertiaShellRazor(projectName); - } - - var content = File.ReadAllText(path); + var content = EmbeddedResourceReader.ReadTemplate("Templates.Host.Components.InertiaShell.razor"); return ReplaceProjectName(content, projectName); } /// - /// Generate a clean Routes.razor WITHOUT the AdditionalAssemblies Users module reference. + /// Copy Routes.razor, stripping AdditionalAssemblies references and + /// closing the Router tag properly. /// public static string RoutesRazor() { - return """ - - - - - - """; + var content = EmbeddedResourceReader.ReadTemplate("Templates.Host.Components.Routes.razor"); + + // Strip AdditionalAssemblies attribute (module-specific) + var lines = content.Split(["\r\n", "\n"], StringSplitOptions.None).ToList(); + lines.RemoveAll(line => line.Contains("AdditionalAssemblies", StringComparison.Ordinal)); + + // Fix unclosed Router tag: replace trailing open attribute list with closing > + for (var i = 0; i < lines.Count; i++) + { + var trimmed = lines[i].TrimEnd(); + if (trimmed.Contains("')) + { + lines[i] = trimmed + ">"; + } + } + + lines = TemplateExtractor.CollapseBlankLines(lines); + + return string.Join(Environment.NewLine, lines); } /// /// Copy _Imports.razor, replace SimpleModule with projectName. /// - public string ImportsRazor(string projectName) + public static string ImportsRazor(string projectName) { - if (_templateHostDir is null) - { - return FallbackImportsRazor(projectName); - } - - var path = Path.Combine(_templateHostDir, "Components", "_Imports.razor"); - if (!File.Exists(path)) - { - return FallbackImportsRazor(projectName); - } - - var content = File.ReadAllText(path); + var content = EmbeddedResourceReader.ReadTemplate("Templates.Host.Components._Imports.razor"); return ReplaceProjectName(content, projectName); } /// /// Copy app.tsx as-is. /// - public string AppTsx() + public static string AppTsx() { - if (_templateHostDir is null) - { - return FallbackAppTsx(); - } - - var path = Path.Combine(_templateHostDir, "ClientApp", "app.tsx"); - return File.Exists(path) ? File.ReadAllText(path) : FallbackAppTsx(); + return EmbeddedResourceReader.ReadTemplate("Templates.Host.ClientApp.app.tsx"); } /// /// Copy vite.config.ts as-is. /// - public string ViteConfig() + public static string ViteConfig() { - if (_templateHostDir is null) - { - return FallbackViteConfig(); - } - - var path = Path.Combine(_templateHostDir, "ClientApp", "vite.config.ts"); - return File.Exists(path) ? File.ReadAllText(path) : FallbackViteConfig(); + return EmbeddedResourceReader.ReadTemplate("Templates.Host.ClientApp.vite.config.ts"); } /// /// Copy validate-pages.mjs as-is. /// - public string ValidatePages() + public static string ValidatePages() { - if (_templateHostDir is null) - { - return FallbackValidatePages(); - } - - var path = Path.Combine(_templateHostDir, "ClientApp", "validate-pages.mjs"); - return File.Exists(path) ? File.ReadAllText(path) : FallbackValidatePages(); + return EmbeddedResourceReader.ReadTemplate("Templates.Host.ClientApp.validate-pages.mjs"); } /// /// Copy ClientApp/package.json, replace @simplemodule/app with @{projectName}/app. /// - public string ClientAppPackageJson(string projectName) + public static string ClientAppPackageJson(string projectName) { - if (_templateHostDir is null) - { - return FallbackClientAppPackageJson(projectName); - } - - var path = Path.Combine(_templateHostDir, "ClientApp", "package.json"); - if (!File.Exists(path)) - { - return FallbackClientAppPackageJson(projectName); - } - - var content = File.ReadAllText(path); + var content = EmbeddedResourceReader.ReadTemplate("Templates.Host.ClientApp.package.json"); return content.Replace( "@simplemodule/app", $"@{projectName.ToLowerInvariant()}/app", @@ -267,64 +187,49 @@ public string ClientAppPackageJson(string projectName) /// /// Return a clean Styles/app.css with tailwindcss import, theme import, - /// and @source directives for modules. NO _scan/ import. + /// and @source directives for modules. Strip _scan/ import if present. /// public static string AppCss() { - return """ - @import "tailwindcss"; - @import "@simplemodule/theme-default/theme.css"; - @source "../../../modules/**/Components/**/*.razor"; - @source "../../../modules/**/Views/**/*.tsx"; - @source "../../../modules/**/Pages/**/*.tsx"; - """; + var content = EmbeddedResourceReader.ReadTemplate("Templates.Host.Styles.app.css"); + + // Strip _scan/ references (build artifact, not needed in new projects) + var lines = content.Split(["\r\n", "\n"], StringSplitOptions.None).ToList(); + lines.RemoveAll(line => line.Contains("_scan/", StringComparison.Ordinal)); + + // Replace module source paths for new project structure (template/ → src/) + var result = string.Join(Environment.NewLine, lines); + result = result.Replace( + "../../modules/", + "../../../modules/", + StringComparison.Ordinal + ); + + return result; } /// /// Copy appsettings.json as-is. /// - public string AppSettings() + public static string AppSettings() { - if (_templateHostDir is null) - { - return FallbackAppSettings(); - } - - var path = Path.Combine(_templateHostDir, "appsettings.json"); - return File.Exists(path) ? File.ReadAllText(path) : FallbackAppSettings(); + return EmbeddedResourceReader.ReadTemplate("Templates.Host.appsettings.json"); } /// /// Copy appsettings.Development.json as-is. /// - public string AppSettingsDevelopment() + public static string AppSettingsDevelopment() { - if (_templateHostDir is null) - { - return FallbackAppSettingsDevelopment(); - } - - var path = Path.Combine(_templateHostDir, "appsettings.Development.json"); - return File.Exists(path) ? File.ReadAllText(path) : FallbackAppSettingsDevelopment(); + return EmbeddedResourceReader.ReadTemplate("Templates.Host.appsettings.Development.json"); } /// /// Copy launchSettings.json, replace SimpleModule with projectName. /// - public string LaunchSettings(string projectName) + public static string LaunchSettings(string projectName) { - if (_templateHostDir is null) - { - return FallbackLaunchSettings(projectName); - } - - var path = Path.Combine(_templateHostDir, "Properties", "launchSettings.json"); - if (!File.Exists(path)) - { - return FallbackLaunchSettings(projectName); - } - - var content = File.ReadAllText(path); + var content = EmbeddedResourceReader.ReadTemplate("Templates.Host.Properties.launchSettings.json"); return ReplaceProjectName(content, projectName); } @@ -424,198 +329,4 @@ private static List RemoveEmptyXmlBlocks(List lines, string elem return result; } - - // ── Fallback templates ──────────────────────────────────────────── - - private static string FallbackHostCsproj(string projectName) => - $""" - - - net10.0 - - - - - - - - - - - - """; - - private static string FallbackProgramCs() => - """ - using SimpleModule.Hosting; - - var builder = WebApplication.CreateBuilder(args); - - builder.AddSimpleModule(); - - var app = builder.Build(); - - await app.UseSimpleModule(); - - await app.RunAsync(); - """; - - private static string FallbackAppRazor(string projectName) => - $$""" - - - - - - {{projectName}} - - - - - - - - - """; - - private static string FallbackInertiaShellRazor(string projectName) => - $$""" - @using Microsoft.AspNetCore.Components - @using Microsoft.AspNetCore.Components.Web - -
- - @code { - [Parameter] public string PageJson { get; set; } = string.Empty; - } - """; - - private static string FallbackImportsRazor(string projectName) => - $$""" - @using Microsoft.AspNetCore.Components.Routing - @using Microsoft.AspNetCore.Components.Web - @using {{projectName}}.Host.Components - """; - - private static string FallbackAppTsx() => - """ - import { createInertiaApp } from '@inertiajs/react'; - import { createRoot } from 'react-dom/client'; - import { resolvePage } from '@simplemodule/client/resolve-page'; - - createInertiaApp({ - resolve: resolvePage, - setup({ el, App, props }) { - createRoot(el).render(); - }, - }); - """; - - private static string FallbackViteConfig() => - """ - import { defineConfig } from 'vite'; - import react from '@vitejs/plugin-react'; - import { simpleModuleVendor } from '@simplemodule/client/vite'; - - export default defineConfig({ - plugins: [simpleModuleVendor(), react()], - build: { - rollupOptions: { - input: 'app.tsx', - output: { - entryFileNames: 'js/app.js', - dir: '../wwwroot', - }, - }, - sourcemap: process.env.VITE_MODE !== 'prod', - minify: process.env.VITE_MODE === 'prod', - }, - }); - """; - - private static string FallbackValidatePages() => - """ - // Placeholder: validate-pages script - // Run this to check that all C# IViewEndpoints have matching page entries - console.log('validate-pages: OK'); - """; - - private static string FallbackClientAppPackageJson(string projectName) - { - var name = projectName.ToLowerInvariant(); - return $$""" - { - "private": true, - "name": "@{{name}}/app", - "version": "0.0.0", - "scripts": { - "build": "vite build", - "build:dev": "cross-env VITE_MODE=dev vite build", - "watch": "cross-env VITE_MODE=dev vite build --watch", - "validate-pages": "node validate-pages.mjs" - }, - "dependencies": { - "@inertiajs/react": "^2.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - } - } - """; - } - - private static string FallbackAppSettings() => - """ - { - "Database": { - "DefaultConnection": "Data Source=app.db" - }, - "Storage": { - "Provider": "Local", - "Local": { - "Path": "./storage" - } - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } - } - """; - - private static string FallbackAppSettingsDevelopment() => - """ - { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.EntityFrameworkCore.Database.Command": "Information" - }, - "Console": { - "FormatterName": "simple", - "FormatterOptions": { - "TimestampFormat": "HH:mm:ss ", - "SingleLine": true - } - } - } - } - """; - - private static string FallbackLaunchSettings(string projectName) => - $$""" - { - "profiles": { - "{{projectName}}.Host": { - "commandName": "Project", - "launchBrowser": false, - "applicationUrl": "https://localhost:5001;http://localhost:5000", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } - } - """; } From 50189571a9b54a0e232bddeda4254ab19647cabf Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 18:51:09 +0000 Subject: [PATCH 4/5] refactor: simplify CLI scaffolding code after review - Use static HttpClient to avoid socket exhaustion - Remove TOCTOU File.Exists check in NuGetVersionResolver - Use ReadTemplateLines in RoutesRazor/AppCss instead of manual split - Remove dead code in IntegrationTestSkeleton and KeepFirstTestMethod - Add missing wwwroot/css/app.css to PlanFiles - Extract test scaffold helper and version constant - Rename FallbackRootPackageJson to GenerateRootPackageJson --- .../Commands/New/NewProjectCommand.cs | 1 + .../Infrastructure/NuGetVersionResolver.cs | 16 +++---- .../Templates/HostTemplates.cs | 10 +---- .../Templates/ModuleTemplates.cs | 41 +---------------- .../Templates/ProjectTemplates.cs | 4 +- .../NewProjectScaffoldTests.cs | 45 ++++++++----------- 6 files changed, 31 insertions(+), 86 deletions(-) diff --git a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs index 5fffbc42..9fcb120f 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs @@ -206,6 +206,7 @@ public static void ScaffoldProject( Plan(Path.Combine(hostDir, "ClientApp", "validate-pages.mjs")); Plan(Path.Combine(hostDir, "ClientApp", "package.json")); Plan(Path.Combine(hostDir, "Styles", "app.css")); + Plan(Path.Combine(hostDir, "wwwroot", "css", "app.css")); Plan(Path.Combine(hostDir, "appsettings.json")); Plan(Path.Combine(hostDir, "appsettings.Development.json")); Plan(Path.Combine(hostDir, "Properties", "launchSettings.json")); diff --git a/cli/SimpleModule.Cli/Infrastructure/NuGetVersionResolver.cs b/cli/SimpleModule.Cli/Infrastructure/NuGetVersionResolver.cs index 2da44780..bb04566b 100644 --- a/cli/SimpleModule.Cli/Infrastructure/NuGetVersionResolver.cs +++ b/cli/SimpleModule.Cli/Infrastructure/NuGetVersionResolver.cs @@ -5,8 +5,11 @@ namespace SimpleModule.Cli.Infrastructure; public static class NuGetVersionResolver { private const string FallbackVersion = "0.0.15"; - private const string NuGetIndexUrl = - "https://api.nuget.org/v3-flatcontainer/simplemodule.core/index.json"; + private static readonly Uri NuGetIndexUri = + new("https://api.nuget.org/v3-flatcontainer/simplemodule.core/index.json"); + + // Shared HttpClient avoids socket exhaustion from repeated instantiation. + private static readonly HttpClient SharedHttpClient = new() { Timeout = TimeSpan.FromSeconds(5) }; /// /// Resolves the SimpleModule framework version to use for a new project. @@ -44,8 +47,8 @@ public static string ResolveVersion( { try { - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - var json = client.GetStringAsync(new Uri(NuGetIndexUrl)).GetAwaiter().GetResult(); + // Intentional sync-over-async: CLI runs single-threaded, no deadlock risk. + var json = SharedHttpClient.GetStringAsync(NuGetIndexUri).GetAwaiter().GetResult(); using var doc = JsonDocument.Parse(json); if ( @@ -81,11 +84,6 @@ public static string ResolveVersion( try { var path = Path.Combine(rootPath, "version.json"); - if (!File.Exists(path)) - { - return null; - } - var json = File.ReadAllText(path); using var doc = JsonDocument.Parse(json); if (doc.RootElement.TryGetProperty("version", out var version)) diff --git a/cli/SimpleModule.Cli/Templates/HostTemplates.cs b/cli/SimpleModule.Cli/Templates/HostTemplates.cs index 59465929..9936f0fa 100644 --- a/cli/SimpleModule.Cli/Templates/HostTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/HostTemplates.cs @@ -118,10 +118,7 @@ public static string InertiaShellRazor(string projectName) /// public static string RoutesRazor() { - var content = EmbeddedResourceReader.ReadTemplate("Templates.Host.Components.Routes.razor"); - - // Strip AdditionalAssemblies attribute (module-specific) - var lines = content.Split(["\r\n", "\n"], StringSplitOptions.None).ToList(); + var lines = EmbeddedResourceReader.ReadTemplateLines("Templates.Host.Components.Routes.razor"); lines.RemoveAll(line => line.Contains("AdditionalAssemblies", StringComparison.Ordinal)); // Fix unclosed Router tag: replace trailing open attribute list with closing > @@ -191,10 +188,7 @@ public static string ClientAppPackageJson(string projectName) /// public static string AppCss() { - var content = EmbeddedResourceReader.ReadTemplate("Templates.Host.Styles.app.css"); - - // Strip _scan/ references (build artifact, not needed in new projects) - var lines = content.Split(["\r\n", "\n"], StringSplitOptions.None).ToList(); + var lines = EmbeddedResourceReader.ReadTemplateLines("Templates.Host.Styles.app.css"); lines.RemoveAll(line => line.Contains("_scan/", StringComparison.Ordinal)); // Replace module source paths for new project structure (template/ → src/) diff --git a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs index c710ed9f..041ce5ef 100644 --- a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs @@ -635,36 +635,7 @@ public string IntegrationTestSkeleton(string moduleName, string singularName) } var otherModulePatterns = OtherModuleStripPatterns(); - - // Also strip test methods that reference cross-module types - var lines = File.ReadAllLines(refPath).ToList(); - - // Strip cross-module usings - lines.RemoveAll(line => - line.TrimStart().StartsWith("using ", StringComparison.Ordinal) - && otherModulePatterns.Any(p => line.Contains(p, StringComparison.Ordinal)) - ); - - // Keep only the first test method (GetAll returns 200), remove others - var testCount = 0; - lines = TemplateExtractor.RemoveBraceBlocks( - lines, - line => - { - if (line.Contains("[Fact]", StringComparison.Ordinal)) - { - testCount++; - return testCount > 1; - } - - return false; - } - ); - - // Also remove the [Fact] attribute lines for stripped tests - // (RemoveBraceBlocks removes from the line AFTER [Fact], so we need to handle this differently) - // Actually, let's re-approach: keep only first [Fact] and its method - lines = KeepFirstTestMethod(File.ReadAllLines(refPath).ToList(), otherModulePatterns); + var lines = KeepFirstTestMethod(File.ReadAllLines(refPath).ToList(), otherModulePatterns); lines = TemplateExtractor.CollapseBlankLines(lines); @@ -842,16 +813,6 @@ List otherModulePatterns { braceDepth += CountBraces(lines[i]); - // Once we see the method's closing brace - if (braceDepth > 0 && CountBraces(lines[i]) < 0) - { - var net = 0; - for (var j = i; j < lines.Count; j++) - { - net += CountBraces(lines[j]); - } - } - if (trimmed.StartsWith('}') && braceDepth <= 0) { skipping = false; diff --git a/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs b/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs index 2c69a18f..bb205ca5 100644 --- a/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs @@ -232,7 +232,7 @@ public string GlobalJson() public string RootPackageJson(string projectName, string? frameworkPackagesPath) { - return FallbackRootPackageJson(projectName, frameworkPackagesPath, _frameworkVersion); + return GenerateRootPackageJson(projectName, frameworkPackagesPath, _frameworkVersion); } public string BiomeJson() @@ -696,7 +696,7 @@ private static string FallbackTestsSharedCsproj(string projectName) => """; - private static string FallbackRootPackageJson( + private static string GenerateRootPackageJson( string projectName, string? frameworkPackagesPath, string frameworkVersion diff --git a/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.cs b/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.cs index 4d0391d0..bbedb75f 100644 --- a/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.cs +++ b/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.cs @@ -8,6 +8,7 @@ namespace SimpleModule.Cli.Tests; [Trait("Category", "Integration")] public sealed class NewProjectScaffoldTests : IDisposable { + private const string TestVersion = "0.0.15"; private readonly string _tempDir; public NewProjectScaffoldTests() @@ -31,12 +32,8 @@ public void Dispose() [Fact] public void Scaffold_CreatesExpectedFiles() { - const string projectName = "TestApp"; - var rootDir = Path.Combine(_tempDir, projectName); - - NewProjectCommand.ScaffoldProject(projectName, rootDir, solution: null, frameworkVersion: "0.0.15"); + var (projectName, rootDir) = ScaffoldStandalone(); - // Verify key files exist File.Exists(Path.Combine(rootDir, $"{projectName}.slnx")).Should().BeTrue(); File.Exists(Path.Combine(rootDir, "Directory.Build.props")).Should().BeTrue(); File.Exists(Path.Combine(rootDir, "Directory.Packages.props")).Should().BeTrue(); @@ -51,23 +48,17 @@ public void Scaffold_CreatesExpectedFiles() [Fact] public void Scaffold_DirectoryPackagesProps_UsesPublishedVersion() { - const string projectName = "TestApp"; - var rootDir = Path.Combine(_tempDir, projectName); - - NewProjectCommand.ScaffoldProject(projectName, rootDir, solution: null, frameworkVersion: "0.0.15"); + var (_, rootDir) = ScaffoldStandalone(); var content = File.ReadAllText(Path.Combine(rootDir, "Directory.Packages.props")); - content.Should().Contain("Version=\"0.0.15\""); + content.Should().Contain($"Version=\"{TestVersion}\""); content.Should().NotContain("0.1.0-local"); } [Fact] public void Scaffold_NugetConfig_OnlyContainsNuGetOrg() { - const string projectName = "TestApp"; - var rootDir = Path.Combine(_tempDir, projectName); - - NewProjectCommand.ScaffoldProject(projectName, rootDir, solution: null, frameworkVersion: "0.0.15"); + var (_, rootDir) = ScaffoldStandalone(); var content = File.ReadAllText(Path.Combine(rootDir, "nuget.config")); content.Should().Contain("nuget.org"); @@ -78,33 +69,28 @@ public void Scaffold_NugetConfig_OnlyContainsNuGetOrg() [Fact] public void Scaffold_PackageJson_UsesPublishedNpmPackages() { - const string projectName = "TestApp"; - var rootDir = Path.Combine(_tempDir, projectName); - - NewProjectCommand.ScaffoldProject(projectName, rootDir, solution: null, frameworkVersion: "0.0.15"); + var (_, rootDir) = ScaffoldStandalone(); var content = File.ReadAllText(Path.Combine(rootDir, "package.json")); - content.Should().Contain("\"@simplemodule/client\": \"^0.0.15\""); - content.Should().Contain("\"@simplemodule/ui\": \"^0.0.15\""); - content.Should().Contain("\"@simplemodule/theme-default\": \"^0.0.15\""); + content.Should().Contain($"\"@simplemodule/client\": \"^{TestVersion}\""); + content.Should().Contain($"\"@simplemodule/ui\": \"^{TestVersion}\""); + content.Should().Contain($"\"@simplemodule/theme-default\": \"^{TestVersion}\""); content.Should().NotContain("file:"); } [Fact] public void Scaffold_WithSolution_UsesLocalPackages() { - // When run from the framework repo, it should still use file: references for npm var solution = SolutionContext.Discover(); if (solution is null) { - // Skip if not running from framework repo return; } const string projectName = "TestApp"; var rootDir = Path.Combine(_tempDir, projectName); - NewProjectCommand.ScaffoldProject(projectName, rootDir, solution, frameworkVersion: "0.0.15"); + NewProjectCommand.ScaffoldProject(projectName, rootDir, solution, frameworkVersion: TestVersion); var content = File.ReadAllText(Path.Combine(rootDir, "package.json")); content.Should().Contain("file:"); @@ -113,14 +99,12 @@ public void Scaffold_WithSolution_UsesLocalPackages() [Fact] public void ScaffoldedProject_DotnetBuildSucceeds() { + var frameworkVersion = NuGetVersionResolver.ResolveVersion(); const string projectName = "TestApp"; var rootDir = Path.Combine(_tempDir, projectName); - var frameworkVersion = NuGetVersionResolver.ResolveVersion(); - NewProjectCommand.ScaffoldProject(projectName, rootDir, solution: null, frameworkVersion: frameworkVersion); - // Run dotnet build on the scaffolded project var slnxPath = Path.Combine(rootDir, $"{projectName}.slnx"); var psi = new ProcessStartInfo { @@ -147,4 +131,11 @@ public void ScaffoldedProject_DotnetBuildSucceeds() process.ExitCode.Should().Be(0, $"dotnet build should succeed.\n{buildOutput}"); } + + private (string ProjectName, string RootDir) ScaffoldStandalone(string projectName = "TestApp") + { + var rootDir = Path.Combine(_tempDir, projectName); + NewProjectCommand.ScaffoldProject(projectName, rootDir, solution: null, frameworkVersion: TestVersion); + return (projectName, rootDir); + } } From e1364838535957f1a48b29451b533b4136f2aa6f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 19:36:37 +0000 Subject: [PATCH 5/5] fix: fully exclude generated types.ts from biome Use file-level exclusion instead of per-tool overrides so biome completely skips auto-generated types.ts files. --- biome.json | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/biome.json b/biome.json index 002781e1..586586b5 100644 --- a/biome.json +++ b/biome.json @@ -38,23 +38,15 @@ } } }, - "overrides": [ - { - "includes": ["modules/*/src/*/types.ts"], - "linter": { - "rules": { - "suspicious": { - "noExplicitAny": "off" - }, - "complexity": { - "noBannedTypes": "off", - "noEmptyTypeParameters": "off" - } - } - } - } - ], "files": { - "includes": ["modules/**", "packages/**", "template/**", "tests/**", "docs/**", "!**/wwwroot"] + "includes": [ + "modules/**", + "packages/**", + "template/**", + "tests/**", + "docs/**", + "!**/wwwroot", + "!modules/*/src/*/types.ts" + ] } }