From 925a7e11b545722807fe0869c05fa3c63334b76b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 26 Mar 2026 21:06:16 +0100 Subject: [PATCH] feat(cli): improve sm new project to scaffold a complete working app Rewrites `sm new project` to generate a fully functional project with: - Host project (Blazor SSR + Inertia.js) from template - Items starter module with API endpoint, welcome page, and tests - All root config files (slnx, package.json, biome.json, tsconfig, etc.) - NuGet package references to framework (local feed at nupkg/) - Frontend build pipeline (Vite + React + module pages) Key changes: - Add HostTemplates class for Host project file generation - Add root config templates (package.json, biome.json, tsconfig, etc.) - Make SolutionContext discover Host/Api csproj dynamically - Make framework projects packable as local NuGet packages - Source generator dynamically resolves host assembly namespace - Build targets gracefully skip when monorepo tools are missing - Register baseline auth and DefaultPublicMenuProvider in framework - Use EnsureCreated fallback when no migrations exist - Add welcome page with gradient landing design and dark mode - Add minimal CSS for Blazor layout without Tailwind compilation - Add doctor checks: ModuleAttribute, ContractsIsolation, ViewEndpoint, PagesRegistry, ViteConfig, PackageJson, NpmWorkspace - Improve sm new module with spinner, tree output, and dry-run - Improve sm new feature with React view scaffolding --- .../Doctor/Checks/ContractsIsolationCheck.cs | 41 + .../Doctor/Checks/CsprojConventionCheck.cs | 27 - .../Doctor/Checks/ModuleAttributeCheck.cs | 31 + .../Doctor/Checks/NpmWorkspaceCheck.cs | 50 + .../Doctor/Checks/PackageJsonCheck.cs | 46 + .../Doctor/Checks/PagesRegistryCheck.cs | 51 + .../Doctor/Checks/ViewEndpointNamingCheck.cs | 28 + .../Commands/Doctor/Checks/ViteConfigCheck.cs | 42 + .../Commands/Doctor/DoctorCommand.cs | 42 +- .../Commands/New/NewFeatureCommand.cs | 85 +- .../Commands/New/NewFeatureSettings.cs | 8 + .../Commands/New/NewModuleCommand.cs | 191 +- .../Commands/New/NewModuleSettings.cs | 8 +- .../Commands/New/NewProjectCommand.cs | 665 ++++- .../Commands/New/NewProjectSettings.cs | 4 + .../Infrastructure/FileAction.cs | 7 + .../Infrastructure/NpmWorkspaceFixer.cs | 31 + .../Infrastructure/PagesRegistryFixer.cs | 30 + .../Infrastructure/SolutionContext.cs | 58 +- .../Templates/FeatureTemplates.cs | 15 + .../Templates/HostTemplates.cs | 328 +++ .../Templates/ModuleTemplates.cs | 97 +- .../Templates/ProjectTemplates.cs | 234 +- .../plans/2026-03-26-cli-improvements.md | 2344 +++++++++++++++++ .../plans/2026-03-26-new-project-scaffold.md | 986 +++++++ .../2026-03-26-cli-improvements-design.md | 203 ++ .../2026-03-26-new-project-scaffold-design.md | 177 ++ .../SimpleModule.Blazor.csproj | 2 + .../SimpleModule.Core.csproj | 2 + .../SimpleModule.Database.csproj | 2 + .../SimpleModule.DevTools.csproj | 2 + .../Discovery/DiscoveryData.cs | 10 +- .../Discovery/SymbolDiscovery.cs | 5 +- .../Emitters/HostDbContextEmitter.cs | 2 +- .../Emitters/HostingExtensionsEmitter.cs | 4 +- .../ValueConverterConventionsEmitter.cs | 2 +- .../SimpleModule.Generator.csproj | 8 + .../DefaultPublicMenuProvider.cs | 18 + .../SimpleModule.Hosting.csproj | 5 + .../SimpleModuleHostExtensions.cs | 26 +- .../build/SimpleModule.Hosting.targets | 11 +- .../ContractsIsolationCheckTests.cs | 71 + .../ModuleAttributeCheckTests.cs | 71 + .../NewFeatureViewScaffoldingTests.cs | 67 + .../NpmWorkspaceCheckTests.cs | 63 + .../PackageJsonCheckTests.cs | 67 + .../PagesRegistryCheckTests.cs | 86 + .../PagesRegistryFixerTests.cs | 45 + .../SolutionContextTests.cs | 20 + .../ViewEndpointNamingCheckTests.cs | 58 + .../ViteConfigCheckTests.cs | 75 + .../HostDbContextGenerationTests.cs | 2 +- .../TopologicalSortTests.cs | 9 +- 53 files changed, 6287 insertions(+), 275 deletions(-) create mode 100644 cli/SimpleModule.Cli/Commands/Doctor/Checks/ContractsIsolationCheck.cs create mode 100644 cli/SimpleModule.Cli/Commands/Doctor/Checks/ModuleAttributeCheck.cs create mode 100644 cli/SimpleModule.Cli/Commands/Doctor/Checks/NpmWorkspaceCheck.cs create mode 100644 cli/SimpleModule.Cli/Commands/Doctor/Checks/PackageJsonCheck.cs create mode 100644 cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs create mode 100644 cli/SimpleModule.Cli/Commands/Doctor/Checks/ViewEndpointNamingCheck.cs create mode 100644 cli/SimpleModule.Cli/Commands/Doctor/Checks/ViteConfigCheck.cs create mode 100644 cli/SimpleModule.Cli/Infrastructure/FileAction.cs create mode 100644 cli/SimpleModule.Cli/Infrastructure/NpmWorkspaceFixer.cs create mode 100644 cli/SimpleModule.Cli/Infrastructure/PagesRegistryFixer.cs create mode 100644 cli/SimpleModule.Cli/Templates/HostTemplates.cs create mode 100644 docs/superpowers/plans/2026-03-26-cli-improvements.md create mode 100644 docs/superpowers/plans/2026-03-26-new-project-scaffold.md create mode 100644 docs/superpowers/specs/2026-03-26-cli-improvements-design.md create mode 100644 docs/superpowers/specs/2026-03-26-new-project-scaffold-design.md create mode 100644 framework/SimpleModule.Hosting/DefaultPublicMenuProvider.cs create mode 100644 tests/SimpleModule.Cli.Tests/ContractsIsolationCheckTests.cs create mode 100644 tests/SimpleModule.Cli.Tests/ModuleAttributeCheckTests.cs create mode 100644 tests/SimpleModule.Cli.Tests/NewFeatureViewScaffoldingTests.cs create mode 100644 tests/SimpleModule.Cli.Tests/NpmWorkspaceCheckTests.cs create mode 100644 tests/SimpleModule.Cli.Tests/PackageJsonCheckTests.cs create mode 100644 tests/SimpleModule.Cli.Tests/PagesRegistryCheckTests.cs create mode 100644 tests/SimpleModule.Cli.Tests/PagesRegistryFixerTests.cs create mode 100644 tests/SimpleModule.Cli.Tests/ViewEndpointNamingCheckTests.cs create mode 100644 tests/SimpleModule.Cli.Tests/ViteConfigCheckTests.cs diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/ContractsIsolationCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ContractsIsolationCheck.cs new file mode 100644 index 00000000..545dc4f4 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ContractsIsolationCheck.cs @@ -0,0 +1,41 @@ +using System.Xml.Linq; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed class ContractsIsolationCheck : IDoctorCheck +{ + public IEnumerable Run(SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var contractsCsproj = Path.Combine( + solution.GetModuleContractsPath(module), + $"{module}.Contracts.csproj" + ); + + if (!File.Exists(contractsCsproj)) + { + yield return new CheckResult( + $"{module}.Contracts isolation", + CheckStatus.Warning, + $"{module}.Contracts.csproj not found" + ); + continue; + } + + var doc = XDocument.Load(contractsCsproj); + var nonCoreRefs = doc.Root! + .Descendants("ProjectReference") + .Select(pr => pr.Attribute("Include")?.Value ?? "") + .Where(r => !r.Contains("Core", StringComparison.OrdinalIgnoreCase) + && !r.Contains("Contracts", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + yield return nonCoreRefs.Count == 0 + ? new CheckResult($"{module}.Contracts isolation", CheckStatus.Pass, "references Core only") + : new CheckResult($"{module}.Contracts isolation", CheckStatus.Fail, + $"references non-Core projects: {string.Join(", ", nonCoreRefs.Select(Path.GetFileNameWithoutExtension))}"); + } + } +} diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs index f8553b27..9d56d421 100644 --- a/cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs @@ -40,33 +40,6 @@ public IEnumerable Run(SolutionContext solution) $"{module}.csproj not found" ); } - - var contractsCsproj = Path.Combine( - solution.GetModuleContractsPath(module), - $"{module}.Contracts.csproj" - ); - if (File.Exists(contractsCsproj)) - { - var doc = XDocument.Load(contractsCsproj); - var refs = doc.Root!.Descendants("ProjectReference") - .Select(pr => pr.Attribute("Include")?.Value ?? "") - .ToList(); - - var onlyRefsCore = refs.All(r => - r.Contains("Core", StringComparison.OrdinalIgnoreCase) - ); - yield return onlyRefsCore - ? new CheckResult( - $"{module}.Contracts refs", - CheckStatus.Pass, - "references Core only" - ) - : new CheckResult( - $"{module}.Contracts refs", - CheckStatus.Warning, - "contracts project references more than just Core" - ); - } } // Generator check diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/ModuleAttributeCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ModuleAttributeCheck.cs new file mode 100644 index 00000000..33d4c5f7 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ModuleAttributeCheck.cs @@ -0,0 +1,31 @@ +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed class ModuleAttributeCheck : IDoctorCheck +{ + public IEnumerable Run(SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var moduleClassPath = Path.Combine(solution.GetModuleProjectPath(module), $"{module}Module.cs"); + + if (!File.Exists(moduleClassPath)) + { + yield return new CheckResult($"{module} [Module] attribute", CheckStatus.Warning, + $"{module}Module.cs not found — skipping attribute check"); + continue; + } + + var content = File.ReadAllText(moduleClassPath); + var hasAttribute = content.Contains("[Module(", StringComparison.Ordinal); + var hasRoutePrefix = content.Contains("RoutePrefix", StringComparison.Ordinal); + + yield return (hasAttribute && hasRoutePrefix) + ? new CheckResult($"{module} [Module] attribute", CheckStatus.Pass, "[Module] attribute with RoutePrefix present") + : new CheckResult($"{module} [Module] attribute", CheckStatus.Fail, + hasAttribute ? "[Module] attribute present but RoutePrefix is missing" + : $"{module}Module.cs missing [Module] attribute"); + } + } +} diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/NpmWorkspaceCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/NpmWorkspaceCheck.cs new file mode 100644 index 00000000..895f897a --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/NpmWorkspaceCheck.cs @@ -0,0 +1,50 @@ +using System.Text.RegularExpressions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed partial class NpmWorkspaceCheck : IDoctorCheck +{ + [GeneratedRegex(@"""workspaces""\s*:\s*\[([^\]]*)\]", RegexOptions.Singleline)] + private static partial Regex WorkspacesPattern(); + + public IEnumerable Run(SolutionContext solution) + { + var rootPackageJson = Path.Combine(solution.RootPath, "package.json"); + + if (!File.Exists(rootPackageJson)) + { + foreach (var module in solution.ExistingModules) + yield return new CheckResult($"NpmWorkspace -> {module}", CheckStatus.Warning, + "root package.json not found — cannot verify workspaces"); + yield break; + } + + var content = File.ReadAllText(rootPackageJson); + var match = WorkspacesPattern().Match(content); + var workspaceGlobs = match.Success + ? match.Groups[1].Value + .Split(',') + .Select(g => g.Trim().Trim('"')) + .Where(g => !string.IsNullOrWhiteSpace(g)) + .ToList() + : []; + + foreach (var module in solution.ExistingModules) + { + var modulePath = $"src/modules/{module}/src/{module}"; + var isCovered = workspaceGlobs.Any(glob => GlobCoversPath(glob, modulePath)); + + yield return isCovered + ? new CheckResult($"NpmWorkspace -> {module}", CheckStatus.Pass, "covered by workspace glob") + : new CheckResult($"NpmWorkspace -> {module}", CheckStatus.Fail, + $"'{modulePath}' not covered by any workspace glob in root package.json"); + } + } + + private static bool GlobCoversPath(string glob, string path) + { + var pattern = "^" + Regex.Escape(glob).Replace(@"\*", "[^/]+", StringComparison.Ordinal) + "$"; + return Regex.IsMatch(path, pattern, RegexOptions.IgnoreCase); + } +} diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/PackageJsonCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/PackageJsonCheck.cs new file mode 100644 index 00000000..d9534c2a --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/PackageJsonCheck.cs @@ -0,0 +1,46 @@ +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed class PackageJsonCheck : IDoctorCheck +{ + private static readonly string[] RequiredPeerDeps = ["react", "@inertiajs/react"]; + + public IEnumerable Run(SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var packageJsonPath = Path.Combine(solution.GetModuleProjectPath(module), "package.json"); + + if (!File.Exists(packageJsonPath)) + { + yield return new CheckResult($"{module} package.json", CheckStatus.Warning, "package.json not found"); + continue; + } + + var content = File.ReadAllText(packageJsonPath); + var peerDepsStart = content.IndexOf("\"peerDependencies\"", StringComparison.Ordinal); + + var missingFromPeerDeps = RequiredPeerDeps + .Where(dep => + { + if (peerDepsStart < 0) return true; + var searchFrom = peerDepsStart; + var idx = content.IndexOf($"\"{dep}\"", searchFrom, StringComparison.Ordinal); + return idx < 0; + }) + .ToList(); + + if (missingFromPeerDeps.Count == 0) + { + yield return new CheckResult($"{module} package.json", CheckStatus.Pass, + "React and @inertiajs/react declared as peerDependencies"); + } + else + { + yield return new CheckResult($"{module} package.json", CheckStatus.Warning, + $"missing from peerDependencies: {string.Join(", ", missingFromPeerDeps)}"); + } + } + } +} diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs new file mode 100644 index 00000000..eff0e6dc --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs @@ -0,0 +1,51 @@ +using System.Text.RegularExpressions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed partial class PagesRegistryCheck : IDoctorCheck +{ + [GeneratedRegex(@"Inertia\.Render\s*\(\s*""([^""]+)""")] + private static partial Regex InertiaRenderPattern(); + + [GeneratedRegex(@"""([^""]+)""\s*:\s*(?:\(\s*\)|(?:async\s*)?\(\s*\)\s*=>|import)")] + private static partial Regex PagesKeyPattern(); + + public IEnumerable Run(SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var moduleDir = solution.GetModuleProjectPath(module); + if (!Directory.Exists(moduleDir)) + continue; + + var csFiles = Directory.GetFiles(moduleDir, "*.cs", SearchOption.AllDirectories); + var inertiaComponents = csFiles + .SelectMany(f => InertiaRenderPattern().Matches(File.ReadAllText(f))) + .Select(m => m.Groups[1].Value) + .Distinct() + .ToList(); + + if (inertiaComponents.Count == 0) + continue; + + var indexPath = solution.GetModulePagesIndexPath(module); + var registeredKeys = new HashSet(StringComparer.Ordinal); + + if (File.Exists(indexPath)) + { + var indexContent = File.ReadAllText(indexPath); + foreach (Match m in PagesKeyPattern().Matches(indexContent)) + registeredKeys.Add(m.Groups[1].Value); + } + + foreach (var component in inertiaComponents) + { + yield return registeredKeys.Contains(component) + ? new CheckResult($"Pages -> {component}", CheckStatus.Pass, "registered in Pages/index.ts") + : new CheckResult($"Pages -> {component}", CheckStatus.Fail, + $"'{component}' used in Inertia.Render but missing from Pages/index.ts"); + } + } + } +} diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/ViewEndpointNamingCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ViewEndpointNamingCheck.cs new file mode 100644 index 00000000..22a6f753 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ViewEndpointNamingCheck.cs @@ -0,0 +1,28 @@ +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed class ViewEndpointNamingCheck : IDoctorCheck +{ + public IEnumerable Run(SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var endpointsDir = Path.Combine(solution.GetModuleProjectPath(module), "Endpoints", module); + if (!Directory.Exists(endpointsDir)) + continue; + + foreach (var file in Directory.GetFiles(endpointsDir, "*.cs")) + { + var fileName = Path.GetFileNameWithoutExtension(file); + if (fileName.EndsWith("Validator", StringComparison.Ordinal)) + continue; + + yield return fileName.EndsWith("Endpoint", StringComparison.Ordinal) + ? new CheckResult($"{module}/{fileName}", CheckStatus.Pass, "follows Endpoint naming convention") + : new CheckResult($"{module}/{fileName}", CheckStatus.Warning, + $"'{fileName}' does not end with 'Endpoint' — rename to '{fileName}Endpoint'"); + } + } + } +} diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/ViteConfigCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ViteConfigCheck.cs new file mode 100644 index 00000000..1f7adb87 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ViteConfigCheck.cs @@ -0,0 +1,42 @@ +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed class ViteConfigCheck : IDoctorCheck +{ + private static readonly string[] RequiredExternals = ["react", "react-dom", "@inertiajs/react"]; + + public IEnumerable Run(SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var viteConfigPath = Path.Combine(solution.GetModuleProjectPath(module), "vite.config.ts"); + + if (!File.Exists(viteConfigPath)) + { + yield return new CheckResult($"{module} vite.config.ts", CheckStatus.Warning, + "vite.config.ts not found — module won't build as a library"); + continue; + } + + var content = File.ReadAllText(viteConfigPath); + var hasLibMode = content.Contains("lib:", StringComparison.Ordinal); + var missingExternals = RequiredExternals + .Where(e => !content.Contains(e, StringComparison.Ordinal)) + .ToList(); + + if (hasLibMode && missingExternals.Count == 0) + { + yield return new CheckResult($"{module} vite.config.ts", CheckStatus.Pass, + "library mode configured with correct externals"); + } + else + { + var issues = new List(); + if (!hasLibMode) issues.Add("missing lib mode"); + if (missingExternals.Count > 0) issues.Add($"missing externals: {string.Join(", ", missingExternals)}"); + yield return new CheckResult($"{module} vite.config.ts", CheckStatus.Warning, string.Join("; ", issues)); + } + } + } +} diff --git a/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs b/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs index 13bdc604..2d996d12 100644 --- a/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs +++ b/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs @@ -27,7 +27,14 @@ public override int Execute(CommandContext context, DoctorSettings settings) new ProjectReferenceCheck(), new SlnxEntriesCheck(), new CsprojConventionCheck(), + new ContractsIsolationCheck(), new ModulePatternCheck(), + new ModuleAttributeCheck(), + new ViewEndpointNamingCheck(), + new PagesRegistryCheck(), + new ViteConfigCheck(), + new PackageJsonCheck(), + new NpmWorkspaceCheck(), ]; var results = new List(); @@ -80,7 +87,7 @@ public override int Execute(CommandContext context, DoctorSettings settings) if (!settings.Fix) { AnsiConsole.MarkupLine( - "[dim]Run with --fix to auto-fix missing slnx entries and project references.[/]" + "[dim]Run with --fix to auto-fix missing slnx entries, project references, Pages registry entries, and npm workspace globs.[/]" ); } @@ -127,6 +134,39 @@ private static void AutoFix(SolutionContext solution, List results) $"[green] Fixed: added {moduleName} reference to API csproj[/]" ); } + + // Fix missing Pages/index.ts entries + if (result.Name.StartsWith("Pages -> ", StringComparison.Ordinal)) + { + var componentKey = result.Name["Pages -> ".Length..]; + var slashIndex = componentKey.IndexOf('/', StringComparison.Ordinal); + var moduleName = slashIndex >= 0 + ? componentKey[..slashIndex] + : componentKey; + var featureName = slashIndex >= 0 + ? componentKey[(slashIndex + 1)..] + : componentKey; + var indexPath = solution.GetModulePagesIndexPath(moduleName); + PagesRegistryFixer.AddEntry(indexPath, componentKey, $"../Views/{featureName}"); + AnsiConsole.MarkupLine($"[green] Fixed: added '{componentKey}' to Pages/index.ts[/]"); + } + + // Fix missing npm workspace entries + if (result.Name.StartsWith("NpmWorkspace -> ", StringComparison.Ordinal)) + { + var moduleName = result.Name["NpmWorkspace -> ".Length..]; + var rootPackageJson = Path.Combine(solution.RootPath, "package.json"); + if (File.Exists(rootPackageJson)) + { + NpmWorkspaceFixer.AddWorkspaceGlob( + rootPackageJson, + $"src/modules/{moduleName}/src/*" + ); + AnsiConsole.MarkupLine( + $"[green] Fixed: added {moduleName} workspace glob to package.json[/]" + ); + } + } } AnsiConsole.MarkupLine(""); diff --git a/cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs index d9697363..002214bf 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs @@ -12,17 +12,13 @@ public override int Execute(CommandContext context, NewFeatureSettings settings) var solution = SolutionContext.Discover(); if (solution is null) { - AnsiConsole.MarkupLine( - "[red]Could not find .slnx file. Run this command from within a SimpleModule project.[/]" - ); + AnsiConsole.MarkupLine("[red]No .slnx file found. Run this command from inside a SimpleModule project.[/]"); return 1; } if (solution.ExistingModules.Count == 0) { - AnsiConsole.MarkupLine( - "[red]No modules found. Create a module first with 'sm new module'.[/]" - ); + AnsiConsole.MarkupLine("[red]No modules found. Create a module first with 'sm new module'.[/]"); return 1; } @@ -34,36 +30,67 @@ public override int Execute(CommandContext context, NewFeatureSettings settings) var singularName = ModuleTemplates.GetSingularName(moduleName); var templates = new FeatureTemplates(solution); + var ops = new List<(string Path, FileAction Action)>(); - var endpointsDir = Path.Combine( - solution.GetModuleProjectPath(moduleName), - "Endpoints", - moduleName - ); - Directory.CreateDirectory(endpointsDir); + var endpointsDir = Path.Combine(solution.GetModuleProjectPath(moduleName), "Endpoints", moduleName); + + ops.Add((Path.Combine(endpointsDir, $"{featureName}Endpoint.cs"), FileAction.Create)); + if (includeValidator) + ops.Add((Path.Combine(endpointsDir, $"{featureName}RequestValidator.cs"), FileAction.Create)); + + if (!settings.NoView) + { + ops.Add((Path.Combine(solution.GetModuleViewsPath(moduleName), $"{featureName}.tsx"), FileAction.Create)); + ops.Add((solution.GetModulePagesIndexPath(moduleName), FileAction.Modify)); + } - // Create endpoint (implements IEndpoint — auto-discovered by source generator) - var endpointPath = Path.Combine(endpointsDir, $"{featureName}Endpoint.cs"); - File.WriteAllText( - endpointPath, - templates.Endpoint(moduleName, featureName, httpMethod, route, singularName) - ); - AnsiConsole.MarkupLine($"[green] + {featureName}Endpoint.cs[/]"); + if (settings.DryRun) + { + RenderDryRunTree(ops); + return 0; + } + + Directory.CreateDirectory(endpointsDir); + WriteFile(Path.Combine(endpointsDir, $"{featureName}Endpoint.cs"), + templates.Endpoint(moduleName, featureName, httpMethod, route, singularName)); - // Create validator if requested if (includeValidator) + WriteFile(Path.Combine(endpointsDir, $"{featureName}RequestValidator.cs"), + templates.Validator(moduleName, featureName, singularName)); + + if (!settings.NoView) { - var validatorPath = Path.Combine(endpointsDir, $"{featureName}RequestValidator.cs"); - File.WriteAllText( - validatorPath, - templates.Validator(moduleName, featureName, singularName) - ); - AnsiConsole.MarkupLine($"[green] + {featureName}RequestValidator.cs[/]"); + var viewsDir = solution.GetModuleViewsPath(moduleName); + Directory.CreateDirectory(viewsDir); + WriteFile(Path.Combine(viewsDir, $"{featureName}.tsx"), + FeatureTemplates.ViewComponent(moduleName, featureName)); + + var indexPath = solution.GetModulePagesIndexPath(moduleName); + PagesRegistryFixer.AddEntry(indexPath, $"{moduleName}/{featureName}", $"../Views/{featureName}"); + AnsiConsole.MarkupLine($"[green] ~ Pages/index.ts[/]"); } - AnsiConsole.MarkupLine( - $"[green]Endpoint '{featureName}' added to '{moduleName}' (auto-discovered via IEndpoint).[/]" - ); + AnsiConsole.MarkupLine($"\n[green]Feature '{featureName}' added to '{moduleName}'.[/]"); return 0; } + + private static void WriteFile(string path, string content) + { + File.WriteAllText(path, content); + AnsiConsole.MarkupLine($"[green] + {Markup.Escape(Path.GetFileName(path))}[/]"); + } + + private static void RenderDryRunTree(List<(string Path, FileAction Action)> ops) + { + AnsiConsole.MarkupLine("[dim]Dry run — no files written[/]\n"); + var tree = new Tree("[dim]Would create/modify:[/]"); + foreach (var (path, action) in ops) + { + var label = action == FileAction.Modify + ? $"[yellow]{Markup.Escape(Path.GetFileName(path))}[/] [dim](modify)[/]" + : $"[green]{Markup.Escape(Path.GetFileName(path))}[/] [dim](create)[/]"; + tree.AddNode(label); + } + AnsiConsole.Write(tree); + } } diff --git a/cli/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs b/cli/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs index 96feeb56..424912ad 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs @@ -27,6 +27,14 @@ public sealed class NewFeatureSettings : CommandSettings [Description("Include a validator class")] public bool IncludeValidator { get; set; } + [CommandOption("--no-view")] + [Description("Skip creating the React view component and Pages/index.ts entry")] + public bool NoView { get; set; } + + [CommandOption("--dry-run")] + [Description("Show what would be created without writing any files")] + public bool DryRun { get; set; } + public string ResolveName() { if (string.IsNullOrWhiteSpace(Name)) diff --git a/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs index ab9ccdfd..603486d4 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs @@ -1,4 +1,4 @@ -using SimpleModule.Cli.Infrastructure; +using SimpleModule.Cli.Infrastructure; using SimpleModule.Cli.Templates; using Spectre.Console; using Spectre.Console.Cli; @@ -15,122 +15,117 @@ public override int Execute(CommandContext context, NewModuleSettings settings) var solution = SolutionContext.Discover(); if (solution is null) { - AnsiConsole.MarkupLine( - "[red]Could not find .slnx file. Run this command from within a SimpleModule project.[/]" - ); + AnsiConsole.MarkupLine("[red]No .slnx file found. Run this command from inside a SimpleModule project.[/]"); return 1; } if (solution.ExistingModules.Contains(moduleName, StringComparer.OrdinalIgnoreCase)) { - AnsiConsole.MarkupLine($"[red]Module '{moduleName}' already exists.[/]"); + AnsiConsole.MarkupLine($"[red]Module '{Markup.Escape(moduleName)}' already exists. Available modules: {Markup.Escape(string.Join(", ", solution.ExistingModules))}[/]"); return 1; } - AnsiConsole.MarkupLine( - $"[blue]Creating module '{moduleName}' (singular: '{singularName}')...[/]" - ); - var templates = new ModuleTemplates(solution); + var ops = new List<(string Path, FileAction Action)>(); - // Create directories var contractsDir = solution.GetModuleContractsPath(moduleName); var moduleDir = solution.GetModuleProjectPath(moduleName); var eventsDir = Path.Combine(contractsDir, "Events"); var endpointsDir = Path.Combine(moduleDir, "Endpoints", moduleName); var testDir = solution.GetTestProjectPath(moduleName); - var unitTestDir = Path.Combine(testDir, "Unit"); - var integrationTestDir = Path.Combine(testDir, "Integration"); - - Directory.CreateDirectory(eventsDir); - Directory.CreateDirectory(endpointsDir); - Directory.CreateDirectory(unitTestDir); - Directory.CreateDirectory(integrationTestDir); - - // Contracts project files - WriteFile( - Path.Combine(contractsDir, $"{moduleName}.Contracts.csproj"), - templates.ContractsCsproj(moduleName) - ); - WriteFile( - Path.Combine(contractsDir, $"I{singularName}Contracts.cs"), - templates.ContractsInterface(moduleName, singularName) - ); - WriteFile( - Path.Combine(contractsDir, $"{singularName}.cs"), - templates.DtoClass(moduleName, singularName) - ); - WriteFile( - Path.Combine(eventsDir, $"{singularName}CreatedEvent.cs"), - templates.EventClass(moduleName, singularName) - ); - - // Module project files - WriteFile( - Path.Combine(moduleDir, $"{moduleName}.csproj"), - templates.ModuleCsproj(moduleName) - ); - WriteFile( - Path.Combine(moduleDir, $"{moduleName}Module.cs"), - templates.ModuleClass(moduleName, singularName) - ); - WriteFile( - Path.Combine(moduleDir, $"{moduleName}Constants.cs"), - templates.ConstantsClass(moduleName, singularName) - ); - WriteFile( - Path.Combine(moduleDir, $"{moduleName}DbContext.cs"), - templates.DbContextClass(moduleName, singularName) - ); - WriteFile( - Path.Combine(moduleDir, $"{singularName}Service.cs"), - templates.ServiceClass(moduleName, singularName) - ); - WriteFile( - Path.Combine(endpointsDir, "GetAllEndpoint.cs"), - templates.GetAllEndpoint(moduleName, singularName) - ); - - // Test project files - WriteFile( - Path.Combine(testDir, $"{moduleName}.Tests.csproj"), - templates.TestCsproj(moduleName) - ); - WriteFile(Path.Combine(testDir, "GlobalUsings.cs"), templates.GlobalUsings()); - WriteFile( - Path.Combine(unitTestDir, $"{singularName}ServiceTests.cs"), - templates.UnitTestSkeleton(moduleName, singularName) - ); - WriteFile( - Path.Combine(integrationTestDir, $"{moduleName}EndpointTests.cs"), - templates.IntegrationTestSkeleton(moduleName, singularName) - ); - - // Modify solution files - AnsiConsole.MarkupLine("[blue]Updating solution files...[/]"); - - SlnxManipulator.AddModuleEntries(solution.SlnxPath, moduleName); - AnsiConsole.MarkupLine("[green] + .slnx entries added[/]"); - - ProjectManipulator.AddProjectReference( - solution.ApiCsprojPath, - $@"..\modules\{moduleName}\src\{moduleName}\{moduleName}.csproj" - ); - AnsiConsole.MarkupLine("[green] + API project reference added[/]"); - - AnsiConsole.MarkupLine($"[green]Module '{moduleName}' created successfully![/]"); - AnsiConsole.MarkupLine(""); - AnsiConsole.MarkupLine("[dim]Files created:[/]"); - AnsiConsole.MarkupLine($"[dim] src/modules/{moduleName}/src/ (10 files)[/]"); - AnsiConsole.MarkupLine($"[dim] src/modules/{moduleName}/tests/ (3 files)[/]"); + void Plan(string path) => ops.Add((path, FileAction.Create)); + Plan(Path.Combine(contractsDir, $"{moduleName}.Contracts.csproj")); + Plan(Path.Combine(contractsDir, $"I{singularName}Contracts.cs")); + Plan(Path.Combine(contractsDir, $"{singularName}.cs")); + Plan(Path.Combine(eventsDir, $"{singularName}CreatedEvent.cs")); + Plan(Path.Combine(moduleDir, $"{moduleName}.csproj")); + Plan(Path.Combine(moduleDir, $"{moduleName}Module.cs")); + Plan(Path.Combine(moduleDir, $"{moduleName}Constants.cs")); + Plan(Path.Combine(moduleDir, $"{moduleName}DbContext.cs")); + Plan(Path.Combine(moduleDir, $"{singularName}Service.cs")); + Plan(Path.Combine(endpointsDir, "GetAllEndpoint.cs")); + Plan(Path.Combine(testDir, $"{moduleName}.Tests.csproj")); + Plan(Path.Combine(testDir, "GlobalUsings.cs")); + Plan(Path.Combine(testDir, "Unit", $"{singularName}ServiceTests.cs")); + Plan(Path.Combine(testDir, "Integration", $"{moduleName}EndpointTests.cs")); + ops.Add((solution.SlnxPath, FileAction.Modify)); + ops.Add((solution.ApiCsprojPath, FileAction.Modify)); + + if (settings.DryRun) + { + RenderDryRunTree(moduleName, ops); + return 0; + } + + AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .Start($"Creating module '{moduleName}'...", ctx => + { + Directory.CreateDirectory(eventsDir); + Directory.CreateDirectory(endpointsDir); + Directory.CreateDirectory(Path.Combine(testDir, "Unit")); + Directory.CreateDirectory(Path.Combine(testDir, "Integration")); + + File.WriteAllText(Path.Combine(contractsDir, $"{moduleName}.Contracts.csproj"), templates.ContractsCsproj(moduleName)); + File.WriteAllText(Path.Combine(contractsDir, $"I{singularName}Contracts.cs"), templates.ContractsInterface(moduleName, singularName)); + File.WriteAllText(Path.Combine(contractsDir, $"{singularName}.cs"), templates.DtoClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(eventsDir, $"{singularName}CreatedEvent.cs"), templates.EventClass(moduleName, singularName)); + + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}.csproj"), templates.ModuleCsproj(moduleName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}Module.cs"), templates.ModuleClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}Constants.cs"), templates.ConstantsClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}DbContext.cs"), templates.DbContextClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{singularName}Service.cs"), templates.ServiceClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(endpointsDir, "GetAllEndpoint.cs"), templates.GetAllEndpoint(moduleName, singularName)); + + File.WriteAllText(Path.Combine(testDir, $"{moduleName}.Tests.csproj"), templates.TestCsproj(moduleName)); + File.WriteAllText(Path.Combine(testDir, "GlobalUsings.cs"), templates.GlobalUsings()); + File.WriteAllText(Path.Combine(testDir, "Unit", $"{singularName}ServiceTests.cs"), templates.UnitTestSkeleton(moduleName, singularName)); + File.WriteAllText(Path.Combine(testDir, "Integration", $"{moduleName}EndpointTests.cs"), templates.IntegrationTestSkeleton(moduleName, singularName)); + + ctx.Status("Updating solution files..."); + SlnxManipulator.AddModuleEntries(solution.SlnxPath, moduleName); + ProjectManipulator.AddProjectReference( + solution.ApiCsprojPath, + $@"..\modules\{moduleName}\src\{moduleName}\{moduleName}.csproj" + ); + }); + + RenderCreatedTree(moduleName, ops); + + AnsiConsole.MarkupLine($"\n[green]Module '{Markup.Escape(moduleName)}' created![/]"); + AnsiConsole.MarkupLine("[dim]Next steps:[/]"); + AnsiConsole.MarkupLine($"[dim] sm new feature --module {Markup.Escape(moduleName)}[/]"); + AnsiConsole.MarkupLine("[dim] dotnet build[/]"); return 0; } - private static void WriteFile(string path, string content) + private static void RenderDryRunTree(string moduleName, List<(string Path, FileAction Action)> ops) { - File.WriteAllText(path, content); - var relativePath = Path.GetFileName(path); - AnsiConsole.MarkupLine($"[green] + {relativePath}[/]"); + AnsiConsole.MarkupLine("[dim]Dry run — no files written[/]\n"); + var tree = new Tree($"[blue]{Markup.Escape(moduleName)}[/]"); + foreach (var (path, action) in ops) + { + var label = action == FileAction.Modify + ? $"[yellow]{Markup.Escape(Path.GetFileName(path))}[/] [dim](modify)[/]" + : $"[green]{Markup.Escape(Path.GetFileName(path))}[/] [dim](create)[/]"; + tree.AddNode(label); + } + AnsiConsole.Write(tree); + } + + private static void RenderCreatedTree(string moduleName, List<(string Path, FileAction Action)> ops) + { + AnsiConsole.MarkupLine(""); + var tree = new Tree($"[blue]{Markup.Escape(moduleName)}[/]"); + foreach (var (path, action) in ops) + { + var label = action == FileAction.Modify + ? $"[yellow]{Markup.Escape(Path.GetFileName(path))}[/] [dim](modified)[/]" + : $"[green]{Markup.Escape(Path.GetFileName(path))}[/]"; + tree.AddNode(label); + } + AnsiConsole.Write(tree); } } diff --git a/cli/SimpleModule.Cli/Commands/New/NewModuleSettings.cs b/cli/SimpleModule.Cli/Commands/New/NewModuleSettings.cs index 1c3cee0f..66febd69 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewModuleSettings.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewModuleSettings.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using Spectre.Console; using Spectre.Console.Cli; @@ -10,6 +10,10 @@ public sealed class NewModuleSettings : CommandSettings [Description("Module name in PascalCase (e.g. Invoices)")] public string? Name { get; set; } + [CommandOption("--dry-run")] + [Description("Show what would be created without writing any files")] + public bool DryRun { get; set; } + public string ResolveName() { if (string.IsNullOrWhiteSpace(Name)) @@ -20,7 +24,7 @@ public string ResolveName() if (!char.IsUpper(Name[0])) { throw new InvalidOperationException( - "Module name must be PascalCase (start with uppercase letter)." + $"'{Name}' is not PascalCase. Did you mean '{char.ToUpperInvariant(Name[0])}{Name[1..]}'?" ); } diff --git a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs index c307fa3d..94df179b 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs @@ -1,4 +1,4 @@ -using SimpleModule.Cli.Infrastructure; +using SimpleModule.Cli.Infrastructure; using SimpleModule.Cli.Templates; using Spectre.Console; using Spectre.Console.Cli; @@ -9,88 +9,615 @@ 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(); var rootDir = Path.Combine(outputDir, projectName); - if (Directory.Exists(rootDir) && Directory.GetFileSystemEntries(rootDir).Length > 0) + if (!settings.DryRun && Directory.Exists(rootDir) && Directory.GetFileSystemEntries(rootDir).Length > 0) { - AnsiConsole.MarkupLine( - $"[red]Directory '{rootDir}' already exists and is not empty.[/]" - ); + AnsiConsole.MarkupLine($"[red]Directory '{Markup.Escape(rootDir)}' already exists and is not empty.[/]"); return 1; } - AnsiConsole.MarkupLine($"[blue]Creating project '{projectName}' in {rootDir}...[/]"); + var projectTemplates = new ProjectTemplates(solution); + var hostTemplates = new HostTemplates(solution); + var moduleTemplates = new ModuleTemplates(solution); - // Try to discover an existing solution for reference templates - var solution = SolutionContext.Discover(); - var templates = new ProjectTemplates(solution); - - // Create directory structure - var srcDir = Path.Combine(rootDir, "src"); - var apiDir = Path.Combine(srcDir, $"{projectName}.Api"); - var coreDir = Path.Combine(srcDir, $"{projectName}.Core"); - var databaseDir = Path.Combine(srcDir, $"{projectName}.Database"); - var generatorDir = Path.Combine(srcDir, $"{projectName}.Generator"); - var modulesDir = Path.Combine(srcDir, "modules"); - var testsDir = Path.Combine(rootDir, "tests"); - var testsSharedDir = Path.Combine(testsDir, $"{projectName}.Tests.Shared"); - var testsModulesDir = Path.Combine(testsDir, "modules"); - - Directory.CreateDirectory(apiDir); - Directory.CreateDirectory(coreDir); - Directory.CreateDirectory(databaseDir); - Directory.CreateDirectory(generatorDir); - Directory.CreateDirectory(modulesDir); - Directory.CreateDirectory(testsSharedDir); - Directory.CreateDirectory(testsModulesDir); - - // Root files - WriteFile(Path.Combine(rootDir, $"{projectName}.slnx"), templates.Slnx(projectName)); - WriteFile(Path.Combine(rootDir, "Directory.Build.props"), templates.DirectoryBuildProps()); - WriteFile( - Path.Combine(rootDir, "Directory.Packages.props"), - templates.DirectoryPackagesProps() - ); - WriteFile(Path.Combine(rootDir, "global.json"), templates.GlobalJson()); - - // Project files - WriteFile( - Path.Combine(apiDir, $"{projectName}.Api.csproj"), - templates.ApiCsproj(projectName) - ); - WriteFile(Path.Combine(apiDir, "Program.cs"), ProjectTemplates.ApiProgram()); - WriteFile( - Path.Combine(coreDir, $"{projectName}.Core.csproj"), - templates.CoreCsproj(projectName) - ); - WriteFile( - Path.Combine(databaseDir, $"{projectName}.Database.csproj"), - templates.DatabaseCsproj(projectName) - ); - WriteFile( - Path.Combine(generatorDir, $"{projectName}.Generator.csproj"), - templates.GeneratorCsproj() - ); - WriteFile( - Path.Combine(testsSharedDir, $"{projectName}.Tests.Shared.csproj"), - templates.TestsSharedCsproj(projectName) - ); - - AnsiConsole.MarkupLine($"[green]Project '{projectName}' created successfully![/]"); - AnsiConsole.MarkupLine(""); + const string moduleName = "Items"; + var singularName = ModuleTemplates.GetSingularName(moduleName); + + // ── Plan all files ──────────────────────────────────────────── + 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"); + + Plan(Path.Combine(rootDir, $"{projectName}.slnx")); + Plan(Path.Combine(rootDir, "Directory.Build.props")); + Plan(Path.Combine(rootDir, "Directory.Packages.props")); + Plan(Path.Combine(rootDir, "global.json")); + Plan(Path.Combine(rootDir, "package.json")); + Plan(Path.Combine(rootDir, "biome.json")); + Plan(Path.Combine(rootDir, "tsconfig.json")); + Plan(Path.Combine(rootDir, ".editorconfig")); + Plan(Path.Combine(rootDir, "nuget.config")); + + // Host project files + Plan(Path.Combine(hostDir, $"{projectName}.Host.csproj")); + Plan(Path.Combine(hostDir, "Program.cs")); + Plan(Path.Combine(hostDir, "Components", "App.razor")); + Plan(Path.Combine(hostDir, "Components", "InertiaShell.razor")); + Plan(Path.Combine(hostDir, "Components", "Routes.razor")); + Plan(Path.Combine(hostDir, "Components", "_Imports.razor")); + Plan(Path.Combine(hostDir, "ClientApp", "app.tsx")); + Plan(Path.Combine(hostDir, "ClientApp", "vite.config.ts")); + 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, "appsettings.json")); + Plan(Path.Combine(hostDir, "appsettings.Development.json")); + Plan(Path.Combine(hostDir, "Properties", "launchSettings.json")); + + // Home module files + 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"); + + Plan(Path.Combine(contractsDir, $"{moduleName}.Contracts.csproj")); + Plan(Path.Combine(contractsDir, $"I{singularName}Contracts.cs")); + Plan(Path.Combine(contractsDir, $"{singularName}.cs")); + Plan(Path.Combine(eventsDir, $"{singularName}CreatedEvent.cs")); + Plan(Path.Combine(moduleDir, $"{moduleName}.csproj")); + Plan(Path.Combine(moduleDir, $"{moduleName}Module.cs")); + Plan(Path.Combine(moduleDir, $"{moduleName}Constants.cs")); + Plan(Path.Combine(moduleDir, $"{moduleName}DbContext.cs")); + Plan(Path.Combine(moduleDir, $"{singularName}Service.cs")); + Plan(Path.Combine(endpointsDir, "GetAllEndpoint.cs")); + Plan(Path.Combine(moduleDir, "Views", "WelcomeEndpoint.cs")); + Plan(Path.Combine(moduleDir, "Pages", "index.ts")); + Plan(Path.Combine(moduleDir, "Pages", "Welcome.tsx")); + Plan(Path.Combine(moduleDir, "vite.config.ts")); + Plan(Path.Combine(moduleDir, "package.json")); + Plan(Path.Combine(moduleTestDir, $"{moduleName}.Tests.csproj")); + Plan(Path.Combine(moduleTestDir, "GlobalUsings.cs")); + Plan(Path.Combine(moduleTestDir, "Unit", $"{singularName}ServiceTests.cs")); + Plan(Path.Combine(moduleTestDir, "Integration", $"{moduleName}EndpointTests.cs")); + + // 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 {projectName}[/]"); - AnsiConsole.MarkupLine("[dim] sm new module [/]"); + 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; } - private static void WriteFile(string path, string content) + private static string GenerateSlnx(string projectName) + { + return $""" + + + + + + + + + + + + + + + """; + } + + private static void RenderDryRunTree(string projectName, List<(string Path, FileAction Action)> ops, string rootDir) { - File.WriteAllText(path, content); - AnsiConsole.MarkupLine($"[green] + {Path.GetFileName(path)}[/]"); + AnsiConsole.MarkupLine("[dim]Dry run — no files written[/]\n"); + RenderFileTree(projectName, ops, rootDir, isDryRun: true); + } + + private static string StarterModuleClass(string moduleName, string singularName) => + $$""" + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + using SimpleModule.Core; + using SimpleModule.Database; + using SimpleModule.{{moduleName}}.Contracts; + + namespace SimpleModule.{{moduleName}}; + + [Module({{moduleName}}Constants.ModuleName, RoutePrefix = {{moduleName}}Constants.RoutePrefix, ViewPrefix = "")] + public class {{moduleName}}Module : IModule + { + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddModuleDbContext<{{moduleName}}DbContext>(configuration, {{moduleName}}Constants.ModuleName); + services.AddScoped(); + } + } + """; + + private static string StarterWelcomeEndpoint(string moduleName) => + $$""" + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + using SimpleModule.Core.Inertia; + + namespace SimpleModule.{{moduleName}}.Views; + + public class WelcomeEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/", + () => Inertia.Render("{{moduleName}}/Welcome", new { }) + ) + .ExcludeFromDescription() + .AllowAnonymous(); + } + } + """; + + private static string StarterWelcomePage(string projectName) => + """ + export default function Welcome() { + return ( +
+
+
+ ✦ +
+

+ __PROJECT_NAME__ +

+

+ Your modular monolith is running. Start building! +

+ +

+ Run sm new module {''} to add more modules +

+
+
+ ); + } + """.Replace("__PROJECT_NAME__", projectName, StringComparison.Ordinal); + + private static string StarterViteConfig() => + """ + import { defineModuleConfig } from '@simplemodule/client/module'; + + export default defineModuleConfig(__dirname); + """; + + private static string StarterPackageJson(string projectName) => + $$""" + { + "private": true, + "name": "@{{projectName.ToLowerInvariant()}}/items", + "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" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + } + """; + + private static string MinimalAppCss() => + """ + /* ============================================================ + SimpleModule — Minimal Layout CSS + Covers PublicLayout nav, dark mode, buttons, and bg-mesh. + Run `npm run build` (Tailwind) for the full design system. + ============================================================ */ + + /* --- Theme Custom Properties (Light) --- */ + :root { + --color-primary: #16a34a; + --color-primary-hover: #15803d; + --color-primary-subtle: rgba(22, 163, 74, 0.08); + --color-accent: #166534; + --color-surface: #ffffff; + --color-surface-raised: #f8fafc; + --color-surface-sunken: #f1f5f9; + --color-surface-overlay: rgba(255, 255, 255, 0.8); + --color-text: #0f172a; + --color-text-secondary: #475569; + --color-text-muted: #94a3b8; + --color-border: #e2e8f0; + --color-border-strong: #cbd5e1; + } + + /* --- Theme Custom Properties (Dark) --- */ + .dark { + --color-primary-subtle: rgba(22, 163, 74, 0.15); + --color-surface: #0f172a; + --color-surface-raised: #1e293b; + --color-surface-sunken: #0b1120; + --color-surface-overlay: rgba(15, 23, 42, 0.85); + --color-text: #f1f5f9; + --color-text-secondary: #94a3b8; + --color-text-muted: #64748b; + --color-border: #1e293b; + --color-border-strong: #334155; + } + + /* --- Base --- */ + *, *::before, *::after { box-sizing: border-box; margin: 0; } + html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; } + body { + font-family: 'DM Sans', system-ui, -apple-system, sans-serif; + background: var(--color-surface-sunken); + color: var(--color-text); + transition: color 0.3s, background-color 0.3s; + } + a { color: var(--color-primary); transition: color 0.15s; text-decoration: none; } + a:hover { color: var(--color-primary-hover); } + + /* --- Layout Utilities --- */ + .flex { display: flex; } + .hidden { display: none; } + .block { display: block; } + .inline-flex { display: inline-flex; } + .items-center { align-items: center; } + .justify-center { justify-content: center; } + .justify-between { justify-content: space-between; } + .ml-auto { margin-left: auto; } + .mx-auto { margin-left: auto; margin-right: auto; } + .gap-1 { gap: 0.25rem; } + .gap-2 { gap: 0.5rem; } + .gap-2\.5 { gap: 0.625rem; } + .gap-3 { gap: 0.75rem; } + .gap-6 { gap: 1.5rem; } + .px-3 { padding-left: 0.75rem; padding-right: 0.75rem; } + .px-3\.5 { padding-left: 0.875rem; padding-right: 0.875rem; } + .px-4 { padding-left: 1rem; padding-right: 1rem; } + .px-5 { padding-left: 1.25rem; padding-right: 1.25rem; } + .px-6 { padding-left: 1.5rem; padding-right: 1.5rem; } + .py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; } + .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; } + .py-2\.5 { padding-top: 0.625rem; padding-bottom: 0.625rem; } + .py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; } + .mt-8 { margin-top: 2rem; } + .mb-16 { margin-bottom: 4rem; } + .w-8 { width: 2rem; } + .w-9 { width: 2.25rem; } + .h-8 { height: 2rem; } + .h-9 { height: 2.25rem; } + .min-h-screen { min-height: 100vh; } + .max-w-7xl { max-width: 80rem; } + .shrink-0 { flex-shrink: 0; } + .overflow-hidden { overflow: hidden; } + + /* --- Positioning --- */ + .sticky { position: sticky; } + .top-0 { top: 0; } + .z-50 { z-index: 50; } + .relative { position: relative; } + .absolute { position: absolute; } + + /* --- Typography --- */ + .text-xs { font-size: 0.75rem; line-height: 1rem; } + .text-sm { font-size: 0.875rem; line-height: 1.25rem; } + .text-base { font-size: 1rem; line-height: 1.5rem; } + .text-white { color: #fff; } + .font-bold { font-weight: 700; } + .font-semibold { font-weight: 600; } + .font-medium { font-weight: 500; } + .no-underline { text-decoration: none; } + + /* --- Colors (theme-aware) --- */ + .text-text { color: var(--color-text); } + .text-text-muted { color: var(--color-text-muted); } + .text-text-secondary { color: var(--color-text-secondary); } + .text-primary { color: var(--color-primary); } + .bg-surface { background-color: var(--color-surface); } + .bg-surface-overlay { background-color: var(--color-surface-overlay); } + .bg-surface-raised { background-color: var(--color-surface-raised); } + .bg-transparent { background-color: transparent; } + .border-border { border-color: var(--color-border); } + .border-b { border-bottom: 1px solid var(--color-border); } + + /* --- Rounded --- */ + .rounded-lg { border-radius: 0.5rem; } + .rounded-xl { border-radius: 0.75rem; } + .rounded-2xl { border-radius: 1rem; } + + /* --- Shadows --- */ + .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -2px rgba(0,0,0,.1); } + + /* --- Transitions --- */ + .transition-colors { transition-property: color, background-color, border-color; transition-duration: 0.15s; } + .transition-transform { transition-property: transform; transition-duration: 0.15s; } + .transition-all { transition-property: all; transition-duration: 0.15s; } + .duration-200 { transition-duration: 0.2s; } + .cursor-pointer { cursor: pointer; } + .border-none { border: none; } + + /* --- Hover --- */ + .hover\:text-primary:hover { color: var(--color-primary); } + .hover\:text-text:hover { color: var(--color-text); } + .hover\:bg-surface-raised:hover { background-color: var(--color-surface-raised); } + .hover\:bg-surface-hover:hover { background-color: var(--color-surface-raised); } + .hover\:bg-primary-subtle:hover { background-color: var(--color-primary-subtle); } + .hover\:scale-105:hover { transform: scale(1.05); } + .group:hover .group-hover\:scale-105 { transform: scale(1.05); } + + /* --- Responsive --- */ + @media (min-width: 640px) { + .sm\:flex { display: flex; } + .sm\:inline-flex { display: inline-flex; } + .sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; } + } + + /* --- Dark mode toggle icons --- */ + .dark\:hidden { display: revert; } + .dark .dark\:hidden { display: none; } + .dark\:block { display: none; } + .dark .dark\:block { display: block; } + .w-\[18px\] { width: 18px; } + .h-\[18px\] { height: 18px; } + + /* --- Buttons --- */ + .btn-primary, .btn-ghost, .btn-secondary { + display: inline-flex; align-items: center; justify-content: center; + gap: 0.5rem; padding: 0.625rem 1.25rem; border-radius: 0.75rem; + font-size: 0.875rem; font-weight: 600; cursor: pointer; + text-decoration: none; transition: all 0.2s ease-out; border: none; + } + .btn-primary { + color: #fff; + background: linear-gradient(135deg, var(--color-primary), var(--color-accent)); + box-shadow: 0 4px 14px rgba(22, 163, 74, 0.35); + } + .btn-primary:hover { box-shadow: 0 6px 20px rgba(22, 163, 74, 0.5); transform: translateY(-1px); } + .btn-ghost { background: transparent; color: var(--color-text-secondary); } + .btn-ghost:hover { background: var(--color-primary-subtle); color: var(--color-primary); } + .btn-sm { padding: 0.375rem 0.875rem; font-size: 0.75rem; border-radius: 0.5rem; } + + /* --- Animated Background Mesh --- */ + .bg-mesh { + position: fixed; inset: 0; z-index: -1; overflow: hidden; + background: var(--color-surface-sunken); + } + .bg-mesh::before, .bg-mesh::after { + content: ""; position: absolute; border-radius: 50%; + filter: blur(100px); opacity: 0.15; + animation: mesh-float 20s ease-in-out infinite; + } + .bg-mesh::before { width: 600px; height: 600px; background: var(--color-primary); top: -10%; right: -10%; } + .bg-mesh::after { width: 500px; height: 500px; background: var(--color-accent); bottom: -10%; left: -10%; animation-delay: -10s; animation-direction: reverse; } + .dark .bg-mesh::before, .dark .bg-mesh::after { opacity: 0.08; } + @keyframes mesh-float { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(40px, -30px) scale(1.05); } + 66% { transform: translate(-20px, 20px) scale(0.95); } + } + + /* --- Dropdown menus (PublicLayout hover menus) --- */ + .group-hover\:block { display: none; } + .group:hover > .group-hover\:block { display: block; } + .group-hover\/sub\:block { display: none; } + .group\/sub:hover > .group-hover\/sub\:block { display: block; } + .top-full { top: 100%; } + .left-0 { left: 0; } + .left-full { left: 100%; } + .mt-1 { margin-top: 0.25rem; } + .ml-0\.5 { margin-left: 0.125rem; } + .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; } + .min-w-\[160px\] { min-width: 160px; } + .shadow-lg { box-shadow: 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.1); } + """; + + private static void RenderFileTree( + string projectName, + List<(string Path, FileAction Action)> ops, + string rootDir, + bool isDryRun = false) + { + AnsiConsole.MarkupLine(""); + var tree = new Tree($"[blue]{Markup.Escape(projectName)}/[/]"); + + foreach (var (path, action) in ops) + { + var relativePath = Path.GetRelativePath(rootDir, path).Replace('\\', '/'); + var label = action == FileAction.Modify + ? $"[yellow]{Markup.Escape(relativePath)}[/] [dim]({(isDryRun ? "modify" : "modified")})[/]" + : isDryRun + ? $"[green]{Markup.Escape(relativePath)}[/] [dim](create)[/]" + : $"[green]{Markup.Escape(relativePath)}[/]"; + tree.AddNode(label); + } + + AnsiConsole.Write(tree); } } diff --git a/cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs b/cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs index 3157ccc6..6d62f5bc 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs @@ -14,6 +14,10 @@ public sealed class NewProjectSettings : CommandSettings [Description("Output directory (defaults to current directory)")] public string? OutputDir { get; set; } + [CommandOption("--dry-run")] + [Description("Show what would be created without writing any files")] + public bool DryRun { get; set; } + public string ResolveName() { if (string.IsNullOrWhiteSpace(Name)) diff --git a/cli/SimpleModule.Cli/Infrastructure/FileAction.cs b/cli/SimpleModule.Cli/Infrastructure/FileAction.cs new file mode 100644 index 00000000..282f345f --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/FileAction.cs @@ -0,0 +1,7 @@ +namespace SimpleModule.Cli.Infrastructure; + +public enum FileAction +{ + Create, + Modify, +} diff --git a/cli/SimpleModule.Cli/Infrastructure/NpmWorkspaceFixer.cs b/cli/SimpleModule.Cli/Infrastructure/NpmWorkspaceFixer.cs new file mode 100644 index 00000000..2f9c9192 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/NpmWorkspaceFixer.cs @@ -0,0 +1,31 @@ +namespace SimpleModule.Cli.Infrastructure; + +public static class NpmWorkspaceFixer +{ + public static void AddWorkspaceGlob(string packageJsonPath, string glob) + { + var content = File.ReadAllText(packageJsonPath); + var workspacesStart = content.IndexOf("\"workspaces\"", StringComparison.Ordinal); + if (workspacesStart >= 0) + { + var arrayStart = content.IndexOf('[', workspacesStart); + var arrayEnd = content.IndexOf(']', arrayStart); + if (arrayStart >= 0 && arrayEnd >= 0) + { + var entry = $"\"{glob}\""; + var existing = content[arrayStart..arrayEnd].Trim('[').Trim(); + var separator = existing.Length > 0 ? ", " : ""; + var newContent = content[..arrayEnd] + separator + entry + content[arrayEnd..]; + File.WriteAllText(packageJsonPath, newContent); + return; + } + } + + var lastBrace = content.LastIndexOf('}'); + if (lastBrace >= 0) + { + var insert = $",\n \"workspaces\": [\"{glob}\"]"; + File.WriteAllText(packageJsonPath, content[..lastBrace] + insert + "\n}"); + } + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/PagesRegistryFixer.cs b/cli/SimpleModule.Cli/Infrastructure/PagesRegistryFixer.cs new file mode 100644 index 00000000..9682b4e8 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/PagesRegistryFixer.cs @@ -0,0 +1,30 @@ +namespace SimpleModule.Cli.Infrastructure; + +public static class PagesRegistryFixer +{ + public static void AddEntry(string indexPath, string componentKey, string importPath) + { + if (!File.Exists(indexPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(indexPath)!); + File.WriteAllText(indexPath, $$""" + export const pages: Record = { + "{{componentKey}}": () => import("{{importPath}}"), + }; + """); + return; + } + + var content = File.ReadAllText(indexPath); + var entry = $" \"{componentKey}\": () => import(\"{importPath}\"),"; + var lastBrace = content.LastIndexOf('}'); + if (lastBrace < 0) + { + File.AppendAllText(indexPath, $"\n{entry}"); + return; + } + + var newContent = content[..lastBrace] + entry + "\n" + content[lastBrace..]; + File.WriteAllText(indexPath, newContent); + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs b/cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs index f05ac4cf..865017b3 100644 --- a/cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs +++ b/cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs @@ -13,12 +13,7 @@ private SolutionContext(string rootPath, string slnxPath) { RootPath = rootPath; SlnxPath = slnxPath; - ApiCsprojPath = Path.Combine( - rootPath, - "src", - "SimpleModule.Host", - "SimpleModule.Host.csproj" - ); + ApiCsprojPath = DiscoverApiCsproj(rootPath); ModulesPath = Path.Combine(rootPath, "src", "modules"); ExistingModules = Directory.Exists(ModulesPath) @@ -60,4 +55,55 @@ public string GetModuleProjectPath(string moduleName) => public string GetTestProjectPath(string moduleName) => Path.Combine(ModulesPath, moduleName, "tests", $"{moduleName}.Tests"); + + public string GetModuleViewsPath(string moduleName) => + Path.Combine(ModulesPath, moduleName, "src", moduleName, "Views"); + + public string GetModulePagesIndexPath(string moduleName) => + Path.Combine(ModulesPath, moduleName, "src", moduleName, "Pages", "index.ts"); + + private static string DiscoverApiCsproj(string rootPath) + { + var srcPath = Path.Combine(rootPath, "src"); + + if (Directory.Exists(srcPath)) + { + // Look for directories ending with .Host + foreach (var dir in Directory.GetDirectories(srcPath, "*.Host")) + { + var dirName = Path.GetFileName(dir); + var csproj = Path.Combine(dir, $"{dirName}.csproj"); + if (File.Exists(csproj)) + { + return csproj; + } + } + + // Fall back to directories ending with .Api + foreach (var dir in Directory.GetDirectories(srcPath, "*.Api")) + { + var dirName = Path.GetFileName(dir); + var csproj = Path.Combine(dir, $"{dirName}.csproj"); + if (File.Exists(csproj)) + { + return csproj; + } + } + } + + // Fall back to template path + var templatePath = Path.Combine( + rootPath, + "template", + "SimpleModule.Host", + "SimpleModule.Host.csproj" + ); + if (File.Exists(templatePath)) + { + return templatePath; + } + + // Last resort: original hardcoded path + return Path.Combine(rootPath, "src", "SimpleModule.Host", "SimpleModule.Host.csproj"); + } } diff --git a/cli/SimpleModule.Cli/Templates/FeatureTemplates.cs b/cli/SimpleModule.Cli/Templates/FeatureTemplates.cs index 41f80e53..9850a908 100644 --- a/cli/SimpleModule.Cli/Templates/FeatureTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/FeatureTemplates.cs @@ -348,6 +348,21 @@ public void Map(IEndpointRouteBuilder app) """; } + public static string ViewComponent(string moduleName, string featureName) => + $$""" + type Props = { + // TODO: add props from your endpoint's response + } + + export default function {{featureName}}({ }: Props) { + return ( +
+

{{featureName}}

+
+ ) + } + """; + private static string FallbackValidator( string moduleName, string featureName, diff --git a/cli/SimpleModule.Cli/Templates/HostTemplates.cs b/cli/SimpleModule.Cli/Templates/HostTemplates.cs new file mode 100644 index 00000000..ce984aa2 --- /dev/null +++ b/cli/SimpleModule.Cli/Templates/HostTemplates.cs @@ -0,0 +1,328 @@ +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Templates; + +public sealed class HostTemplates +{ + private const string BaseProjectName = "SimpleModule"; + + /// + /// Patterns to strip from the template Host .csproj when generating a new project. + /// Each pattern causes any line containing it to be removed. + /// + private static readonly string[] CsprojStripPatterns = + [ + "modules", // Module ProjectReferences + "ServiceDefaults", // Aspire ServiceDefaults + "InternalsVisibleTo", // Test assembly visibility + "NoWarn", "SM0011", "SM0035", "SM0038", // Suppressed analyzer warnings + "TODO", // Dev comments + "EntityFrameworkCore.Design", // EF migrations tooling + " + /// 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) + { + var path = Path.Combine(_templateHostDir, "SimpleModule.Host.csproj"); + var lines = File.ReadAllLines(path).ToList(); + + // Strip lines containing patterns that should not appear in a new project + lines.RemoveAll(line => + CsprojStripPatterns.Any(p => line.Contains(p, StringComparison.Ordinal)) + ); + + // Remove orphaned multi-line XML fragments (e.g., after attribute stripping) + lines.RemoveAll(line => + { + var trimmed = line.TrimStart(); + return trimmed == "/>" + || (trimmed.StartsWith(" tag + var packageRefs = """ + + + + + """; + + content = content.Replace( + "", + packageRefs + Environment.NewLine + "", + StringComparison.Ordinal + ); + + return content; + } + + /// + /// Copy Program.cs, remove ServiceDefaults and MapDefaultEndpoints lines. + /// Framework method names (AddSimpleModule, UseSimpleModule) are kept as-is. + /// + public string ProgramCs() + { + var path = Path.Combine(_templateHostDir, "Program.cs"); + var lines = File.ReadAllLines(path).ToList(); + + lines.RemoveAll(line => + line.Contains("ServiceDefaults", StringComparison.Ordinal) + || line.Contains("MapDefaultEndpoints", StringComparison.Ordinal) + ); + + lines = TemplateExtractor.CollapseBlankLines(lines); + + return string.Join(Environment.NewLine, lines); + } + + /// + /// Copy App.razor, replace SimpleModule with projectName. + /// + public string AppRazor(string projectName) + { + var path = Path.Combine(_templateHostDir, "Components", "App.razor"); + var content = File.ReadAllText(path); + return ReplaceProjectName(content, projectName); + } + + /// + /// Copy InertiaShell.razor, replace SimpleModule with projectName. + /// + public string InertiaShellRazor(string projectName) + { + var path = Path.Combine(_templateHostDir, "Components", "InertiaShell.razor"); + var content = File.ReadAllText(path); + return ReplaceProjectName(content, projectName); + } + + /// + /// Generate a clean Routes.razor WITHOUT the AdditionalAssemblies Users module reference. + /// + public static string RoutesRazor() + { + return """ + + + + + + """; + } + + /// + /// Copy _Imports.razor, replace SimpleModule with projectName. + /// + public string ImportsRazor(string projectName) + { + var path = Path.Combine(_templateHostDir, "Components", "_Imports.razor"); + var content = File.ReadAllText(path); + return ReplaceProjectName(content, projectName); + } + + /// + /// Copy app.tsx as-is. + /// + public string AppTsx() + { + var path = Path.Combine(_templateHostDir, "ClientApp", "app.tsx"); + return File.ReadAllText(path); + } + + /// + /// Copy vite.config.ts as-is. + /// + public string ViteConfig() + { + var path = Path.Combine(_templateHostDir, "ClientApp", "vite.config.ts"); + return File.ReadAllText(path); + } + + /// + /// Copy validate-pages.mjs as-is. + /// + public string ValidatePages() + { + var path = Path.Combine(_templateHostDir, "ClientApp", "validate-pages.mjs"); + return File.ReadAllText(path); + } + + /// + /// Copy ClientApp/package.json, replace @simplemodule/app with @{projectName}/app. + /// + public string ClientAppPackageJson(string projectName) + { + var path = Path.Combine(_templateHostDir, "ClientApp", "package.json"); + var content = File.ReadAllText(path); + return content.Replace( + "@simplemodule/app", + $"@{projectName.ToLowerInvariant()}/app", + StringComparison.Ordinal + ); + } + + /// + /// Return a clean Styles/app.css with tailwindcss import, theme import, + /// and @source directives for modules. NO _scan/ import. + /// + 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"; + """; + } + + /// + /// Copy appsettings.json as-is. + /// + public string AppSettings() + { + var path = Path.Combine(_templateHostDir, "appsettings.json"); + return File.ReadAllText(path); + } + + /// + /// Copy appsettings.Development.json as-is. + /// + public string AppSettingsDevelopment() + { + var path = Path.Combine(_templateHostDir, "appsettings.Development.json"); + return File.ReadAllText(path); + } + + /// + /// Copy launchSettings.json, replace SimpleModule with projectName. + /// + public string LaunchSettings(string projectName) + { + var path = Path.Combine(_templateHostDir, "Properties", "launchSettings.json"); + var content = File.ReadAllText(path); + return ReplaceProjectName(content, projectName); + } + + // ── Helpers ──────────────────────────────────────────────────────── + + /// + /// Replaces "SimpleModule" with projectName in content, preserving framework names + /// (SimpleModule.Hosting, SimpleModule.Generator, SimpleModule.Blazor, SimpleModule.Core, + /// SimpleModule.Database, SimpleModule.DevTools). + /// + private static string ReplaceProjectName(string content, string projectName) + { + // Framework package/project names that must NOT be renamed + var frameworkNames = new[] + { + "SimpleModule.Hosting", + "SimpleModule.Generator", + "SimpleModule.Blazor", + "SimpleModule.Core", + "SimpleModule.Database", + "SimpleModule.DevTools", + }; + + // Protect framework names with placeholders + var placeholders = new (string Original, string Placeholder)[frameworkNames.Length]; + for (var i = 0; i < frameworkNames.Length; i++) + { + placeholders[i] = (frameworkNames[i], $"%%FW_{i}%%"); + content = content.Replace(frameworkNames[i], placeholders[i].Placeholder, StringComparison.Ordinal); + } + + // Replace the project name + content = content.Replace(BaseProjectName, projectName, StringComparison.Ordinal); + + // Restore framework names + for (var i = 0; i < placeholders.Length; i++) + { + content = content.Replace(placeholders[i].Placeholder, placeholders[i].Original, StringComparison.Ordinal); + } + + return content; + } + + /// + /// Removes XML element blocks (e.g., PropertyGroup, ItemGroup) that are empty + /// (contain only whitespace between opening and closing tags). + /// + private static List RemoveEmptyXmlBlocks(List lines, string elementName) + { + var result = new List(); + var openTag = $"<{elementName}"; + var closeTag = $""; + + for (var i = 0; i < lines.Count; i++) + { + var trimmed = lines[i].TrimStart(); + + if (trimmed.StartsWith(openTag, StringComparison.Ordinal)) + { + // Look ahead to see if the block is empty + var blockLines = new List { lines[i] }; + var j = i + 1; + var isEmpty = true; + + while (j < lines.Count) + { + var innerTrimmed = lines[j].TrimStart(); + blockLines.Add(lines[j]); + + if (innerTrimmed.StartsWith(closeTag, StringComparison.Ordinal)) + { + break; + } + + if (!string.IsNullOrWhiteSpace(lines[j])) + { + isEmpty = false; + } + + j++; + } + + if (isEmpty && j < lines.Count) + { + // Skip the entire empty block + i = j; + continue; + } + + // Not empty, add all lines normally + result.Add(lines[i]); + continue; + } + + result.Add(lines[i]); + } + + return result; + } +} diff --git a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs index 52c0925f..07ceaf6a 100644 --- a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs @@ -33,7 +33,9 @@ public string ContractsCsproj(string moduleName) return FallbackContractsCsproj(); } - return TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName); + return ReplaceFrameworkProjectRefs( + TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName) + ); } public string ModuleCsproj(string moduleName) @@ -47,7 +49,9 @@ public string ModuleCsproj(string moduleName) // Strip references to other modules and non-essential packages var stripPatterns = _otherModuleNames.Select(m => m).Append("Bogus").ToList(); - return TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName, stripPatterns); + return ReplaceFrameworkProjectRefs( + TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName, stripPatterns) + ); } public string TestCsproj(string moduleName) @@ -59,7 +63,9 @@ public string TestCsproj(string moduleName) } var stripPatterns = _otherModuleNames.ToList(); - return TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName, stripPatterns); + return ReplaceFrameworkProjectRefs( + TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName, stripPatterns) + ); } // ── Simple C# files (read + rename) ────────────────────────────── @@ -156,7 +162,9 @@ public string EventClass(string moduleName, string singularName) public string GetAllEndpoint(string moduleName, string singularName) { - var refPath = RefModulePath(Path.Combine("Endpoints", _refModule!, $"GetAllEndpoint.cs")); + var refPath = _refModule is not null + ? RefModulePath(Path.Combine("Endpoints", _refModule, "GetAllEndpoint.cs")) + : null; if (refPath is null) { return FallbackGetAllEndpoint(moduleName, singularName); @@ -724,6 +732,59 @@ public static string GetSingularName(string pluralName) return File.Exists(path) ? path : null; } + /// + /// Replaces framework ProjectReference entries (SimpleModule.Core, SimpleModule.Database, etc.) + /// with PackageReference entries. Also adjusts Tests.Shared reference path. + /// + private static string ReplaceFrameworkProjectRefs(string csprojContent) + { + var lines = csprojContent.Split(["\r\n", "\n"], StringSplitOptions.None).ToList(); + + // Map of framework project names to their NuGet package names + var frameworkPackages = new Dictionary(StringComparer.Ordinal) + { + ["SimpleModule.Core"] = "SimpleModule.Core", + ["SimpleModule.Database"] = "SimpleModule.Database", + ["SimpleModule.Hosting"] = "SimpleModule.Hosting", + ["SimpleModule.Blazor"] = "SimpleModule.Blazor", + ["SimpleModule.DevTools"] = "SimpleModule.DevTools", + }; + + var result = new List(); + foreach (var line in lines) + { + var replaced = false; + if (line.Contains(""); + replaced = true; + break; + } + } + } + + // Strip Tests.Shared reference (not available as a NuGet package) + if (!replaced && line.Contains("Tests.Shared", StringComparison.Ordinal) + && line.Contains(" OtherModuleStripPatterns() { return _otherModuleNames.Select(m => $"SimpleModule.{m}").ToList(); @@ -831,22 +892,22 @@ private static string FallbackContractsCsproj() => Library - + """; private static string FallbackModuleCsproj(string moduleName) => $""" - + net10.0 Library - - + + @@ -874,7 +935,6 @@ private static string FallbackTestCsproj(string moduleName) => - """; @@ -1018,7 +1078,7 @@ public void Map(IEndpointRouteBuilder app) var items = await contracts.GetAll{{moduleName}}Async(); return TypedResults.Ok(items); } - ); + ).AllowAnonymous(); } } """; @@ -1041,27 +1101,16 @@ public void Placeholder_ShouldPass() private static string FallbackIntegrationTestSkeleton(string moduleName, string singularName) => $$""" - using System.Net; using FluentAssertions; - using SimpleModule.Tests.Shared.Fixtures; namespace {{moduleName}}.Tests.Integration; - public class {{moduleName}}EndpointTests : IClassFixture + public sealed class {{moduleName}}EndpointTests { - private readonly HttpClient _client; - - public {{moduleName}}EndpointTests(SimpleModuleWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } - [Fact] - public async Task GetAll{{moduleName}}_Returns200() + public void Placeholder_ShouldPass() { - var response = await _client.GetAsync("/api/{{moduleName.ToLowerInvariant()}}"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); + true.Should().BeTrue(); } } """; diff --git a/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs b/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs index 5e64c8cf..be3c3525 100644 --- a/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs @@ -170,9 +170,41 @@ nextContentLine is not null cleaned.Add(lines[i]); } + // Add SimpleModule framework package versions before the closing + var frameworkPackages = new[] + { + " ", + " ", + " ", + " ", + " ", + }; + + // Find the last and insert before it + var lastItemGroupClose = cleaned.FindLastIndex(l => + l.TrimStart().StartsWith("", StringComparison.Ordinal)); + if (lastItemGroupClose >= 0) + { + cleaned.InsertRange(lastItemGroupClose, frameworkPackages); + } + return string.Join(Environment.NewLine, TemplateExtractor.CollapseBlankLines(cleaned)); } + public static string NugetConfig(string simpleModuleRepoPath) + { + var nupkgPath = Path.Combine(simpleModuleRepoPath, "nupkg").Replace('\\', '/'); + return $""" + + + + + + + + """; + } + public string GlobalJson() { if (_solution is null) @@ -184,6 +216,94 @@ public string GlobalJson() return File.Exists(path) ? File.ReadAllText(path) : FallbackGlobalJson(); } + public static string RootPackageJson(string projectName, string frameworkPackagesPath) + { + return FallbackRootPackageJson(projectName, frameworkPackagesPath); + } + + public string BiomeJson() + { + if (_solution is null) + { + return FallbackBiomeJson(); + } + + var path = Path.Combine(_solution.RootPath, "biome.json"); + if (!File.Exists(path)) + { + return FallbackBiomeJson(); + } + + var content = File.ReadAllText(path); + + // Replace file includes: monorepo paths → project paths + content = content.Replace( + "\"modules/**\", \"packages/**\", \"template/**\", \"tests/**\"", + "\"src/**\", \"tests/**\"", + StringComparison.Ordinal + ); + + return content; + } + + public string TsconfigJson() + { + if (_solution is null) + { + return FallbackTsconfigJson(); + } + + var path = Path.Combine(_solution.RootPath, "tsconfig.json"); + if (!File.Exists(path)) + { + return FallbackTsconfigJson(); + } + + var content = File.ReadAllText(path); + + // Remove the SimpleModule.UI registry templates exclude entry + content = content.Replace( + ", \"src/SimpleModule.UI/registry/templates\"", + "", + StringComparison.Ordinal + ); + content = content.Replace( + "\"src/SimpleModule.UI/registry/templates\", ", + "", + StringComparison.Ordinal + ); + content = content.Replace( + "\"src/SimpleModule.UI/registry/templates\"", + "", + StringComparison.Ordinal + ); + + return content; + } + + public string EditorConfig() + { + if (_solution is null) + { + return string.Empty; + } + + var path = Path.Combine(_solution.RootPath, ".editorconfig"); + if (!File.Exists(path)) + { + return string.Empty; + } + + var content = File.ReadAllText(path); + // Adjust test path glob for new project structure (modules under src/) + content = content.Replace( + "{tests,modules/*/tests}", + "{tests,src/modules/*/tests}", + StringComparison.Ordinal + ); + return content; + } + public string ApiCsproj(string projectName) { if (_solution is null) @@ -350,7 +470,9 @@ public string TestsSharedCsproj(string projectName) // Strip module contract references, OpenIddict, Bogus var stripPatterns = new List { "modules", "OpenIddict", "Bogus" }; - return TemplateExtractor.TransformCsproj(path, BaseProjectName, projectName, stripPatterns); + var result = TemplateExtractor.TransformCsproj(path, BaseProjectName, projectName, stripPatterns); + // Fix Host path: template\ → src\ (in repo it's under template/, in generated project it's under src/) + return result.Replace(@"template\", @"src\", StringComparison.Ordinal); } // ── Fallback templates ────────────────────────────────────────── @@ -399,6 +521,11 @@ private static string FallbackDirectoryPackagesProps() => true + + + + + @@ -506,8 +633,111 @@ private static string FallbackTestsSharedCsproj(string projectName) => - + """; + + private static string FallbackRootPackageJson(string projectName, string frameworkPackagesPath) + { + var name = projectName.ToLowerInvariant(); + var pkgPath = frameworkPackagesPath.Replace('\\', '/'); + return $$""" + { + "private": true, + "name": "{{name}}", + "version": "0.0.0", + "workspaces": [ + "src/modules/*/src/*", + "src/{{name}}.Host/ClientApp" + ], + "scripts": { + "lint": "biome lint .", + "format": "biome format --write .", + "check": "biome check .", + "check:fix": "biome check --write .", + "build": "cross-env VITE_MODE=prod npm run build --workspaces --if-present", + "build:dev": "cross-env VITE_MODE=dev npm run build --workspaces --if-present" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.6", + "@tailwindcss/vite": "^4.2.1", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "cross-env": "^7.0.3", + "typescript": "^5.8.0", + "vite": "^6.2.0" + }, + "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", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwindcss": "^4.2.1" + } + } + """; + } + + private static string FallbackBiomeJson() => + """ + { + "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "always" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "files": { + "includes": ["src/**", "tests/**", "!**/wwwroot"] + } + } + """; + + private static string FallbackTsconfigJson() => + """ + { + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "exclude": ["node_modules", "**/wwwroot/**"] + } + """; } diff --git a/docs/superpowers/plans/2026-03-26-cli-improvements.md b/docs/superpowers/plans/2026-03-26-cli-improvements.md new file mode 100644 index 00000000..704c8aaf --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-cli-improvements.md @@ -0,0 +1,2344 @@ +# CLI Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend `sm` CLI with 7 new doctor checks, full-stack feature scaffolding (Views + Pages/index.ts), dry-run mode on all `new` commands, and tree output / progress spinners. + +**Architecture:** New doctor checks implement `IDoctorCheck` and get registered in `DoctorCommand`. Feature scaffolding extends `NewFeatureCommand` with two new artifacts (React view + Pages/index.ts entry). Dry-run is a cross-cutting flag that collects `(path, FileAction)` pairs instead of writing files, then renders a Spectre.Console `Tree`. + +**Tech Stack:** C# 13 / .NET 10, Spectre.Console 0.49, xUnit.v3, FluentAssertions, System.Xml.Linq + +--- + +## File Map + +### New files +| Path | Responsibility | +|---|---| +| `cli/SimpleModule.Cli/Infrastructure/FileAction.cs` | Enum: `Create` / `Modify` — used by dry-run tree | +| `cli/SimpleModule.Cli/Commands/Doctor/Checks/ContractsIsolationCheck.cs` | FAIL if contracts csproj refs anything besides Core | +| `cli/SimpleModule.Cli/Commands/Doctor/Checks/ModuleAttributeCheck.cs` | FAIL if module class missing `[Module]` attribute with RoutePrefix | +| `cli/SimpleModule.Cli/Commands/Doctor/Checks/ViewEndpointNamingCheck.cs` | WARN if endpoint classes don't follow naming/location convention | +| `cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs` | FAIL if Inertia.Render call has no matching Pages/index.ts entry | +| `cli/SimpleModule.Cli/Commands/Doctor/Checks/ViteConfigCheck.cs` | WARN if vite.config.ts missing or not configured for library mode | +| `cli/SimpleModule.Cli/Commands/Doctor/Checks/PackageJsonCheck.cs` | WARN if React/Inertia listed as `dependencies` instead of `peerDependencies` | +| `cli/SimpleModule.Cli/Commands/Doctor/Checks/NpmWorkspaceCheck.cs` | FAIL if module not covered by root package.json workspaces | +| `cli/SimpleModule.Cli/Infrastructure/PagesRegistryFixer.cs` | Adds stub entry to Pages/index.ts (used by `--fix`) | +| `cli/SimpleModule.Cli/Infrastructure/NpmWorkspaceFixer.cs` | Adds workspace glob to root package.json (used by `--fix`) | +| `tests/SimpleModule.Cli.Tests/ContractsIsolationCheckTests.cs` | Tests for ContractsIsolationCheck | +| `tests/SimpleModule.Cli.Tests/ModuleAttributeCheckTests.cs` | Tests for ModuleAttributeCheck | +| `tests/SimpleModule.Cli.Tests/ViewEndpointNamingCheckTests.cs` | Tests for ViewEndpointNamingCheck | +| `tests/SimpleModule.Cli.Tests/PagesRegistryCheckTests.cs` | Tests for PagesRegistryCheck | +| `tests/SimpleModule.Cli.Tests/ViteConfigCheckTests.cs` | Tests for ViteConfigCheck | +| `tests/SimpleModule.Cli.Tests/PackageJsonCheckTests.cs` | Tests for PackageJsonCheck | +| `tests/SimpleModule.Cli.Tests/NpmWorkspaceCheckTests.cs` | Tests for NpmWorkspaceCheck | +| `tests/SimpleModule.Cli.Tests/PagesRegistryFixerTests.cs` | Tests for PagesRegistryFixer | +| `tests/SimpleModule.Cli.Tests/NewFeatureViewScaffoldingTests.cs` | Tests for View + Pages/index.ts generation | +| `tests/SimpleModule.Cli.Tests/DryRunTests.cs` | Tests for dry-run flag behavior | + +### Modified files +| Path | Change | +|---|---| +| `cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs` | Add `GetModuleViewsPath()`, `GetModulePagesIndexPath()` | +| `cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs` | Remove duplicate contracts-isolation warning (superseded by `ContractsIsolationCheck`) | +| `cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs` | Register 7 new checks + 2 new fix dispatchers | +| `cli/SimpleModule.Cli/Templates/FeatureTemplates.cs` | Add `ViewComponent()` method | +| `cli/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs` | Add `--dry-run`, `--no-view` flags | +| `cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs` | Add Views + Pages/index.ts generation, dry-run, tree output | +| `cli/SimpleModule.Cli/Commands/New/NewModuleSettings.cs` | Add `--dry-run` flag | +| `cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs` | Add spinner, tree output, dry-run | +| `cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs` | Add `--dry-run` flag | +| `cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs` | Add dry-run, tree output, improved error messages | + +--- + +## Task 1: Foundations — FileAction enum + SolutionContext helpers + +**Files:** +- Create: `cli/SimpleModule.Cli/Infrastructure/FileAction.cs` +- Modify: `cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs` +- Test: `tests/SimpleModule.Cli.Tests/SolutionContextTests.cs` + +- [ ] **Step 1: Write failing tests for new SolutionContext helpers** + +Add to `tests/SimpleModule.Cli.Tests/SolutionContextTests.cs`: + +```csharp +[Fact] +public void GetModuleViewsPath_ReturnsCorrectPath() +{ + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + + var ctx = SolutionContext.Discover(_tempDir)!; + + ctx.GetModuleViewsPath("Products") + .Should() + .Be(Path.Combine(_tempDir, "src", "modules", "Products", "src", "Products", "Views")); +} + +[Fact] +public void GetModulePagesIndexPath_ReturnsCorrectPath() +{ + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + + var ctx = SolutionContext.Discover(_tempDir)!; + + ctx.GetModulePagesIndexPath("Products") + .Should() + .Be(Path.Combine(_tempDir, "src", "modules", "Products", "src", "Products", "Pages", "index.ts")); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "GetModuleViewsPath_ReturnsCorrectPath|GetModulePagesIndexPath_ReturnsCorrectPath" +``` + +Expected: FAIL — `GetModuleViewsPath` and `GetModulePagesIndexPath` not defined. + +- [ ] **Step 3: Create FileAction enum** + +Create `cli/SimpleModule.Cli/Infrastructure/FileAction.cs`: + +```csharp +namespace SimpleModule.Cli.Infrastructure; + +public enum FileAction +{ + Create, + Modify, +} +``` + +- [ ] **Step 4: Add helpers to SolutionContext** + +In `cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs`, add after `GetTestProjectPath`: + +```csharp +public string GetModuleViewsPath(string moduleName) => + Path.Combine(ModulesPath, moduleName, "src", moduleName, "Views"); + +public string GetModulePagesIndexPath(string moduleName) => + Path.Combine(ModulesPath, moduleName, "src", moduleName, "Pages", "index.ts"); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "GetModuleViewsPath_ReturnsCorrectPath|GetModulePagesIndexPath_ReturnsCorrectPath" +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +``` +git add cli/SimpleModule.Cli/Infrastructure/FileAction.cs cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs tests/SimpleModule.Cli.Tests/SolutionContextTests.cs +git commit -m "feat(cli): add FileAction enum and SolutionContext path helpers" +``` + +--- + +## Task 2: ContractsIsolationCheck + +**Files:** +- Create: `cli/SimpleModule.Cli/Commands/Doctor/Checks/ContractsIsolationCheck.cs` +- Modify: `cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs` +- Test: `tests/SimpleModule.Cli.Tests/ContractsIsolationCheckTests.cs` + +- [ ] **Step 1: Write failing tests** + +Create `tests/SimpleModule.Cli.Tests/ContractsIsolationCheckTests.cs`: + +```csharp +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class ContractsIsolationCheckTests : IDisposable +{ + private readonly string _tempDir; + + public ContractsIsolationCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolution(string moduleName, string contractsCsprojContent) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var modulesDir = Path.Combine(_tempDir, "src", "modules"); + var contractsDir = Path.Combine(modulesDir, moduleName, "src", $"{moduleName}.Contracts"); + Directory.CreateDirectory(contractsDir); + File.WriteAllText(Path.Combine(contractsDir, $"{moduleName}.Contracts.csproj"), contractsCsprojContent); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenContractsOnlyRefsCore() + { + var solution = CreateSolution("Products", """ + + + + + + """); + + var results = new ContractsIsolationCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products.Contracts isolation" && r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Fail_WhenContractsRefsAnotherModuleImpl() + { + var solution = CreateSolution("Products", """ + + + + + + + """); + + var results = new ContractsIsolationCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products.Contracts isolation" && r.Status == CheckStatus.Fail); + } + + [Fact] + public void Run_Warning_WhenContractsCsprojNotFound() + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var modulesDir = Path.Combine(_tempDir, "src", "modules"); + Directory.CreateDirectory(Path.Combine(modulesDir, "Products")); + var solution = SolutionContext.Discover(_tempDir)!; + + var results = new ContractsIsolationCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products.Contracts isolation" && r.Status == CheckStatus.Warning); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "ContractsIsolationCheckTests" +``` + +Expected: FAIL — `ContractsIsolationCheck` not defined. + +- [ ] **Step 3: Implement ContractsIsolationCheck** + +Create `cli/SimpleModule.Cli/Commands/Doctor/Checks/ContractsIsolationCheck.cs`: + +```csharp +using System.Xml.Linq; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed class ContractsIsolationCheck : IDoctorCheck +{ + public IEnumerable Run(SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var contractsCsproj = Path.Combine( + solution.GetModuleContractsPath(module), + $"{module}.Contracts.csproj" + ); + + if (!File.Exists(contractsCsproj)) + { + yield return new CheckResult( + $"{module}.Contracts isolation", + CheckStatus.Warning, + $"{module}.Contracts.csproj not found" + ); + continue; + } + + var doc = XDocument.Load(contractsCsproj); + var nonCoreRefs = doc.Root! + .Descendants("ProjectReference") + .Select(pr => pr.Attribute("Include")?.Value ?? "") + .Where(r => !r.Contains("Core", StringComparison.OrdinalIgnoreCase) + && !r.Contains("Contracts", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + yield return nonCoreRefs.Count == 0 + ? new CheckResult( + $"{module}.Contracts isolation", + CheckStatus.Pass, + "references Core only" + ) + : new CheckResult( + $"{module}.Contracts isolation", + CheckStatus.Fail, + $"references non-Core projects: {string.Join(", ", nonCoreRefs.Select(Path.GetFileNameWithoutExtension))}" + ); + } + } +} +``` + +- [ ] **Step 4: Remove duplicate warning from CsprojConventionCheck** + +In `cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs`, remove the entire contracts-refs block (lines that check `onlyRefsCore`). The `ContractsIsolationCheck` now owns this. The method body for the contracts section should become: + +```csharp +// Contracts isolation is checked by ContractsIsolationCheck +``` + +Specifically, delete this block from `CsprojConventionCheck.Run`: + +```csharp +var contractsCsproj = Path.Combine( + solution.GetModuleContractsPath(module), + $"{module}.Contracts.csproj" +); +if (File.Exists(contractsCsproj)) +{ + var doc = XDocument.Load(contractsCsproj); + var refs = doc.Root!.Descendants("ProjectReference") + .Select(pr => pr.Attribute("Include")?.Value ?? "") + .ToList(); + + var onlyRefsCore = refs.All(r => + r.Contains("Core", StringComparison.OrdinalIgnoreCase) + ); + yield return onlyRefsCore + ? new CheckResult( + $"{module}.Contracts refs", + CheckStatus.Pass, + "references Core only" + ) + : new CheckResult( + $"{module}.Contracts refs", + CheckStatus.Warning, + "contracts project references more than just Core" + ); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "ContractsIsolationCheckTests" +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +``` +git add cli/SimpleModule.Cli/Commands/Doctor/Checks/ContractsIsolationCheck.cs cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs tests/SimpleModule.Cli.Tests/ContractsIsolationCheckTests.cs +git commit -m "feat(cli): add ContractsIsolationCheck doctor check" +``` + +--- + +## Task 3: ModuleAttributeCheck + +**Files:** +- Create: `cli/SimpleModule.Cli/Commands/Doctor/Checks/ModuleAttributeCheck.cs` +- Test: `tests/SimpleModule.Cli.Tests/ModuleAttributeCheckTests.cs` + +- [ ] **Step 1: Write failing tests** + +Create `tests/SimpleModule.Cli.Tests/ModuleAttributeCheckTests.cs`: + +```csharp +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class ModuleAttributeCheckTests : IDisposable +{ + private readonly string _tempDir; + + public ModuleAttributeCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolutionWithModule(string moduleName, string? moduleClassContent = null) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var moduleDir = Path.Combine(_tempDir, "src", "modules", moduleName, "src", moduleName); + Directory.CreateDirectory(moduleDir); + if (moduleClassContent is not null) + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}Module.cs"), moduleClassContent); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenModuleAttributePresentWithRoutePrefix() + { + var solution = CreateSolutionWithModule("Products", """ + [Module("Products", RoutePrefix = "products")] + public class ProductsModule : IModule { } + """); + + var results = new ModuleAttributeCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products [Module] attribute" && r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Fail_WhenModuleClassHasNoModuleAttribute() + { + var solution = CreateSolutionWithModule("Products", """ + public class ProductsModule : IModule { } + """); + + var results = new ModuleAttributeCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products [Module] attribute" && r.Status == CheckStatus.Fail); + } + + [Fact] + public void Run_Warning_WhenModuleClassFileMissing() + { + var solution = CreateSolutionWithModule("Products", moduleClassContent: null); + + var results = new ModuleAttributeCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products [Module] attribute" && r.Status == CheckStatus.Warning); + } + + [Fact] + public void Run_Fail_WhenModuleAttributeMissingRoutePrefix() + { + var solution = CreateSolutionWithModule("Products", """ + [Module("Products")] + public class ProductsModule : IModule { } + """); + + var results = new ModuleAttributeCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products [Module] attribute" && r.Status == CheckStatus.Fail); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "ModuleAttributeCheckTests" +``` + +Expected: FAIL — `ModuleAttributeCheck` not defined. + +- [ ] **Step 3: Implement ModuleAttributeCheck** + +Create `cli/SimpleModule.Cli/Commands/Doctor/Checks/ModuleAttributeCheck.cs`: + +```csharp +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed class ModuleAttributeCheck : IDoctorCheck +{ + public IEnumerable Run(SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var moduleClassPath = Path.Combine( + solution.GetModuleProjectPath(module), + $"{module}Module.cs" + ); + + if (!File.Exists(moduleClassPath)) + { + yield return new CheckResult( + $"{module} [Module] attribute", + CheckStatus.Warning, + $"{module}Module.cs not found — skipping attribute check" + ); + continue; + } + + var content = File.ReadAllText(moduleClassPath); + var hasAttribute = content.Contains("[Module(", StringComparison.Ordinal); + var hasRoutePrefix = content.Contains("RoutePrefix", StringComparison.Ordinal); + + yield return (hasAttribute && hasRoutePrefix) + ? new CheckResult( + $"{module} [Module] attribute", + CheckStatus.Pass, + "[Module] attribute with RoutePrefix present" + ) + : new CheckResult( + $"{module} [Module] attribute", + CheckStatus.Fail, + hasAttribute + ? "[Module] attribute present but RoutePrefix is missing" + : $"{module}Module.cs missing [Module] attribute" + ); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "ModuleAttributeCheckTests" +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add cli/SimpleModule.Cli/Commands/Doctor/Checks/ModuleAttributeCheck.cs tests/SimpleModule.Cli.Tests/ModuleAttributeCheckTests.cs +git commit -m "feat(cli): add ModuleAttributeCheck doctor check" +``` + +--- + +## Task 4: ViewEndpointNamingCheck + +**Files:** +- Create: `cli/SimpleModule.Cli/Commands/Doctor/Checks/ViewEndpointNamingCheck.cs` +- Test: `tests/SimpleModule.Cli.Tests/ViewEndpointNamingCheckTests.cs` + +- [ ] **Step 1: Write failing tests** + +Create `tests/SimpleModule.Cli.Tests/ViewEndpointNamingCheckTests.cs`: + +```csharp +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class ViewEndpointNamingCheckTests : IDisposable +{ + private readonly string _tempDir; + + public ViewEndpointNamingCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolutionWithEndpoints(string moduleName, params string[] endpointFileNames) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var endpointsDir = Path.Combine(_tempDir, "src", "modules", moduleName, "src", moduleName, "Endpoints", moduleName); + Directory.CreateDirectory(endpointsDir); + foreach (var name in endpointFileNames) + File.WriteAllText(Path.Combine(endpointsDir, name), "// stub"); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenAllEndpointFilesFollowConvention() + { + var solution = CreateSolutionWithEndpoints("Products", "GetAllEndpoint.cs", "CreateEndpoint.cs"); + + var results = new ViewEndpointNamingCheck().Run(solution).ToList(); + + results.Should().OnlyContain(r => r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Warn_WhenEndpointFileDoesNotEndWithEndpoint() + { + var solution = CreateSolutionWithEndpoints("Products", "GetProducts.cs"); + + var results = new ViewEndpointNamingCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name.Contains("GetProducts") && r.Status == CheckStatus.Warning); + } + + [Fact] + public void Run_Pass_WhenNoEndpointsDirectory() + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var moduleDir = Path.Combine(_tempDir, "src", "modules", "Products", "src", "Products"); + Directory.CreateDirectory(moduleDir); + var solution = SolutionContext.Discover(_tempDir)!; + + var results = new ViewEndpointNamingCheck().Run(solution).ToList(); + + results.Should().BeEmpty(); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "ViewEndpointNamingCheckTests" +``` + +Expected: FAIL — `ViewEndpointNamingCheck` not defined. + +- [ ] **Step 3: Implement ViewEndpointNamingCheck** + +Create `cli/SimpleModule.Cli/Commands/Doctor/Checks/ViewEndpointNamingCheck.cs`: + +```csharp +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed class ViewEndpointNamingCheck : IDoctorCheck +{ + public IEnumerable Run(SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var endpointsDir = Path.Combine( + solution.GetModuleProjectPath(module), + "Endpoints", + module + ); + + if (!Directory.Exists(endpointsDir)) + continue; + + foreach (var file in Directory.GetFiles(endpointsDir, "*.cs")) + { + var fileName = Path.GetFileNameWithoutExtension(file); + // Skip validators — they follow a different convention + if (fileName.EndsWith("Validator", StringComparison.Ordinal)) + continue; + + yield return fileName.EndsWith("Endpoint", StringComparison.Ordinal) + ? new CheckResult( + $"{module}/{fileName}", + CheckStatus.Pass, + "follows Endpoint naming convention" + ) + : new CheckResult( + $"{module}/{fileName}", + CheckStatus.Warning, + $"'{fileName}' does not end with 'Endpoint' — rename to '{fileName}Endpoint'" + ); + } + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "ViewEndpointNamingCheckTests" +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add cli/SimpleModule.Cli/Commands/Doctor/Checks/ViewEndpointNamingCheck.cs tests/SimpleModule.Cli.Tests/ViewEndpointNamingCheckTests.cs +git commit -m "feat(cli): add ViewEndpointNamingCheck doctor check" +``` + +--- + +## Task 5: PagesRegistryCheck + PagesRegistryFixer + +**Files:** +- Create: `cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs` +- Create: `cli/SimpleModule.Cli/Infrastructure/PagesRegistryFixer.cs` +- Test: `tests/SimpleModule.Cli.Tests/PagesRegistryCheckTests.cs` +- Test: `tests/SimpleModule.Cli.Tests/PagesRegistryFixerTests.cs` + +- [ ] **Step 1: Write failing tests for PagesRegistryCheck** + +Create `tests/SimpleModule.Cli.Tests/PagesRegistryCheckTests.cs`: + +```csharp +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class PagesRegistryCheckTests : IDisposable +{ + private readonly string _tempDir; + + public PagesRegistryCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolution( + string moduleName, + string[]? csFiles = null, + string? pagesIndexContent = null) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var moduleDir = Path.Combine(_tempDir, "src", "modules", moduleName, "src", moduleName); + var pagesDir = Path.Combine(moduleDir, "Pages"); + Directory.CreateDirectory(pagesDir); + + if (csFiles is not null) + { + var endpointsDir = Path.Combine(moduleDir, "Endpoints", moduleName); + Directory.CreateDirectory(endpointsDir); + foreach (var (fileName, content) in csFiles.Select((c, i) => ($"Endpoint{i}.cs", c))) + File.WriteAllText(Path.Combine(endpointsDir, fileName), content); + } + + if (pagesIndexContent is not null) + File.WriteAllText(Path.Combine(pagesDir, "index.ts"), pagesIndexContent); + + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenAllInertiaCallsHavePageEntry() + { + var solution = CreateSolution( + "Products", + csFiles: [@"Inertia.Render(""Products/Browse"", props)"], + pagesIndexContent: """ + export const pages: Record = { + "Products/Browse": () => import("../Views/Browse"), + }; + """); + + var results = new PagesRegistryCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Pages -> Products/Browse" && r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Fail_WhenInertiaCallHasNoPageEntry() + { + var solution = CreateSolution( + "Products", + csFiles: [@"Inertia.Render(""Products/Browse"", props)"], + pagesIndexContent: """ + export const pages: Record = {}; + """); + + var results = new PagesRegistryCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Pages -> Products/Browse" && r.Status == CheckStatus.Fail); + } + + [Fact] + public void Run_Fail_WhenPagesIndexTsMissing() + { + var solution = CreateSolution( + "Products", + csFiles: [@"Inertia.Render(""Products/Browse"", props)"], + pagesIndexContent: null); + + var results = new PagesRegistryCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Pages -> Products/Browse" && r.Status == CheckStatus.Fail); + } + + [Fact] + public void Run_Pass_WhenNoInertiaCallsExist() + { + var solution = CreateSolution( + "Products", + csFiles: ["// no inertia calls here"], + pagesIndexContent: "export const pages: Record = {};"); + + var results = new PagesRegistryCheck().Run(solution).ToList(); + + results.Should().BeEmpty(); + } +} +``` + +- [ ] **Step 2: Write failing tests for PagesRegistryFixer** + +Create `tests/SimpleModule.Cli.Tests/PagesRegistryFixerTests.cs`: + +```csharp +using FluentAssertions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class PagesRegistryFixerTests : IDisposable +{ + private readonly string _tempDir; + + public PagesRegistryFixerTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + [Fact] + public void AddEntry_AppendsToExistingPagesIndex() + { + var indexPath = Path.Combine(_tempDir, "index.ts"); + File.WriteAllText(indexPath, """ + export const pages: Record = { + "Products/Browse": () => import("../Views/Browse"), + }; + """); + + PagesRegistryFixer.AddEntry(indexPath, "Products/Create", "../Views/Create"); + + var content = File.ReadAllText(indexPath); + content.Should().Contain("\"Products/Create\": () => import(\"../Views/Create\")"); + content.Should().Contain("\"Products/Browse\""); + } + + [Fact] + public void AddEntry_CreatesFileFromScratchWhenMissing() + { + var indexPath = Path.Combine(_tempDir, "index.ts"); + + PagesRegistryFixer.AddEntry(indexPath, "Products/Create", "../Views/Create"); + + var content = File.ReadAllText(indexPath); + content.Should().Contain("export const pages"); + content.Should().Contain("\"Products/Create\": () => import(\"../Views/Create\")"); + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "PagesRegistryCheckTests|PagesRegistryFixerTests" +``` + +Expected: FAIL — types not defined. + +- [ ] **Step 4: Implement PagesRegistryCheck** + +Create `cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs`: + +```csharp +using System.Text.RegularExpressions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed partial class PagesRegistryCheck : IDoctorCheck +{ + [GeneratedRegex(@"Inertia\.Render\s*\(\s*""([^""]+)""")] + private static partial Regex InertiaRenderPattern(); + + [GeneratedRegex(@"""([^""]+)""\s*:\s*(?:\(\s*\)|(?:async\s*)?\(\s*\)\s*=>|import)")] + private static partial Regex PagesKeyPattern(); + + public IEnumerable Run(SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var moduleDir = solution.GetModuleProjectPath(module); + if (!Directory.Exists(moduleDir)) + continue; + + var csFiles = Directory.GetFiles(moduleDir, "*.cs", SearchOption.AllDirectories); + var inertiaComponents = csFiles + .SelectMany(f => InertiaRenderPattern().Matches(File.ReadAllText(f))) + .Select(m => m.Groups[1].Value) + .Distinct() + .ToList(); + + if (inertiaComponents.Count == 0) + continue; + + var indexPath = solution.GetModulePagesIndexPath(module); + var registeredKeys = new HashSet(StringComparer.Ordinal); + + if (File.Exists(indexPath)) + { + var indexContent = File.ReadAllText(indexPath); + foreach (Match m in PagesKeyPattern().Matches(indexContent)) + registeredKeys.Add(m.Groups[1].Value); + } + + foreach (var component in inertiaComponents) + { + yield return registeredKeys.Contains(component) + ? new CheckResult( + $"Pages -> {component}", + CheckStatus.Pass, + "registered in Pages/index.ts" + ) + : new CheckResult( + $"Pages -> {component}", + CheckStatus.Fail, + $"'{component}' used in Inertia.Render but missing from Pages/index.ts" + ); + } + } + } +} +``` + +- [ ] **Step 5: Implement PagesRegistryFixer** + +Create `cli/SimpleModule.Cli/Infrastructure/PagesRegistryFixer.cs`: + +```csharp +namespace SimpleModule.Cli.Infrastructure; + +public static class PagesRegistryFixer +{ + public static void AddEntry(string indexPath, string componentKey, string importPath) + { + if (!File.Exists(indexPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(indexPath)!); + File.WriteAllText(indexPath, $$""" + export const pages: Record = { + "{{componentKey}}": () => import("{{importPath}}"), + }; + """); + return; + } + + var content = File.ReadAllText(indexPath); + var entry = $" \"{componentKey}\": () => import(\"{importPath}\"),"; + + // Find last closing } of the pages object and insert before it + var lastBrace = content.LastIndexOf('}'); + if (lastBrace < 0) + { + File.AppendAllText(indexPath, $"\n{entry}"); + return; + } + + // Insert before the last } + var newContent = content[..lastBrace] + entry + "\n" + content[lastBrace..]; + File.WriteAllText(indexPath, newContent); + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "PagesRegistryCheckTests|PagesRegistryFixerTests" +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +``` +git add cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs cli/SimpleModule.Cli/Infrastructure/PagesRegistryFixer.cs tests/SimpleModule.Cli.Tests/PagesRegistryCheckTests.cs tests/SimpleModule.Cli.Tests/PagesRegistryFixerTests.cs +git commit -m "feat(cli): add PagesRegistryCheck and PagesRegistryFixer" +``` + +--- + +## Task 6: ViteConfigCheck + +**Files:** +- Create: `cli/SimpleModule.Cli/Commands/Doctor/Checks/ViteConfigCheck.cs` +- Test: `tests/SimpleModule.Cli.Tests/ViteConfigCheckTests.cs` + +- [ ] **Step 1: Write failing tests** + +Create `tests/SimpleModule.Cli.Tests/ViteConfigCheckTests.cs`: + +```csharp +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class ViteConfigCheckTests : IDisposable +{ + private readonly string _tempDir; + + public ViteConfigCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolutionWithViteConfig(string moduleName, string? viteConfigContent) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var moduleDir = Path.Combine(_tempDir, "src", "modules", moduleName, "src", moduleName); + Directory.CreateDirectory(moduleDir); + if (viteConfigContent is not null) + File.WriteAllText(Path.Combine(moduleDir, "vite.config.ts"), viteConfigContent); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenViteConfigCorrect() + { + var solution = CreateSolutionWithViteConfig("Products", """ + import { defineConfig } from 'vite' + export default defineConfig({ + build: { lib: { entry: 'Pages/index.ts' } }, + external: ['react', 'react-dom', '@inertiajs/react'], + }) + """); + + var results = new ViteConfigCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products vite.config.ts" && r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Warn_WhenViteConfigMissing() + { + var solution = CreateSolutionWithViteConfig("Products", viteConfigContent: null); + + var results = new ViteConfigCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products vite.config.ts" && r.Status == CheckStatus.Warning); + } + + [Fact] + public void Run_Warn_WhenLibModeNotConfigured() + { + var solution = CreateSolutionWithViteConfig("Products", """ + import { defineConfig } from 'vite' + export default defineConfig({}) + """); + + var results = new ViteConfigCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products vite.config.ts" && r.Status == CheckStatus.Warning); + } + + [Fact] + public void Run_Warn_WhenExternalsIncomplete() + { + var solution = CreateSolutionWithViteConfig("Products", """ + build: { lib: { entry: 'Pages/index.ts' } }, + external: ['react'], + """); + + var results = new ViteConfigCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products vite.config.ts" && r.Status == CheckStatus.Warning); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "ViteConfigCheckTests" +``` + +Expected: FAIL — `ViteConfigCheck` not defined. + +- [ ] **Step 3: Implement ViteConfigCheck** + +Create `cli/SimpleModule.Cli/Commands/Doctor/Checks/ViteConfigCheck.cs`: + +```csharp +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed class ViteConfigCheck : IDoctorCheck +{ + private static readonly string[] RequiredExternals = ["react", "react-dom", "@inertiajs/react"]; + + public IEnumerable Run(SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var viteConfigPath = Path.Combine( + solution.GetModuleProjectPath(module), + "vite.config.ts" + ); + + if (!File.Exists(viteConfigPath)) + { + yield return new CheckResult( + $"{module} vite.config.ts", + CheckStatus.Warning, + "vite.config.ts not found — module won't build as a library" + ); + continue; + } + + var content = File.ReadAllText(viteConfigPath); + var hasLibMode = content.Contains("lib:", StringComparison.Ordinal); + var missingExternals = RequiredExternals + .Where(e => !content.Contains(e, StringComparison.Ordinal)) + .ToList(); + + if (hasLibMode && missingExternals.Count == 0) + { + yield return new CheckResult( + $"{module} vite.config.ts", + CheckStatus.Pass, + "library mode configured with correct externals" + ); + } + else + { + var issues = new List(); + if (!hasLibMode) issues.Add("missing lib mode"); + if (missingExternals.Count > 0) issues.Add($"missing externals: {string.Join(", ", missingExternals)}"); + + yield return new CheckResult( + $"{module} vite.config.ts", + CheckStatus.Warning, + string.Join("; ", issues) + ); + } + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "ViteConfigCheckTests" +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add cli/SimpleModule.Cli/Commands/Doctor/Checks/ViteConfigCheck.cs tests/SimpleModule.Cli.Tests/ViteConfigCheckTests.cs +git commit -m "feat(cli): add ViteConfigCheck doctor check" +``` + +--- + +## Task 7: PackageJsonCheck + +**Files:** +- Create: `cli/SimpleModule.Cli/Commands/Doctor/Checks/PackageJsonCheck.cs` +- Test: `tests/SimpleModule.Cli.Tests/PackageJsonCheckTests.cs` + +- [ ] **Step 1: Write failing tests** + +Create `tests/SimpleModule.Cli.Tests/PackageJsonCheckTests.cs`: + +```csharp +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class PackageJsonCheckTests : IDisposable +{ + private readonly string _tempDir; + + public PackageJsonCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolutionWithPackageJson(string moduleName, string? packageJsonContent) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var moduleDir = Path.Combine(_tempDir, "src", "modules", moduleName, "src", moduleName); + Directory.CreateDirectory(moduleDir); + if (packageJsonContent is not null) + File.WriteAllText(Path.Combine(moduleDir, "package.json"), packageJsonContent); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenReactAndInertiaAreInPeerDeps() + { + var solution = CreateSolutionWithPackageJson("Products", """ + { + "peerDependencies": { + "react": "^19.0.0", + "@inertiajs/react": "^2.0.0" + } + } + """); + + var results = new PackageJsonCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products package.json" && r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Warn_WhenReactIsInDependenciesNotPeerDeps() + { + var solution = CreateSolutionWithPackageJson("Products", """ + { + "dependencies": { "react": "^19.0.0" }, + "peerDependencies": { "@inertiajs/react": "^2.0.0" } + } + """); + + var results = new PackageJsonCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products package.json" && r.Status == CheckStatus.Warning); + } + + [Fact] + public void Run_Warn_WhenPackageJsonMissing() + { + var solution = CreateSolutionWithPackageJson("Products", packageJsonContent: null); + + var results = new PackageJsonCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "Products package.json" && r.Status == CheckStatus.Warning); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "PackageJsonCheckTests" +``` + +Expected: FAIL — `PackageJsonCheck` not defined. + +- [ ] **Step 3: Implement PackageJsonCheck** + +Create `cli/SimpleModule.Cli/Commands/Doctor/Checks/PackageJsonCheck.cs`: + +```csharp +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed class PackageJsonCheck : IDoctorCheck +{ + private static readonly string[] RequiredPeerDeps = ["react", "@inertiajs/react"]; + + public IEnumerable Run(SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var packageJsonPath = Path.Combine( + solution.GetModuleProjectPath(module), + "package.json" + ); + + if (!File.Exists(packageJsonPath)) + { + yield return new CheckResult( + $"{module} package.json", + CheckStatus.Warning, + "package.json not found" + ); + continue; + } + + var content = File.ReadAllText(packageJsonPath); + + // Check peerDependencies section contains required packages + var peerDepsStart = content.IndexOf("\"peerDependencies\"", StringComparison.Ordinal); + var missingFromPeerDeps = RequiredPeerDeps + .Where(dep => + { + var inPeerDeps = peerDepsStart >= 0 && + content.IndexOf($"\"{dep}\"", peerDepsStart, StringComparison.Ordinal) >= 0; + return !inPeerDeps; + }) + .ToList(); + + // Check if any required packages are wrongly in dependencies + var depsStart = content.IndexOf("\"dependencies\"", StringComparison.Ordinal); + var inWrongSection = peerDepsStart >= 0 ? RequiredPeerDeps + .Where(dep => + { + var inDeps = depsStart >= 0 && depsStart < peerDepsStart && + content.IndexOf($"\"{dep}\"", depsStart, StringComparison.Ordinal) is var idx && + idx >= 0 && idx < peerDepsStart; + return inDeps; + }) + .ToList() : []; + + if (missingFromPeerDeps.Count == 0 && inWrongSection.Count == 0) + { + yield return new CheckResult( + $"{module} package.json", + CheckStatus.Pass, + "React and @inertiajs/react declared as peerDependencies" + ); + } + else + { + var issues = new List(); + if (missingFromPeerDeps.Count > 0) + issues.Add($"missing from peerDependencies: {string.Join(", ", missingFromPeerDeps)}"); + if (inWrongSection.Count > 0) + issues.Add($"should be peerDependencies, not dependencies: {string.Join(", ", inWrongSection)}"); + + yield return new CheckResult( + $"{module} package.json", + CheckStatus.Warning, + string.Join("; ", issues) + ); + } + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "PackageJsonCheckTests" +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add cli/SimpleModule.Cli/Commands/Doctor/Checks/PackageJsonCheck.cs tests/SimpleModule.Cli.Tests/PackageJsonCheckTests.cs +git commit -m "feat(cli): add PackageJsonCheck doctor check" +``` + +--- + +## Task 8: NpmWorkspaceCheck + NpmWorkspaceFixer + +**Files:** +- Create: `cli/SimpleModule.Cli/Commands/Doctor/Checks/NpmWorkspaceCheck.cs` +- Create: `cli/SimpleModule.Cli/Infrastructure/NpmWorkspaceFixer.cs` +- Test: `tests/SimpleModule.Cli.Tests/NpmWorkspaceCheckTests.cs` + +- [ ] **Step 1: Write failing tests** + +Create `tests/SimpleModule.Cli.Tests/NpmWorkspaceCheckTests.cs`: + +```csharp +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class NpmWorkspaceCheckTests : IDisposable +{ + private readonly string _tempDir; + + public NpmWorkspaceCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolution(string moduleName, string? rootPackageJson) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var modulesDir = Path.Combine(_tempDir, "src", "modules"); + Directory.CreateDirectory(Path.Combine(modulesDir, moduleName)); + if (rootPackageJson is not null) + File.WriteAllText(Path.Combine(_tempDir, "package.json"), rootPackageJson); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenModuleCoveredByWorkspaceGlob() + { + var solution = CreateSolution("Products", """ + { + "workspaces": ["src/modules/*/src/*"] + } + """); + + var results = new NpmWorkspaceCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "NpmWorkspace -> Products" && r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Fail_WhenModuleNotInWorkspaces() + { + var solution = CreateSolution("Products", """ + { + "workspaces": ["packages/*"] + } + """); + + var results = new NpmWorkspaceCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "NpmWorkspace -> Products" && r.Status == CheckStatus.Fail); + } + + [Fact] + public void Run_Warning_WhenRootPackageJsonMissing() + { + var solution = CreateSolution("Products", rootPackageJson: null); + + var results = new NpmWorkspaceCheck().Run(solution).ToList(); + + results.Should().ContainSingle(r => + r.Name == "NpmWorkspace -> Products" && r.Status == CheckStatus.Warning); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "NpmWorkspaceCheckTests" +``` + +Expected: FAIL — `NpmWorkspaceCheck` not defined. + +- [ ] **Step 3: Implement NpmWorkspaceCheck** + +Create `cli/SimpleModule.Cli/Commands/Doctor/Checks/NpmWorkspaceCheck.cs`: + +```csharp +using System.Text.RegularExpressions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +public sealed partial class NpmWorkspaceCheck : IDoctorCheck +{ + [GeneratedRegex(@"""workspaces""\s*:\s*\[([^\]]*)\]", RegexOptions.Singleline)] + private static partial Regex WorkspacesPattern(); + + public IEnumerable Run(SolutionContext solution) + { + var rootPackageJson = Path.Combine(solution.RootPath, "package.json"); + + if (!File.Exists(rootPackageJson)) + { + foreach (var module in solution.ExistingModules) + { + yield return new CheckResult( + $"NpmWorkspace -> {module}", + CheckStatus.Warning, + "root package.json not found — cannot verify workspaces" + ); + } + yield break; + } + + var content = File.ReadAllText(rootPackageJson); + var match = WorkspacesPattern().Match(content); + var workspaceGlobs = match.Success + ? match.Groups[1].Value + .Split(',') + .Select(g => g.Trim().Trim('"')) + .Where(g => !string.IsNullOrWhiteSpace(g)) + .ToList() + : []; + + foreach (var module in solution.ExistingModules) + { + // modules live at src/modules/{Name}/src/{Name} — check if any glob covers this pattern + var modulePath = $"src/modules/{module}/src/{module}"; + var isCovered = workspaceGlobs.Any(glob => GlobCoversPath(glob, modulePath)); + + yield return isCovered + ? new CheckResult( + $"NpmWorkspace -> {module}", + CheckStatus.Pass, + "covered by workspace glob" + ) + : new CheckResult( + $"NpmWorkspace -> {module}", + CheckStatus.Fail, + $"'{modulePath}' not covered by any workspace glob in root package.json" + ); + } + } + + private static bool GlobCoversPath(string glob, string path) + { + // Convert glob wildcards to a simple prefix match + // "src/modules/*/src/*" covers "src/modules/Products/src/Products" + var pattern = "^" + Regex.Escape(glob).Replace(@"\*", "[^/]+") + "$"; + return Regex.IsMatch(path, pattern, RegexOptions.IgnoreCase); + } +} +``` + +- [ ] **Step 4: Implement NpmWorkspaceFixer** + +Create `cli/SimpleModule.Cli/Infrastructure/NpmWorkspaceFixer.cs`: + +```csharp +namespace SimpleModule.Cli.Infrastructure; + +public static class NpmWorkspaceFixer +{ + public static void AddWorkspaceGlob(string packageJsonPath, string glob) + { + var content = File.ReadAllText(packageJsonPath); + + // Find existing workspaces array and append + var workspacesStart = content.IndexOf("\"workspaces\"", StringComparison.Ordinal); + if (workspacesStart >= 0) + { + var arrayStart = content.IndexOf('[', workspacesStart); + var arrayEnd = content.IndexOf(']', arrayStart); + if (arrayStart >= 0 && arrayEnd >= 0) + { + var entry = $"\"{glob}\""; + var existing = content[arrayStart..arrayEnd].Trim('[').Trim(); + var separator = existing.Length > 0 ? ", " : ""; + var newContent = content[..(arrayEnd)] + separator + entry + content[arrayEnd..]; + File.WriteAllText(packageJsonPath, newContent); + return; + } + } + + // No workspaces key — insert before closing } + var lastBrace = content.LastIndexOf('}'); + if (lastBrace >= 0) + { + var insert = $",\n \"workspaces\": [\"{glob}\"]"; + File.WriteAllText(packageJsonPath, content[..lastBrace] + insert + "\n}"); + } + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "NpmWorkspaceCheckTests" +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +``` +git add cli/SimpleModule.Cli/Commands/Doctor/Checks/NpmWorkspaceCheck.cs cli/SimpleModule.Cli/Infrastructure/NpmWorkspaceFixer.cs tests/SimpleModule.Cli.Tests/NpmWorkspaceCheckTests.cs +git commit -m "feat(cli): add NpmWorkspaceCheck and NpmWorkspaceFixer" +``` + +--- + +## Task 9: Register all new checks and fixers in DoctorCommand + +**Files:** +- Modify: `cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs` + +- [ ] **Step 1: Update checks array** + +In `cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs`, replace the existing `checks` array: + +```csharp +IDoctorCheck[] checks = +[ + new SolutionStructureCheck(), + new ProjectReferenceCheck(), + new SlnxEntriesCheck(), + new CsprojConventionCheck(), + new ContractsIsolationCheck(), + new ModulePatternCheck(), + new ModuleAttributeCheck(), + new ViewEndpointNamingCheck(), + new PagesRegistryCheck(), + new ViteConfigCheck(), + new PackageJsonCheck(), + new NpmWorkspaceCheck(), +]; +``` + +- [ ] **Step 2: Add new fix dispatchers to AutoFix method** + +In the `AutoFix` method, add after the existing fix blocks: + +```csharp +// Fix missing Pages/index.ts entries +if (result.Name.StartsWith("Pages -> ", StringComparison.Ordinal)) +{ + var componentKey = result.Name["Pages -> ".Length..]; + // Derive module name from the component key (e.g. "Products/Browse" → "Products") + var moduleName = componentKey.Contains('/') ? componentKey[..componentKey.IndexOf('/')] : componentKey; + var featureName = componentKey.Contains('/') ? componentKey[(componentKey.IndexOf('/') + 1)..] : componentKey; + var indexPath = solution.GetModulePagesIndexPath(moduleName); + var importPath = $"../Views/{featureName}"; + PagesRegistryFixer.AddEntry(indexPath, componentKey, importPath); + AnsiConsole.MarkupLine($"[green] Fixed: added '{componentKey}' to Pages/index.ts[/]"); +} + +// Fix missing npm workspace entries +if (result.Name.StartsWith("NpmWorkspace -> ", StringComparison.Ordinal)) +{ + var moduleName = result.Name["NpmWorkspace -> ".Length..]; + var rootPackageJson = Path.Combine(solution.RootPath, "package.json"); + if (File.Exists(rootPackageJson)) + { + NpmWorkspaceFixer.AddWorkspaceGlob(rootPackageJson, $"src/modules/{moduleName}/src/*"); + AnsiConsole.MarkupLine($"[green] Fixed: added {moduleName} workspace glob to package.json[/]"); + } +} +``` + +- [ ] **Step 3: Update --fix hint message** + +Replace the existing hint line: +```csharp +AnsiConsole.MarkupLine( + "[dim]Run with --fix to auto-fix missing slnx entries, project references, Pages registry entries, and npm workspace globs.[/]" +); +``` + +- [ ] **Step 4: Build to verify** + +``` +dotnet build cli/SimpleModule.Cli/ +``` + +Expected: Build succeeds with no errors. + +- [ ] **Step 5: Run all CLI tests** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ +``` + +Expected: All tests PASS. + +- [ ] **Step 6: Commit** + +``` +git add cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs +git commit -m "feat(cli): register all new doctor checks and fix dispatchers" +``` + +--- + +## Task 10: FeatureTemplates.ViewComponent() + Pages/index.ts updater + +**Files:** +- Modify: `cli/SimpleModule.Cli/Templates/FeatureTemplates.cs` +- Test: `tests/SimpleModule.Cli.Tests/NewFeatureViewScaffoldingTests.cs` (partial — template method tests) + +- [ ] **Step 1: Write failing tests for ViewComponent** + +Create `tests/SimpleModule.Cli.Tests/NewFeatureViewScaffoldingTests.cs`: + +```csharp +using FluentAssertions; +using SimpleModule.Cli.Infrastructure; +using SimpleModule.Cli.Templates; + +namespace SimpleModule.Cli.Tests; + +public sealed class NewFeatureViewScaffoldingTests : IDisposable +{ + private readonly string _tempDir; + + public NewFeatureViewScaffoldingTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolution() + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void ViewComponent_ContainsFeatureNameAsComponentName() + { + var solution = CreateSolution(); + var templates = new FeatureTemplates(solution); + + var result = templates.ViewComponent("Products", "Create"); + + result.Should().Contain("export default function Create"); + } + + [Fact] + public void ViewComponent_ContainsPropsType() + { + var solution = CreateSolution(); + var templates = new FeatureTemplates(solution); + + var result = templates.ViewComponent("Products", "Browse"); + + result.Should().Contain("type Props ="); + } + + [Fact] + public void ViewComponent_ContainsHeadingWithFeatureName() + { + var solution = CreateSolution(); + var templates = new FeatureTemplates(solution); + + var result = templates.ViewComponent("Products", "Create"); + + result.Should().Contain("

Create

"); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "NewFeatureViewScaffoldingTests" +``` + +Expected: FAIL — `ViewComponent` method not defined. + +- [ ] **Step 3: Add ViewComponent to FeatureTemplates** + +In `cli/SimpleModule.Cli/Templates/FeatureTemplates.cs`, add this public method: + +```csharp +public static string ViewComponent(string moduleName, string featureName) => + $$""" + type Props = { + // TODO: add props from your endpoint's response + } + + export default function {{featureName}}({ }: Props) { + return ( +
+

{{featureName}}

+
+ ) + } + """; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "NewFeatureViewScaffoldingTests" +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add cli/SimpleModule.Cli/Templates/FeatureTemplates.cs tests/SimpleModule.Cli.Tests/NewFeatureViewScaffoldingTests.cs +git commit -m "feat(cli): add ViewComponent template method to FeatureTemplates" +``` + +--- + +## Task 11: NewFeatureSettings flags + NewFeatureCommand view scaffolding + +**Files:** +- Modify: `cli/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs` +- Modify: `cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs` +- Test: `tests/SimpleModule.Cli.Tests/NewFeatureViewScaffoldingTests.cs` (extend) + +- [ ] **Step 1: Write failing tests for view scaffolding integration** + +Add to `tests/SimpleModule.Cli.Tests/NewFeatureViewScaffoldingTests.cs`: + +```csharp +[Fact] +public void PagesRegistryFixer_AddsEntryForNewFeature() +{ + var pagesDir = Path.Combine(_tempDir, "Pages"); + Directory.CreateDirectory(pagesDir); + var indexPath = Path.Combine(pagesDir, "index.ts"); + File.WriteAllText(indexPath, """ + export const pages: Record = { + "Products/Browse": () => import("../Views/Browse"), + }; + """); + + PagesRegistryFixer.AddEntry(indexPath, "Products/Create", "../Views/Create"); + + var content = File.ReadAllText(indexPath); + content.Should().Contain("\"Products/Create\": () => import(\"../Views/Create\")"); + content.Should().Contain("\"Products/Browse\""); +} +``` + +- [ ] **Step 2: Run test to confirm it passes** (PagesRegistryFixer already implemented) + +``` +dotnet test tests/SimpleModule.Cli.Tests/ --filter "PagesRegistryFixer_AddsEntryForNewFeature" +``` + +Expected: PASS. + +- [ ] **Step 3: Add --dry-run and --no-view flags to NewFeatureSettings** + +In `cli/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs`, add: + +```csharp +[CommandOption("--no-view")] +[Description("Skip creating the React view component and Pages/index.ts entry")] +public bool NoView { get; set; } + +[CommandOption("--dry-run")] +[Description("Show what would be created without writing any files")] +public bool DryRun { get; set; } +``` + +- [ ] **Step 4: Rewrite NewFeatureCommand.Execute to add view scaffolding and dry-run** + +Replace the body of `Execute` in `cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs`: + +```csharp +public override int Execute(CommandContext context, NewFeatureSettings settings) +{ + var solution = SolutionContext.Discover(); + if (solution is null) + { + AnsiConsole.MarkupLine("[red]No .slnx file found. Run this command from inside a SimpleModule project.[/]"); + return 1; + } + + if (solution.ExistingModules.Count == 0) + { + AnsiConsole.MarkupLine("[red]No modules found. Create a module first with 'sm new module'.[/]"); + return 1; + } + + var moduleName = settings.ResolveModule(solution); + var featureName = settings.ResolveName(); + var httpMethod = settings.ResolveHttpMethod(); + var route = settings.ResolveRoute(); + var includeValidator = settings.ResolveIncludeValidator(); + var singularName = ModuleTemplates.GetSingularName(moduleName); + + var templates = new FeatureTemplates(solution); + var ops = new List<(string Path, FileAction Action)>(); + + var endpointsDir = Path.Combine(solution.GetModuleProjectPath(moduleName), "Endpoints", moduleName); + + // Collect operations + ops.Add((Path.Combine(endpointsDir, $"{featureName}Endpoint.cs"), FileAction.Create)); + if (includeValidator) + ops.Add((Path.Combine(endpointsDir, $"{featureName}RequestValidator.cs"), FileAction.Create)); + + if (!settings.NoView) + { + ops.Add((Path.Combine(solution.GetModuleViewsPath(moduleName), $"{featureName}.tsx"), FileAction.Create)); + ops.Add((solution.GetModulePagesIndexPath(moduleName), FileAction.Modify)); + } + + if (settings.DryRun) + { + RenderDryRunTree(ops); + return 0; + } + + // Execute + Directory.CreateDirectory(endpointsDir); + WriteFile(Path.Combine(endpointsDir, $"{featureName}Endpoint.cs"), + templates.Endpoint(moduleName, featureName, httpMethod, route, singularName)); + + if (includeValidator) + WriteFile(Path.Combine(endpointsDir, $"{featureName}RequestValidator.cs"), + templates.Validator(moduleName, featureName, singularName)); + + if (!settings.NoView) + { + var viewsDir = solution.GetModuleViewsPath(moduleName); + Directory.CreateDirectory(viewsDir); + WriteFile(Path.Combine(viewsDir, $"{featureName}.tsx"), + FeatureTemplates.ViewComponent(moduleName, featureName)); + + var indexPath = solution.GetModulePagesIndexPath(moduleName); + PagesRegistryFixer.AddEntry(indexPath, $"{moduleName}/{featureName}", $"../Views/{featureName}"); + AnsiConsole.MarkupLine($"[green] ~ Pages/index.ts[/]"); + } + + AnsiConsole.MarkupLine($"\n[green]Feature '{featureName}' added to '{moduleName}'.[/]"); + return 0; +} + +private static void WriteFile(string path, string content) +{ + File.WriteAllText(path, content); + AnsiConsole.MarkupLine($"[green] + {Path.GetRelativePath(Directory.GetCurrentDirectory(), path)}[/]"); +} + +private static void RenderDryRunTree(List<(string Path, FileAction Action)> ops) +{ + AnsiConsole.MarkupLine("[dim]Dry run — no files written[/]\n"); + var tree = new Tree("[dim]Would create/modify:[/]"); + foreach (var (path, action) in ops) + { + var label = action == FileAction.Modify + ? $"[yellow]{Markup.Escape(Path.GetFileName(path))}[/] [dim](modify)[/]" + : $"[green]{Markup.Escape(Path.GetFileName(path))}[/] [dim](create)[/]"; + tree.AddNode(label); + } + AnsiConsole.Write(tree); +} +``` + +- [ ] **Step 5: Add missing using statements to NewFeatureCommand.cs** + +Ensure top of file has: + +```csharp +using SimpleModule.Cli.Infrastructure; +using SimpleModule.Cli.Templates; +using Spectre.Console; +using Spectre.Console.Cli; +``` + +- [ ] **Step 6: Build to verify** + +``` +dotnet build cli/SimpleModule.Cli/ +``` + +Expected: Build succeeds. + +- [ ] **Step 7: Run all CLI tests** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ +``` + +Expected: All PASS. + +- [ ] **Step 8: Commit** + +``` +git add cli/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs tests/SimpleModule.Cli.Tests/NewFeatureViewScaffoldingTests.cs +git commit -m "feat(cli): scaffold React view + Pages/index.ts in sm new feature" +``` + +--- + +## Task 12: Dry-run + tree output for NewModuleCommand + +**Files:** +- Modify: `cli/SimpleModule.Cli/Commands/New/NewModuleSettings.cs` +- Modify: `cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs` + +- [ ] **Step 1: Add --dry-run to NewModuleSettings** + +In `cli/SimpleModule.Cli/Commands/New/NewModuleSettings.cs`, add: + +```csharp +[CommandOption("--dry-run")] +[Description("Show what would be created without writing any files")] +public bool DryRun { get; set; } +``` + +- [ ] **Step 2: Add spinner, tree output, and dry-run to NewModuleCommand** + +Replace the `Execute` method body in `cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs`: + +```csharp +public override int Execute(CommandContext context, NewModuleSettings settings) +{ + var moduleName = settings.ResolveName(); + var singularName = ModuleTemplates.GetSingularName(moduleName); + + var solution = SolutionContext.Discover(); + if (solution is null) + { + AnsiConsole.MarkupLine("[red]No .slnx file found. Run this command from inside a SimpleModule project.[/]"); + return 1; + } + + if (solution.ExistingModules.Contains(moduleName, StringComparer.OrdinalIgnoreCase)) + { + AnsiConsole.MarkupLine($"[red]Module '{moduleName}' already exists. Available modules: {string.Join(", ", solution.ExistingModules)}[/]"); + return 1; + } + + var templates = new ModuleTemplates(solution); + var ops = new List<(string Path, FileAction Action)>(); + + var contractsDir = solution.GetModuleContractsPath(moduleName); + var moduleDir = solution.GetModuleProjectPath(moduleName); + var eventsDir = Path.Combine(contractsDir, "Events"); + var endpointsDir = Path.Combine(moduleDir, "Endpoints", moduleName); + var testDir = solution.GetTestProjectPath(moduleName); + + // Collect all ops + void Plan(string path) => ops.Add((path, FileAction.Create)); + Plan(Path.Combine(contractsDir, $"{moduleName}.Contracts.csproj")); + Plan(Path.Combine(contractsDir, $"I{singularName}Contracts.cs")); + Plan(Path.Combine(contractsDir, $"{singularName}.cs")); + Plan(Path.Combine(eventsDir, $"{singularName}CreatedEvent.cs")); + Plan(Path.Combine(moduleDir, $"{moduleName}.csproj")); + Plan(Path.Combine(moduleDir, $"{moduleName}Module.cs")); + Plan(Path.Combine(moduleDir, $"{moduleName}Constants.cs")); + Plan(Path.Combine(moduleDir, $"{moduleName}DbContext.cs")); + Plan(Path.Combine(moduleDir, $"{singularName}Service.cs")); + Plan(Path.Combine(endpointsDir, "GetAllEndpoint.cs")); + Plan(Path.Combine(testDir, $"{moduleName}.Tests.csproj")); + Plan(Path.Combine(testDir, "GlobalUsings.cs")); + Plan(Path.Combine(testDir, "Unit", $"{singularName}ServiceTests.cs")); + Plan(Path.Combine(testDir, "Integration", $"{moduleName}EndpointTests.cs")); + ops.Add((solution.SlnxPath, FileAction.Modify)); + ops.Add((solution.ApiCsprojPath, FileAction.Modify)); + + if (settings.DryRun) + { + RenderDryRunTree(moduleName, ops); + return 0; + } + + AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .Start($"Creating module '{moduleName}'...", ctx => + { + Directory.CreateDirectory(eventsDir); + Directory.CreateDirectory(endpointsDir); + Directory.CreateDirectory(Path.Combine(testDir, "Unit")); + Directory.CreateDirectory(Path.Combine(testDir, "Integration")); + + File.WriteAllText(Path.Combine(contractsDir, $"{moduleName}.Contracts.csproj"), templates.ContractsCsproj(moduleName)); + File.WriteAllText(Path.Combine(contractsDir, $"I{singularName}Contracts.cs"), templates.ContractsInterface(moduleName, singularName)); + File.WriteAllText(Path.Combine(contractsDir, $"{singularName}.cs"), templates.DtoClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(eventsDir, $"{singularName}CreatedEvent.cs"), templates.EventClass(moduleName, singularName)); + + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}.csproj"), templates.ModuleCsproj(moduleName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}Module.cs"), templates.ModuleClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}Constants.cs"), templates.ConstantsClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}DbContext.cs"), templates.DbContextClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{singularName}Service.cs"), templates.ServiceClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(endpointsDir, "GetAllEndpoint.cs"), templates.GetAllEndpoint(moduleName, singularName)); + + File.WriteAllText(Path.Combine(testDir, $"{moduleName}.Tests.csproj"), templates.TestCsproj(moduleName)); + File.WriteAllText(Path.Combine(testDir, "GlobalUsings.cs"), templates.GlobalUsings()); + File.WriteAllText(Path.Combine(testDir, "Unit", $"{singularName}ServiceTests.cs"), templates.UnitTestSkeleton(moduleName, singularName)); + File.WriteAllText(Path.Combine(testDir, "Integration", $"{moduleName}EndpointTests.cs"), templates.IntegrationTestSkeleton(moduleName, singularName)); + + ctx.Status("Updating solution files..."); + SlnxManipulator.AddModuleEntries(solution.SlnxPath, moduleName); + ProjectManipulator.AddProjectReference( + solution.ApiCsprojPath, + $@"..\modules\{moduleName}\src\{moduleName}\{moduleName}.csproj" + ); + }); + + RenderCreatedTree(moduleName, ops); + + AnsiConsole.MarkupLine($"\n[green]Module '{moduleName}' created![/]"); + AnsiConsole.MarkupLine("[dim]Next steps:[/]"); + AnsiConsole.MarkupLine($"[dim] sm new feature --module {moduleName}[/]"); + AnsiConsole.MarkupLine("[dim] dotnet build[/]"); + return 0; +} + +private static void RenderDryRunTree(string moduleName, List<(string Path, FileAction Action)> ops) +{ + AnsiConsole.MarkupLine("[dim]Dry run — no files written[/]\n"); + var tree = new Tree($"[blue]{moduleName}[/]"); + foreach (var (path, action) in ops) + { + var label = action == FileAction.Modify + ? $"[yellow]{Markup.Escape(Path.GetFileName(path))}[/] [dim](modify)[/]" + : $"[green]{Markup.Escape(Path.GetFileName(path))}[/] [dim](create)[/]"; + tree.AddNode(label); + } + AnsiConsole.Write(tree); +} + +private static void RenderCreatedTree(string moduleName, List<(string Path, FileAction Action)> ops) +{ + AnsiConsole.MarkupLine(""); + var tree = new Tree($"[blue]{moduleName}[/]"); + foreach (var (path, action) in ops) + { + var label = action == FileAction.Modify + ? $"[yellow]{Markup.Escape(Path.GetFileName(path))}[/] [dim](modified)[/]" + : $"[green]{Markup.Escape(Path.GetFileName(path))}[/]"; + tree.AddNode(label); + } + AnsiConsole.Write(tree); +} +``` + +- [ ] **Step 3: Build to verify** + +``` +dotnet build cli/SimpleModule.Cli/ +``` + +Expected: Build succeeds. + +- [ ] **Step 4: Run all CLI tests** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ +``` + +Expected: All PASS. + +- [ ] **Step 5: Commit** + +``` +git add cli/SimpleModule.Cli/Commands/New/NewModuleSettings.cs cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs +git commit -m "feat(cli): add spinner, tree output, and dry-run to sm new module" +``` + +--- + +## Task 13: Dry-run + tree output for NewProjectCommand + +**Files:** +- Modify: `cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs` +- Modify: `cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs` + +- [ ] **Step 1: Add --dry-run to NewProjectSettings** + +In `cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs`, add: + +```csharp +[CommandOption("--dry-run")] +[Description("Show what would be created without writing any files")] +public bool DryRun { get; set; } +``` + +- [ ] **Step 2: Add dry-run + tree output to NewProjectCommand** + +In `cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs`, replace `Execute`: + +```csharp +public override int Execute(CommandContext context, NewProjectSettings settings) +{ + var projectName = settings.ResolveName(); + var outputDir = settings.ResolveOutputDir(); + var rootDir = Path.Combine(outputDir, projectName); + + if (!settings.DryRun && Directory.Exists(rootDir) && Directory.GetFileSystemEntries(rootDir).Length > 0) + { + AnsiConsole.MarkupLine($"[red]Directory '{rootDir}' already exists and is not empty.[/]"); + return 1; + } + + var solution = SolutionContext.Discover(); + var templates = new ProjectTemplates(solution); + + var srcDir = Path.Combine(rootDir, "src"); + var apiDir = Path.Combine(srcDir, $"{projectName}.Api"); + var coreDir = Path.Combine(srcDir, $"{projectName}.Core"); + var databaseDir = Path.Combine(srcDir, $"{projectName}.Database"); + var generatorDir = Path.Combine(srcDir, $"{projectName}.Generator"); + var testsSharedDir = Path.Combine(rootDir, "tests", $"{projectName}.Tests.Shared"); + + var ops = new List<(string Path, FileAction Action)> + { + (Path.Combine(rootDir, $"{projectName}.slnx"), FileAction.Create), + (Path.Combine(rootDir, "Directory.Build.props"), FileAction.Create), + (Path.Combine(rootDir, "Directory.Packages.props"), FileAction.Create), + (Path.Combine(rootDir, "global.json"), FileAction.Create), + (Path.Combine(apiDir, $"{projectName}.Api.csproj"), FileAction.Create), + (Path.Combine(apiDir, "Program.cs"), FileAction.Create), + (Path.Combine(coreDir, $"{projectName}.Core.csproj"), FileAction.Create), + (Path.Combine(databaseDir, $"{projectName}.Database.csproj"), FileAction.Create), + (Path.Combine(generatorDir, $"{projectName}.Generator.csproj"), FileAction.Create), + (Path.Combine(testsSharedDir, $"{projectName}.Tests.Shared.csproj"), FileAction.Create), + }; + + if (settings.DryRun) + { + AnsiConsole.MarkupLine("[dim]Dry run — no files written[/]\n"); + var tree = new Tree($"[blue]{projectName}/[/]"); + foreach (var (path, _) in ops) + tree.AddNode($"[green]{Markup.Escape(Path.GetFileName(path))}[/]"); + AnsiConsole.Write(tree); + return 0; + } + + AnsiConsole.MarkupLine($"[blue]Creating project '{projectName}'...[/]"); + + Directory.CreateDirectory(apiDir); + Directory.CreateDirectory(coreDir); + Directory.CreateDirectory(databaseDir); + Directory.CreateDirectory(generatorDir); + Directory.CreateDirectory(Path.Combine(rootDir, "src", "modules")); + Directory.CreateDirectory(testsSharedDir); + Directory.CreateDirectory(Path.Combine(rootDir, "tests", "modules")); + + WriteFile(Path.Combine(rootDir, $"{projectName}.slnx"), templates.Slnx(projectName)); + WriteFile(Path.Combine(rootDir, "Directory.Build.props"), templates.DirectoryBuildProps()); + WriteFile(Path.Combine(rootDir, "Directory.Packages.props"), templates.DirectoryPackagesProps()); + WriteFile(Path.Combine(rootDir, "global.json"), templates.GlobalJson()); + WriteFile(Path.Combine(apiDir, $"{projectName}.Api.csproj"), templates.ApiCsproj(projectName)); + WriteFile(Path.Combine(apiDir, "Program.cs"), ProjectTemplates.ApiProgram()); + WriteFile(Path.Combine(coreDir, $"{projectName}.Core.csproj"), templates.CoreCsproj(projectName)); + WriteFile(Path.Combine(databaseDir, $"{projectName}.Database.csproj"), templates.DatabaseCsproj(projectName)); + WriteFile(Path.Combine(generatorDir, $"{projectName}.Generator.csproj"), templates.GeneratorCsproj()); + WriteFile(Path.Combine(testsSharedDir, $"{projectName}.Tests.Shared.csproj"), templates.TestsSharedCsproj(projectName)); + + AnsiConsole.MarkupLine($"\n[green]Project '{projectName}' created![/]"); + AnsiConsole.MarkupLine("[dim]Next steps:[/]"); + AnsiConsole.MarkupLine($"[dim] cd {projectName}[/]"); + AnsiConsole.MarkupLine("[dim] sm new module [/]"); + AnsiConsole.MarkupLine("[dim] dotnet build[/]"); + return 0; +} + +private static void WriteFile(string path, string content) +{ + File.WriteAllText(path, content); + AnsiConsole.MarkupLine($"[green] + {Path.GetFileName(path)}[/]"); +} +``` + +- [ ] **Step 3: Build to verify** + +``` +dotnet build cli/SimpleModule.Cli/ +``` + +Expected: Build succeeds. + +- [ ] **Step 4: Run all CLI tests** + +``` +dotnet test tests/SimpleModule.Cli.Tests/ +``` + +Expected: All PASS. + +- [ ] **Step 5: Commit** + +``` +git add cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs +git commit -m "feat(cli): add dry-run and tree output to sm new project" +``` + +--- + +## Task 14: Final build + full test run + +- [ ] **Step 1: Full build** + +``` +dotnet build +``` + +Expected: Build succeeds with 0 errors. + +- [ ] **Step 2: Full test suite** + +``` +dotnet test +``` + +Expected: All tests PASS. + +- [ ] **Step 3: Commit (if any fixups needed)** + +``` +git add -u +git commit -m "fix(cli): address build warnings from full solution build" +``` + +--- + +## Self-Review + +**Spec coverage check:** + +| Spec requirement | Task | +|---|---| +| PagesRegistryCheck (FAIL, auto-fix) | Task 5, 9 | +| ViteConfigCheck (WARN) | Task 6, 9 | +| PackageJsonCheck (WARN) | Task 7, 9 | +| NpmWorkspaceCheck (FAIL, auto-fix) | Task 8, 9 | +| ModuleAttributeCheck (FAIL) | Task 3, 9 | +| ViewEndpointNamingCheck (WARN) | Task 4, 9 | +| ContractsIsolationCheck (FAIL) | Task 2, 9 | +| Views/FeatureName.tsx generated by sm new feature | Task 10, 11 | +| Pages/index.ts updated by sm new feature | Task 5, 11 | +| --no-view flag | Task 11 | +| Dry-run on sm new feature | Task 11 | +| Dry-run on sm new module | Task 12 | +| Dry-run on sm new project | Task 13 | +| Spinner on sm new module | Task 12 | +| Tree output on sm new feature | Task 11 | +| Tree output on sm new module | Task 12 | +| Tree output on sm new project | Task 13 | +| FileAction enum | Task 1 | +| SolutionContext GetModuleViewsPath/GetModulePagesIndexPath | Task 1 | + +All requirements covered. No placeholders. Method names are consistent across tasks. diff --git a/docs/superpowers/plans/2026-03-26-new-project-scaffold.md b/docs/superpowers/plans/2026-03-26-new-project-scaffold.md new file mode 100644 index 00000000..fee9677d --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-new-project-scaffold.md @@ -0,0 +1,986 @@ +# Improved `sm new project` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite `sm new project` to scaffold a working project by copying the template Host, adding a "Home" starter module with tests, and creating all root config files. + +**Architecture:** The command copies `template/SimpleModule.Host/` with `SimpleModule`→`{ProjectName}` transforms, generates root config files (slnx, package.json, biome.json, etc.), then reuses `ModuleTemplates` to create a "Home" module. The new project references the framework via relative project references. + +**Tech Stack:** C# / Spectre.Console.Cli / existing TemplateExtractor + SlnxManipulator + ProjectManipulator infrastructure + +--- + +### Task 1: Add `HostTemplates` class for Host project file generation + +**Files:** +- Create: `cli/SimpleModule.Cli/Templates/HostTemplates.cs` + +This class reads template Host files and transforms them for the new project. + +- [ ] **Step 1: Create `HostTemplates.cs`** + +```csharp +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Templates; + +public sealed class HostTemplates +{ + private readonly SolutionContext _solution; + private readonly string _templateHostDir; + + public HostTemplates(SolutionContext solution) + { + _solution = solution; + _templateHostDir = Path.Combine(solution.RootPath, "template", "SimpleModule.Host"); + } + + /// + /// Transforms the Host .csproj: strips all module refs, ServiceDefaults, InternalsVisibleTo, + /// NoWarn, EF Design, and the Hosting.targets import. Keeps framework refs with adjusted paths. + /// + public string HostCsproj(string projectName, string frameworkRelativePath) + { + var path = Path.Combine(_templateHostDir, "SimpleModule.Host.csproj"); + var lines = File.ReadAllLines(path).ToList(); + + // Strip lines containing these patterns + var stripPatterns = new[] + { + "modules", + "ServiceDefaults", + "InternalsVisibleTo", + "NoWarn", + "SM0011", + "SM0035", + "SM0038", + "TODO:", + "EntityFrameworkCore.Design", + "Import Project", + }; + + lines.RemoveAll(line => + stripPatterns.Any(p => line.Contains(p, StringComparison.Ordinal)) + ); + + // Remove empty PropertyGroup/ItemGroup elements + var cleaned = new List(); + for (var i = 0; i < lines.Count; i++) + { + var trimmed = lines[i].TrimStart(); + // Check for empty element pairs (opening tag followed immediately by closing tag) + if (i + 1 < lines.Count) + { + var nextTrimmed = lines[i + 1].TrimStart(); + if ( + (trimmed == "" && nextTrimmed == "") + || (trimmed == "" && nextTrimmed == "") + ) + { + i++; // Skip both lines + continue; + } + } + + cleaned.Add(lines[i]); + } + + lines = TemplateExtractor.CollapseBlankLines(cleaned); + + // Adjust framework project reference paths + var content = string.Join(Environment.NewLine, lines); + content = content.Replace( + @"..\..\framework\SimpleModule.Hosting\SimpleModule.Hosting.csproj", + Path.Combine(frameworkRelativePath, "SimpleModule.Hosting", "SimpleModule.Hosting.csproj"), + StringComparison.Ordinal + ); + content = content.Replace( + @"..\..\framework\SimpleModule.Generator\SimpleModule.Generator.csproj", + Path.Combine(frameworkRelativePath, "SimpleModule.Generator", "SimpleModule.Generator.csproj"), + StringComparison.Ordinal + ); + + // Rename SimpleModule → ProjectName + content = content.Replace("SimpleModule", projectName, StringComparison.Ordinal); + + return content; + } + + /// + /// Program.cs — copy as-is (the generated extension methods handle everything). + /// + public string ProgramCs(string projectName) + { + var path = Path.Combine(_templateHostDir, "Program.cs"); + var content = File.ReadAllText(path); + + // Remove Aspire ServiceDefaults lines + var lines = content.Split(["\r\n", "\n"], StringSplitOptions.None).ToList(); + lines.RemoveAll(line => + line.Contains("ServiceDefaults", StringComparison.Ordinal) + || line.Contains("MapDefaultEndpoints", StringComparison.Ordinal) + ); + lines = TemplateExtractor.CollapseBlankLines(lines); + + content = string.Join(Environment.NewLine, lines); + return content.Replace("SimpleModule", projectName, StringComparison.Ordinal); + } + + /// + /// App.razor — replace title and namespace references. + /// + public string AppRazor(string projectName) + { + var path = Path.Combine(_templateHostDir, "Components", "App.razor"); + var content = File.ReadAllText(path); + return content.Replace("SimpleModule", projectName, StringComparison.Ordinal); + } + + /// + /// InertiaShell.razor — copy as-is (uses framework components). + /// + public string InertiaShellRazor(string projectName) + { + var path = Path.Combine(_templateHostDir, "Components", "InertiaShell.razor"); + var content = File.ReadAllText(path); + return content.Replace("SimpleModule", projectName, StringComparison.Ordinal); + } + + /// + /// Routes.razor — remove module-specific assembly references. + /// + public string RoutesRazor(string projectName) + { + // Generate a clean Routes.razor without module assembly references + return $$""" + + + + + + """; + } + + /// + /// _Imports.razor — replace namespace. + /// + public string ImportsRazor(string projectName) + { + var path = Path.Combine(_templateHostDir, "Components", "_Imports.razor"); + var content = File.ReadAllText(path); + return content.Replace("SimpleModule", projectName, StringComparison.Ordinal); + } + + /// + /// ClientApp/app.tsx — copy as-is. + /// + public string AppTsx() + { + return File.ReadAllText(Path.Combine(_templateHostDir, "ClientApp", "app.tsx")); + } + + /// + /// ClientApp/vite.config.ts — copy as-is. + /// + public string ViteConfig() + { + return File.ReadAllText(Path.Combine(_templateHostDir, "ClientApp", "vite.config.ts")); + } + + /// + /// ClientApp/validate-pages.mjs — copy as-is. + /// + public string ValidatePages() + { + return File.ReadAllText(Path.Combine(_templateHostDir, "ClientApp", "validate-pages.mjs")); + } + + /// + /// ClientApp/package.json — rename package. + /// + public string ClientAppPackageJson(string projectName) + { + var path = Path.Combine(_templateHostDir, "ClientApp", "package.json"); + var content = File.ReadAllText(path); + return content.Replace("@simplemodule/app", $"@{projectName.ToLowerInvariant()}/app", StringComparison.Ordinal); + } + + /// + /// Styles/app.css — clean up for new project (remove _scan, adjust module source paths). + /// + public string AppCss() + { + return """ + @import "tailwindcss"; + @import "@simplemodule/theme-default/theme.css"; + @source "../../modules/**/Components/**/*.razor"; + @source "../../modules/**/Views/**/*.tsx"; + @source "../../modules/**/Pages/**/*.tsx"; + """; + } + + /// + /// appsettings.json — copy as-is. + /// + public string AppSettings() + { + return File.ReadAllText(Path.Combine(_templateHostDir, "appsettings.json")); + } + + /// + /// appsettings.Development.json — copy as-is. + /// + public string AppSettingsDevelopment() + { + return File.ReadAllText(Path.Combine(_templateHostDir, "appsettings.Development.json")); + } + + /// + /// Properties/launchSettings.json — rename project. + /// + public string LaunchSettings(string projectName) + { + var path = Path.Combine(_templateHostDir, "Properties", "launchSettings.json"); + var content = File.ReadAllText(path); + return content.Replace("SimpleModule", projectName, StringComparison.Ordinal); + } +} +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build cli/SimpleModule.Cli/SimpleModule.Cli.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +``` +feat(cli): add HostTemplates for project scaffolding +``` + +--- + +### Task 2: Add root config file templates to `ProjectTemplates` + +**Files:** +- Modify: `cli/SimpleModule.Cli/Templates/ProjectTemplates.cs` + +Add methods for the new root config files that the current implementation doesn't generate: `package.json`, `biome.json`, `tsconfig.json`, `.editorconfig`. + +- [ ] **Step 1: Add root config template methods to `ProjectTemplates.cs`** + +Add these methods after the existing `GlobalJson()` method: + +```csharp +public string RootPackageJson(string projectName) +{ + var projectNameLower = projectName.ToLowerInvariant(); + if (_solution is null) + { + return FallbackRootPackageJson(projectNameLower); + } + + var path = Path.Combine(_solution.RootPath, "package.json"); + if (!File.Exists(path)) + { + return FallbackRootPackageJson(projectNameLower); + } + + // Generate a clean workspace package.json for the new project + return FallbackRootPackageJson(projectNameLower); +} + +public string BiomeJson() +{ + if (_solution is null) + { + return FallbackBiomeJson(); + } + + var path = Path.Combine(_solution.RootPath, "biome.json"); + if (!File.Exists(path)) + { + return FallbackBiomeJson(); + } + + var content = File.ReadAllText(path); + // Adjust file includes for new project structure + content = content.Replace( + "\"modules/**\", \"packages/**\", \"template/**\", \"tests/**\", \"!**/wwwroot\"", + "\"src/**\", \"tests/**\", \"!**/wwwroot\"", + StringComparison.Ordinal + ); + return content; +} + +public string TsconfigJson() +{ + if (_solution is null) + { + return FallbackTsconfigJson(); + } + + var path = Path.Combine(_solution.RootPath, "tsconfig.json"); + if (!File.Exists(path)) + { + return FallbackTsconfigJson(); + } + + var content = File.ReadAllText(path); + // Remove SimpleModule-specific excludes + content = content.Replace( + ", \"src/SimpleModule.UI/registry/templates\"", + "", + StringComparison.Ordinal + ); + return content; +} + +public string EditorConfig() +{ + if (_solution is null) + { + return ""; // .editorconfig is optional + } + + var path = Path.Combine(_solution.RootPath, ".editorconfig"); + return File.Exists(path) ? File.ReadAllText(path) : ""; +} +``` + +And the fallback methods: + +```csharp +private static string FallbackRootPackageJson(string projectNameLower) => + $$""" + { + "private": true, + "name": "{{projectNameLower}}", + "version": "0.0.0", + "workspaces": [ + "src/modules/*/src/*", + "src/{{projectNameLower}}.Host/ClientApp" + ], + "scripts": { + "lint": "biome lint .", + "format": "biome format --write .", + "check": "biome check .", + "check:fix": "biome check --write .", + "build": "cross-env VITE_MODE=prod npm run build --workspaces --if-present", + "build:dev": "cross-env VITE_MODE=dev npm run build --workspaces --if-present" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.6", + "@tailwindcss/vite": "^4.2.1", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "cross-env": "^7.0.3", + "typescript": "^5.8.0", + "vite": "^6.2.0" + }, + "dependencies": { + "tailwindcss": "^4.2.1" + } + } + """; + +private static string FallbackBiomeJson() => + """ + { + "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, + "css": { "parser": { "tailwindDirectives": true } }, + "javascript": { "formatter": { "quoteStyle": "single", "trailingCommas": "all", "semicolons": "always" } }, + "linter": { "enabled": true, "rules": { "recommended": true } }, + "files": { "includes": ["src/**", "tests/**", "!**/wwwroot"] } + } + """; + +private static string FallbackTsconfigJson() => + """ + { + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "exclude": ["node_modules", "**/wwwroot/**"] + } + """; +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build cli/SimpleModule.Cli/SimpleModule.Cli.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +``` +feat(cli): add root config file templates for new project scaffolding +``` + +--- + +### Task 3: Update `SolutionContext` to support new project structure + +**Files:** +- Modify: `cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs` + +The current `SolutionContext` hardcodes `SimpleModule.Host` as the API project path. The new project uses `{ProjectName}.Host`. We need to make `ApiCsprojPath` discoverable. + +- [ ] **Step 1: Update `SolutionContext` to discover the Host .csproj dynamically** + +Replace the hardcoded `ApiCsprojPath` assignment in the constructor: + +```csharp +private SolutionContext(string rootPath, string slnxPath) +{ + RootPath = rootPath; + SlnxPath = slnxPath; + ModulesPath = Path.Combine(rootPath, "src", "modules"); + + // Discover the Host/API project — look for *.Host.csproj or *.Api.csproj in src/ + ApiCsprojPath = DiscoverApiCsproj(rootPath); + + ExistingModules = Directory.Exists(ModulesPath) + ? Directory + .GetDirectories(ModulesPath) + .Select(Path.GetFileName) + .Where(n => n is not null) + .Cast() + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList() + : []; +} + +private static string DiscoverApiCsproj(string rootPath) +{ + var srcDir = Path.Combine(rootPath, "src"); + + if (Directory.Exists(srcDir)) + { + // Look for *.Host.csproj first (new convention) + foreach (var dir in Directory.GetDirectories(srcDir)) + { + var dirName = Path.GetFileName(dir); + if (dirName is not null && dirName.EndsWith(".Host", StringComparison.Ordinal)) + { + var csproj = Path.Combine(dir, $"{dirName}.csproj"); + if (File.Exists(csproj)) + { + return csproj; + } + } + } + + // Fall back to *.Api.csproj + foreach (var dir in Directory.GetDirectories(srcDir)) + { + var dirName = Path.GetFileName(dir); + if (dirName is not null && dirName.EndsWith(".Api", StringComparison.Ordinal)) + { + var csproj = Path.Combine(dir, $"{dirName}.csproj"); + if (File.Exists(csproj)) + { + return csproj; + } + } + } + } + + // Look in template/ for the original SimpleModule.Host + var templateHost = Path.Combine(rootPath, "template", "SimpleModule.Host", "SimpleModule.Host.csproj"); + if (File.Exists(templateHost)) + { + return templateHost; + } + + // Absolute fallback + return Path.Combine(rootPath, "src", "SimpleModule.Host", "SimpleModule.Host.csproj"); +} +``` + +- [ ] **Step 2: Build and run existing tests to verify no regression** + +Run: `dotnet build cli/SimpleModule.Cli/SimpleModule.Cli.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +``` +refactor(cli): make SolutionContext discover Host/Api csproj dynamically +``` + +--- + +### Task 4: Rewrite `NewProjectCommand` to scaffold from template + +**Files:** +- Modify: `cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs` + +This is the main task. Replace the current bare-skeleton scaffolding with the full template mirroring. + +- [ ] **Step 1: Rewrite `NewProjectCommand.Execute()`** + +```csharp +using SimpleModule.Cli.Infrastructure; +using SimpleModule.Cli.Templates; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.New; + +public sealed class NewProjectCommand : Command +{ + private const string StarterModuleName = "Home"; + + public override int Execute(CommandContext context, NewProjectSettings settings) + { + var projectName = settings.ResolveName(); + var outputDir = settings.ResolveOutputDir(); + var rootDir = Path.Combine(outputDir, projectName); + + if (!settings.DryRun && Directory.Exists(rootDir) && Directory.GetFileSystemEntries(rootDir).Length > 0) + { + AnsiConsole.MarkupLine($"[red]Directory '{Markup.Escape(rootDir)}' already exists and is not empty.[/]"); + return 1; + } + + var solution = SolutionContext.Discover(); + if (solution is null) + { + AnsiConsole.MarkupLine("[red]No .slnx file found. Run this command from inside the SimpleModule repository.[/]"); + return 1; + } + + var templates = new ProjectTemplates(solution); + var hostTemplates = new HostTemplates(solution); + + var hostDir = Path.Combine(rootDir, "src", $"{projectName}.Host"); + var modulesDir = Path.Combine(rootDir, "src", "modules"); + var testsSharedDir = Path.Combine(rootDir, "tests", $"{projectName}.Tests.Shared"); + + // Calculate relative path from host project to the framework directory + // New project is at: {outputDir}/{projectName}/src/{projectName}.Host/ + // Framework is at: {solution.RootPath}/framework/ + var frameworkRelativePath = Path.GetRelativePath(hostDir, Path.Combine(solution.RootPath, "framework")); + + var ops = new List<(string Path, FileAction Action)>(); + + void Plan(string path) => ops.Add((path, FileAction.Create)); + + // Root config files + Plan(Path.Combine(rootDir, $"{projectName}.slnx")); + Plan(Path.Combine(rootDir, "Directory.Build.props")); + Plan(Path.Combine(rootDir, "Directory.Packages.props")); + Plan(Path.Combine(rootDir, "global.json")); + Plan(Path.Combine(rootDir, "package.json")); + Plan(Path.Combine(rootDir, "biome.json")); + Plan(Path.Combine(rootDir, "tsconfig.json")); + + // Host project files + Plan(Path.Combine(hostDir, $"{projectName}.Host.csproj")); + Plan(Path.Combine(hostDir, "Program.cs")); + Plan(Path.Combine(hostDir, "appsettings.json")); + Plan(Path.Combine(hostDir, "appsettings.Development.json")); + Plan(Path.Combine(hostDir, "Properties", "launchSettings.json")); + Plan(Path.Combine(hostDir, "Components", "App.razor")); + Plan(Path.Combine(hostDir, "Components", "InertiaShell.razor")); + Plan(Path.Combine(hostDir, "Components", "Routes.razor")); + Plan(Path.Combine(hostDir, "Components", "_Imports.razor")); + Plan(Path.Combine(hostDir, "ClientApp", "app.tsx")); + Plan(Path.Combine(hostDir, "ClientApp", "vite.config.ts")); + Plan(Path.Combine(hostDir, "ClientApp", "validate-pages.mjs")); + Plan(Path.Combine(hostDir, "ClientApp", "package.json")); + Plan(Path.Combine(hostDir, "Styles", "app.css")); + + // Tests.Shared + Plan(Path.Combine(testsSharedDir, $"{projectName}.Tests.Shared.csproj")); + + if (settings.DryRun) + { + RenderDryRunTree(projectName, ops); + 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")); + Directory.CreateDirectory(modulesDir); + Directory.CreateDirectory(testsSharedDir); + + // Root config files + ctx.Status("Writing root config files..."); + File.WriteAllText(Path.Combine(rootDir, $"{projectName}.slnx"), GenerateSlnx(projectName)); + File.WriteAllText(Path.Combine(rootDir, "Directory.Build.props"), templates.DirectoryBuildProps()); + File.WriteAllText(Path.Combine(rootDir, "Directory.Packages.props"), templates.DirectoryPackagesProps()); + File.WriteAllText(Path.Combine(rootDir, "global.json"), templates.GlobalJson()); + File.WriteAllText(Path.Combine(rootDir, "package.json"), templates.RootPackageJson(projectName)); + File.WriteAllText(Path.Combine(rootDir, "biome.json"), templates.BiomeJson()); + File.WriteAllText(Path.Combine(rootDir, "tsconfig.json"), templates.TsconfigJson()); + + var editorConfig = templates.EditorConfig(); + if (!string.IsNullOrEmpty(editorConfig)) + { + File.WriteAllText(Path.Combine(rootDir, ".editorconfig"), editorConfig); + } + + // Host project files + ctx.Status("Writing host project..."); + File.WriteAllText(Path.Combine(hostDir, $"{projectName}.Host.csproj"), hostTemplates.HostCsproj(projectName, frameworkRelativePath)); + File.WriteAllText(Path.Combine(hostDir, "Program.cs"), hostTemplates.ProgramCs(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)); + + // Blazor components + 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(projectName)); + File.WriteAllText(Path.Combine(hostDir, "Components", "_Imports.razor"), hostTemplates.ImportsRazor(projectName)); + + // ClientApp + 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)); + + // Styles + File.WriteAllText(Path.Combine(hostDir, "Styles", "app.css"), hostTemplates.AppCss()); + + // Tests.Shared + File.WriteAllText(Path.Combine(testsSharedDir, $"{projectName}.Tests.Shared.csproj"), templates.TestsSharedCsproj(projectName)); + + // Create the starter module + ctx.Status($"Creating '{StarterModuleName}' module..."); + CreateStarterModule(solution, rootDir, projectName); + }); + + RenderCreatedTree(projectName, ops); + + 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; + } + + private static void CreateStarterModule(SolutionContext repoSolution, string rootDir, string projectName) + { + const string moduleName = StarterModuleName; + var singularName = ModuleTemplates.GetSingularName(moduleName); + var templates = new ModuleTemplates(repoSolution); + + var modulesDir = Path.Combine(rootDir, "src", "modules"); + 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 pagesDir = Path.Combine(moduleDir, "Pages"); + var testDir = Path.Combine(modulesDir, moduleName, "tests", $"{moduleName}.Tests"); + + Directory.CreateDirectory(eventsDir); + Directory.CreateDirectory(endpointsDir); + Directory.CreateDirectory(pagesDir); + Directory.CreateDirectory(Path.Combine(testDir, "Unit")); + Directory.CreateDirectory(Path.Combine(testDir, "Integration")); + + // Contracts + File.WriteAllText(Path.Combine(contractsDir, $"{moduleName}.Contracts.csproj"), templates.ContractsCsproj(moduleName)); + File.WriteAllText(Path.Combine(contractsDir, $"I{singularName}Contracts.cs"), templates.ContractsInterface(moduleName, singularName)); + File.WriteAllText(Path.Combine(contractsDir, $"{singularName}.cs"), templates.DtoClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(eventsDir, $"{singularName}CreatedEvent.cs"), templates.EventClass(moduleName, singularName)); + + // Module implementation + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}.csproj"), templates.ModuleCsproj(moduleName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}Module.cs"), templates.ModuleClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}Constants.cs"), templates.ConstantsClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}DbContext.cs"), templates.DbContextClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(moduleDir, $"{singularName}Service.cs"), templates.ServiceClass(moduleName, singularName)); + File.WriteAllText(Path.Combine(endpointsDir, "GetAllEndpoint.cs"), templates.GetAllEndpoint(moduleName, singularName)); + + // Pages/index.ts for the module + File.WriteAllText(Path.Combine(pagesDir, "index.ts"), $$""" + export const pages: Record = {}; + """); + + // Tests + File.WriteAllText(Path.Combine(testDir, $"{moduleName}.Tests.csproj"), templates.TestCsproj(moduleName)); + File.WriteAllText(Path.Combine(testDir, "GlobalUsings.cs"), templates.GlobalUsings()); + File.WriteAllText(Path.Combine(testDir, "Unit", $"{singularName}ServiceTests.cs"), templates.UnitTestSkeleton(moduleName, singularName)); + File.WriteAllText(Path.Combine(testDir, "Integration", $"{moduleName}EndpointTests.cs"), templates.IntegrationTestSkeleton(moduleName, singularName)); + + // Register module in Host .csproj and .slnx + var slnxPath = Path.Combine(rootDir, $"{projectName}.slnx"); + var hostCsprojPath = Path.Combine(rootDir, "src", $"{projectName}.Host", $"{projectName}.Host.csproj"); + + SlnxManipulator.AddModuleEntries(slnxPath, moduleName); + ProjectManipulator.AddProjectReference( + hostCsprojPath, + $@"..\modules\{moduleName}\src\{moduleName}\{moduleName}.csproj" + ); + } + + private static string GenerateSlnx(string projectName) => + $""" + + + + + + + + + + + + + + + """; + + private static void RenderDryRunTree(string projectName, List<(string Path, FileAction Action)> ops) + { + AnsiConsole.MarkupLine("[dim]Dry run — no files written[/]\n"); + var tree = new Tree($"[blue]{Markup.Escape(projectName)}/[/]"); + foreach (var (path, _) in ops) + { + tree.AddNode($"[green]{Markup.Escape(Path.GetFileName(path))}[/]"); + } + AnsiConsole.Write(tree); + } + + private static void RenderCreatedTree(string projectName, List<(string Path, FileAction Action)> ops) + { + AnsiConsole.MarkupLine(""); + var tree = new Tree($"[blue]{Markup.Escape(projectName)}/[/]"); + foreach (var (path, action) in ops) + { + var label = action == FileAction.Modify + ? $"[yellow]{Markup.Escape(Path.GetFileName(path))}[/] [dim](modified)[/]" + : $"[green]{Markup.Escape(Path.GetFileName(path))}[/]"; + tree.AddNode(label); + } + AnsiConsole.Write(tree); + } +} +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build cli/SimpleModule.Cli/SimpleModule.Cli.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +``` +feat(cli): rewrite sm new project to scaffold from template with starter module +``` + +--- + +### Task 5: Update `TestsSharedCsproj` to reference Host instead of Api + +**Files:** +- Modify: `cli/SimpleModule.Cli/Templates/ProjectTemplates.cs` + +The `TestsSharedCsproj` fallback references `{ProjectName}.Api` but new projects use `{ProjectName}.Host`. + +- [ ] **Step 1: Update the fallback template** + +In `ProjectTemplates.cs`, update `FallbackTestsSharedCsproj`: + +```csharp +private static string FallbackTestsSharedCsproj(string projectName) => + $""" + + + net10.0 + false + + + + + + + + + + + + """; +``` + +Also update the `TestsSharedCsproj` method to handle both Api and Host naming: + +In the non-fallback path, after calling `TransformCsproj`, also replace `.Api.` → `.Host.` if the new project uses Host convention: + +```csharp +public string TestsSharedCsproj(string projectName) +{ + if (_solution is null) + { + return FallbackTestsSharedCsproj(projectName); + } + + var path = Path.Combine( + _solution.RootPath, + "tests", + $"{BaseProjectName}.Tests.Shared", + $"{BaseProjectName}.Tests.Shared.csproj" + ); + if (!File.Exists(path)) + { + return FallbackTestsSharedCsproj(projectName); + } + + // Strip module contract references, OpenIddict, Bogus + var stripPatterns = new List { "modules", "OpenIddict", "Bogus" }; + return TemplateExtractor.TransformCsproj(path, BaseProjectName, projectName, stripPatterns); +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build cli/SimpleModule.Cli/SimpleModule.Cli.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +``` +fix(cli): update TestsSharedCsproj fallback to reference Host project +``` + +--- + +### Task 6: Manual testing — run `sm new project` and verify output + +**Files:** None (testing only) + +- [ ] **Step 1: Run a dry run** + +Run: `dotnet run --project cli/SimpleModule.Cli -- new project TestApp --dry-run` +Expected: Tree output showing all planned files + +- [ ] **Step 2: Run the actual scaffolding** + +Run from a temp directory: + +```bash +cd /tmp +dotnet run --project /path/to/cli/SimpleModule.Cli -- new project TestApp +``` + +Expected: Project created with success message and next steps + +- [ ] **Step 3: Verify the output structure** + +```bash +find /tmp/TestApp -type f | sort +``` + +Expected files: +- Root: `TestApp.slnx`, `Directory.Build.props`, `Directory.Packages.props`, `global.json`, `package.json`, `biome.json`, `tsconfig.json`, `.editorconfig` +- Host: `src/TestApp.Host/TestApp.Host.csproj`, `Program.cs`, `appsettings.json`, etc. +- Components: `App.razor`, `InertiaShell.razor`, `Routes.razor`, `_Imports.razor` +- ClientApp: `app.tsx`, `vite.config.ts`, `validate-pages.mjs`, `package.json` +- Home module: contracts, implementation, endpoints, tests +- Tests.Shared: `TestApp.Tests.Shared.csproj` + +- [ ] **Step 4: Verify the generated project builds** + +```bash +cd /tmp/TestApp +dotnet build +``` + +Expected: Build succeeded (may have warnings about framework references if not in repo tree) + +- [ ] **Step 5: Verify the Home module .slnx entry exists** + +Check `TestApp.slnx` contains a `/modules/Home/` folder with three project entries. + +- [ ] **Step 6: Verify the Host .csproj has Home module reference** + +Check `src/TestApp.Host/TestApp.Host.csproj` contains ``. + +- [ ] **Step 7: Clean up and commit** + +```bash +rm -rf /tmp/TestApp +``` + +No code commit needed — this was a verification task. + +--- + +### Task 7: Remove old project structure references + +**Files:** +- Modify: `cli/SimpleModule.Cli/Templates/ProjectTemplates.cs` + +Clean up the old Api/Core/Database/Generator-specific methods and fallbacks that are no longer used by `NewProjectCommand`. Keep them only if other commands use them. + +- [ ] **Step 1: Check if any other command references the old methods** + +Search for `ApiCsproj`, `CoreCsproj`, `DatabaseCsproj`, `GeneratorCsproj`, `ApiProgram` usage outside `NewProjectCommand`. + +If they're only used by `NewProjectCommand`, remove them. If other code references them, keep them. + +- [ ] **Step 2: Remove unused methods if confirmed** + +Remove: `ApiCsproj()`, `ApiProgram()`, `CoreCsproj()`, `DatabaseCsproj()`, `GeneratorCsproj()`, and their fallbacks. + +Also update `FallbackSlnx()` to match the new Host-based structure: + +```csharp +private static string FallbackSlnx(string projectName) => + $""" + + + + + + + + + + + + + + + """; +``` + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build cli/SimpleModule.Cli/SimpleModule.Cli.csproj` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +``` +refactor(cli): remove unused Api/Core/Database/Generator template methods +``` diff --git a/docs/superpowers/specs/2026-03-26-cli-improvements-design.md b/docs/superpowers/specs/2026-03-26-cli-improvements-design.md new file mode 100644 index 00000000..6409df4c --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-cli-improvements-design.md @@ -0,0 +1,203 @@ +# CLI Improvements Design + +**Date:** 2026-03-26 +**Branch:** claude/strange-heyrovsky +**Scope:** Three improvement areas — comprehensive doctor checks, smarter `sm new feature` scaffolding, and UX polish. + +--- + +## 1. Comprehensive Doctor Checks + +### Goal + +`sm doctor` becomes the authoritative validator for the entire project — catching every convention violation described in CLAUDE.md before it causes a silent failure. + +### New Checks + +All new checks implement the existing `IDoctorCheck` interface, returning `IEnumerable`. They are added to the `checks` array in `DoctorCommand.cs`. + +| Check Class | What It Validates | Severity | Auto-fixable | +|---|---|---|---| +| `PagesRegistryCheck` | Every `Inertia.Render("X/Y", ...)` call in C# has a matching key in the module's `Pages/index.ts` | FAIL | Yes — adds stub entry | +| `ViteConfigCheck` | Each module has a `vite.config.ts`, uses library mode, externalizes React, ReactDOM, and `@inertiajs/react` | WARN | No | +| `PackageJsonCheck` | Module `package.json` lists React and `@inertiajs/react` as `peerDependencies`, not `dependencies` | WARN | No | +| `NpmWorkspaceCheck` | Each module directory is covered by the root `package.json` `workspaces` glob | FAIL | Yes — adds workspace glob | +| `ModuleAttributeCheck` | The module class (implements `IModule`) has `[Module(...)]` attribute with a non-empty `RoutePrefix` | FAIL | No | +| `ViewEndpointNamingCheck` | Endpoint classes end in `Endpoint`, are located under an `Endpoints/` directory | WARN | No | +| `ContractsIsolationCheck` | Contracts `.csproj` references only `SimpleModule.Core` — no references to other modules' impl projects | FAIL | No | + +### PagesRegistryCheck Implementation + +1. For each module, scan all `.cs` files under `src/{ModuleName}/` with regex: `Inertia\.Render\s*\(\s*"([^"]+)"` +2. Parse `Pages/index.ts` with regex matching known key patterns: `['"\`]([^'"\`]+)['"\`]\s*:\s*(?:\(\s*\)|(?:async\s*)?\(\s*\)\s*=>|import)` +3. Diff the two sets; report each missing key as a FAIL result named `"Pages -> {ModuleName}/{ComponentName}"` + +**Auto-fix:** When `--fix` is set, append a stub entry to the `pages` object in `Pages/index.ts`: +```typescript +"Products/Create": () => import("../Views/Create"), +``` + +### Auto-fix Registration + +`DoctorCommand` already matches `CheckResult.Name` patterns to dispatch fixes. Extend the pattern matching: +- `"Pages -> {module}/{component}"` → call new `PagesRegistryFixer` +- `"NpmWorkspace -> {module}"` → call new `NpmWorkspaceFixer` + +--- + +## 2. Smarter `sm new feature` Scaffolding + +### Goal + +When a view endpoint is created, the CLI fully wires it up — both backend (C# endpoint) and frontend (React component + Pages registry entry) — so the developer never hits the silent 404 described in CLAUDE.md. + +### Artifacts Created + +Given `sm new feature Create --module Products --method POST --route /`: + +| Artifact | Action | Location | +|---|---|---| +| `CreateEndpoint.cs` | Create (existing) | `src/modules/Products/src/Products/Endpoints/Products/` | +| `CreateValidator.cs` | Create (existing, if `--validator`) | `src/modules/Products/src/Products/Endpoints/Products/` | +| `Create.tsx` | Create (new) | `src/modules/Products/src/Products/Views/` | +| `Pages/index.ts` | Modify (new) | `src/modules/Products/src/Products/Pages/` | + +### Views/Create.tsx Template + +```tsx +import type { InferPageProps } from '@inertiajs/react' + +type Props = { + // TODO: add props from your endpoint's response +} + +export default function Create({ }: Props) { + return ( +
+

Create

+
+ ) +} +``` + +Component name and heading are derived from the feature name. + +### Pages/index.ts Update Strategy + +1. Read existing `Pages/index.ts` +2. Find the last `}` closing the `pages` object +3. Insert the new entry on the line before it, matching existing indentation +4. If `Pages/index.ts` does not exist, create it from scratch: + +```typescript +export const pages: Record = { + "Products/Create": () => import("../Views/Create"), +} +``` + +Entry key format: `"{ModuleName}/{FeatureName}"` — matches the `Inertia.Render` call pattern. + +### `--no-view` Flag + +Skips creation of `Views/{FeatureName}.tsx` and the `Pages/index.ts` update. For pure API endpoints that render no React page. + +--- + +## 3. UX Polish + +### Dry-Run Mode + +All `new` commands (`new project`, `new module`, `new feature`) gain a `--dry-run` flag. When set: + +1. Settings resolve interactively as normal (prompts still run) +2. Instead of writing files, print a tree showing what would happen +3. Exit without writing anything + +Example output: +``` +Dry run — no files written + + modules/Products/ + ├── src/Products/ + │ ├── Endpoints/Products/ + │ │ └── CreateEndpoint.cs [create] + │ └── Views/ + │ └── Create.tsx [create] + └── Pages/ + └── index.ts [modify] +``` + +Implementation: commands accumulate a `List<(string path, FileAction action)>` during execution. In dry-run mode, render this list as a Spectre.Console `Tree` instead of calling `File.WriteAllText`. + +`FileAction` enum: `Create`, `Modify`. + +### Tree Output on Real Execution + +After successful `sm new feature` or `sm new module`, replace the current flat `+ file.cs` list with a Spectre.Console `Tree` of created/modified files, grouped by directory. Same information, easier to scan. + +### Better Error Messages + +When validation fails, include a concrete suggestion: + +| Error | Current | Improved | +|---|---|---| +| Non-PascalCase name | `Name must be PascalCase` | `'products' is not PascalCase. Did you mean 'Products'?` | +| Module not found | `Module 'xyz' not found` | `Module 'xyz' not found. Available modules: Products, Orders, Users` | +| Solution not found | `No .slnx file found` | `No .slnx file found. Run this command from inside a SimpleModule project.` | + +### Progress Spinner + +`sm new module` wraps its multi-step execution in a Spectre.Console `Status` spinner: + +``` +⠋ Creating module structure... +✓ Done +``` + +Steps: create directories → write C# files → update .slnx → update Host .csproj → write frontend files. + +--- + +## Out of Scope + +- Version checks / update notifications (no NuGet publish pipeline yet) +- Full AST-based csproj editing (regex + XDocument is sufficient) +- New top-level commands beyond `new` and `doctor` +- DtoAttributeCheck (removed per user request) + +--- + +## File Changes Summary + +### New files +- `cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs` +- `cli/SimpleModule.Cli/Commands/Doctor/Checks/ViteConfigCheck.cs` +- `cli/SimpleModule.Cli/Commands/Doctor/Checks/PackageJsonCheck.cs` +- `cli/SimpleModule.Cli/Commands/Doctor/Checks/NpmWorkspaceCheck.cs` +- `cli/SimpleModule.Cli/Commands/Doctor/Checks/ModuleAttributeCheck.cs` +- `cli/SimpleModule.Cli/Commands/Doctor/Checks/ViewEndpointNamingCheck.cs` +- `cli/SimpleModule.Cli/Commands/Doctor/Checks/ContractsIsolationCheck.cs` +- `cli/SimpleModule.Cli/Infrastructure/PagesRegistryFixer.cs` +- `cli/SimpleModule.Cli/Infrastructure/NpmWorkspaceFixer.cs` + +### Modified files +- `cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs` — register new checks + new fix dispatchers +- `cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs` — add Views + Pages/index.ts generation, tree output, dry-run +- `cli/SimpleModule.Cli/Commands/New/NewFeatureSettings.cs` — add `--no-view`, `--dry-run` flags +- `cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs` — add spinner, tree output, dry-run +- `cli/SimpleModule.Cli/Commands/New/NewModuleSettings.cs` — add `--dry-run` flag +- `cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs` — add dry-run +- `cli/SimpleModule.Cli/Commands/New/NewProjectSettings.cs` — add `--dry-run` flag +- `cli/SimpleModule.Cli/Templates/FeatureTemplates.cs` — add `ViewComponent()` template method +- `cli/SimpleModule.Cli/Infrastructure/SolutionContext.cs` — add `GetModuleViewsPath()`, `GetModulePagesIndexPath()` helpers +- `cli/SimpleModule.Cli/Infrastructure/FileAction.cs` — new `FileAction` enum (`Create`, `Modify`) used by dry-run tree rendering + +### New test files +- `tests/SimpleModule.Cli.Tests/PagesRegistryCheckTests.cs` +- `tests/SimpleModule.Cli.Tests/ViteConfigCheckTests.cs` +- `tests/SimpleModule.Cli.Tests/PackageJsonCheckTests.cs` +- `tests/SimpleModule.Cli.Tests/NpmWorkspaceCheckTests.cs` +- `tests/SimpleModule.Cli.Tests/ContractsIsolationCheckTests.cs` +- `tests/SimpleModule.Cli.Tests/ModuleAttributeCheckTests.cs` +- `tests/SimpleModule.Cli.Tests/NewFeatureViewScaffoldingTests.cs` +- `tests/SimpleModule.Cli.Tests/DryRunTests.cs` diff --git a/docs/superpowers/specs/2026-03-26-new-project-scaffold-design.md b/docs/superpowers/specs/2026-03-26-new-project-scaffold-design.md new file mode 100644 index 00000000..6f14194b --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-new-project-scaffold-design.md @@ -0,0 +1,177 @@ +# Design: Improved `sm new project` Scaffold + +## Summary + +Rewrite `sm new project` to produce a working project by copying the template Host and adding one starter module with tests. The scaffolded project mirrors the real template structure so users get a runnable app immediately. + +## What Changes + +The current `sm new project` creates bare Core/Database/Generator/Api projects with no frontend. The new version instead: + +1. Copies `template/SimpleModule.Host/` as `src/{ProjectName}.Host/` (with name transformations) +2. Runs `sm new module Home` logic to create a starter module +3. Adds root config files (slnx, Directory.Build.props, etc.) +4. Creates `tests/{ProjectName}.Tests.Shared/` for test infrastructure + +No separate Core, Database, or Generator projects — those are part of the framework, referenced via the Host's project references. + +## Output Structure + +``` +{ProjectName}/ +├── src/ +│ ├── {ProjectName}.Host/ # Copied from template/SimpleModule.Host +│ │ ├── ClientApp/ +│ │ │ ├── app.tsx +│ │ │ ├── vite.config.ts +│ │ │ ├── validate-pages.mjs +│ │ │ └── package.json +│ │ ├── Components/ +│ │ │ ├── App.razor +│ │ │ ├── InertiaShell.razor +│ │ │ ├── Routes.razor +│ │ │ └── _Imports.razor +│ │ ├── Styles/ +│ │ │ └── app.css +│ │ ├── Properties/ +│ │ │ └── launchSettings.json +│ │ ├── wwwroot/ # Empty (built by npm run build) +│ │ ├── Program.cs +│ │ ├── {ProjectName}.Host.csproj +│ │ ├── appsettings.json +│ │ └── appsettings.Development.json +│ └── modules/ +│ └── Home/ # Starter module (created by module scaffolding logic) +│ ├── src/ +│ │ ├── Home.Contracts/ +│ │ │ ├── Home.Contracts.csproj +│ │ │ ├── IItemContracts.cs +│ │ │ ├── Item.cs +│ │ │ └── Events/ItemCreatedEvent.cs +│ │ └── Home/ +│ │ ├── Home.csproj +│ │ ├── HomeModule.cs +│ │ ├── HomeConstants.cs +│ │ ├── HomeDbContext.cs +│ │ ├── ItemService.cs +│ │ ├── Endpoints/Home/GetAllEndpoint.cs +│ │ ├── Pages/index.ts +│ │ ├── Views/Browse.tsx # React page (from sm new feature) +│ │ ├── vite.config.ts +│ │ └── package.json +│ └── tests/ +│ └── Home.Tests/ +│ ├── Home.Tests.csproj +│ ├── GlobalUsings.cs +│ ├── Unit/ItemServiceTests.cs +│ └── Integration/HomeEndpointTests.cs +├── tests/ +│ └── {ProjectName}.Tests.Shared/ +│ └── {ProjectName}.Tests.Shared.csproj +├── {ProjectName}.slnx +├── Directory.Build.props +├── Directory.Packages.props +├── global.json +├── package.json # npm workspace root +├── biome.json +├── tsconfig.json +└── .editorconfig +``` + +## Host .csproj Transformation + +Source: `template/SimpleModule.Host/SimpleModule.Host.csproj` + +Transformations: +- Rename `SimpleModule` → `{ProjectName}` everywhere +- **Strip all module ProjectReferences** (Dashboard, Users, OpenIddict, etc.) +- **Strip ServiceDefaults reference** (Aspire-specific) +- **Strip the Import** for `SimpleModule.Hosting.targets` (build infrastructure for the monorepo) +- **Strip InternalsVisibleTo** (project-specific) +- **Strip NoWarn suppressions** (project-specific TODOs) +- **Strip EF Core Design package** (not needed for starter) +- **Keep**: Framework reference to `SimpleModule.Hosting` and Generator analyzer reference (paths adjusted to point to NuGet packages or framework — TBD based on how the new project references the framework) +- **Add**: ProjectReference to `Home` module + +### Framework Reference Strategy + +The Host.csproj references `SimpleModule.Hosting` and `SimpleModule.Generator` via relative project references. For a new project, these need to point to the framework: + +**Option A (project reference to framework):** New project is created inside the SimpleModule repo structure. References are relative paths to `framework/`. + +**Decision:** Use project references. The `sm` CLI already requires being run from within the repo. The new project is created as a sibling or child directory. Relative paths are adjusted accordingly. + +## Host Files — Copy & Transform + +| Source File | Transform | +|-------------|-----------| +| `Program.cs` | Copy as-is (uses `AddSimpleModule()` / `UseSimpleModule()` which are generated) | +| `Components/App.razor` | Replace `SimpleModule` in title and component references | +| `Components/InertiaShell.razor` | Copy as-is (uses framework component) | +| `Components/Routes.razor` | Remove Users module assembly reference | +| `Components/_Imports.razor` | Replace namespace `SimpleModule.Host` → `{ProjectName}.Host` | +| `ClientApp/app.tsx` | Copy as-is (no project-specific references) | +| `ClientApp/vite.config.ts` | Copy as-is | +| `ClientApp/validate-pages.mjs` | Copy as-is | +| `ClientApp/package.json` | Replace `@simplemodule/app` → `@{projectname}/app` | +| `Styles/app.css` | Remove `_scan/` import line. Keep Tailwind, theme, UI, and module source lines. Adjust relative paths. | +| `Properties/launchSettings.json` | Replace `SimpleModule` → `{ProjectName}` | +| `appsettings.json` | Copy as-is | +| `appsettings.Development.json` | Copy as-is | + +Files **NOT** copied: +- `wwwroot/` contents (built artifacts — start empty, user runs `npm run build`) +- `Styles/_scan/` (pre-built CSS scans for existing modules) +- `Migrations/` (no migrations for a fresh project) +- `HostDbContextFactory.cs` (not needed until user wants migrations) + +## Root Config Files + +| File | Source | Transform | +|------|--------|-----------| +| `{ProjectName}.slnx` | Generate new (not from existing) | Contains Host + Home module + Tests.Shared | +| `Directory.Build.props` | Copy from repo root | Strip Roslynator (existing logic) | +| `Directory.Packages.props` | Copy from repo root | Strip project-specific packages (existing logic) | +| `global.json` | Copy from repo root | As-is | +| `package.json` | Generate new | Workspace root pointing to `src/{ProjectName}.Host/ClientApp` and `src/modules/*/src/*` | +| `biome.json` | Copy from repo root | Adjust paths for new structure | +| `tsconfig.json` | Copy from repo root | As-is | +| `.editorconfig` | Copy from repo root | As-is | + +## Starter Module ("Home") + +Created using the existing `ModuleTemplates` / `NewModuleCommand` logic. The module includes: +- Contracts project with a sample DTO and event +- Module implementation with DbContext, service, endpoint +- `Pages/index.ts` page registry +- Test project with unit and integration test skeletons + +After creation, the module is registered in the Host .csproj and .slnx. + +## Implementation Approach + +1. **New class `HostTemplates`** — handles reading/transforming template Host files +2. **Refactor `NewProjectCommand`** — replaces current bare skeleton with: + - Create root directory + config files + - Copy & transform Host project files + - Call module creation logic for "Home" + - Create Tests.Shared +3. **Update `ProjectTemplates`** — add methods for new root config files (package.json, biome.json, tsconfig.json, .editorconfig) +4. **Reuse existing infrastructure** — `SlnxManipulator`, `ProjectManipulator`, `ModuleTemplates` + +## Post-Scaffold Next Steps (shown to user) + +``` +cd {ProjectName} +npm install +npm run build +dotnet build +dotnet run --project src/{ProjectName}.Host +``` + +## Out of Scope + +- NuGet package publishing for the framework +- Template Engine (`dotnet new`) template +- Interactive module name prompt (fixed as "Home") +- React view scaffolding within the starter module (just Pages/index.ts + endpoint) diff --git a/framework/SimpleModule.Blazor/SimpleModule.Blazor.csproj b/framework/SimpleModule.Blazor/SimpleModule.Blazor.csproj index 25875af2..456c0153 100644 --- a/framework/SimpleModule.Blazor/SimpleModule.Blazor.csproj +++ b/framework/SimpleModule.Blazor/SimpleModule.Blazor.csproj @@ -1,6 +1,8 @@ net10.0 + true + 0.1.0-local diff --git a/framework/SimpleModule.Core/SimpleModule.Core.csproj b/framework/SimpleModule.Core/SimpleModule.Core.csproj index d840f9b4..b7ebe8e5 100644 --- a/framework/SimpleModule.Core/SimpleModule.Core.csproj +++ b/framework/SimpleModule.Core/SimpleModule.Core.csproj @@ -2,6 +2,8 @@ net10.0 Library + true + 0.1.0-local diff --git a/framework/SimpleModule.Database/SimpleModule.Database.csproj b/framework/SimpleModule.Database/SimpleModule.Database.csproj index 59bc7e67..4533a4b6 100644 --- a/framework/SimpleModule.Database/SimpleModule.Database.csproj +++ b/framework/SimpleModule.Database/SimpleModule.Database.csproj @@ -2,6 +2,8 @@ net10.0 Library + true + 0.1.0-local diff --git a/framework/SimpleModule.DevTools/SimpleModule.DevTools.csproj b/framework/SimpleModule.DevTools/SimpleModule.DevTools.csproj index f15fe2b6..b5faa767 100644 --- a/framework/SimpleModule.DevTools/SimpleModule.DevTools.csproj +++ b/framework/SimpleModule.DevTools/SimpleModule.DevTools.csproj @@ -2,6 +2,8 @@ net10.0 Library + true + 0.1.0-local diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index 1e60c6a3..5c1016d2 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -28,7 +28,8 @@ internal readonly record struct DiscoveryData( ImmutableArray ContractImplementations, ImmutableArray PermissionClasses, ImmutableArray Interceptors, - ImmutableArray VogenValueObjects + ImmutableArray VogenValueObjects, + string HostAssemblyName ) { public static readonly DiscoveryData Empty = new( @@ -42,7 +43,8 @@ ImmutableArray VogenValueObjects ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, - ImmutableArray.Empty + ImmutableArray.Empty, + "" ); public bool Equals(DiscoveryData other) @@ -57,7 +59,8 @@ public bool Equals(DiscoveryData other) && ContractImplementations.SequenceEqual(other.ContractImplementations) && PermissionClasses.SequenceEqual(other.PermissionClasses) && Interceptors.SequenceEqual(other.Interceptors) - && VogenValueObjects.SequenceEqual(other.VogenValueObjects); + && VogenValueObjects.SequenceEqual(other.VogenValueObjects) + && HostAssemblyName == other.HostAssemblyName; } public override int GetHashCode() @@ -74,6 +77,7 @@ public override int GetHashCode() hash = HashHelper.HashArray(hash, PermissionClasses); hash = HashHelper.HashArray(hash, Interceptors); hash = HashHelper.HashArray(hash, VogenValueObjects); + hash = HashHelper.Combine(hash, (HostAssemblyName ?? "").GetHashCode()); return hash; } } diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 5bfd85f4..fddde914 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -10,6 +10,8 @@ internal static class SymbolDiscovery { internal static DiscoveryData Extract(Compilation compilation) { + var hostAssemblyName = compilation.Assembly.Name; + var moduleAttributeSymbol = compilation.GetTypeByMetadataName( "SimpleModule.Core.ModuleAttribute" ); @@ -487,7 +489,8 @@ is not IAssemblySymbol assemblySymbol i.ConstructorParamTypeFqns.ToImmutableArray() )) .ToImmutableArray(), - vogenValueObjects.ToImmutableArray() + vogenValueObjects.ToImmutableArray(), + hostAssemblyName ); } diff --git a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs index 5a045684..556f3931 100644 --- a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs @@ -123,7 +123,7 @@ string ModuleName } sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Host;"); + sb.AppendLine($"namespace {data.HostAssemblyName};"); sb.AppendLine(); // Class declaration with primary constructor diff --git a/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs index ced34584..7788d2ee 100644 --- a/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs @@ -29,8 +29,8 @@ public void Emit(SourceProductionContext context, DiscoveryData data) sb.AppendLine("using SimpleModule.Core.Menu;"); sb.AppendLine("using SimpleModule.Database;"); sb.AppendLine("using SimpleModule.Hosting;"); - sb.AppendLine("using SimpleModule.Host;"); - sb.AppendLine("using SimpleModule.Host.Components;"); + sb.AppendLine($"using {data.HostAssemblyName};"); + sb.AppendLine($"using {data.HostAssemblyName}.Components;"); sb.AppendLine(); sb.AppendLine("namespace SimpleModule.Hosting;"); sb.AppendLine(); diff --git a/framework/SimpleModule.Generator/Emitters/ValueConverterConventionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/ValueConverterConventionsEmitter.cs index f5a4f977..2827ea89 100644 --- a/framework/SimpleModule.Generator/Emitters/ValueConverterConventionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/ValueConverterConventionsEmitter.cs @@ -17,7 +17,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) sb.AppendLine(); sb.AppendLine("using Microsoft.EntityFrameworkCore;"); sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Host;"); + sb.AppendLine($"namespace {data.HostAssemblyName};"); sb.AppendLine(); sb.AppendLine("public partial class HostDbContext"); sb.AppendLine("{"); diff --git a/framework/SimpleModule.Generator/SimpleModule.Generator.csproj b/framework/SimpleModule.Generator/SimpleModule.Generator.csproj index 858a5e07..0ac228ba 100644 --- a/framework/SimpleModule.Generator/SimpleModule.Generator.csproj +++ b/framework/SimpleModule.Generator/SimpleModule.Generator.csproj @@ -3,11 +3,19 @@ netstandard2.0 latest true + true + 0.1.0-local + false + true + $(NoWarn);NU5128 + + + diff --git a/framework/SimpleModule.Hosting/DefaultPublicMenuProvider.cs b/framework/SimpleModule.Hosting/DefaultPublicMenuProvider.cs new file mode 100644 index 00000000..ab931337 --- /dev/null +++ b/framework/SimpleModule.Hosting/DefaultPublicMenuProvider.cs @@ -0,0 +1,18 @@ +using SimpleModule.Core.Menu; + +namespace SimpleModule.Hosting; + +/// +/// Default no-op implementation of used when no module +/// registers its own provider. Returns an empty menu and no home page URL. +/// +#pragma warning disable CA1812 // Instantiated via DI (TryAddScoped) +internal sealed class DefaultPublicMenuProvider : IPublicMenuProvider +#pragma warning restore CA1812 +{ + public Task> GetMenuTreeAsync() => + Task.FromResult>(Array.Empty()); + + public Task GetHomePageUrlAsync() => + Task.FromResult(null); +} diff --git a/framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj b/framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj index 320a5d6b..e437a1f1 100644 --- a/framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj +++ b/framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj @@ -2,6 +2,8 @@ net10.0 Library + true + 0.1.0-local @@ -13,4 +15,7 @@ + + + diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index a69e3d9b..3bd506b5 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -5,7 +5,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using SimpleModule.Blazor; using SimpleModule.Core.Constants; using SimpleModule.Core.Events; @@ -66,7 +68,12 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( // Authentication is configured by modules via their ConfigureServices // (e.g., OpenIddict registers SmartAuth policy scheme). - // No base AddAuthentication() call needed here — modules own it. + // Register a baseline so the middleware pipeline works even without an auth module. + builder.Services.AddAuthentication(); + builder.Services.AddAuthorization(); + + // Register default IPublicMenuProvider if no module provides one + builder.Services.TryAddScoped(); if (options.EnableHealthChecks) { @@ -105,7 +112,22 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) if (scope.ServiceProvider.GetService(info.DbContextType) is DbContext db) { - await db.Database.MigrateAsync(); + // Use MigrateAsync when migrations exist, EnsureCreated otherwise + // (projects scaffolded with sm new project have no migrations initially) + if ((await db.Database.GetPendingMigrationsAsync()).Any() + || (await db.Database.GetAppliedMigrationsAsync()).Any()) + { + await db.Database.MigrateAsync(); + } + else + { + var logger = scope.ServiceProvider.GetService() + ?.CreateLogger("SimpleModule.Hosting"); + logger?.LogInformation( + "No migrations found — using EnsureCreated to initialize database. " + + "Add migrations for production use."); + await db.Database.EnsureCreatedAsync(); + } } } } diff --git a/framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets b/framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets index adde3007..ad5178a6 100644 --- a/framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets +++ b/framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets @@ -47,11 +47,15 @@ Inputs="@(TailwindSourceFiles)" Outputs="$(TailwindOutput)" > - - + + <_TailwindAvailable>false + <_TailwindAvailable Condition="Exists('$(TailwindCli)')">true + + diff --git a/tests/SimpleModule.Cli.Tests/ContractsIsolationCheckTests.cs b/tests/SimpleModule.Cli.Tests/ContractsIsolationCheckTests.cs new file mode 100644 index 00000000..5413f92d --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/ContractsIsolationCheckTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class ContractsIsolationCheckTests : IDisposable +{ + private readonly string _tempDir; + + public ContractsIsolationCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolution(string moduleName, string contractsCsprojContent) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var modulesDir = Path.Combine(_tempDir, "src", "modules"); + var contractsDir = Path.Combine(modulesDir, moduleName, "src", $"{moduleName}.Contracts"); + Directory.CreateDirectory(contractsDir); + File.WriteAllText(Path.Combine(contractsDir, $"{moduleName}.Contracts.csproj"), contractsCsprojContent); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenContractsOnlyRefsCore() + { + var solution = CreateSolution("Products", """ + + + + + + """); + var results = new ContractsIsolationCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products.Contracts isolation" && r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Fail_WhenContractsRefsAnotherModuleProject() + { + var solution = CreateSolution("Products", """ + + + + + + + """); + var results = new ContractsIsolationCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products.Contracts isolation" && r.Status == CheckStatus.Fail); + } + + [Fact] + public void Run_Warning_WhenContractsCsprojNotFound() + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var modulesDir = Path.Combine(_tempDir, "src", "modules"); + Directory.CreateDirectory(Path.Combine(modulesDir, "Products")); + var solution = SolutionContext.Discover(_tempDir)!; + var results = new ContractsIsolationCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products.Contracts isolation" && r.Status == CheckStatus.Warning); + } +} diff --git a/tests/SimpleModule.Cli.Tests/ModuleAttributeCheckTests.cs b/tests/SimpleModule.Cli.Tests/ModuleAttributeCheckTests.cs new file mode 100644 index 00000000..13454fa5 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/ModuleAttributeCheckTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class ModuleAttributeCheckTests : IDisposable +{ + private readonly string _tempDir; + + public ModuleAttributeCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolutionWithModule(string moduleName, string? moduleClassContent = null) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var moduleDir = Path.Combine(_tempDir, "src", "modules", moduleName, "src", moduleName); + Directory.CreateDirectory(moduleDir); + if (moduleClassContent is not null) + File.WriteAllText(Path.Combine(moduleDir, $"{moduleName}Module.cs"), moduleClassContent); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenModuleAttributePresentWithRoutePrefix() + { + var solution = CreateSolutionWithModule("Products", """ + [Module("Products", RoutePrefix = "products")] + public class ProductsModule : IModule { } + """); + var results = new ModuleAttributeCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products [Module] attribute" && r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Fail_WhenModuleClassHasNoModuleAttribute() + { + var solution = CreateSolutionWithModule("Products", """ + public class ProductsModule : IModule { } + """); + var results = new ModuleAttributeCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products [Module] attribute" && r.Status == CheckStatus.Fail); + } + + [Fact] + public void Run_Warning_WhenModuleClassFileMissing() + { + var solution = CreateSolutionWithModule("Products", moduleClassContent: null); + var results = new ModuleAttributeCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products [Module] attribute" && r.Status == CheckStatus.Warning); + } + + [Fact] + public void Run_Fail_WhenModuleAttributeMissingRoutePrefix() + { + var solution = CreateSolutionWithModule("Products", """ + [Module("Products")] + public class ProductsModule : IModule { } + """); + var results = new ModuleAttributeCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products [Module] attribute" && r.Status == CheckStatus.Fail); + } +} diff --git a/tests/SimpleModule.Cli.Tests/NewFeatureViewScaffoldingTests.cs b/tests/SimpleModule.Cli.Tests/NewFeatureViewScaffoldingTests.cs new file mode 100644 index 00000000..6ad3049a --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/NewFeatureViewScaffoldingTests.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using SimpleModule.Cli.Infrastructure; +using SimpleModule.Cli.Templates; + +namespace SimpleModule.Cli.Tests; + +public sealed class NewFeatureViewScaffoldingTests : IDisposable +{ + private readonly string _tempDir; + + public NewFeatureViewScaffoldingTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolution() + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void ViewComponent_ContainsFeatureNameAsComponentName() + { + var result = FeatureTemplates.ViewComponent("Products", "Create"); + result.Should().Contain("export default function Create"); + } + + [Fact] + public void ViewComponent_ContainsPropsType() + { + var result = FeatureTemplates.ViewComponent("Products", "Browse"); + result.Should().Contain("type Props ="); + } + + [Fact] + public void ViewComponent_ContainsHeadingWithFeatureName() + { + var result = FeatureTemplates.ViewComponent("Products", "Create"); + result.Should().Contain("

Create

"); + } + + [Fact] + public void PagesRegistryFixer_AddsEntryForNewFeature() + { + var pagesDir = Path.Combine(_tempDir, "Pages"); + Directory.CreateDirectory(pagesDir); + var indexPath = Path.Combine(pagesDir, "index.ts"); + File.WriteAllText(indexPath, """ + export const pages: Record = { + "Products/Browse": () => import("../Views/Browse"), + }; + """); + + PagesRegistryFixer.AddEntry(indexPath, "Products/Create", "../Views/Create"); + + var content = File.ReadAllText(indexPath); + content.Should().Contain("\"Products/Create\": () => import(\"../Views/Create\")"); + content.Should().Contain("\"Products/Browse\""); + } +} diff --git a/tests/SimpleModule.Cli.Tests/NpmWorkspaceCheckTests.cs b/tests/SimpleModule.Cli.Tests/NpmWorkspaceCheckTests.cs new file mode 100644 index 00000000..c4686105 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/NpmWorkspaceCheckTests.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class NpmWorkspaceCheckTests : IDisposable +{ + private readonly string _tempDir; + + public NpmWorkspaceCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolution(string moduleName, string? rootPackageJson) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var modulesDir = Path.Combine(_tempDir, "src", "modules"); + Directory.CreateDirectory(Path.Combine(modulesDir, moduleName)); + if (rootPackageJson is not null) + File.WriteAllText(Path.Combine(_tempDir, "package.json"), rootPackageJson); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenModuleCoveredByWorkspaceGlob() + { + var solution = CreateSolution("Products", """ + { + "workspaces": ["src/modules/*/src/*"] + } + """); + var results = new NpmWorkspaceCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "NpmWorkspace -> Products" && r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Fail_WhenModuleNotInWorkspaces() + { + var solution = CreateSolution("Products", """ + { + "workspaces": ["packages/*"] + } + """); + var results = new NpmWorkspaceCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "NpmWorkspace -> Products" && r.Status == CheckStatus.Fail); + } + + [Fact] + public void Run_Warning_WhenRootPackageJsonMissing() + { + var solution = CreateSolution("Products", rootPackageJson: null); + var results = new NpmWorkspaceCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "NpmWorkspace -> Products" && r.Status == CheckStatus.Warning); + } +} diff --git a/tests/SimpleModule.Cli.Tests/PackageJsonCheckTests.cs b/tests/SimpleModule.Cli.Tests/PackageJsonCheckTests.cs new file mode 100644 index 00000000..9451f3e3 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/PackageJsonCheckTests.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class PackageJsonCheckTests : IDisposable +{ + private readonly string _tempDir; + + public PackageJsonCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolutionWithPackageJson(string moduleName, string? packageJsonContent) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var moduleDir = Path.Combine(_tempDir, "src", "modules", moduleName, "src", moduleName); + Directory.CreateDirectory(moduleDir); + if (packageJsonContent is not null) + File.WriteAllText(Path.Combine(moduleDir, "package.json"), packageJsonContent); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenReactAndInertiaAreInPeerDeps() + { + var solution = CreateSolutionWithPackageJson("Products", """ + { + "peerDependencies": { + "react": "^19.0.0", + "@inertiajs/react": "^2.0.0" + } + } + """); + var results = new PackageJsonCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products package.json" && r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Warn_WhenReactIsInDependenciesNotPeerDeps() + { + var solution = CreateSolutionWithPackageJson("Products", """ + { + "dependencies": { "react": "^19.0.0" }, + "peerDependencies": { "@inertiajs/react": "^2.0.0" } + } + """); + var results = new PackageJsonCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products package.json" && r.Status == CheckStatus.Warning); + } + + [Fact] + public void Run_Warn_WhenPackageJsonMissing() + { + var solution = CreateSolutionWithPackageJson("Products", packageJsonContent: null); + var results = new PackageJsonCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products package.json" && r.Status == CheckStatus.Warning); + } +} diff --git a/tests/SimpleModule.Cli.Tests/PagesRegistryCheckTests.cs b/tests/SimpleModule.Cli.Tests/PagesRegistryCheckTests.cs new file mode 100644 index 00000000..814d46cc --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/PagesRegistryCheckTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class PagesRegistryCheckTests : IDisposable +{ + private readonly string _tempDir; + + public PagesRegistryCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolution(string moduleName, string[]? csFiles = null, string? pagesIndexContent = null) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var moduleDir = Path.Combine(_tempDir, "src", "modules", moduleName, "src", moduleName); + var pagesDir = Path.Combine(moduleDir, "Pages"); + Directory.CreateDirectory(pagesDir); + + if (csFiles is not null) + { + var endpointsDir = Path.Combine(moduleDir, "Endpoints", moduleName); + Directory.CreateDirectory(endpointsDir); + foreach (var (content, i) in csFiles.Select((c, i) => (c, i))) + File.WriteAllText(Path.Combine(endpointsDir, $"Endpoint{i}.cs"), content); + } + + if (pagesIndexContent is not null) + File.WriteAllText(Path.Combine(pagesDir, "index.ts"), pagesIndexContent); + + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenAllInertiaCallsHavePageEntry() + { + var solution = CreateSolution("Products", + csFiles: [@"Inertia.Render(""Products/Browse"", props)"], + pagesIndexContent: """ + export const pages: Record = { + "Products/Browse": () => import("../Views/Browse"), + }; + """); + var results = new PagesRegistryCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Pages -> Products/Browse" && r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Fail_WhenInertiaCallHasNoPageEntry() + { + var solution = CreateSolution("Products", + csFiles: [@"Inertia.Render(""Products/Browse"", props)"], + pagesIndexContent: "export const pages: Record = {};"); + var results = new PagesRegistryCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Pages -> Products/Browse" && r.Status == CheckStatus.Fail); + } + + [Fact] + public void Run_Fail_WhenPagesIndexTsMissing() + { + var solution = CreateSolution("Products", + csFiles: [@"Inertia.Render(""Products/Browse"", props)"], + pagesIndexContent: null); + var results = new PagesRegistryCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Pages -> Products/Browse" && r.Status == CheckStatus.Fail); + } + + [Fact] + public void Run_Pass_WhenNoInertiaCallsExist() + { + var solution = CreateSolution("Products", + csFiles: ["// no inertia calls here"], + pagesIndexContent: "export const pages: Record = {};"); + var results = new PagesRegistryCheck().Run(solution).ToList(); + results.Should().BeEmpty(); + } +} diff --git a/tests/SimpleModule.Cli.Tests/PagesRegistryFixerTests.cs b/tests/SimpleModule.Cli.Tests/PagesRegistryFixerTests.cs new file mode 100644 index 00000000..3e6a467a --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/PagesRegistryFixerTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class PagesRegistryFixerTests : IDisposable +{ + private readonly string _tempDir; + + public PagesRegistryFixerTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + [Fact] + public void AddEntry_AppendsToExistingPagesIndex() + { + var indexPath = Path.Combine(_tempDir, "index.ts"); + File.WriteAllText(indexPath, """ + export const pages: Record = { + "Products/Browse": () => import("../Views/Browse"), + }; + """); + PagesRegistryFixer.AddEntry(indexPath, "Products/Create", "../Views/Create"); + var content = File.ReadAllText(indexPath); + content.Should().Contain("\"Products/Create\": () => import(\"../Views/Create\")"); + content.Should().Contain("\"Products/Browse\""); + } + + [Fact] + public void AddEntry_CreatesFileFromScratchWhenMissing() + { + var indexPath = Path.Combine(_tempDir, "index.ts"); + PagesRegistryFixer.AddEntry(indexPath, "Products/Create", "../Views/Create"); + var content = File.ReadAllText(indexPath); + content.Should().Contain("export const pages"); + content.Should().Contain("\"Products/Create\": () => import(\"../Views/Create\")"); + } +} diff --git a/tests/SimpleModule.Cli.Tests/SolutionContextTests.cs b/tests/SimpleModule.Cli.Tests/SolutionContextTests.cs index 3d6711dd..b30e88cb 100644 --- a/tests/SimpleModule.Cli.Tests/SolutionContextTests.cs +++ b/tests/SimpleModule.Cli.Tests/SolutionContextTests.cs @@ -148,4 +148,24 @@ public void ApiCsprojPath_FollowsConvention() ctx.ApiCsprojPath.Should().Contain("SimpleModule.Host"); ctx.ApiCsprojPath.Should().EndWith(".csproj"); } + + [Fact] + public void GetModuleViewsPath_ReturnsCorrectPath() + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var ctx = SolutionContext.Discover(_tempDir)!; + ctx.GetModuleViewsPath("Products") + .Should() + .Be(Path.Combine(_tempDir, "src", "modules", "Products", "src", "Products", "Views")); + } + + [Fact] + public void GetModulePagesIndexPath_ReturnsCorrectPath() + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var ctx = SolutionContext.Discover(_tempDir)!; + ctx.GetModulePagesIndexPath("Products") + .Should() + .Be(Path.Combine(_tempDir, "src", "modules", "Products", "src", "Products", "Pages", "index.ts")); + } } diff --git a/tests/SimpleModule.Cli.Tests/ViewEndpointNamingCheckTests.cs b/tests/SimpleModule.Cli.Tests/ViewEndpointNamingCheckTests.cs new file mode 100644 index 00000000..daa0baee --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/ViewEndpointNamingCheckTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class ViewEndpointNamingCheckTests : IDisposable +{ + private readonly string _tempDir; + + public ViewEndpointNamingCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolutionWithEndpoints(string moduleName, params string[] endpointFileNames) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var endpointsDir = Path.Combine(_tempDir, "src", "modules", moduleName, "src", moduleName, "Endpoints", moduleName); + Directory.CreateDirectory(endpointsDir); + foreach (var name in endpointFileNames) + File.WriteAllText(Path.Combine(endpointsDir, name), "// stub"); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenAllEndpointFilesFollowConvention() + { + var solution = CreateSolutionWithEndpoints("Products", "GetAllEndpoint.cs", "CreateEndpoint.cs"); + var results = new ViewEndpointNamingCheck().Run(solution).ToList(); + results.Should().OnlyContain(r => r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Warn_WhenEndpointFileDoesNotEndWithEndpoint() + { + var solution = CreateSolutionWithEndpoints("Products", "GetProducts.cs"); + var results = new ViewEndpointNamingCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name.Contains("GetProducts") && r.Status == CheckStatus.Warning); + } + + [Fact] + public void Run_Pass_WhenNoEndpointsDirectory() + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var moduleDir = Path.Combine(_tempDir, "src", "modules", "Products", "src", "Products"); + Directory.CreateDirectory(moduleDir); + var solution = SolutionContext.Discover(_tempDir)!; + var results = new ViewEndpointNamingCheck().Run(solution).ToList(); + results.Should().BeEmpty(); + } +} diff --git a/tests/SimpleModule.Cli.Tests/ViteConfigCheckTests.cs b/tests/SimpleModule.Cli.Tests/ViteConfigCheckTests.cs new file mode 100644 index 00000000..cb9673f2 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/ViteConfigCheckTests.cs @@ -0,0 +1,75 @@ +using FluentAssertions; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class ViteConfigCheckTests : IDisposable +{ + private readonly string _tempDir; + + public ViteConfigCheckTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + } + + private SolutionContext CreateSolutionWithViteConfig(string moduleName, string? viteConfigContent) + { + File.WriteAllText(Path.Combine(_tempDir, "Test.slnx"), ""); + var moduleDir = Path.Combine(_tempDir, "src", "modules", moduleName, "src", moduleName); + Directory.CreateDirectory(moduleDir); + if (viteConfigContent is not null) + File.WriteAllText(Path.Combine(moduleDir, "vite.config.ts"), viteConfigContent); + return SolutionContext.Discover(_tempDir)!; + } + + [Fact] + public void Run_Pass_WhenViteConfigCorrect() + { + var solution = CreateSolutionWithViteConfig("Products", """ + import { defineConfig } from 'vite' + export default defineConfig({ + build: { lib: { entry: 'Pages/index.ts' } }, + external: ['react', 'react-dom', '@inertiajs/react'], + }) + """); + var results = new ViteConfigCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products vite.config.ts" && r.Status == CheckStatus.Pass); + } + + [Fact] + public void Run_Warn_WhenViteConfigMissing() + { + var solution = CreateSolutionWithViteConfig("Products", viteConfigContent: null); + var results = new ViteConfigCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products vite.config.ts" && r.Status == CheckStatus.Warning); + } + + [Fact] + public void Run_Warn_WhenLibModeNotConfigured() + { + var solution = CreateSolutionWithViteConfig("Products", """ + import { defineConfig } from 'vite' + export default defineConfig({}) + """); + var results = new ViteConfigCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products vite.config.ts" && r.Status == CheckStatus.Warning); + } + + [Fact] + public void Run_Warn_WhenExternalsIncomplete() + { + var solution = CreateSolutionWithViteConfig("Products", """ + build: { lib: { entry: 'Pages/index.ts' } }, + external: ['react'], + """); + var results = new ViteConfigCheck().Run(solution).ToList(); + results.Should().ContainSingle(r => r.Name == "Products vite.config.ts" && r.Status == CheckStatus.Warning); + } +} diff --git a/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs b/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs index a904896b..de281490 100644 --- a/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs +++ b/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs @@ -175,7 +175,7 @@ public void HostDbContext_HasCorrectNamespace() var result = GeneratorTestHelper.RunGenerator(compilation); var source = GetHostDbContext(result); - source.Should().Contain("namespace SimpleModule.Host;"); + source.Should().Contain("namespace TestAssembly;"); } #endregion diff --git a/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs b/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs index acdb2694..d3c461f3 100644 --- a/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs +++ b/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs @@ -233,7 +233,8 @@ public void SortModules_WithDependencies_ReordersByDependency() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, - ImmutableArray.Empty + ImmutableArray.Empty, + "SimpleModule.Host" ); var result = TopologicalSort.SortModules(data); @@ -292,7 +293,8 @@ public void SortModules_WithCycle_ReturnsOriginalOrder() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, - ImmutableArray.Empty + ImmutableArray.Empty, + "SimpleModule.Host" ); var result = TopologicalSort.SortModules(data); @@ -363,7 +365,8 @@ public void SortModules_NoDependencies_PreservesOriginalOrder() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, - ImmutableArray.Empty + ImmutableArray.Empty, + "SimpleModule.Host" ); var result = TopologicalSort.SortModules(data);