diff --git a/biome.json b/biome.json index 50f586a7..a75fb757 100644 --- a/biome.json +++ b/biome.json @@ -38,22 +38,6 @@ } } }, - "overrides": [ - { - "includes": ["modules/*/src/*/types.ts"], - "linter": { - "rules": { - "suspicious": { - "noExplicitAny": "off" - }, - "complexity": { - "noBannedTypes": "off", - "noEmptyTypeParameters": "off" - } - } - } - } - ], "files": { "includes": [ "modules/**", @@ -62,7 +46,8 @@ "tests/**", "docs/**", "website/**", - "!**/wwwroot" + "!**/wwwroot", + "!modules/*/src/*/types.ts" ] } } diff --git a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs index 94df179b..9fcb120f 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,169 @@ public override int Execute(CommandContext context, NewProjectSettings settings) return 1; } - var projectTemplates = new ProjectTemplates(solution); - var hostTemplates = new HostTemplates(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 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")); @@ -65,6 +206,7 @@ public override int Execute(CommandContext context, NewProjectSettings settings) 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")); @@ -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/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/Infrastructure/NuGetVersionResolver.cs b/cli/SimpleModule.Cli/Infrastructure/NuGetVersionResolver.cs new file mode 100644 index 00000000..bb04566b --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/NuGetVersionResolver.cs @@ -0,0 +1,105 @@ +using System.Text.Json; + +namespace SimpleModule.Cli.Infrastructure; + +public static class NuGetVersionResolver +{ + private const string FallbackVersion = "0.0.15"; + 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. + /// 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 + { + // 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 ( + 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"); + 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/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 ce984aa2..9936f0fa 100644 --- a/cli/SimpleModule.Cli/Templates/HostTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/HostTemplates.cs @@ -24,23 +24,15 @@ public sealed class HostTemplates "ReferenceOutputAssembly=", ]; - private readonly string _templateHostDir; - - public HostTemplates(SolutionContext solution) - { - _templateHostDir = Path.Combine(solution.RootPath, "template", "SimpleModule.Host"); - } - /// /// 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) { - var path = Path.Combine(_templateHostDir, "SimpleModule.Host.csproj"); - 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 => @@ -83,17 +75,18 @@ 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() { - var path = Path.Combine(_templateHostDir, "Program.cs"); - var lines = File.ReadAllLines(path).ToList(); + var lines = EmbeddedResourceReader.ReadTemplateLines("Templates.Host.Program.cs"); 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); @@ -104,81 +97,84 @@ public string ProgramCs() /// /// Copy App.razor, replace SimpleModule with projectName. /// - public string AppRazor(string projectName) + public static string AppRazor(string projectName) { - var path = Path.Combine(_templateHostDir, "Components", "App.razor"); - 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) { - var path = Path.Combine(_templateHostDir, "Components", "InertiaShell.razor"); - 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 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 > + 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) { - var path = Path.Combine(_templateHostDir, "Components", "_Imports.razor"); - 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() { - var path = Path.Combine(_templateHostDir, "ClientApp", "app.tsx"); - return File.ReadAllText(path); + return EmbeddedResourceReader.ReadTemplate("Templates.Host.ClientApp.app.tsx"); } /// /// Copy vite.config.ts as-is. /// - public string ViteConfig() + public static string ViteConfig() { - var path = Path.Combine(_templateHostDir, "ClientApp", "vite.config.ts"); - return File.ReadAllText(path); + return EmbeddedResourceReader.ReadTemplate("Templates.Host.ClientApp.vite.config.ts"); } /// /// Copy validate-pages.mjs as-is. /// - public string ValidatePages() + public static string ValidatePages() { - var path = Path.Combine(_templateHostDir, "ClientApp", "validate-pages.mjs"); - return File.ReadAllText(path); + 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) { - var path = Path.Combine(_templateHostDir, "ClientApp", "package.json"); - var content = File.ReadAllText(path); + var content = EmbeddedResourceReader.ReadTemplate("Templates.Host.ClientApp.package.json"); return content.Replace( "@simplemodule/app", $"@{projectName.ToLowerInvariant()}/app", @@ -188,44 +184,46 @@ 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 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/) + 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() { - var path = Path.Combine(_templateHostDir, "appsettings.json"); - return File.ReadAllText(path); + return EmbeddedResourceReader.ReadTemplate("Templates.Host.appsettings.json"); } /// /// Copy appsettings.Development.json as-is. /// - public string AppSettingsDevelopment() + public static string AppSettingsDevelopment() { - var path = Path.Combine(_templateHostDir, "appsettings.Development.json"); - return File.ReadAllText(path); + 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) { - var path = Path.Combine(_templateHostDir, "Properties", "launchSettings.json"); - var content = File.ReadAllText(path); + var content = EmbeddedResourceReader.ReadTemplate("Templates.Host.Properties.launchSettings.json"); return ReplaceProjectName(content, projectName); } diff --git a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs index 07ceaf6a..041ce5ef 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) @@ -633,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); @@ -701,7 +674,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 +685,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 +696,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; } @@ -840,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; @@ -1002,16 +965,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 +988,8 @@ IOptions dbOptions protected override void OnModelCreating(ModelBuilder modelBuilder) { + ArgumentNullException.ThrowIfNull(modelBuilder); + modelBuilder.Entity<{{singularName}}>(entity => { entity.HasKey(e => e.Id); @@ -1053,7 +1010,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 +1032,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..bb205ca5 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 GenerateRootPackageJson(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 GenerateRootPackageJson( + 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/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; } + diff --git a/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.cs b/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.cs new file mode 100644 index 00000000..bbedb75f --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.cs @@ -0,0 +1,141 @@ +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 const string TestVersion = "0.0.15"; + 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() + { + var (projectName, rootDir) = ScaffoldStandalone(); + + 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() + { + var (_, rootDir) = ScaffoldStandalone(); + + var content = File.ReadAllText(Path.Combine(rootDir, "Directory.Packages.props")); + content.Should().Contain($"Version=\"{TestVersion}\""); + content.Should().NotContain("0.1.0-local"); + } + + [Fact] + public void Scaffold_NugetConfig_OnlyContainsNuGetOrg() + { + var (_, rootDir) = ScaffoldStandalone(); + + 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() + { + var (_, rootDir) = ScaffoldStandalone(); + + var content = File.ReadAllText(Path.Combine(rootDir, "package.json")); + 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() + { + var solution = SolutionContext.Discover(); + if (solution is null) + { + return; + } + + const string projectName = "TestApp"; + var rootDir = Path.Combine(_tempDir, projectName); + + NewProjectCommand.ScaffoldProject(projectName, rootDir, solution, frameworkVersion: TestVersion); + + var content = File.ReadAllText(Path.Combine(rootDir, "package.json")); + content.Should().Contain("file:"); + } + + [Fact] + public void ScaffoldedProject_DotnetBuildSucceeds() + { + var frameworkVersion = NuGetVersionResolver.ResolveVersion(); + const string projectName = "TestApp"; + var rootDir = Path.Combine(_tempDir, projectName); + + NewProjectCommand.ScaffoldProject(projectName, rootDir, solution: null, frameworkVersion: frameworkVersion); + + 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}"); + } + + 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); + } +}