diff --git a/.gitignore b/.gitignore index f3c169dd..c9ae4714 100644 --- a/.gitignore +++ b/.gitignore @@ -451,3 +451,4 @@ baseline/ # Verification artifacts .verify/ .qa/ +module-manifest.json diff --git a/.qa/reports/qa-report-iteration-1.md b/.qa/reports/qa-report-iteration-1.md new file mode 100644 index 00000000..0fecb26b --- /dev/null +++ b/.qa/reports/qa-report-iteration-1.md @@ -0,0 +1,23 @@ +# QA Report: Module packaging (manifest emission, manifest-driven loading, bundled migrations, sm CLI) +**Date:** 2026-06-10 · **Tester:** Claude QA (Senior) · **Target:** https://localhost:5003 + sm CLI · **Depth:** normal · **Iteration:** 1 of 3 + +## Summary +| Category | Passed | Failed | +|----------|--------|--------| +| Browser (happy path + edge) | 13 | 1 (P3) | +| CLI packaging commands | 14 | 1 (P3) | +| **Total** | **27** | **2 (both P3)** | + +## Critical/Major/Minor issues (P0–P2) +None. + +## Observations (P3) +### [OBS-001] /admin/audit-logs 404s — actual route is /audit-logs/browse +App behaves correctly (styled 404); the QA inventory route was stale. Optional: add an alias. +### [OBS-002] sm publish success line showed the [Module] manifest version instead of the requested package version +Fixed inline during QA (PackCommand success line now prints the package version). + +## Feature verdicts +- **Manifest-driven frontend loading: VERIFIED** — sm-module-assets present exactly once, 13 modules mapped, every module page renders, bundle-cache navigation works, zero console errors, graceful 404s. +- **Bundled module migrations: VERIFIED** — FeatureFlags_FeatureFlagChangeLog created via MigrateAsync at startup (schema-prefix convention respected), history row recorded; doctor reports "1 migration(s) applied". +- **sm CLI: VERIFIED** — all 14 adversarial scenarios pass with correct exit codes, clean errors, zero unintended writes (byte-identical csproj/props after refused add), registry abstraction honored, dry-run side-effect-free. diff --git a/.verify/screenshot.png b/.verify/screenshot.png index 1e42c599..23e204f6 100644 Binary files a/.verify/screenshot.png and b/.verify/screenshot.png differ diff --git a/cli/SimpleModule.Cli/Commands/Add/AddCommand.cs b/cli/SimpleModule.Cli/Commands/Add/AddCommand.cs new file mode 100644 index 00000000..10c1125d --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Add/AddCommand.cs @@ -0,0 +1,248 @@ +using System.ComponentModel; +using SimpleModule.Cli.Commands.Doctor; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Add; + +public sealed class AddSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("NuGet package id of the module (e.g. SimpleModule.Products).")] + public string PackageId { get; init; } = ""; + + [CommandOption("--version")] + [Description("Exact package version. Default: highest available.")] + public string? Version { get; init; } + + [CommandOption("--source")] + [Description( + "Package source: a local folder feed or a NuGet V3 service index URL. Default: the registry from sm.json (nuget.org)." + )] + public string? Source { get; init; } + + [CommandOption("--skip-migrations")] + [Description("Do not run the host in migrate-only mode after install.")] + public bool SkipMigrations { get; init; } + + [CommandOption("--skip-doctor")] + [Description("Do not run sm doctor after install.")] + public bool SkipDoctor { get; init; } +} + +/// +/// Installs a packaged module into the host: resolves the nupkg, validates the +/// module manifest and framework compatibility BEFORE touching any file, wires +/// the (CPM-aware) package reference, restores + builds, applies module +/// migrations via the SIMPLEMODULE_MIGRATE_ONLY hook, then runs doctor. +/// +public sealed class AddCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, AddSettings settings) + { + var solution = SolutionContext.Discover(); + if (solution is null) + { + return Fail( + "No .slnx file found. Run this command from inside a SimpleModule project." + ); + } + + // 1. Resolve the package source and obtain the nupkg + var source = settings.Source ?? SmConfig.Load(solution.RootPath).Registry; + var isLocalSource = NuGetClient.IsLocalDirectorySource(source); + + string? nupkgPath; + string resolvedVersion; + if (isLocalSource) + { + var feedDir = Path.GetFullPath(source); + nupkgPath = NuGetClient.FindLocalNupkg(feedDir, settings.PackageId, settings.Version); + if (nupkgPath is null) + { + return Fail( + $"Package {settings.PackageId}" + + (settings.Version is null ? "" : $" {settings.Version}") + + $" not found in local feed '{feedDir}'." + ); + } + + resolvedVersion = Path.GetFileNameWithoutExtension(nupkgPath)[ + (settings.PackageId.Length + 1).. + ]; + } + else + { + var serviceIndex = new Uri(source); + var versions = await NuGetClient.GetVersionsAsync(serviceIndex, settings.PackageId); + if (versions.Count == 0) + { + return Fail($"Package {settings.PackageId} not found on '{source}'."); + } + + // "Latest" prefers the highest stable version; prereleases only when + // nothing stable exists. The flat-container array's order is not + // guaranteed by every feed, so sort explicitly. + var sorted = versions.OrderBy(v => v, SemVerStringComparer.Instance).ToList(); + resolvedVersion = + settings.Version + ?? sorted.LastOrDefault(v => !SemVerStringComparer.IsPrerelease(v)) + ?? sorted[^1]; + if (!versions.Contains(resolvedVersion)) + { + return Fail( + $"Version {resolvedVersion} of {settings.PackageId} not found on '{source}'. " + + $"Available: {string.Join(", ", versions.TakeLast(8))}" + ); + } + + nupkgPath = Path.Combine( + Path.GetTempPath(), + $"{settings.PackageId}.{resolvedVersion}.nupkg" + ); + AnsiConsole.MarkupLine( + $"[dim]→ downloading {Markup.Escape(settings.PackageId)} {Markup.Escape(resolvedVersion)}...[/]" + ); + await NuGetClient.DownloadNupkgAsync( + serviceIndex, + settings.PackageId, + resolvedVersion, + nupkgPath + ); + } + + // 2. Manifest — required for module packages + var manifest = NupkgManifestReader.TryRead(nupkgPath, settings.PackageId); + if (manifest is null) + { + return Fail( + $"{settings.PackageId} carries no SimpleModule module manifest — it is not a " + + "SimpleModule module package. Use 'sm install' for plain NuGet packages." + ); + } + + // 3. Framework compatibility gate BEFORE any file change + var hostVersion = HostFrameworkVersionResolver.Resolve(solution.RootPath); + if (hostVersion is null) + { + AnsiConsole.MarkupLine( + "[yellow]! could not determine the host's SimpleModule.Core version — skipping compat check[/]" + ); + } + else + { + var compat = FrameworkCompatChecker.Check(manifest.FrameworkCompat, hostVersion); + if (!compat.Compatible) + { + return Fail( + $"Refusing to install {settings.PackageId} {resolvedVersion}: {compat.Reason}" + ); + } + + AnsiConsole.MarkupLine($"[dim]✓ {Markup.Escape(compat.Reason)}[/]"); + } + + // 4. Local feeds must be resolvable at restore time + if (isLocalSource) + { + NuGetConfigManipulator.EnsureLocalSource(solution.RootPath, Path.GetFullPath(source)); + } + + // 5. Wire the package reference (CPM-aware) + PackageReferenceManipulator.AddPackage( + solution.ApiCsprojPath, + solution.RootPath, + settings.PackageId, + resolvedVersion + ); + AnsiConsole.MarkupLine( + $"[green]✓ added {Markup.Escape(settings.PackageId)} {Markup.Escape(resolvedVersion)} to {Markup.Escape(Path.GetFileName(solution.ApiCsprojPath))}[/]" + ); + + // 6. Restore + build + AnsiConsole.MarkupLine("[dim]→ dotnet restore + build...[/]"); + var build = await ProcessRunner.RunAsync( + "dotnet", + ["build", solution.ApiCsprojPath], + solution.RootPath + ); + if (!build.Success) + { + return Fail( + "Build failed after adding the package — the reference was left in place for inspection:\n" + + Tail(build.Error, build.Output) + ); + } + + // 7. Apply module migrations deterministically + if (!settings.SkipMigrations && manifest.HasDbContext) + { + AnsiConsole.MarkupLine("[dim]→ applying module migrations (migrate-only run)...[/]"); + var migrate = await ProcessRunner.RunAsync( + "dotnet", + ["run", "--project", Path.GetDirectoryName(solution.ApiCsprojPath)!, "--no-build"], + solution.RootPath, + new Dictionary { ["SIMPLEMODULE_MIGRATE_ONLY"] = "1" } + ); + if (!migrate.Success) + { + return Fail( + "Module migration run failed (the package reference is in place; fix the " + + "database issue and re-run 'SIMPLEMODULE_MIGRATE_ONLY=1 dotnet run --project '):\n" + + Tail(migrate.Error, migrate.Output) + ); + } + + AnsiConsole.MarkupLine("[green]✓ database initialized[/]"); + } + + PrintSummary(manifest, resolvedVersion); + + // 8. Doctor + if (!settings.SkipDoctor) + { + AnsiConsole.Write(new Rule("[blue]sm doctor[/]").LeftJustified()); + var results = DoctorCommand.RunChecks(solution); + DoctorCommand.RenderResults(results); + if (results.Exists(r => r.Status == CheckStatus.Fail)) + { + AnsiConsole.MarkupLine( + "[yellow]! doctor reported failures — run 'sm doctor --fix'[/]" + ); + } + } + + return 0; + } + + private static void PrintSummary(ModuleManifestData manifest, string version) + { + AnsiConsole.MarkupLine( + $"[green]✓ Installed {Markup.Escape(manifest.DisplayName)}[/] [dim]({Markup.Escape(manifest.Id)} {Markup.Escape(version)})[/]" + ); + AnsiConsole.MarkupLine( + $"[dim] schema: {Markup.Escape(manifest.Schema)} permissions: {manifest.Permissions.Count}" + + $" pages: {manifest.Pages.Count}" + + ( + manifest.FrontendEntry is null + ? " (backend-only)" + : $" frontend: {Markup.Escape(manifest.FrontendEntry)}" + ) + + "[/]" + ); + } + + private static string Tail(string error, string output) + { + var text = string.IsNullOrWhiteSpace(error) ? output : error; + return string.Join('\n', text.Split('\n').TakeLast(25)).Trim(); + } + + private static int Fail(string message) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]"); + return 1; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/ModuleBundleExternalsCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ModuleBundleExternalsCheck.cs new file mode 100644 index 00000000..237badc4 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/ModuleBundleExternalsCheck.cs @@ -0,0 +1,48 @@ +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +/// +/// Detects bundles that inline host-provided libraries (a duplicate React copy +/// breaks hooks at runtime). Same validation `sm pack` enforces, surfaced +/// during development. +/// +public sealed class ModuleBundleExternalsCheck : IDoctorCheck +{ + public IEnumerable Run(Infrastructure.SolutionContext solution) + { + foreach (var module in solution.ExistingModules) + { + var wwwroot = Path.Combine(solution.GetModuleProjectPath(module), "wwwroot"); + if (!Directory.Exists(wwwroot)) + { + continue; + } + + var violations = BundleExternalsValidator.Validate(wwwroot); + if (violations.Count == 0) + { + yield return new CheckResult( + $"{module} bundle externals", + CheckStatus.Pass, + "no inlined React markers" + ); + } + else + { + foreach (var violation in violations) + { + // Warning, not Fail: markers can come from legitimately + // bundled React-ecosystem libs (react-is); `sm pack` is the + // enforcement gate and has --skip-externals-check. + yield return new CheckResult( + $"{module} bundle externals", + CheckStatus.Warning, + $"{Path.GetFileName(violation.File)} contains inlined-React marker {violation.Marker} — " + + "externalize react/react-dom/@inertiajs/react via defineModuleConfig" + ); + } + } + } + } +} diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/PackagedModuleManifestCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/PackagedModuleManifestCheck.cs new file mode 100644 index 00000000..8f509d34 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/PackagedModuleManifestCheck.cs @@ -0,0 +1,61 @@ +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +/// +/// Validates installed packaged modules: the manifest must be readable, at a +/// supported schemaVersion, and framework-compatible with this host. +/// +public sealed class PackagedModuleManifestCheck : IDoctorCheck +{ + private const int SupportedSchemaVersion = 1; + + public IEnumerable Run(Infrastructure.SolutionContext solution) + { + var hostVersion = HostFrameworkVersionResolver.Resolve(solution.RootPath); + + foreach ( + var reference in PackageReferenceManipulator.GetPackageReferences( + solution.ApiCsprojPath, + solution.RootPath + ) + ) + { + // Framework/3rd-party packages have no manifest — only inspect ones + // that look like SimpleModule modules to avoid scanning everything. + var manifest = GlobalPackagesCache.TryReadManifest(reference.Id, reference.Version); + if (manifest is null) + { + continue; + } + + var name = $"Package {reference.Id}"; + if (manifest.SchemaVersion > SupportedSchemaVersion) + { + yield return new CheckResult( + name, + CheckStatus.Fail, + $"manifest schemaVersion {manifest.SchemaVersion} is newer than this tooling " + + $"supports ({SupportedSchemaVersion}) — update the SimpleModule framework/CLI" + ); + continue; + } + + if (hostVersion is not null) + { + var compat = FrameworkCompatChecker.Check(manifest.FrameworkCompat, hostVersion); + if (!compat.Compatible) + { + yield return new CheckResult(name, CheckStatus.Fail, compat.Reason); + continue; + } + } + + yield return new CheckResult( + name, + CheckStatus.Pass, + $"{manifest.DisplayName} {reference.Version} (manifest v{manifest.SchemaVersion})" + ); + } + } +} diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/PendingModuleMigrationsCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/PendingModuleMigrationsCheck.cs new file mode 100644 index 00000000..e3e57cd9 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/PendingModuleMigrationsCheck.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Data.Sqlite; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +/// +/// Detects installed packaged modules whose bundled EF migrations have not been +/// applied yet. Applied state is read from the SQLite database's +/// __EFMigrationsHistory table (the local dev default); other providers get a +/// warning that the state cannot be verified offline. +/// +public sealed partial class PendingModuleMigrationsCheck : IDoctorCheck +{ + public IEnumerable Run(Infrastructure.SolutionContext solution) + { + // Modules with migrations: installed packages whose manifest says hasDbContext. + var modulesWithMigrations = new List<(string PackageId, IReadOnlyList Ids)>(); + foreach ( + var reference in PackageReferenceManipulator.GetPackageReferences( + solution.ApiCsprojPath, + solution.RootPath + ) + ) + { + var manifest = GlobalPackagesCache.TryReadManifest(reference.Id, reference.Version); + if (manifest is not { HasDbContext: true }) + { + continue; + } + + var dll = GlobalPackagesCache.FindAssemblyPath(reference.Id, reference.Version); + if (dll is null) + { + continue; + } + + var ids = AssemblyMigrationReader.ReadMigrationIds(dll); + if (ids.Count > 0) + { + modulesWithMigrations.Add((reference.Id, ids)); + } + } + + if (modulesWithMigrations.Count == 0) + { + yield break; + } + + var dbPath = FindSqliteDatabasePath(solution); + if (dbPath is null || !File.Exists(dbPath)) + { + yield return new CheckResult( + "Module migrations", + CheckStatus.Warning, + $"{modulesWithMigrations.Count} module(s) bundle migrations but the database " + + "could not be inspected offline (non-SQLite or not yet created) — they " + + "apply on next start or 'SIMPLEMODULE_MIGRATE_ONLY=1 dotnet run'" + ); + yield break; + } + + var applied = ReadAppliedMigrations(dbPath); + if (applied is null) + { + yield return new CheckResult( + "Module migrations", + CheckStatus.Warning, + "the database could not be read (locked or recovering) — migration state unverified" + ); + yield break; + } + + foreach (var (packageId, ids) in modulesWithMigrations) + { + var pending = ids.Where(id => !applied.Contains(id)).ToList(); + yield return pending.Count == 0 + ? new CheckResult( + $"Migrations {packageId}", + CheckStatus.Pass, + $"{ids.Count} migration(s) applied" + ) + : new CheckResult( + $"Migrations {packageId}", + CheckStatus.Fail, + $"{pending.Count} pending migration(s) ({string.Join(", ", pending.Take(3))}" + + (pending.Count > 3 ? ", …" : "") + + ") — run 'SIMPLEMODULE_MIGRATE_ONLY=1 dotnet run --project '" + ); + } + } + + private static string? FindSqliteDatabasePath(Infrastructure.SolutionContext solution) + { + var hostDir = Path.GetDirectoryName(solution.ApiCsprojPath)!; + foreach (var settingsFile in new[] { "appsettings.Development.json", "appsettings.json" }) + { + var path = Path.Combine(hostDir, settingsFile); + if (!File.Exists(path)) + { + continue; + } + + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + if ( + doc.RootElement.TryGetProperty("Database", out var db) + && db.TryGetProperty("DefaultConnection", out var conn) + && conn.GetString() is { } connectionString + ) + { + var match = DataSourceRegex().Match(connectionString); + if (match.Success) + { + var dataSource = match.Groups["path"].Value.Trim(); + return Path.IsPathRooted(dataSource) + ? dataSource + : Path.Combine(hostDir, dataSource); + } + } + } + catch (JsonException) + { + // malformed settings — fall through + } + } + + return null; + } + + private static HashSet? ReadAppliedMigrations(string dbPath) + { + var applied = new HashSet(StringComparer.Ordinal); + try + { + using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT MigrationId FROM __EFMigrationsHistory"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + applied.Add(reader.GetString(0)); + } + } + catch (SqliteException ex) when (ex.SqliteErrorCode == 1) + { + // "no such table": EnsureCreated database — everything is pending. + } + catch (SqliteException) + { + // Locked/busy/corrupt: unknown state, NOT "all pending". + return null; + } + + return applied; + } + + [GeneratedRegex("Data Source=(?[^;]+)", RegexOptions.IgnoreCase)] + private static partial Regex DataSourceRegex(); +} diff --git a/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs b/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs index 99faef6b..7fb2050c 100644 --- a/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs +++ b/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs @@ -20,27 +20,8 @@ public override int Execute(CommandContext context, DoctorSettings settings) AnsiConsole.Write(new Rule("[blue]Project health check[/]").LeftJustified()); - 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(), - ]; - - var results = new List(); - foreach (var check in checks) - { - results.AddRange(check.Run(solution)); - } + var checks = CreateChecks(); + var results = RunChecks(solution); var fixedCount = 0; if (settings.Fix) @@ -69,7 +50,39 @@ public override int Execute(CommandContext context, DoctorSettings settings) return failCount > 0 ? 1 : 0; } - private static void RenderResults(IReadOnlyList results) + private static IDoctorCheck[] CreateChecks() => + [ + new SolutionStructureCheck(), + new ProjectReferenceCheck(), + new SlnxEntriesCheck(), + new CsprojConventionCheck(), + new ContractsIsolationCheck(), + new ModulePatternCheck(), + new ModuleAttributeCheck(), + new ViewEndpointNamingCheck(), + new PagesRegistryCheck(), + new ViteConfigCheck(), + new PackageJsonCheck(), + new NpmWorkspaceCheck(), + new ModuleBundleExternalsCheck(), + new PackagedModuleManifestCheck(), + new PendingModuleMigrationsCheck(), + ]; + + /// Runs all doctor checks; reused by other commands (e.g. sm add). + public static List RunChecks(SolutionContext solution) + { + var results = new List(); + foreach (var check in CreateChecks()) + { + results.AddRange(check.Run(solution)); + } + + return results; + } + + /// Renders check results as the standard doctor table. + public static void RenderResults(IReadOnlyList results) { // Failures first so they're the first thing the user sees, then warnings, // then passes. Preserve discovery order within each status bucket. diff --git a/cli/SimpleModule.Cli/Commands/List/ListCommand.cs b/cli/SimpleModule.Cli/Commands/List/ListCommand.cs index 8f3e9145..31f52ba7 100644 --- a/cli/SimpleModule.Cli/Commands/List/ListCommand.cs +++ b/cli/SimpleModule.Cli/Commands/List/ListCommand.cs @@ -32,38 +32,104 @@ public override int Execute(CommandContext context, ListSettings settings) return 1; } - if (solution.ExistingModules.Count == 0) + var packagedModules = CollectPackagedModules(solution); + + if (solution.ExistingModules.Count == 0 && packagedModules.Count == 0) { AnsiConsole.MarkupLine( - "[yellow]No modules found.[/] Create one with [green]sm new module [/]." + "[yellow]No modules found.[/] Create one with [green]sm new module [/] " + + "or install one with [green]sm add [/]." ); return 0; } - var table = new Table().RoundedBorder(); - table.AddColumn("Module"); - table.AddColumn("Route prefix"); - table.AddColumn(new TableColumn("Endpoints").RightAligned()); + if (solution.ExistingModules.Count > 0) + { + var table = new Table().RoundedBorder(); + table.AddColumn("Module"); + table.AddColumn("Route prefix"); + table.AddColumn(new TableColumn("Endpoints").RightAligned()); + + foreach (var module in solution.ExistingModules) + { + var routePrefix = ReadRoutePrefix(solution, module); + var endpointCount = CountEndpoints(solution, module); + + table.AddRow( + $"[green]{Markup.Escape(module)}[/]", + Markup.Escape(routePrefix ?? "—"), + endpointCount.ToString(System.Globalization.CultureInfo.InvariantCulture) + ); + } - foreach (var module in solution.ExistingModules) + AnsiConsole.Write(table); + } + + if (packagedModules.Count > 0) { - var routePrefix = ReadRoutePrefix(solution, module); - var endpointCount = CountEndpoints(solution, module); + var hostVersion = HostFrameworkVersionResolver.Resolve(solution.RootPath); + var packagedTable = new Table().RoundedBorder(); + packagedTable.AddColumn("Installed package"); + packagedTable.AddColumn("Version"); + packagedTable.AddColumn("Module"); + packagedTable.AddColumn("Framework compat"); + + foreach (var (reference, manifest) in packagedModules) + { + var compatCell = "[dim]unknown[/]"; + if (hostVersion is not null) + { + var compat = FrameworkCompatChecker.Check( + manifest.FrameworkCompat, + hostVersion + ); + compatCell = compat.Compatible + ? $"[green]✓[/] {Markup.Escape(manifest.FrameworkCompat)}" + : $"[red]✗[/] {Markup.Escape(manifest.FrameworkCompat)}"; + } + + packagedTable.AddRow( + $"[blue]{Markup.Escape(reference.Id)}[/]", + Markup.Escape(reference.Version ?? "?"), + Markup.Escape(manifest.DisplayName), + compatCell + ); + } - table.AddRow( - $"[green]{Markup.Escape(module)}[/]", - Markup.Escape(routePrefix ?? "—"), - endpointCount.ToString(System.Globalization.CultureInfo.InvariantCulture) - ); + AnsiConsole.Write(packagedTable); } - AnsiConsole.Write(table); AnsiConsole.MarkupLine( - $"\n[dim]{solution.ExistingModules.Count} module(s) in {Markup.Escape(solution.RootPath)}[/]" + $"\n[dim]{solution.ExistingModules.Count} source module(s), {packagedModules.Count} packaged module(s) in {Markup.Escape(solution.RootPath)}[/]" ); return 0; } + private static List<( + PackageReferenceEntry Reference, + ModuleManifestData Manifest + )> CollectPackagedModules(SolutionContext solution) + { + var packaged = new List<(PackageReferenceEntry, ModuleManifestData)>(); + foreach ( + var reference in PackageReferenceManipulator.GetPackageReferences( + solution.ApiCsprojPath, + solution.RootPath + ) + ) + { + // Only packages carrying a module manifest are SimpleModule modules; + // framework packages (SimpleModule.Hosting, ...) have none. + var manifest = GlobalPackagesCache.TryReadManifest(reference.Id, reference.Version); + if (manifest is not null) + { + packaged.Add((reference, manifest)); + } + } + + return packaged; + } + private static string? ReadRoutePrefix(SolutionContext solution, string module) { var moduleClassPath = Path.Combine( diff --git a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs index 9fd7ed70..39a9dad8 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs @@ -122,6 +122,10 @@ public static void ScaffoldProject( Path.Combine(rootDir, "Directory.Packages.props"), projectTemplates.DirectoryPackagesProps() ); + File.WriteAllText( + Path.Combine(modulesDir, "Directory.Build.props"), + projectTemplates.ModulesDirectoryBuildProps() + ); File.WriteAllText(Path.Combine(rootDir, "global.json"), projectTemplates.GlobalJson()); File.WriteAllText( Path.Combine(rootDir, "package.json"), @@ -310,6 +314,7 @@ string rootDir Plan(Path.Combine(rootDir, $"{projectName}.slnx")); Plan(Path.Combine(rootDir, "Directory.Build.props")); Plan(Path.Combine(rootDir, "Directory.Packages.props")); + Plan(Path.Combine(modulesDir, "Directory.Build.props")); Plan(Path.Combine(rootDir, "global.json")); Plan(Path.Combine(rootDir, "package.json")); Plan(Path.Combine(rootDir, "biome.json")); diff --git a/cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs b/cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs new file mode 100644 index 00000000..b13294fe --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs @@ -0,0 +1,320 @@ +using System.ComponentModel; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Pack; + +public sealed class PackSettings : CommandSettings +{ + [CommandArgument(0, "[module-path]")] + [Description("Path to the module directory (defaults to the current directory).")] + public string? ModulePath { get; init; } + + [CommandOption("-o|--output")] + [Description("Output directory for the nupkg(s). Default: ./artifacts/packages")] + public string? Output { get; init; } + + [CommandOption("--version")] + [Description("Package version override (passed as -p:Version to build and pack).")] + public string? Version { get; init; } + + [CommandOption("--skip-tests")] + [Description("Skip running the module's test project.")] + public bool SkipTests { get; init; } + + [CommandOption("--skip-externals-check")] + [Description( + "Treat inlined-React markers in the bundle as warnings instead of errors (escape hatch for false positives from React-ecosystem libraries like react-is)." + )] + public bool SkipExternalsCheck { get; init; } + + [CommandOption("-c|--configuration")] + [Description("Build configuration. Default: Release")] + public string Configuration { get; init; } = "Release"; +} + +/// +/// Builds, validates and packs a module into a standard nupkg: +/// production frontend build → externals validation → dotnet build → tests → +/// manifest validation → dotnet pack (module + contracts). Fails closed at the +/// first violated step. +/// +public sealed class PackCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, PackSettings settings) + { + var (exitCode, _) = await RunAsync(settings); + return exitCode; + } + + /// + /// Runs the full pack pipeline; reused by sm publish. Returns the + /// produced nupkg paths on success. + /// + public static async Task<(int ExitCode, IReadOnlyList Packages)> RunAsync( + PackSettings settings + ) + { + var packages = new List(); + var moduleDir = Path.GetFullPath(settings.ModulePath ?? "."); + var (projects, resolveError) = PackPipeline.ResolveModuleProjects(moduleDir); + if (projects is null) + { + return FailResult(resolveError!); + } + + var outputDir = Path.GetFullPath( + settings.Output + ?? Path.Combine( + SolutionContext.Discover()?.RootPath ?? moduleDir, + "artifacts", + "packages" + ) + ); + Directory.CreateDirectory(outputDir); + + AnsiConsole.MarkupLine( + $"Packing module [green]{Markup.Escape(projects.AssemblyName)}[/] from [blue]{Markup.Escape(moduleDir)}[/]" + ); + + // 1. Production frontend build (when the module has one) + var implDir = projects.ImplDirectory; + if (File.Exists(Path.Combine(implDir, "package.json"))) + { + if (!HasNodeModules(implDir)) + { + return FailResult( + "package.json found but no node_modules is available. " + + "Run 'npm install' before packing so the frontend bundle can be built." + ); + } + + AnsiConsole.MarkupLine("[dim]→ building frontend (vite, production)...[/]"); + var vite = await ProcessRunner.RunAsync( + "npx", + ["vite", "build", "--configLoader", "runner"], + implDir + ); + if (!vite.Success) + { + return FailResult("Frontend build failed:\n" + Tail(vite.Error, vite.Output)); + } + + // 2. Externals validation — fail closed on inlined React + var violations = BundleExternalsValidator.Validate(Path.Combine(implDir, "wwwroot")); + if (violations.Count > 0) + { + var color = settings.SkipExternalsCheck ? "yellow" : "red"; + var symbol = settings.SkipExternalsCheck ? "!" : "✗"; + foreach (var violation in violations) + { + AnsiConsole.MarkupLine( + $"[{color}]{symbol} {Markup.Escape(Path.GetFileName(violation.File))} contains an inlined-React marker ({Markup.Escape(violation.Marker)})[/]" + ); + } + + if (!settings.SkipExternalsCheck) + { + return FailResult( + "Module bundles must externalize react, react-dom, react/jsx-runtime and " + + "@inertiajs/react — they are provided by the host. Check the module's " + + "vite.config.ts uses defineModuleConfig from @simplemodule/client. " + + "If the marker comes from a legitimately bundled library (e.g. react-is), " + + "re-run with --skip-externals-check." + ); + } + } + } + + var versionProps = settings.Version is null + ? Array.Empty() + : [$"-p:Version={settings.Version}"]; + + // 3. Backend build + AnsiConsole.MarkupLine("[dim]→ dotnet build...[/]"); + var build = await ProcessRunner.RunAsync( + "dotnet", + ["build", projects.ImplCsproj, "-c", settings.Configuration, .. versionProps], + implDir + ); + if (!build.Success) + { + return FailResult("dotnet build failed:\n" + Tail(build.Error, build.Output)); + } + + // 4. Tests + if (!settings.SkipTests && projects.TestsCsproj is not null) + { + AnsiConsole.MarkupLine("[dim]→ dotnet test...[/]"); + var test = await ProcessRunner.RunAsync( + "dotnet", + ["test", projects.TestsCsproj, "-c", settings.Configuration, .. versionProps], + implDir + ); + if (!test.Success) + { + return FailResult("Module tests failed:\n" + Tail(test.Error, test.Output)); + } + } + else if (!settings.SkipTests) + { + AnsiConsole.MarkupLine("[yellow]! no test project found — skipping tests[/]"); + } + + // 5. Manifest validation from the built assembly + var builtDll = FindBuiltAssembly(implDir, settings.Configuration, projects.AssemblyName); + if (builtDll is null) + { + return FailResult( + $"Could not locate the built assembly {projects.AssemblyName}.dll under bin/{settings.Configuration}." + ); + } + + var manifest = AssemblyManifestReader.TryRead(builtDll); + var manifestErrors = PackPipeline.ValidateManifest( + manifest, + projects.AssemblyName, + Path.Combine(implDir, "wwwroot") + ); + if (manifestErrors.Count > 0) + { + foreach (var error in manifestErrors) + { + AnsiConsole.MarkupLine($"[red]✗ {Markup.Escape(error)}[/]"); + } + + return FailResult("Module manifest validation failed."); + } + + // 6. Write module-manifest.json next to the project so the injected + // targets pack it into the nupkg root for registry/tooling consumption. + var manifestJson = AssemblyManifestReader.TryReadJson(builtDll)!; + await File.WriteAllTextAsync(Path.Combine(implDir, "module-manifest.json"), manifestJson); + + var targetsPath = Path.Combine( + Path.GetTempPath(), + "sm-pack-" + Guid.NewGuid().ToString("N") + ".targets" + ); + await File.WriteAllTextAsync(targetsPath, PackPipeline.PackTargetsContent); + + try + { + // 7. Pack module (+ contracts) + var packTargets = new List { projects.ImplCsproj }; + if (projects.ContractsCsproj is not null) + { + packTargets.Add(projects.ContractsCsproj); + } + + foreach (var csproj in packTargets) + { + AnsiConsole.MarkupLine( + $"[dim]→ dotnet pack {Markup.Escape(Path.GetFileName(csproj))}...[/]" + ); + var pack = await ProcessRunner.RunAsync( + "dotnet", + [ + "pack", + csproj, + "-c", + settings.Configuration, + "-o", + outputDir, + $"-p:CustomAfterMicrosoftCommonTargets={targetsPath}", + .. versionProps, + ], + implDir + ); + if (!pack.Success) + { + return FailResult("dotnet pack failed:\n" + Tail(pack.Error, pack.Output)); + } + + // Locate the produced nupkg on disk — MSBuild's "Successfully + // created package" message is localized and unreliable to parse. + var packageId = Path.GetFileNameWithoutExtension(csproj); + var produced = Directory + .EnumerateFiles(outputDir, packageId + ".*.nupkg") + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + if (produced is null) + { + return FailResult( + $"dotnet pack reported success but no {packageId}.*.nupkg was found in {outputDir}." + ); + } + + packages.Add(produced); + } + } + finally + { + try + { + File.Delete(targetsPath); + } + catch (IOException) { } + } + + var packageVersion = settings.Version ?? manifest!.Version; + AnsiConsole.MarkupLine( + $"[green]✓ Packed {Markup.Escape(manifest!.DisplayName)} {Markup.Escape(packageVersion)}[/] → [blue]{Markup.Escape(outputDir)}[/]" + ); + AnsiConsole.MarkupLine( + $"[dim] schema: {Markup.Escape(manifest.Schema)} permissions: {manifest.Permissions.Count} pages: {manifest.Pages.Count} frameworkCompat: {Markup.Escape(manifest.FrameworkCompat)}[/]" + ); + + return (0, packages); + } + + private static bool HasNodeModules(string implDir) + { + var dir = new DirectoryInfo(implDir); + while (dir is not null) + { + if (Directory.Exists(Path.Combine(dir.FullName, "node_modules"))) + { + return true; + } + + dir = dir.Parent; + } + + return false; + } + + private static string? FindBuiltAssembly( + string implDir, + string configuration, + string assemblyName + ) + { + var binDir = Path.Combine(implDir, "bin", configuration); + if (!Directory.Exists(binDir)) + { + return null; + } + + return Directory + .EnumerateFiles(binDir, assemblyName + ".dll", SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + } + + private static string Tail(string error, string output) + { + var text = string.IsNullOrWhiteSpace(error) ? output : error; + var lines = text.Split('\n'); + return string.Join('\n', lines.TakeLast(25)).Trim(); + } + + private static int Fail(string message) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]"); + return 1; + } + + private static (int ExitCode, IReadOnlyList Packages) FailResult(string message) => + (Fail(message), []); +} diff --git a/cli/SimpleModule.Cli/Commands/Pack/PackPipeline.cs b/cli/SimpleModule.Cli/Commands/Pack/PackPipeline.cs new file mode 100644 index 00000000..dce10636 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Pack/PackPipeline.cs @@ -0,0 +1,161 @@ +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Pack; + +public sealed record ModuleProjectSet( + string ImplCsproj, + string? ContractsCsproj, + string? TestsCsproj +) +{ + public string ImplDirectory => Path.GetDirectoryName(ImplCsproj)!; + public string AssemblyName => Path.GetFileNameWithoutExtension(ImplCsproj); +} + +/// +/// Pure decision logic behind sm pack: project resolution and manifest +/// validation. Subprocess orchestration lives in . +/// +public static class PackPipeline +{ + /// + /// MSBuild targets injected via -p:CustomAfterMicrosoftCommonTargets so every + /// packed module — in-repo or downstream — ships module-manifest.json, is + /// packable, and carries the simplemodule-module tag, without editing the + /// user's project files. + /// + public const string PackTargetsContent = """ + + + true + $(PackageTags);simplemodule-module + + + + + + + + """; + + /// + /// Locates the implementation, contracts and test projects under a module + /// directory (works for both modules/X roots and the impl project dir + /// itself). Errors are returned, not thrown — pack fails closed with them. + /// + public static (ModuleProjectSet? Projects, string? Error) ResolveModuleProjects( + string moduleDirectory + ) + { + if (!Directory.Exists(moduleDirectory)) + { + return (null, $"Module directory '{moduleDirectory}' does not exist."); + } + + var csprojs = Directory + .EnumerateFiles(moduleDirectory, "*.csproj", SearchOption.AllDirectories) + .Where(p => + !p.Contains( + $"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", + StringComparison.Ordinal + ) + ) + .ToList(); + + if (csprojs.Count == 0) + { + return (null, $"No .csproj files found under '{moduleDirectory}'."); + } + + var contracts = csprojs + .Where(p => + Path.GetFileNameWithoutExtension(p).EndsWith(".Contracts", StringComparison.Ordinal) + ) + .ToList(); + var tests = csprojs + .Where(p => + Path.GetFileNameWithoutExtension(p).EndsWith(".Tests", StringComparison.Ordinal) + ) + .ToList(); + var impls = csprojs.Except(contracts).Except(tests).ToList(); + + if (impls.Count == 0) + { + return ( + null, + $"No module implementation project found under '{moduleDirectory}' " + + "(only Contracts/Tests projects present)." + ); + } + + if (impls.Count > 1) + { + return ( + null, + $"Found {impls.Count} candidate implementation projects under '{moduleDirectory}': " + + string.Join(", ", impls.Select(Path.GetFileName)) + + ". Point sm pack at a single module directory." + ); + } + + return ( + new ModuleProjectSet(impls[0], contracts.FirstOrDefault(), tests.FirstOrDefault()), + null + ); + } + + /// Validates the built assembly's manifest before packing. + public static IReadOnlyList ValidateManifest( + ModuleManifestData? manifest, + string expectedAssemblyName, + string wwwrootDirectory + ) + { + var errors = new List(); + if (manifest is null) + { + errors.Add( + "The built assembly carries no module manifest. Ensure the project builds with " + + "SimpleModuleProjectKind=Module and references the SimpleModule.Generator analyzer." + ); + return errors; + } + + if (manifest.SchemaVersion != 1) + { + errors.Add( + $"Manifest schemaVersion {manifest.SchemaVersion} is not supported by this CLI (expected 1)." + ); + } + + if (!string.Equals(manifest.Id, expectedAssemblyName, StringComparison.Ordinal)) + { + errors.Add( + $"Manifest id '{manifest.Id}' does not match the assembly name '{expectedAssemblyName}'." + ); + } + + if (string.IsNullOrEmpty(manifest.Name)) + { + errors.Add("Manifest has no module name — is the [Module] attribute present?"); + } + + if (!string.IsNullOrEmpty(manifest.FrontendEntry)) + { + var bundleFileName = manifest.FrontendEntry.Split('/').Last(); + var bundlePath = Path.Combine(wwwrootDirectory, bundleFileName); + if (!File.Exists(bundlePath)) + { + errors.Add( + $"Manifest declares frontend entry '{manifest.FrontendEntry}' but " + + $"'{bundlePath}' does not exist. Run the module's Vite build." + ); + } + } + + return errors; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Publish/PublishCommand.cs b/cli/SimpleModule.Cli/Commands/Publish/PublishCommand.cs new file mode 100644 index 00000000..086c2ff8 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Publish/PublishCommand.cs @@ -0,0 +1,168 @@ +using System.ComponentModel; +using SimpleModule.Cli.Commands.Pack; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Publish; + +public sealed class PublishSettings : CommandSettings +{ + [CommandArgument(0, "[module-path]")] + [Description("Path to the module directory (defaults to the current directory).")] + public string? ModulePath { get; init; } + + [CommandOption("--version")] + [Description("Package version (passed to the pack step).")] + public string? Version { get; init; } + + [CommandOption("--source")] + [Description( + "Push target: a NuGet source URL or a local folder feed. Default: the registry from sm.json (nuget.org)." + )] + public string? Source { get; init; } + + [CommandOption("--api-key")] + [Description("NuGet API key. Falls back to the NUGET_API_KEY environment variable.")] + public string? ApiKey { get; init; } + + [CommandOption("--dry-run")] + [Description( + "Run the full pack + validation pipeline and show what would be pushed, without pushing." + )] + public bool DryRun { get; init; } + + [CommandOption("--skip-tests")] + [Description("Skip running the module's test project during pack.")] + public bool SkipTests { get; init; } + + [CommandOption("--register")] + [Description("Register the package with the SimpleModule marketplace (not available yet).")] + public bool Register { get; init; } +} + +/// +/// Packs a module (full sm pack pipeline) and pushes the resulting nupkgs via +/// `dotnet nuget push`. `--dry-run` stops after pack and prints the would-be +/// push; `--register` is a documented extension point for the future +/// marketplace and currently only explains that it is not available. +/// +public sealed class PublishCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, PublishSettings settings) + { + var outputDir = Path.Combine( + Path.GetTempPath(), + "sm-publish-" + Guid.NewGuid().ToString("N") + ); + + try + { + var (packExit, packages) = await PackCommand.RunAsync( + new PackSettings + { + ModulePath = settings.ModulePath, + Version = settings.Version, + SkipTests = settings.SkipTests, + Output = outputDir, + } + ); + if (packExit != 0) + { + return packExit; + } + + if (packages.Count == 0) + { + return Fail("Pack produced no packages — nothing to push."); + } + + var source = settings.Source ?? SmConfig.Load(Directory.GetCurrentDirectory()).Registry; + var apiKey = settings.ApiKey ?? Environment.GetEnvironmentVariable("NUGET_API_KEY"); + + if (settings.DryRun) + { + AnsiConsole.MarkupLine( + $"[yellow]--dry-run:[/] would push to [blue]{Markup.Escape(source)}[/]" + + ( + apiKey is null + ? " [dim](no API key configured)[/]" + : " [dim](API key configured)[/]" + ) + ); + foreach (var package in packages) + { + AnsiConsole.MarkupLine($"[dim] {Markup.Escape(Path.GetFileName(package))}[/]"); + } + + return 0; + } + + var isLocal = NuGetClient.IsLocalDirectorySource(source); + if (!isLocal && string.IsNullOrEmpty(apiKey)) + { + return Fail( + "No API key. Pass --api-key or set the NUGET_API_KEY environment variable." + ); + } + + if (isLocal) + { + Directory.CreateDirectory(source); + } + + foreach (var package in packages) + { + AnsiConsole.MarkupLine( + $"[dim]→ pushing {Markup.Escape(Path.GetFileName(package))} to {Markup.Escape(source)}...[/]" + ); + var pushArgs = new List { "nuget", "push", package, "--source", source }; + if (!isLocal) + { + pushArgs.AddRange(["--api-key", apiKey!]); + } + + var push = await ProcessRunner.RunAsync("dotnet", pushArgs); + if (!push.Success) + { + return Fail( + "dotnet nuget push failed:\n" + + ( + string.IsNullOrWhiteSpace(push.Error) ? push.Output : push.Error + ).Trim() + ); + } + } + + AnsiConsole.MarkupLine( + $"[green]✓ published {packages.Count} package(s) to {Markup.Escape(source)}[/]" + ); + + if (settings.Register) + { + AnsiConsole.MarkupLine( + "[yellow]--register: marketplace registration is not available yet.[/] " + + "The package is already discoverable through the registry via the " + + "[bold]simplemodule-module[/] tag (sm search). This flag will register " + + "the module with the SimpleModule marketplace once it launches." + ); + } + + return 0; + } + finally + { + try + { + Directory.Delete(outputDir, recursive: true); + } + catch (IOException) { } + } + } + + private static int Fail(string message) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]"); + return 1; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Remove/RemoveCommand.cs b/cli/SimpleModule.Cli/Commands/Remove/RemoveCommand.cs new file mode 100644 index 00000000..5ddbf618 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Remove/RemoveCommand.cs @@ -0,0 +1,84 @@ +using System.ComponentModel; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Remove; + +public sealed class RemoveSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("NuGet package id of the installed module to remove.")] + public string PackageId { get; init; } = ""; +} + +/// +/// Removes a packaged module's reference from the host (csproj + CPM entry). +/// The module's database schema and data are deliberately left untouched — the +/// command warns loudly about what stays behind instead of dropping anything. +/// +public sealed class RemoveCommand : Command +{ + public override int Execute(CommandContext context, RemoveSettings 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; + } + + // Resolve the installed version + manifest BEFORE removing the reference + // so the schema warning can name what is left behind. + var installed = PackageReferenceManipulator + .GetPackageReferences(solution.ApiCsprojPath, solution.RootPath) + .FirstOrDefault(r => + string.Equals(r.Id, settings.PackageId, StringComparison.OrdinalIgnoreCase) + ); + var manifest = GlobalPackagesCache.TryReadManifest(settings.PackageId, installed.Version); + + var removed = PackageReferenceManipulator.RemovePackage( + solution.ApiCsprojPath, + solution.RootPath, + settings.PackageId + ); + if (!removed) + { + AnsiConsole.MarkupLine( + $"[red]{Markup.Escape(settings.PackageId)} is not referenced by {Markup.Escape(Path.GetFileName(solution.ApiCsprojPath))}.[/]" + ); + return 1; + } + + AnsiConsole.MarkupLine( + $"[green]✓ removed {Markup.Escape(settings.PackageId)} from {Markup.Escape(Path.GetFileName(solution.ApiCsprojPath))}[/]" + ); + + var schemaName = manifest?.Schema ?? "(unknown — manifest unavailable)"; + var warning = new List + { + $"Database schema [bold]{Markup.Escape(schemaName)}[/] and all of its tables/data were [bold]left in place[/].", + "Removing a module never drops data. To clean up manually:", + $" · drop the module's tables (schema/prefix: {Markup.Escape(schemaName)})", + " · remove its rows from the __EFMigrationsHistory table (if it shipped migrations)", + }; + if (manifest is not null && manifest.Permissions.Count > 0) + { + warning.Add( + $" · {manifest.Permissions.Count} permission(s) granted to roles remain until re-saved" + ); + } + + AnsiConsole.Write( + new Panel(string.Join("\n", warning)) + .Header("[yellow bold]Left behind[/]") + .Border(BoxBorder.Rounded) + .BorderColor(Color.Yellow) + ); + AnsiConsole.MarkupLine("[dim]Run 'dotnet build' to verify the host still compiles.[/]"); + + return 0; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Search/SearchCommand.cs b/cli/SimpleModule.Cli/Commands/Search/SearchCommand.cs new file mode 100644 index 00000000..0fb6d39d --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Search/SearchCommand.cs @@ -0,0 +1,280 @@ +using System.ComponentModel; +using System.Text.Json; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Search; + +public sealed class SearchSettings : CommandSettings +{ + [CommandArgument(0, "[query]")] + [Description("Search text. Empty lists all SimpleModule modules on the registry.")] + public string? Query { get; init; } + + [CommandOption("--source")] + [Description( + "Registry to search: NuGet V3 service index URL or a local folder feed. Default: sm.json registry." + )] + public string? Source { get; init; } + + [CommandOption("--take")] + [Description("Maximum results. Default: 10")] + public int Take { get; init; } = 10; + + [CommandOption("--prerelease")] + [Description("Include prerelease packages.")] + public bool Prerelease { get; init; } +} + +/// +/// Searches a registry for SimpleModule modules (the `simplemodule-module` +/// tag). Local folder feeds are scanned by reading each nupkg's manifest; +/// remote registries go through the NuGet search API. When run inside a host +/// solution, each result shows framework compatibility with that host. +/// +public sealed class SearchCommand : AsyncCommand +{ + private static readonly HttpClient SharedHttpClient = new() + { + Timeout = TimeSpan.FromSeconds(30), + }; + + public override async Task ExecuteAsync(CommandContext context, SearchSettings settings) + { + var solution = SolutionContext.Discover(); + var hostVersion = solution is null + ? null + : HostFrameworkVersionResolver.Resolve(solution.RootPath); + + var source = + settings.Source + ?? SmConfig.Load(solution?.RootPath ?? Directory.GetCurrentDirectory()).Registry; + + var results = NuGetClient.IsLocalDirectorySource(source) + ? SearchLocalFeed(Path.GetFullPath(source), settings) + : await SearchRegistryAsync(source, settings); + + if (results.Count == 0) + { + AnsiConsole.MarkupLine( + $"[yellow]No SimpleModule modules found on {Markup.Escape(source)}" + + ( + string.IsNullOrEmpty(settings.Query) + ? "" + : $" for '{Markup.Escape(settings.Query)}'" + ) + + ".[/]" + ); + return 0; + } + + var table = new Table().RoundedBorder(); + table.AddColumn("Package"); + table.AddColumn("Version"); + table.AddColumn("Description"); + if (hostVersion is not null) + { + table.AddColumn("Compat"); + } + + foreach (var result in results.Take(settings.Take)) + { + var row = new List + { + $"[blue]{Markup.Escape(result.Id)}[/]", + Markup.Escape(result.Version), + Markup.Escape(Truncate(result.Description, 60)), + }; + if (hostVersion is not null) + { + row.Add(CompatCell(result.FrameworkCompat, hostVersion)); + } + + table.AddRow([.. row]); + } + + AnsiConsole.Write(table); + if (hostVersion is not null) + { + AnsiConsole.MarkupLine( + $"[dim]Compat evaluated against host framework {Markup.Escape(hostVersion)}.[/]" + ); + } + + return 0; + } + + private sealed record SearchResult( + string Id, + string Version, + string Description, + string? FrameworkCompat + ); + + private static List SearchLocalFeed(string feedDir, SearchSettings settings) + { + var results = new List(); + if (!Directory.Exists(feedDir)) + { + return results; + } + + // Highest version per package id, manifest-bearing packages only. + var byId = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + foreach (var nupkg in Directory.EnumerateFiles(feedDir, "*.nupkg")) + { + var name = Path.GetFileNameWithoutExtension(nupkg); + var split = SplitIdAndVersion(name); + if (split is null) + { + continue; + } + + var (id, version) = split.Value; + if (!settings.Prerelease && SemVerStringComparer.IsPrerelease(version)) + { + continue; + } + + if ( + !byId.TryGetValue(id, out var existing) + || SemVerStringComparer.Instance.Compare(version, existing.Version) > 0 + ) + { + byId[id] = (version, nupkg); + } + } + + foreach (var (id, entry) in byId.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)) + { + var manifest = NupkgManifestReader.TryRead(entry.Path, id); + if (manifest is null) + { + continue; // not a SimpleModule module + } + + if ( + !string.IsNullOrEmpty(settings.Query) + && !id.Contains(settings.Query, StringComparison.OrdinalIgnoreCase) + && !manifest.DisplayName.Contains( + settings.Query, + StringComparison.OrdinalIgnoreCase + ) + ) + { + continue; + } + + results.Add( + new SearchResult(id, entry.Version, manifest.DisplayName, manifest.FrameworkCompat) + ); + } + + return results; + } + + private static async Task> SearchRegistryAsync( + string serviceIndexUrl, + SearchSettings settings + ) + { + var searchBase = await ResolveSearchServiceAsync(new Uri(serviceIndexUrl)); + // Include the tag in the server-side query so tagged modules rank into + // the fetched window; the client-side tag filter below stays the gate. + var query = Uri.EscapeDataString(("simplemodule-module " + (settings.Query ?? "")).Trim()); + var url = + $"{searchBase}?q={query}&take={Math.Max(settings.Take * 3, 30)}" + + $"&prerelease={(settings.Prerelease ? "true" : "false")}"; + + using var response = await SharedHttpClient.GetAsync(new Uri(url)); + response.EnsureSuccessStatusCode(); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + + var results = new List(); + foreach (var item in doc.RootElement.GetProperty("data").EnumerateArray()) + { + // Only SimpleModule modules: the tag convention is the registry contract. + var tags = item.TryGetProperty("tags", out var tagsEl) + ? tagsEl.EnumerateArray().Select(t => t.GetString() ?? "").ToList() + : []; + if (!tags.Contains("simplemodule-module", StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + results.Add( + new SearchResult( + item.GetProperty("id").GetString() ?? "", + item.GetProperty("version").GetString() ?? "", + item.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "", + // The manifest's compat range lives inside the nupkg; avoid N + // downloads at search time. Compat is shown from the manifest + // for local feeds and verified definitively by `sm add`. + FrameworkCompat: null + ) + ); + } + + return results; + } + + private static async Task ResolveSearchServiceAsync(Uri serviceIndexUrl) + { + using var response = await SharedHttpClient.GetAsync(serviceIndexUrl); + response.EnsureSuccessStatusCode(); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + foreach (var resource in doc.RootElement.GetProperty("resources").EnumerateArray()) + { + var type = resource.GetProperty("@type").GetString() ?? ""; + if (type.StartsWith("SearchQueryService", StringComparison.Ordinal)) + { + return resource.GetProperty("@id").GetString() ?? ""; + } + } + + throw new InvalidOperationException( + $"Registry '{serviceIndexUrl}' exposes no SearchQueryService resource." + ); + } + + private static string CompatCell(string? frameworkCompat, string hostVersion) + { + if (frameworkCompat is null) + { + return "[dim]install-time check[/]"; + } + + var compat = FrameworkCompatChecker.Check(frameworkCompat, hostVersion); + return compat.Compatible + ? $"[green]✓[/] {Markup.Escape(frameworkCompat)}" + : $"[red]✗[/] {Markup.Escape(frameworkCompat)}"; + } + + /// Splits "Some.Package.1.2.3-pre" into id and version. + public static (string Id, string Version)? SplitIdAndVersion(string fileName) + { + var parts = fileName.Split('.'); + for (var i = 1; i < parts.Length; i++) + { + var version = string.Join('.', parts[i..]); + // The version must look like "N.N..."; a digit-leading id segment + // (e.g. Acme.2FA) must not be mistaken for the version start. + if (System.Text.RegularExpressions.Regex.IsMatch(version, @"^\d+\.\d+")) + { + var id = string.Join('.', parts[..i]); + if (id.Length > 0) + { + return (id, version); + } + } + } + + return null; + } + + private static string Truncate(string text, int max) => + text.Length <= max ? text : text[..(max - 1)] + "…"; +} diff --git a/cli/SimpleModule.Cli/Commands/Upgrade/UpgradeCommand.cs b/cli/SimpleModule.Cli/Commands/Upgrade/UpgradeCommand.cs new file mode 100644 index 00000000..932dbaa8 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Upgrade/UpgradeCommand.cs @@ -0,0 +1,253 @@ +using System.ComponentModel; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Upgrade; + +public sealed class UpgradeSettings : CommandSettings +{ + [CommandArgument(0, "[package-id]")] + [Description("Module package to upgrade. Omit to upgrade every installed module.")] + public string? PackageId { get; init; } + + [CommandOption("--version")] + [Description("Target version. Default: highest stable available.")] + public string? Version { get; init; } + + [CommandOption("--source")] + [Description( + "Package source (local folder feed or NuGet V3 service index URL). Default: sm.json registry." + )] + public string? Source { get; init; } + + [CommandOption("--force")] + [Description( + "Upgrade even when the target version's framework compat range rejects this host." + )] + public bool Force { get; init; } + + [CommandOption("--skip-migrations")] + [Description("Do not run the host in migrate-only mode after upgrading.")] + public bool SkipMigrations { get; init; } +} + +/// +/// Upgrades installed module packages: resolves the target version, validates +/// the new manifest's framework compat range (refusing violations unless +/// --force), bumps the (CPM-aware) reference, rebuilds and applies migrations. +/// +public sealed class UpgradeCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, UpgradeSettings settings) + { + var solution = SolutionContext.Discover(); + if (solution is null) + { + return Fail( + "No .slnx file found. Run this command from inside a SimpleModule project." + ); + } + + // Installed module packages = references whose cached package carries a manifest. + var installed = PackageReferenceManipulator + .GetPackageReferences(solution.ApiCsprojPath, solution.RootPath) + .Where(r => + settings.PackageId is null + ? GlobalPackagesCache.TryReadManifest(r.Id, r.Version) is not null + : string.Equals(r.Id, settings.PackageId, StringComparison.OrdinalIgnoreCase) + ) + .ToList(); + + if (settings.Version is not null && settings.PackageId is null) + { + return Fail("--version requires a package id (versions are per-package)."); + } + + if (installed.Count == 0) + { + return Fail( + settings.PackageId is null + ? "No installed module packages found." + : $"{settings.PackageId} is not referenced by {Path.GetFileName(solution.ApiCsprojPath)}." + ); + } + + var source = settings.Source ?? SmConfig.Load(solution.RootPath).Registry; + if (NuGetClient.IsLocalDirectorySource(source)) + { + // The bumped reference must be restorable: register local feeds just + // like sm add does. + NuGetConfigManipulator.EnsureLocalSource(solution.RootPath, Path.GetFullPath(source)); + } + + var hostVersion = HostFrameworkVersionResolver.Resolve(solution.RootPath); + var upgraded = 0; + var anyHasDbContext = false; + + foreach (var reference in installed) + { + var (nupkgPath, targetVersion, error) = await ResolveTarget( + source, + reference.Id, + settings.Version + ); + if (nupkgPath is null) + { + return Fail(error!); + } + + if (targetVersion == reference.Version) + { + AnsiConsole.MarkupLine( + $"[dim]{Markup.Escape(reference.Id)} already at {Markup.Escape(targetVersion)} — skipping[/]" + ); + continue; + } + + var manifest = NupkgManifestReader.TryRead(nupkgPath, reference.Id); + if (nupkgPath.Contains("sm-upgrade-", StringComparison.Ordinal)) + { + try + { + File.Delete(nupkgPath); + } + catch (IOException) { } + } + + if (manifest is null) + { + return Fail( + $"{reference.Id} {targetVersion} carries no module manifest — refusing to upgrade." + ); + } + + if (hostVersion is not null) + { + var compat = FrameworkCompatChecker.Check(manifest.FrameworkCompat, hostVersion); + if (!compat.Compatible && !settings.Force) + { + return Fail( + $"Refusing to upgrade {reference.Id} {reference.Version} → {targetVersion}: " + + $"{compat.Reason} (use --force to override)" + ); + } + + if (!compat.Compatible) + { + AnsiConsole.MarkupLine( + $"[yellow]! --force: upgrading despite incompatibility — {Markup.Escape(compat.Reason)}[/]" + ); + } + } + + PackageReferenceManipulator.AddPackage( + solution.ApiCsprojPath, + solution.RootPath, + reference.Id, + targetVersion + ); + AnsiConsole.MarkupLine( + $"[green]✓ {Markup.Escape(reference.Id)} {Markup.Escape(reference.Version ?? "?")} → {Markup.Escape(targetVersion)}[/]" + ); + upgraded++; + anyHasDbContext |= manifest.HasDbContext; + } + + if (upgraded == 0) + { + AnsiConsole.MarkupLine("[green]Everything is up to date.[/]"); + return 0; + } + + AnsiConsole.MarkupLine("[dim]→ dotnet build...[/]"); + var build = await ProcessRunner.RunAsync( + "dotnet", + ["build", solution.ApiCsprojPath], + solution.RootPath + ); + if (!build.Success) + { + return Fail( + "Build failed after the upgrade — references were left in place for inspection:\n" + + Tail(build.Error, build.Output) + ); + } + + if (!settings.SkipMigrations && anyHasDbContext) + { + AnsiConsole.MarkupLine("[dim]→ applying module migrations (migrate-only run)...[/]"); + var migrate = await ProcessRunner.RunAsync( + "dotnet", + ["run", "--project", Path.GetDirectoryName(solution.ApiCsprojPath)!, "--no-build"], + solution.RootPath, + new Dictionary { ["SIMPLEMODULE_MIGRATE_ONLY"] = "1" } + ); + if (!migrate.Success) + { + return Fail( + "Module migration run failed (fix the database issue and re-run " + + "'SIMPLEMODULE_MIGRATE_ONLY=1 dotnet run --project '):\n" + + Tail(migrate.Error, migrate.Output) + ); + } + + AnsiConsole.MarkupLine("[green]✓ database migrated[/]"); + } + + AnsiConsole.MarkupLine($"[green]✓ upgraded {upgraded} module(s)[/]"); + return 0; + } + + private static async Task<(string? NupkgPath, string Version, string? Error)> ResolveTarget( + string source, + string packageId, + string? requestedVersion + ) + { + if (NuGetClient.IsLocalDirectorySource(source)) + { + var feedDir = Path.GetFullPath(source); + var path = NuGetClient.FindLocalNupkg(feedDir, packageId, requestedVersion); + return path is null + ? (null, "", $"Package {packageId} not found in local feed '{feedDir}'.") + : (path, Path.GetFileNameWithoutExtension(path)[(packageId.Length + 1)..], null); + } + + var serviceIndex = new Uri(source); + var versions = await NuGetClient.GetVersionsAsync(serviceIndex, packageId); + if (versions.Count == 0) + { + return (null, "", $"Package {packageId} not found on '{source}'."); + } + + var sorted = versions.OrderBy(v => v, SemVerStringComparer.Instance).ToList(); + var target = + requestedVersion + ?? sorted.LastOrDefault(v => !SemVerStringComparer.IsPrerelease(v)) + ?? sorted[^1]; + if (!versions.Contains(target)) + { + return (null, "", $"Version {target} of {packageId} not found on '{source}'."); + } + + var tempPath = Path.Combine( + Path.GetTempPath(), + $"sm-upgrade-{Guid.NewGuid():N}-{packageId}.{target}.nupkg" + ); + await NuGetClient.DownloadNupkgAsync(serviceIndex, packageId, target, tempPath); + return (tempPath, target, null); + } + + private static string Tail(string error, string output) + { + var text = string.IsNullOrWhiteSpace(error) ? output : error; + return string.Join('\n', text.Split('\n').TakeLast(25)).Trim(); + } + + private static int Fail(string message) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]"); + return 1; + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/AssemblyManifestReader.cs b/cli/SimpleModule.Cli/Infrastructure/AssemblyManifestReader.cs new file mode 100644 index 00000000..602e4e11 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/AssemblyManifestReader.cs @@ -0,0 +1,97 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Reads the [assembly: ModuleManifest("{json}")] attribute from a module +/// assembly using System.Reflection.Metadata — the assembly is never loaded, so +/// this works without resolving its dependencies (framework, ASP.NET, ...). +/// +public static class AssemblyManifestReader +{ + private const string AttributeNamespace = "SimpleModule.Core.Modules"; + private const string AttributeName = "ModuleManifestAttribute"; + + public static ModuleManifestData? TryRead(string assemblyPath) + { + var json = TryReadJson(assemblyPath); + return json is null ? null : ModuleManifestData.TryParse(json); + } + + public static string? TryReadJson(string assemblyPath) + { + if (!File.Exists(assemblyPath)) + { + return null; + } + + using var stream = File.OpenRead(assemblyPath); + using var peReader = new PEReader(stream); + if (!peReader.HasMetadata) + { + return null; + } + + var reader = peReader.GetMetadataReader(); + foreach (var handle in reader.GetAssemblyDefinition().GetCustomAttributes()) + { + var attribute = reader.GetCustomAttribute(handle); + if (!IsModuleManifestAttribute(reader, attribute)) + { + continue; + } + + // Value blob: 0x0001 prolog, then the single string fixed argument. + var blobReader = reader.GetBlobReader(attribute.Value); + if (blobReader.ReadUInt16() != 0x0001) + { + return null; + } + + return blobReader.ReadSerializedString(); + } + + return null; + } + + private static bool IsModuleManifestAttribute(MetadataReader reader, CustomAttribute attribute) + { + StringHandle nameHandle; + StringHandle namespaceHandle; + + switch (attribute.Constructor.Kind) + { + case HandleKind.MemberReference: + { + var member = reader.GetMemberReference( + (MemberReferenceHandle)attribute.Constructor + ); + if (member.Parent.Kind != HandleKind.TypeReference) + { + return false; + } + + var type = reader.GetTypeReference((TypeReferenceHandle)member.Parent); + nameHandle = type.Name; + namespaceHandle = type.Namespace; + break; + } + case HandleKind.MethodDefinition: + { + var method = reader.GetMethodDefinition( + (MethodDefinitionHandle)attribute.Constructor + ); + var type = reader.GetTypeDefinition(method.GetDeclaringType()); + nameHandle = type.Name; + namespaceHandle = type.Namespace; + break; + } + default: + return false; + } + + return reader.StringComparer.Equals(nameHandle, AttributeName) + && reader.StringComparer.Equals(namespaceHandle, AttributeNamespace); + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/AssemblyMigrationReader.cs b/cli/SimpleModule.Cli/Infrastructure/AssemblyMigrationReader.cs new file mode 100644 index 00000000..a6a1ab4e --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/AssemblyMigrationReader.cs @@ -0,0 +1,75 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Lists EF Core migration ids ([Migration("...")] attribute values) declared +/// in an assembly, via System.Reflection.Metadata — no assembly loading. +/// +public static class AssemblyMigrationReader +{ + public static IReadOnlyList ReadMigrationIds(string assemblyPath) + { + var ids = new List(); + if (!File.Exists(assemblyPath)) + { + return ids; + } + + using var stream = File.OpenRead(assemblyPath); + using var peReader = new PEReader(stream); + if (!peReader.HasMetadata) + { + return ids; + } + + var reader = peReader.GetMetadataReader(); + foreach (var typeHandle in reader.TypeDefinitions) + { + var type = reader.GetTypeDefinition(typeHandle); + foreach (var attrHandle in type.GetCustomAttributes()) + { + var attribute = reader.GetCustomAttribute(attrHandle); + if (!IsMigrationAttribute(reader, attribute)) + { + continue; + } + + var blobReader = reader.GetBlobReader(attribute.Value); + if (blobReader.ReadUInt16() == 0x0001) + { + var id = blobReader.ReadSerializedString(); + if (!string.IsNullOrEmpty(id)) + { + ids.Add(id); + } + } + } + } + + ids.Sort(StringComparer.Ordinal); + return ids; + } + + private static bool IsMigrationAttribute(MetadataReader reader, CustomAttribute attribute) + { + if (attribute.Constructor.Kind != HandleKind.MemberReference) + { + return false; + } + + var member = reader.GetMemberReference((MemberReferenceHandle)attribute.Constructor); + if (member.Parent.Kind != HandleKind.TypeReference) + { + return false; + } + + var type = reader.GetTypeReference((TypeReferenceHandle)member.Parent); + return reader.StringComparer.Equals(type.Name, "MigrationAttribute") + && reader.StringComparer.Equals( + type.Namespace, + "Microsoft.EntityFrameworkCore.Migrations" + ); + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/BundleExternalsValidator.cs b/cli/SimpleModule.Cli/Infrastructure/BundleExternalsValidator.cs new file mode 100644 index 00000000..b131d1be --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/BundleExternalsValidator.cs @@ -0,0 +1,58 @@ +namespace SimpleModule.Cli.Infrastructure; + +public readonly record struct ExternalsViolation(string File, string Marker); + +/// +/// Verifies that a module's built frontend bundle externalizes the host-provided +/// libraries (react, react-dom, react/jsx-runtime, @inertiajs/react). A module +/// that inlines its own React copy breaks hooks at runtime (two React instances) +/// — pack fails closed when an inlined-React marker is found. +/// +public static class BundleExternalsValidator +{ + // Strings that only appear inside React's own source, never in code that + // imports React as an external. + private static readonly string[] InlinedReactMarkers = + [ + "Symbol.for(\"react.element\")", + "Symbol.for('react.element')", + "Symbol.for(\"react.transitional.element\")", + "Symbol.for('react.transitional.element')", + "react.production.min", + "react.development", + "__CLIENT_INTERNALS_DO_NOT_USE", + ]; + + public static IReadOnlyList Validate(string wwwrootPath) + { + var violations = new List(); + if (!Directory.Exists(wwwrootPath)) + { + return violations; + } + + var bundleFiles = Directory + .EnumerateFiles(wwwrootPath, "*", SearchOption.AllDirectories) + .Where(f => + ( + f.EndsWith(".js", StringComparison.OrdinalIgnoreCase) + || f.EndsWith(".mjs", StringComparison.OrdinalIgnoreCase) + ) && !f.EndsWith(".map", StringComparison.OrdinalIgnoreCase) + ); + + foreach (var file in bundleFiles) + { + var content = File.ReadAllText(file); + foreach (var marker in InlinedReactMarkers) + { + if (content.Contains(marker, StringComparison.Ordinal)) + { + violations.Add(new ExternalsViolation(file, marker)); + break; + } + } + } + + return violations; + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs b/cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs new file mode 100644 index 00000000..97fed5c1 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs @@ -0,0 +1,203 @@ +using System.Globalization; + +namespace SimpleModule.Cli.Infrastructure; + +public readonly record struct CompatResult(bool Compatible, string Reason); + +/// +/// Evaluates a module manifest's frameworkCompat SemVer range against the +/// host's SimpleModule.Core version. Supported range grammar (what the source +/// generator emits): >=X.Y.Z[-pre] optionally followed by <A.B.C[-pre]. +/// +public static class FrameworkCompatChecker +{ + public static CompatResult Check(string range, string hostVersion) + { + if (string.IsNullOrWhiteSpace(range)) + { + return new CompatResult( + true, + "Module declares no framework compatibility range; assuming compatible." + ); + } + + if (!SemVer.TryParse(hostVersion, out var host)) + { + return new CompatResult( + false, + $"Host framework version '{hostVersion}' is not a valid semantic version." + ); + } + + SemVer? lower = null; + SemVer? upper = null; + foreach (var part in range.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (part.StartsWith(">=", StringComparison.Ordinal)) + { + if (!SemVer.TryParse(part[2..], out var parsed)) + { + return Unparseable(range); + } + + // The generator derives the lower bound from the referenced + // assembly's numeric version, which cannot carry a prerelease tag. + // A bare ">=X.Y.Z" therefore means ">=X.Y.Z-0": prereleases of + // X.Y.Z (e.g. a host on 0.0.99-local with a module built against + // that same prerelease) satisfy the bound. + if (parsed.Prerelease.Length == 0) + { + parsed = parsed with { Prerelease = "0" }; + } + + lower = parsed; + } + else if (part.StartsWith('<')) + { + if (!SemVer.TryParse(part[1..], out var parsed)) + { + return Unparseable(range); + } + + upper = parsed; + } + else + { + return Unparseable(range); + } + } + + if (lower is null && upper is null) + { + return Unparseable(range); + } + + if (lower is not null && host.CompareTo(lower.Value) < 0) + { + return new CompatResult( + false, + $"Host framework {hostVersion} is older than the module's minimum {lower}." + ); + } + + if (upper is not null && host.CompareTo(upper.Value) >= 0) + { + return new CompatResult( + false, + $"Host framework {hostVersion} is at or above the module's exclusive upper bound {upper}." + ); + } + + return new CompatResult(true, $"Host framework {hostVersion} satisfies '{range}'."); + } + + private static CompatResult Unparseable(string range) => + new(false, $"Could not parse framework compatibility range '{range}'."); + + private readonly record struct SemVer(int Major, int Minor, int Patch, string Prerelease) + : IComparable + { + public static bool TryParse(string input, out SemVer version) + { + version = default; + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + var core = input; + var prerelease = ""; + var dash = input.IndexOf('-', StringComparison.Ordinal); + if (dash >= 0) + { + core = input[..dash]; + prerelease = input[(dash + 1)..]; + } + + var parts = core.Split('.'); + if (parts.Length is < 2 or > 3) + { + return false; + } + + if ( + !int.TryParse( + parts[0], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var major + ) + || !int.TryParse( + parts[1], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var minor + ) + ) + { + return false; + } + + var patch = 0; + if ( + parts.Length == 3 + && !int.TryParse( + parts[2], + NumberStyles.None, + CultureInfo.InvariantCulture, + out patch + ) + ) + { + return false; + } + + version = new SemVer(major, minor, patch, prerelease); + return true; + } + + public int CompareTo(SemVer other) + { + var byMajor = Major.CompareTo(other.Major); + if (byMajor != 0) + { + return byMajor; + } + + var byMinor = Minor.CompareTo(other.Minor); + if (byMinor != 0) + { + return byMinor; + } + + var byPatch = Patch.CompareTo(other.Patch); + if (byPatch != 0) + { + return byPatch; + } + + // SemVer: a prerelease sorts BELOW its release (1.0.0-x < 1.0.0). + if (Prerelease.Length == 0 && other.Prerelease.Length == 0) + { + return 0; + } + + if (Prerelease.Length == 0) + { + return 1; + } + + if (other.Prerelease.Length == 0) + { + return -1; + } + + return string.CompareOrdinal(Prerelease, other.Prerelease); + } + + public override string ToString() => + Prerelease.Length == 0 + ? $"{Major}.{Minor}.{Patch}" + : $"{Major}.{Minor}.{Patch}-{Prerelease}"; + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/GlobalPackagesCache.cs b/cli/SimpleModule.Cli/Infrastructure/GlobalPackagesCache.cs new file mode 100644 index 00000000..7239f3a4 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/GlobalPackagesCache.cs @@ -0,0 +1,115 @@ +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Reads module manifests out of the NuGet global packages cache +/// (~/.nuget/packages or NUGET_PACKAGES). +/// +public static class GlobalPackagesCache +{ + public static string RootPath => + Environment.GetEnvironmentVariable("NUGET_PACKAGES") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nuget", + "packages" + ); + + /// + /// Path of the package's main assembly in the cache, or null when absent. + /// Without a version the highest cached one is used. + /// + public static string? FindAssemblyPath(string packageId, string? version) + { + var packageDir = Path.Combine(RootPath, packageId.ToLowerInvariant()); + if (!Directory.Exists(packageDir)) + { + return null; + } + + var versionDirs = version is not null + ? new[] { Path.Combine(packageDir, version.ToLowerInvariant()) } + : Directory + .EnumerateDirectories(packageDir) + .OrderByDescending(d => Path.GetFileName(d), SemVerStringComparer.Instance) + .ToArray(); + + foreach (var versionDir in versionDirs) + { + var libDir = Path.Combine(versionDir, "lib"); + if (!Directory.Exists(libDir)) + { + continue; + } + + var dll = Directory + .EnumerateFiles(libDir, packageId + ".dll", SearchOption.AllDirectories) + .FirstOrDefault(); + if (dll is not null) + { + return dll; + } + } + + return null; + } + + /// + /// Returns the manifest for an installed package, or null when the package + /// (or a manifest inside it) cannot be found. Without a version the highest + /// cached one is used. + /// + public static ModuleManifestData? TryReadManifest(string packageId, string? version) + { + var packageDir = Path.Combine(RootPath, packageId.ToLowerInvariant()); + if (!Directory.Exists(packageDir)) + { + return null; + } + + var versionDirs = version is not null + ? new[] { Path.Combine(packageDir, version.ToLowerInvariant()) } + : Directory + .EnumerateDirectories(packageDir) + .OrderByDescending(d => Path.GetFileName(d), SemVerStringComparer.Instance) + .ToArray(); + + foreach (var versionDir in versionDirs) + { + if (!Directory.Exists(versionDir)) + { + continue; + } + + // sm pack puts module-manifest.json at the package root. + var manifestPath = Path.Combine(versionDir, "module-manifest.json"); + if (File.Exists(manifestPath)) + { + var parsed = ModuleManifestData.TryParse(File.ReadAllText(manifestPath)); + if (parsed is not null) + { + return parsed; + } + } + + var libDir = Path.Combine(versionDir, "lib"); + if (!Directory.Exists(libDir)) + { + continue; + } + + var dll = Directory + .EnumerateFiles(libDir, packageId + ".dll", SearchOption.AllDirectories) + .FirstOrDefault(); + if (dll is not null) + { + var manifest = AssemblyManifestReader.TryRead(dll); + if (manifest is not null) + { + return manifest; + } + } + } + + return null; + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/HostFrameworkVersionResolver.cs b/cli/SimpleModule.Cli/Infrastructure/HostFrameworkVersionResolver.cs new file mode 100644 index 00000000..f43b4519 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/HostFrameworkVersionResolver.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Determines the SimpleModule framework version a host application targets: +/// the CPM-pinned SimpleModule.Core PackageVersion, else version.json (the +/// in-repo development convention), else unknown. +/// +public static partial class HostFrameworkVersionResolver +{ + public static string? Resolve(string solutionRoot) + { + var propsPath = Path.Combine(solutionRoot, "Directory.Packages.props"); + if (File.Exists(propsPath)) + { + var match = CorePackageVersionRegex().Match(File.ReadAllText(propsPath)); + if (match.Success) + { + return match.Groups["version"].Value; + } + } + + var versionJsonPath = Path.Combine(solutionRoot, "version.json"); + if (File.Exists(versionJsonPath)) + { + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(versionJsonPath)); + if (doc.RootElement.TryGetProperty("version", out var version)) + { + return version.GetString(); + } + } + catch (JsonException) + { + // fall through to null + } + } + + return null; + } + + [GeneratedRegex( + "[^\"]+)\"" + )] + private static partial Regex CorePackageVersionRegex(); +} diff --git a/cli/SimpleModule.Cli/Infrastructure/ModuleManifestData.cs b/cli/SimpleModule.Cli/Infrastructure/ModuleManifestData.cs new file mode 100644 index 00000000..14f0826a --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/ModuleManifestData.cs @@ -0,0 +1,45 @@ +using System.Text.Json; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// CLI-local view of the module manifest (schema v1) emitted by +/// SimpleModule.Generator. Kept independent of SimpleModule.Core so the CLI +/// has no framework assembly dependency. +/// +public sealed class ModuleManifestData +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + public int SchemaVersion { get; init; } + public string Id { get; init; } = ""; + public string Name { get; init; } = ""; + public string DisplayName { get; init; } = ""; + public string Version { get; init; } = ""; + public string FrameworkCompat { get; init; } = ""; + public string RoutePrefix { get; init; } = ""; + public string ViewPrefix { get; init; } = ""; + public string Schema { get; init; } = ""; + public IReadOnlyList Permissions { get; init; } = []; + public string? FrontendEntry { get; init; } + public IReadOnlyList Pages { get; init; } = []; + public IReadOnlyList EventsPublished { get; init; } = []; + public IReadOnlyList EventsConsumed { get; init; } = []; + public bool HasDbContext { get; init; } + + public static ModuleManifestData? TryParse(string json) + { + try + { + return JsonSerializer.Deserialize(json, SerializerOptions); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs b/cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs new file mode 100644 index 00000000..0062bf45 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs @@ -0,0 +1,123 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Minimal NuGet V3 client: resolves the flat-container resource from a service +/// index, lists versions, and downloads nupkgs. Local directory feeds bypass +/// HTTP entirely via . +/// +public static partial class NuGetClient +{ + private static readonly HttpClient SharedHttpClient = new() + { + Timeout = TimeSpan.FromSeconds(30), + }; + + public static bool IsLocalDirectorySource(string source) => + !source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + && !source.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + + /// + /// Finds {id}.{version}.nupkg in a local folder feed; without a + /// version the highest one wins. Returns null when absent. + /// + public static string? FindLocalNupkg(string feedDirectory, string packageId, string? version) + { + if (!Directory.Exists(feedDirectory)) + { + return null; + } + + if (version is not null) + { + var exact = Path.Combine(feedDirectory, $"{packageId}.{version}.nupkg"); + return File.Exists(exact) ? exact : null; + } + + var prefix = packageId + "."; + var candidates = new List<(string Path, string Version)>(); + foreach (var file in Directory.EnumerateFiles(feedDirectory, prefix + "*.nupkg")) + { + var name = Path.GetFileNameWithoutExtension(file); + var versionPart = name[prefix.Length..]; + // Reject longer package ids (SimpleModule.X must not match + // SimpleModule.X.Contracts.1.0.0): the remainder must start with a digit. + if (VersionStartRegex().IsMatch(versionPart)) + { + candidates.Add((file, versionPart)); + } + } + + return candidates + .OrderByDescending(c => c.Version, SemVerStringComparer.Instance) + .Select(c => c.Path) + .FirstOrDefault(); + } + + public static async Task> GetVersionsAsync( + Uri serviceIndexUrl, + string packageId + ) + { + var baseUrl = await ResolveFlatContainerAsync(serviceIndexUrl); + var url = $"{baseUrl}{packageId.ToLowerInvariant()}/index.json"; + using var response = await SharedHttpClient.GetAsync(new Uri(url)); + if (!response.IsSuccessStatusCode) + { + return []; + } + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return + [ + .. doc + .RootElement.GetProperty("versions") + .EnumerateArray() + .Select(v => v.GetString() ?? ""), + ]; + } + + public static async Task DownloadNupkgAsync( + Uri serviceIndexUrl, + string packageId, + string version, + string destinationPath + ) + { + var baseUrl = await ResolveFlatContainerAsync(serviceIndexUrl); + var idLower = packageId.ToLowerInvariant(); + var versionLower = version.ToLowerInvariant(); + var url = $"{baseUrl}{idLower}/{versionLower}/{idLower}.{versionLower}.nupkg"; + + using var response = await SharedHttpClient.GetAsync(new Uri(url)); + response.EnsureSuccessStatusCode(); + await using var file = File.Create(destinationPath); + await response.Content.CopyToAsync(file); + } + + private static async Task ResolveFlatContainerAsync(Uri serviceIndexUrl) + { + using var response = await SharedHttpClient.GetAsync(serviceIndexUrl); + response.EnsureSuccessStatusCode(); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + foreach (var resource in doc.RootElement.GetProperty("resources").EnumerateArray()) + { + var type = resource.GetProperty("@type").GetString() ?? ""; + if (type.StartsWith("PackageBaseAddress/3.0.0", StringComparison.Ordinal)) + { + var id = resource.GetProperty("@id").GetString() ?? ""; + return id.EndsWith('/') ? id : id + "/"; + } + } + + throw new InvalidOperationException( + $"Registry '{serviceIndexUrl}' exposes no PackageBaseAddress/3.0.0 resource." + ); + } + + [GeneratedRegex("^[0-9]")] + private static partial Regex VersionStartRegex(); +} diff --git a/cli/SimpleModule.Cli/Infrastructure/NuGetConfigManipulator.cs b/cli/SimpleModule.Cli/Infrastructure/NuGetConfigManipulator.cs new file mode 100644 index 00000000..557373c5 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/NuGetConfigManipulator.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Ensures a local folder feed is registered in the solution's nuget.config so +/// restores can resolve module packages added from a local source. +/// +public static class NuGetConfigManipulator +{ + public static void EnsureLocalSource(string solutionRoot, string feedDirectory) + { + var configPath = Path.Combine(solutionRoot, "nuget.config"); + var normalizedFeed = Path.GetFullPath(feedDirectory); + + if (!File.Exists(configPath)) + { + File.WriteAllText( + configPath, + $""" + + + + + + + + """ + ); + return; + } + + var content = File.ReadAllText(configPath); + if (content.Contains(normalizedFeed, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var lines = File.ReadAllLines(configPath).ToList(); + var close = lines.FindIndex(l => l.Contains("", StringComparison.Ordinal)); + if (close < 0) + { + throw new InvalidOperationException( + $"{configPath} has no section; add the feed '{normalizedFeed}' manually." + ); + } + + lines.Insert( + close, + $" " + ); + File.WriteAllLines(configPath, lines); + } + + private static string SourceKey(string feedDirectory) + { + // Key on the full path (hashed) — two different feeds sharing a leaf + // directory name must not produce duplicate keys, which NuGet + // rejects when parsing the config. + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(feedDirectory))); +#pragma warning disable CA1308 // lowercase is conventional for nuget.config keys + return "sm-local-" + hash[..8].ToLowerInvariant(); +#pragma warning restore CA1308 + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/NupkgManifestReader.cs b/cli/SimpleModule.Cli/Infrastructure/NupkgManifestReader.cs new file mode 100644 index 00000000..3419d57f --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/NupkgManifestReader.cs @@ -0,0 +1,62 @@ +using System.IO.Compression; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Extracts the module manifest from a nupkg: prefers the +/// module-manifest.json file at the package root (written by +/// sm pack), falling back to the [assembly: ModuleManifest] +/// attribute on the package's main assembly. +/// +public static class NupkgManifestReader +{ + public static ModuleManifestData? TryRead(string nupkgPath, string packageId) + { + if (!File.Exists(nupkgPath)) + { + return null; + } + + using var zip = ZipFile.OpenRead(nupkgPath); + + var manifestEntry = zip.GetEntry("module-manifest.json"); + if (manifestEntry is not null) + { + using var reader = new StreamReader(manifestEntry.Open()); + var parsed = ModuleManifestData.TryParse(reader.ReadToEnd()); + if (parsed is not null) + { + return parsed; + } + } + + var dllName = packageId + ".dll"; + var dllEntry = zip.Entries.FirstOrDefault(e => + e.FullName.StartsWith("lib/", StringComparison.OrdinalIgnoreCase) + && string.Equals(e.Name, dllName, StringComparison.OrdinalIgnoreCase) + ); + if (dllEntry is null) + { + return null; + } + + // PEReader needs a seekable stream; zip entry streams are not. + var tempDll = Path.Combine( + Path.GetTempPath(), + "sm-" + Guid.NewGuid().ToString("N") + ".dll" + ); + try + { + dllEntry.ExtractToFile(tempDll); + return AssemblyManifestReader.TryRead(tempDll); + } + finally + { + try + { + File.Delete(tempDll); + } + catch (IOException) { } + } + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/PackageReferenceManipulator.cs b/cli/SimpleModule.Cli/Infrastructure/PackageReferenceManipulator.cs new file mode 100644 index 00000000..c2ec1779 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/PackageReferenceManipulator.cs @@ -0,0 +1,239 @@ +using System.Text.RegularExpressions; + +namespace SimpleModule.Cli.Infrastructure; + +public readonly record struct PackageReferenceEntry(string Id, string? Version); + +/// +/// Adds/removes NuGet package references on a host project, transparently +/// handling Central Package Management: with CPM the version lives in +/// Directory.Packages.props and the csproj reference is version-less (NU1008); +/// without CPM the version is inlined on the reference. +/// All edits are line-based to preserve the user's file formatting. +/// +public static partial class PackageReferenceManipulator +{ + public static void AddPackage( + string csprojPath, + string solutionRoot, + string packageId, + string version + ) + { + var propsPath = CpmPropsPath(solutionRoot); + if (propsPath is not null) + { + SetCpmPackageVersion(propsPath, packageId, version); + InsertReferenceLine(csprojPath, $""); + } + else + { + // An existing single-line reference must have its Version UPDATED — + // InsertReferenceLine alone would no-op and silently skip upgrades. + if (!TryUpdateInlineVersion(csprojPath, packageId, version)) + { + InsertReferenceLine( + csprojPath, + $"" + ); + } + } + } + + private static bool TryUpdateInlineVersion(string csprojPath, string packageId, string version) + { + var token = $" l.Contains(token, StringComparison.Ordinal)); + if (index < 0) + { + return false; + } + + var indent = DetectIndent(lines[index], " "); + lines[index] = + $"{indent}"; + File.WriteAllLines(csprojPath, lines); + return true; + } + + public static bool RemovePackage(string csprojPath, string solutionRoot, string packageId) + { + var removed = RemoveElementLine(csprojPath, "PackageReference", packageId); + + var propsPath = CpmPropsPath(solutionRoot); + if (propsPath is not null) + { + removed |= RemoveElementLine(propsPath, "PackageVersion", packageId); + } + + return removed; + } + + public static IReadOnlyList GetPackageReferences( + string csprojPath, + string solutionRoot + ) + { + if (!File.Exists(csprojPath)) + { + return []; + } + + var cpmVersions = ReadCpmVersions(solutionRoot); + var results = new List(); + foreach (Match match in PackageReferenceRegex().Matches(File.ReadAllText(csprojPath))) + { + var id = match.Groups["id"].Value; + var version = match.Groups["version"].Success + ? match.Groups["version"].Value + : cpmVersions.GetValueOrDefault(id); + results.Add(new PackageReferenceEntry(id, version)); + } + + return results; + } + + private static string? CpmPropsPath(string solutionRoot) + { + var path = Path.Combine(solutionRoot, "Directory.Packages.props"); + return File.Exists(path) ? path : null; + } + + private static Dictionary ReadCpmVersions(string solutionRoot) + { + var versions = new Dictionary(StringComparer.OrdinalIgnoreCase); + var propsPath = CpmPropsPath(solutionRoot); + if (propsPath is null) + { + return versions; + } + + foreach (Match match in PackageVersionRegex().Matches(File.ReadAllText(propsPath))) + { + versions[match.Groups["id"].Value] = match.Groups["version"].Value; + } + + return versions; + } + + private static void SetCpmPackageVersion(string propsPath, string packageId, string version) + { + var lines = File.ReadAllLines(propsPath).ToList(); + var token = $" l.Contains(token, StringComparison.Ordinal)); + if (existing >= 0) + { + var indent = lines[existing][..^lines[existing].TrimStart().Length]; + lines[existing] = + $"{indent}"; + File.WriteAllLines(propsPath, lines); + return; + } + + var anchor = lines.FindLastIndex(l => + l.Contains(" l.Contains("", StringComparison.Ordinal)); + if (anchor < 0) + { + throw new InvalidOperationException( + $"Could not find an in {propsPath} to add the PackageVersion entry." + ); + } + } + + var indentation = DetectIndent(lines[anchor], fallback: " "); + lines.Insert( + anchor + 1, + $"{indentation}" + ); + File.WriteAllLines(propsPath, lines); + } + + private static void InsertReferenceLine(string csprojPath, string element) + { + var content = File.ReadAllText(csprojPath); + var includeToken = ExtractIncludeToken(element); + if (content.Contains(includeToken, StringComparison.Ordinal)) + { + return; + } + + var lines = File.ReadAllLines(csprojPath).ToList(); + var anchor = lines.FindLastIndex(l => + l.Contains(" + l.Contains("= 0) + { + lines.Insert(anchor + 1, DetectIndent(lines[anchor], " ") + element); + } + else + { + var close = lines.FindIndex(l => l.Contains("", StringComparison.Ordinal)); + if (close < 0) + { + throw new InvalidOperationException($"{csprojPath} has no closing tag."); + } + + lines.Insert(close, " "); + lines.Insert(close + 1, " " + element); + lines.Insert(close + 2, " "); + } + + File.WriteAllLines(csprojPath, lines); + } + + private static bool RemoveElementLine(string filePath, string elementName, string packageId) + { + if (!File.Exists(filePath)) + { + return false; + } + + var token = $"<{elementName} Include=\"{packageId}\""; + var lines = File.ReadAllLines(filePath).ToList(); + var removedCount = lines.RemoveAll(l => l.Contains(token, StringComparison.Ordinal)); + if (removedCount > 0) + { + File.WriteAllLines(filePath, lines); + } + + return removedCount > 0; + } + + private static string DetectIndent(string line, string fallback) + { + var indent = line[..^line.TrimStart().Length]; + return indent.Length > 0 ? indent : fallback; + } + + private static string ExtractIncludeToken(string element) + { + var match = IncludeRegex().Match(element); + return match.Success ? match.Value : element; + } + + [GeneratedRegex( + "[^\"]+)\"(?:\\s+Version=\"(?[^\"]+)\")?" + )] + private static partial Regex PackageReferenceRegex(); + + [GeneratedRegex( + "[^\"]+)\"\\s+Version=\"(?[^\"]+)\"" + )] + private static partial Regex PackageVersionRegex(); + + [GeneratedRegex("Include=\"[^\"]+\"")] + private static partial Regex IncludeRegex(); +} diff --git a/cli/SimpleModule.Cli/Infrastructure/ProcessRunner.cs b/cli/SimpleModule.Cli/Infrastructure/ProcessRunner.cs new file mode 100644 index 00000000..b7915101 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/ProcessRunner.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; + +namespace SimpleModule.Cli.Infrastructure; + +public sealed record ProcessResult(int ExitCode, string Output, string Error) +{ + public bool Success => ExitCode == 0; +} + +/// Runs external tools (dotnet, npx) capturing output. +public static class ProcessRunner +{ + public static async Task RunAsync( + string fileName, + IReadOnlyList arguments, + string? workingDirectory = null, + IReadOnlyDictionary? environment = null + ) + { + // npm/npx are .cmd shims on Windows; CreateProcess cannot start them + // directly (and .NET blocks cmd files with ArgumentList), so route + // through cmd.exe there. + var actualArguments = arguments; + if (OperatingSystem.IsWindows() && fileName is "npx" or "npm") + { + actualArguments = ["/c", fileName, .. arguments]; + fileName = "cmd.exe"; + } + + var psi = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory, + }; + foreach (var argument in actualArguments) + { + psi.ArgumentList.Add(argument); + } + + if (environment is not null) + { + foreach (var kvp in environment) + { + psi.Environment[kvp.Key] = kvp.Value; + } + } + + using var process = new Process { StartInfo = psi }; + process.Start(); + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync().ConfigureAwait(false); + + return new ProcessResult( + process.ExitCode, + await outputTask.ConfigureAwait(false), + await errorTask.ConfigureAwait(false) + ); + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/SemVerStringComparer.cs b/cli/SimpleModule.Cli/Infrastructure/SemVerStringComparer.cs new file mode 100644 index 00000000..27d3dac9 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/SemVerStringComparer.cs @@ -0,0 +1,57 @@ +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Orders dotted version strings numerically (1.10.0 > 1.9.0) with releases +/// above their prereleases (1.0.0 > 1.0.0-rc). The single source of version +/// ordering for the CLI — local feed selection, registry version picking and +/// global-cache lookups all share it. +/// +public sealed class SemVerStringComparer : IComparer +{ + public static readonly SemVerStringComparer Instance = new(); + + public int Compare(string? x, string? y) + { + var (xCore, xPre) = Split(x ?? ""); + var (yCore, yPre) = Split(y ?? ""); + + var xParts = xCore.Split('.'); + var yParts = yCore.Split('.'); + for (var i = 0; i < Math.Max(xParts.Length, yParts.Length); i++) + { + var xNum = i < xParts.Length && int.TryParse(xParts[i], out var xv) ? xv : 0; + var yNum = i < yParts.Length && int.TryParse(yParts[i], out var yv) ? yv : 0; + var byNum = xNum.CompareTo(yNum); + if (byNum != 0) + { + return byNum; + } + } + + if (xPre.Length == 0 && yPre.Length == 0) + { + return 0; + } + + if (xPre.Length == 0) + { + return 1; + } + + if (yPre.Length == 0) + { + return -1; + } + + return string.CompareOrdinal(xPre, yPre); + } + + public static bool IsPrerelease(string version) => + version.Contains('-', StringComparison.Ordinal); + + private static (string Core, string Prerelease) Split(string version) + { + var dash = version.IndexOf('-', StringComparison.Ordinal); + return dash < 0 ? (version, "") : (version[..dash], version[(dash + 1)..]); + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/SmConfig.cs b/cli/SimpleModule.Cli/Infrastructure/SmConfig.cs new file mode 100644 index 00000000..026685a0 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/SmConfig.cs @@ -0,0 +1,59 @@ +using System.Text.Json; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Solution-level CLI configuration stored in sm.json at the solution root. +/// The registry URL abstracts the package feed so a marketplace feed can replace +/// nuget.org later without CLI changes. +/// +public sealed class SmConfig +{ + public const string FileName = "sm.json"; + public const string DefaultRegistry = "https://api.nuget.org/v3/index.json"; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + }; + + /// NuGet V3 service index URL used for search, resolve and download. + public string Registry { get; set; } = DefaultRegistry; + + public static SmConfig Load(string solutionRoot) + { + var path = Path.Combine(solutionRoot, FileName); + if (!File.Exists(path)) + { + return new SmConfig(); + } + + try + { + var config = JsonSerializer.Deserialize( + File.ReadAllText(path), + SerializerOptions + ); + if (config is null || string.IsNullOrWhiteSpace(config.Registry)) + { + return new SmConfig(); + } + + return config; + } + catch (JsonException) + { + // A broken sm.json should not brick every command; commands that care + // can warn. Fall back to the public registry. + return new SmConfig(); + } + } + + public void Save(string solutionRoot) + { + var path = Path.Combine(solutionRoot, FileName); + File.WriteAllText(path, JsonSerializer.Serialize(this, SerializerOptions)); + } +} diff --git a/cli/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs index 00b5d7c0..620e4aeb 100644 --- a/cli/SimpleModule.Cli/Program.cs +++ b/cli/SimpleModule.Cli/Program.cs @@ -1,3 +1,4 @@ +using SimpleModule.Cli.Commands.Add; using SimpleModule.Cli.Commands.Dev; using SimpleModule.Cli.Commands.Doctor; using SimpleModule.Cli.Commands.Install; @@ -5,7 +6,12 @@ using SimpleModule.Cli.Commands.List; using SimpleModule.Cli.Commands.Maintenance; using SimpleModule.Cli.Commands.New; +using SimpleModule.Cli.Commands.Pack; +using SimpleModule.Cli.Commands.Publish; +using SimpleModule.Cli.Commands.Remove; +using SimpleModule.Cli.Commands.Search; using SimpleModule.Cli.Commands.Skill; +using SimpleModule.Cli.Commands.Upgrade; using SimpleModule.Cli.Commands.Version; using Spectre.Console.Cli; @@ -59,6 +65,49 @@ .AddCommand("install") .WithDescription("Install a SimpleModule package from NuGet"); + config + .AddCommand("add") + .WithDescription( + "Install a packaged SimpleModule module: compat check, CPM-aware reference, build, migrations, doctor" + ) + .WithExample("add", "SimpleModule.Products") + .WithExample("add", "SimpleModule.Products", "--version", "1.2.0", "--source", "./feed"); + + config + .AddCommand("remove") + .WithDescription( + "Remove an installed module's reference (database schema and data are left in place)" + ) + .WithExample("remove", "SimpleModule.Products"); + + config + .AddCommand("publish") + .WithDescription("Pack a module and push it to a NuGet registry or local feed") + .WithExample("publish", "modules/Products", "--version", "1.2.0", "--dry-run") + .WithExample("publish", "modules/Products", "--source", "./feed"); + + config + .AddCommand("search") + .WithDescription("Search a registry for SimpleModule modules (simplemodule-module tag)") + .WithExample("search", "products") + .WithExample("search", "--source", "./feed"); + + config + .AddCommand("upgrade") + .WithDescription( + "Upgrade installed module packages (compat-checked; refuses violations unless --force)" + ) + .WithExample("upgrade", "SimpleModule.Products") + .WithExample("upgrade", "--source", "./feed"); + + config + .AddCommand("pack") + .WithDescription( + "Build, validate and pack a module into a distributable nupkg (frontend build, externals check, tests, manifest validation)" + ) + .WithExample("pack", "modules/Products") + .WithExample("pack", "modules/Products", "--version", "1.2.0", "--output", "./feed"); + config .AddCommand("doctor") .WithDescription("Validate project structure and conventions"); diff --git a/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs b/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs index ae98a4fc..3436c5ac 100644 --- a/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs @@ -53,6 +53,42 @@ public string Slnx(string projectName) return content.Replace(BaseProjectName, projectName, StringComparison.Ordinal); } + /// + /// Directory.Build.props for src/modules: runs the source generator in + /// module-kind on every module project so each module assembly carries its + /// compile-time manifest ([assembly: ModuleManifest]) — required by + /// sm pack and host-side bundle discovery. Standalone scaffolds get + /// the generator from NuGet (CPM-pinned); repo-clone scaffolds reference the + /// repo's generator project as an analyzer like in-repo modules do. + /// + public string ModulesDirectoryBuildProps() + { + var generatorReference = _solution is null + ? """""" + : $""""""; + + return $""" + + + + Module + + + + + + + + {generatorReference} + + + + """; + } + public string DirectoryBuildProps() { if (_solution is null) diff --git a/docs/site/.vitepress/config.ts b/docs/site/.vitepress/config.ts index 6b4e0e26..2134d984 100644 --- a/docs/site/.vitepress/config.ts +++ b/docs/site/.vitepress/config.ts @@ -94,6 +94,7 @@ export default defineConfig({ { text: 'sm new module', link: '/cli/new-module' }, { text: 'sm new feature', link: '/cli/new-feature' }, { text: 'sm doctor', link: '/cli/doctor' }, + { text: 'sm pack / add / remove', link: '/cli/packaging' }, ], }, ], @@ -117,6 +118,7 @@ export default defineConfig({ { text: 'Source Generator', link: '/advanced/source-generator' }, { text: 'Type Generation', link: '/advanced/type-generation' }, { text: 'EF Core Interceptors', link: '/advanced/interceptors' }, + { text: 'Module Packaging', link: '/advanced/module-packaging' }, { text: 'Deployment', link: '/advanced/deployment' }, ], }, diff --git a/docs/site/advanced/module-packaging.md b/docs/site/advanced/module-packaging.md new file mode 100644 index 00000000..0f47a482 --- /dev/null +++ b/docs/site/advanced/module-packaging.md @@ -0,0 +1,220 @@ +# Module Packaging + +SimpleModule modules are distributed as **standard NuGet packages**. A packaged +module is a normal `.nupkg` containing the module assembly, its prebuilt +frontend bundle as static web assets, and its EF Core migrations. No custom +registry is required: discovery and installation work against any NuGet V3 feed +(nuget.org by default), with packages identified by the `simplemodule-module` +tag. + +This page is the authoritative contract for module packages: the manifest +schema, the nupkg layout, the frontend externals contract, and the version +compatibility rules. + +## The module manifest + +Every module assembly carries a compile-time manifest describing the module to +hosts and tooling. The manifest is JSON emitted by `SimpleModule.Generator` +during the module's own build. + +::: info Why an assembly attribute and not an embedded resource? +Roslyn source generators can only add *source* to a compilation — they cannot +attach embedded resources. The manifest therefore travels as an assembly-level +attribute, which is the closest in-assembly equivalent: readable at runtime via +reflection (`ModuleManifestReader.TryRead(assembly)`) and by tooling via +`System.Reflection.Metadata` without loading the assembly or its dependencies. +`sm pack` additionally extracts the manifest to a +`module-manifest.json` inside the nupkg so feeds and registries can read it +without touching the assembly at all. +::: + +The generated source looks like this: + +```csharp +// ModuleManifest.g.cs (auto-generated into the module assembly) +[assembly: global::SimpleModule.Core.Modules.ModuleManifest(@"{""schemaVersion"":1,...}")] +``` + +### Schema (version 1) + +```json +{ + "schemaVersion": 1, + "id": "SimpleModule.FeatureFlags", + "name": "FeatureFlags", + "displayName": "Feature Flags", + "version": "1.0.0", + "frameworkCompat": ">=0.0.38 <1.0.0", + "routePrefix": "/api/feature-flags", + "viewPrefix": "/feature-flags", + "schema": "FeatureFlags", + "permissions": ["FeatureFlags.Manage", "FeatureFlags.View"], + "frontendEntry": "_content/SimpleModule.FeatureFlags/SimpleModule.FeatureFlags.pages.js", + "pages": ["FeatureFlags/Manage"], + "eventsPublished": ["SimpleModule.FeatureFlags.Contracts.Events.FeatureFlagToggledEvent"], + "eventsConsumed": [], + "hasDbContext": true +} +``` + +| Field | Source | Meaning | +|-------|--------|---------| +| `schemaVersion` | generator constant | Manifest schema this assembly was built against. Hosts refuse manifests newer than they understand. | +| `id` | assembly name | Package/assembly identity. | +| `name` | `[Module("Name")]` | Module name — also the database schema/prefix and `ModuleConnections` key. | +| `displayName` | `[Module(DisplayName = "...")]` | Human-readable name; defaults to `name`. | +| `version` | `[Module]` version argument | Module's own version. | +| `frameworkCompat` | derived or `SimpleModuleFrameworkCompat` MSBuild property | SemVer range of `SimpleModule.Core` versions the module supports. | +| `routePrefix` / `viewPrefix` | `[Module]` | API and view route prefixes. | +| `schema` | module name | Schema/prefix name for module data isolation. | +| `permissions` | `IModulePermissions` const fields | All permission values the module declares. | +| `frontendEntry` | view discovery | Static web asset path of the prebuilt pages bundle; `null` for backend-only modules. | +| `pages` | `IViewEndpoint` discovery | Inertia page names the module serves. | +| `eventsPublished` | `IEvent` implementors declared in the module's assemblies | Domain events the module defines. | +| `eventsConsumed` | Wolverine-convention handlers (`*Handler`/`*Consumer` with `Handle`/`Consume` methods) | Domain events the module subscribes to. | +| `hasDbContext` | DbContext discovery | Whether the module owns a DbContext. | + +Unknown fields are ignored when parsing (forward compatibility). A +`schemaVersion` higher than the framework understands fails closed with a +descriptive error. + +### How emission is wired + +Module projects build with the source generator attached in **module kind**: + +```xml + + + Module + + + + + +``` + +In module kind the generator emits **only** the manifest attribute. Host-level +artifacts (`AddModules()`, endpoint maps, the host DbContext, TypeScript +definitions, …) remain exclusive to host projects, which run the generator +without `SimpleModuleProjectKind` set. + +## Nupkg layout + +A packed module is a regular Razor-class-library-style package: + +``` +SimpleModule.FeatureFlags.1.2.0.nupkg +├── lib/net10.0/ +│ └── SimpleModule.FeatureFlags.dll # module assembly + [ModuleManifest] +│ # + bundled EF Core migrations +├── staticwebassets/ +│ ├── SimpleModule.FeatureFlags.pages.js # prebuilt Vite bundle (library mode) +│ └── simplemodule.featureflags.css # optional module CSS +├── README.md +└── (package metadata: tags include `simplemodule-module`) +``` + +The contracts assembly ships as its own package +(`SimpleModule.FeatureFlags.Contracts`) so other modules can depend on the +public surface without pulling the implementation. + +Key facts: + +- **Frontend bundles ship prebuilt.** The module's Vite build runs before + `pack` (wired into `GenerateNuspec` via `modules/Directory.Build.targets`), + so consumers never need Node to install a module. +- **Migrations ship inside the module assembly.** Packaged modules MUST bundle + EF Core migrations for their DbContext. `EnsureCreated` is not acceptable for + installed modules — it cannot evolve an existing database. At startup the + host applies migrations for every module context that ships them + (`MigrateAsync`); module contexts without migrations (in-repo modules) keep + relying on the unified host DbContext. +- **The `simplemodule-module` package tag** marks the package as a SimpleModule + module for search and discovery. + +::: warning EnsureCreated → migrations transition +A database first created via `EnsureCreated` has no `__EFMigrationsHistory` +table. Installing a packaged module into such a database applies the module's +migrations from zero, which is safe because module tables are schema-isolated — +but the *host's* own tables must not be managed by both mechanisms. Fresh hosts +should use migrations from the start. +::: + +## Frontend externals contract + +Module bundles are Vite **library-mode** builds and MUST externalize: + +- `react` (and `react/jsx-runtime`) +- `react-dom` (and `react-dom/client`) +- `@inertiajs/react` + +These are provided by the host at runtime via the import map in the HTML shell. +A module that bundles its own React copy breaks hooks (two React instances) and +bloats every page load. `sm pack` validates the built bundle and +fails closed when one of the externals is found inlined. + +::: warning @simplemodule/ui is currently inlined +The shared UI component library is not yet host-provided (no vendor bundle / +import-map entry), so each module bundle statically includes the components it +uses. Externalizing it is planned; when that lands the externals contract and +the pack-time validation will extend to `@simplemodule/ui`. +::: + +### How the host finds module bundles + +The host builds a module → bundle map from the loaded manifests +(`IModuleManifestRegistry`) and injects it into the HTML shell: + +```html + +``` + +The client-side page resolver imports the exact path from this map. Modules +without a manifest (built before manifest emission existed) fall back to the +legacy convention probe: `/_content/SimpleModule.{Module}/…` then +`/_content/{Module}/…`. + +## Version compatibility rules + +`frameworkCompat` is a SemVer range over the `SimpleModule.Core` version: + +- **Default:** derived at compile time from the referenced `SimpleModule.Core` + assembly: `>={version} <{nextMajor}.0.0`. +- **Override:** set the MSBuild property explicitly when you have verified a + wider or narrower range: + + ```xml + + >=0.0.38 <1.0.0 + + ``` + +- Installation tooling (`sm add`) checks the host's framework version + against this range **before** installing and refuses incompatible modules + (override with `--force` at your own risk). + +::: tip Pre-1.0 caveat +While the framework is on `0.x`, the default range `>=0.0.N <1.0.0` is +optimistic — SemVer reserves the right to break between 0.x minors. The range +semantics tighten when the framework reaches 1.0. Pin a narrower override if +your module depends on unstable surface. +::: + +## Reading manifests programmatically + +```csharp +using SimpleModule.Core.Modules; + +// At runtime (host side) — all loaded modules: +var registry = serviceProvider.GetRequiredService(); +foreach (var manifest in registry.Manifests) +{ + Console.WriteLine($"{manifest.DisplayName} {manifest.Version} ({manifest.Id})"); +} + +// From a specific assembly: +ModuleManifest? manifest = ModuleManifestReader.TryRead(typeof(MyModule).Assembly); +``` diff --git a/docs/site/cli/packaging.md b/docs/site/cli/packaging.md new file mode 100644 index 00000000..f04073a4 --- /dev/null +++ b/docs/site/cli/packaging.md @@ -0,0 +1,161 @@ +# Module packaging commands + +`sm` distributes modules as standard NuGet packages (see +[Module Packaging](/advanced/module-packaging) for the package contract). +Four commands cover the local lifecycle: `pack`, `add`, `remove`, and the +packaged-modules view in `list`. + +The package registry defaults to nuget.org. Point a solution at a different +NuGet V3 feed by adding `sm.json` to the solution root: + +```json +{ "registry": "https://my-feed.example.com/v3/index.json" } +``` + +## sm pack + +```bash +sm pack [module-path] [--version ] [--output ] [--skip-tests] [-c ] +``` + +Builds, validates and packs a module (and its `.Contracts` project) into +nupkgs. The pipeline fails closed at the first violated step: + +1. **Frontend build** — a fresh production Vite build (when the module has a + `package.json`). +2. **Externals validation** — the built bundle must not inline react, + react-dom, react/jsx-runtime or @inertiajs/react (host-provided). A module + that bundles React breaks hooks at runtime. +3. **`dotnet build`** (Release by default). +4. **`dotnet test`** of the module's test project (skip with `--skip-tests`). +5. **Manifest validation** — the built assembly must carry a parseable + schema-v1 manifest whose id matches the assembly and whose declared + frontend entry exists on disk. +6. **`dotnet pack`** — also writes `module-manifest.json` into the nupkg root + and guarantees the `simplemodule-module` package tag, without editing your + project files. + +::: tip Prerelease frameworks +Packing a *stable* module version against a *prerelease* framework fails with +NU5104 — pass a prerelease `--version` (e.g. `1.2.0-rc.1`) in that case. +::: + +## sm add + +```bash +sm add [--version ] [--source ] [--skip-migrations] [--skip-doctor] +``` + +Installs a packaged module into the host application: + +1. Resolves the nupkg from `--source` (local folder feed or NuGet V3 service + index URL), or the `sm.json` registry. +2. Reads the module manifest — packages without one are refused (use + `sm install` for plain NuGet packages). +3. **Compatibility gate**: checks the manifest's `frameworkCompat` range + against the host's `SimpleModule.Core` version *before touching any file*. +4. Registers local folder feeds in `nuget.config`. +5. Adds the package reference — **CPM-aware**: with Central Package + Management the version goes into `Directory.Packages.props` and the csproj + gets a version-less `PackageReference`. +6. `dotnet build`, then applies the module's migrations by running the host + once with `SIMPLEMODULE_MIGRATE_ONLY=1` (database initialization runs and + the process exits without serving traffic). +7. Runs `sm doctor`. + +## sm remove + +```bash +sm remove +``` + +Removes the package reference (csproj + CPM entry). The module's database +schema and data are **never dropped** — the command prints exactly what was +left behind (schema name, migration history rows, permission grants) so the +cleanup is a deliberate, manual decision. + +## sm list + +`sm list` shows source modules (with route prefixes and endpoint counts) and a +second table of installed packaged modules with their versions and framework +compatibility status against the current host. + +## sm publish + +```bash +sm publish [module-path] [--version ] [--source ] [--api-key ] [--dry-run] [--register] +``` + +Runs the full `sm pack` pipeline, then pushes the nupkgs with +`dotnet nuget push`. The API key comes from `--api-key` or `NUGET_API_KEY` +(not needed for local folder feeds). `--dry-run` validates everything and +shows what would be pushed. `--register` is the extension point for the +future SimpleModule marketplace — today it explains that registration is not +available yet (packages are already discoverable via the +`simplemodule-module` tag). + +## sm search + +```bash +sm search [query] [--source ] [--take ] [--prerelease] +``` + +Lists SimpleModule modules on a registry. Local folder feeds are scanned by +reading each nupkg's manifest (framework compatibility shown inline); remote +registries use the NuGet search API filtered by the `simplemodule-module` +tag (compatibility is verified definitively at `sm add` time). + +## sm upgrade + +```bash +sm upgrade [package-id] [--version ] [--source ] [--force] [--skip-migrations] +``` + +Upgrades one installed module (or all of them when no id is given): resolves +the highest stable version, validates the new manifest's `frameworkCompat` +range — **refusing violations unless `--force`** — bumps the CPM-aware +reference, rebuilds, and applies bundled migrations via the migrate-only run. + +## Doctor packaging checks + +`sm doctor` validates the packaging contract too: + +- **bundle externals** — source modules whose built bundles inline a React + copy (the duplicate-React failure mode) fail the check; +- **packaged module manifests** — unreadable/newer `schemaVersion` manifests + and framework-incompatible installed modules fail; +- **pending module migrations** — bundled migration ids are compared against + the SQLite `__EFMigrationsHistory` table (other providers get a + cannot-verify warning) and pending ones are listed with the command to run. + +## Full lifecycle walkthrough + +The complete loop, runnable against a local folder feed (`./feed`): + +```bash +# 1. New host + module +sm new project Shop && cd Shop && npm install +sm new module Catalog + +# 2. Pack & publish v1 +sm publish src/modules/Catalog --version 1.0.0 --source ../feed + +# 3. Discover and install (in any other SimpleModule host) +sm search catalog --source ../feed +sm add Catalog --version 1.0.0 --source ../feed +# → compat gate, CPM reference, build, migrations, doctor + +# 4. Ship v2 with a schema change: add an EF migration to the module +# (Migrations/_AddXyz.cs with [DbContext]+[Migration]), then +sm publish src/modules/Catalog --version 1.1.0 --source ../feed + +# 5. Upgrade — compat-checked, migrations applied +sm upgrade Catalog --source ../feed +sm doctor # "Migrations Catalog: N migration(s) applied" + +# 6. Uninstall (data stays, loudly) +sm remove Catalog +``` + +An incompatible upgrade (module built for a newer framework) is refused with +the reason and a `--force` override hint. diff --git a/docs/superpowers/plans/2026-06-10-module-packaging-session1.md b/docs/superpowers/plans/2026-06-10-module-packaging-session1.md new file mode 100644 index 00000000..f9a3d164 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-module-packaging-session1.md @@ -0,0 +1,298 @@ +# Module Packaging — Session 1 (Package Contract) 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:** Every SimpleModule module assembly carries a machine-readable JSON manifest emitted at compile time; the host discovers module frontend bundles via that manifest instead of filesystem probing; module DbContexts that ship EF migrations get them applied; an in-repo module (FeatureFlags) loads end-to-end as a packed nupkg from a local folder feed. + +**Architecture:** A new `ModuleManifestEmitter` in the Roslyn generator emits `[assembly: ModuleManifest("{json}")]` into each module assembly (source generators cannot emit literal embedded resources — the assembly-level attribute is the closest equivalent readable both via reflection and via `System.Reflection.Metadata` without loading; `sm pack` in Session 2 will additionally extract it to a `module-manifest.json` in the nupkg). The generator gains a `SimpleModuleProjectKind` analyzer-config switch: `Module` projects get ONLY the manifest emitter; hosts keep current behavior. Hosting builds a `ModuleManifestRegistry` from the DI-registered `IModule` instances and injects a `` right before the `data-page` script. Empty map → emit nothing. +- Test: integration test using `SimpleModuleWebApplicationFactory` — GET a view page, assert response HTML contains `id="sm-module-assets"` and the FeatureFlags entry. Place beside existing hosting/host integration tests (find with `grep -rl SimpleModuleWebApplicationFactory tests/ | head`). + +- [ ] Steps: failing test → implement → pass → **Commit** `feat(hosting): module manifest registry and frontend asset map injection` + +### Task 6: Client — manifest-first page resolution + +**Files:** +- Modify: `packages/SimpleModule.Client/src/resolve-page.ts` + +```typescript +let moduleAssets: Record | null | undefined; +function getModuleAssets(): Record | null { + if (moduleAssets !== undefined) return moduleAssets; + const el = document.getElementById('sm-module-assets'); + moduleAssets = el?.textContent ? JSON.parse(el.textContent) : null; + return moduleAssets; +} +// In resolvePage: before probing, const entry = getModuleAssets()?.[moduleName]; +// if entry → import(`/${entry}${suffix}`) and on success skip candidate loop. +// On failure (or no manifest) fall through to existing candidates probing. +``` + +- [ ] **Step 1:** implement (keep fallback intact, JSON.parse wrapped in try/catch → null). **Step 2:** `npm run check` green; `npm run dev:build` green. **Step 3: Commit** `feat(client): resolve module bundles via sm-module-assets manifest map` + +### Task 7: Database — apply migrations for module contexts that ship them + +**Files:** +- Modify: `framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs:189-209` + +```csharp +foreach (var info in infos) +{ + if (scope.ServiceProvider.GetService(info.DbContextType) is not DbContext db) + continue; + + var hasMigrations = db.Database.GetMigrations().Any(); + if (info.ModuleName == DatabaseConstants.HostModuleName) + { + if (hasMigrations) await db.Database.MigrateAsync(); + else await db.Database.EnsureCreatedAsync(); + } + else if (hasMigrations) + { + // Packaged modules ship their own EF migrations (EnsureCreated is not + // acceptable for installed modules). In-repo module contexts without + // migrations still get their schema from the unified HostDbContext. + await db.Database.MigrateAsync(); + } +} +``` + +- [ ] Test: existing host startup integration tests stay green (`dotnet test`), behavior unchanged for migration-less contexts. Document the EnsureCreated→Migrate transition caveat in the design doc. **Commit** `feat(hosting): apply EF migrations for module DbContexts that bundle them` + +### Task 8: Design doc + +**Files:** +- Create: `docs/site/advanced/module-packaging.md` — manifest schema v1 (table per field + JSON example), nupkg layout (lib/net10.0 assembly + contracts package + `staticwebassets/` tree + migrations inside the module assembly), frontend externals contract (react, react-dom, @inertiajs/react, SimpleModule.UI host-provided; validated at pack time in Session 2), version compat rules (`frameworkCompat` semantics, 0.x caveat, override property), the embedded-resource-vs-attribute deviation rationale, migration application contract, and the `simplemodule-module` tag convention. +- Check `docs/site/.vitepress/config.*` (or equivalent sidebar config) and add the page to the Advanced sidebar if pages are listed explicitly. + +- [ ] Write doc → `npm run check` (if docs are covered) → **Commit** `docs: module packaging contract (manifest schema v1, nupkg layout, externals)` + +### Task 9: Checkpoint — FeatureFlags as a packed nupkg + +- [ ] **Step 1:** Pack to a local feed (Version defaults to 1.0.0, matching project-reference identities so NuGet unifies Core deps with in-solution projects): + +```bash +FEED=$CLAUDE_JOB_DIR/tmp/local-feed && mkdir -p $FEED +dotnet pack modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts -o $FEED +dotnet pack modules/FeatureFlags/src/SimpleModule.FeatureFlags -o $FEED +unzip -l $FEED/SimpleModule.FeatureFlags.1.0.0.nupkg | grep -E "pages.js|dll" # static assets present +``` + +- [ ] **Step 2:** In `template/SimpleModule.Host/SimpleModule.Host.csproj` swap the two FeatureFlags `ProjectReference`s for `` (temporary, working tree only). +- [ ] **Step 3:** `dotnet restore template/SimpleModule.Host -p:RestoreAdditionalProjectSources=$FEED && dotnet build template/SimpleModule.Host` — green. +- [ ] **Step 4:** Run the host, then verify: `curl -sk https://localhost:5001/feature-flags` returns HTML containing `sm-module-assets` with the FeatureFlags entry; `curl -sk https://localhost:5001/_content/SimpleModule.FeatureFlags/SimpleModule.FeatureFlags.pages.js -o /dev/null -w '%{http_code}'` → 200. Drive the page in a browser (playwright) to confirm React renders. +- [ ] **Step 5:** Revert the Host csproj swap (`git checkout template/SimpleModule.Host/SimpleModule.Host.csproj`); record results in the checkpoint report. + +### Task 10: Full verification + +- [ ] `dotnet build` (TreatWarningsAsErrors — zero warnings) +- [ ] `dotnet test` (all) +- [ ] `npm run check` + `npm run validate-pages` + `npm run build:dev` +- [ ] Write checkpoint report: frozen API surface (ModuleManifestAttribute ctor, manifest schema v1 field set, `sm-module-assets` element id, `SimpleModuleProjectKind`/`SimpleModuleFrameworkCompat` build properties, `IModuleManifestRegistry`), framework friction found (GitHub issues labeled `packaging`), assumptions made. + +--- + +## Self-review notes + +- Spec coverage: manifest schema/emission ✔ (Tasks 1-4), frontend loading via manifest ✔ (5-6), migrations hook ✔ (7), design doc ✔ (8), checkpoint ✔ (9). Marketplace audit was completed pre-plan (module already deleted in b2698964; recommendation: leave deleted). +- Deviation from spec: "embedded resource" → assembly-level attribute (Roslyn limitation); documented in Task 8 and the checkpoint report. +- Out of scope kept out: no CLI commands (Session 2), no publish/search (Session 3), no custom registry. diff --git a/docs/superpowers/plans/2026-06-10-module-packaging-session2.md b/docs/superpowers/plans/2026-06-10-module-packaging-session2.md new file mode 100644 index 00000000..46a692e3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-module-packaging-session2.md @@ -0,0 +1,102 @@ +# Module Packaging — Session 2 (Pack & Add) 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:** `sm pack` produces a validated module nupkg (production frontend build, externals check, manifest check, tests); `sm add` installs a packaged module into a host (compat check first, CPM-aware reference, local-feed nuget.config, migration run, auto-doctor); `sm remove` reverses it with a schema warning; `sm list` shows packaged modules with compat status. + +**Architecture:** New CLI infrastructure shared with Session 3: `SmConfig` (sm.json registry abstraction), `NuGetClient` (V3 service-index + flat-container), `NupkgManifestReader`/`AssemblyManifestReader` (System.Reflection.Metadata — no assembly loading), `FrameworkCompatChecker` (SemVer ranges `>=X `/`AsyncCommand`, `SolutionContext.Discover()`, exit codes 0/1, AnsiConsole markup; tests exercise infra classes directly with temp dirs (no CommandApp). +- `sm install` already runs `dotnet add package` (kept as the dumb low-level command; `sm add` is the module-aware one). +- Scaffolded hosts use CPM (`Directory.Packages.props` with `SimpleModule.Core` pinned) — host framework version is readable from there; fallback `version.json`. +- Doctor: 12 `IDoctorCheck` classes returning `CheckResult(Name, Status, Message)`; auto-fix by name prefix. +- Externals actually host-provided today: react, react-dom, react/jsx-runtime, react-dom/client, @inertiajs/react (`packages/SimpleModule.Client/src/vite-plugin-vendor.ts:13`). `@simplemodule/ui` is NOT vendored → pack validates the react/inertia set; UI externalization filed as a packaging issue (out of scope here). +- In-repo modules: `modules/Directory.Build.targets` runs Vite before `GenerateNuspec`; default JsBuildCommand is a production build, but a stale dev stamp can leave dev bundles (issue #260) — `sm pack` always runs a fresh production Vite build first. + +--- + +### Task 1: CLI infra — SmConfig, FrameworkCompatChecker + +**Files:** Create `cli/SimpleModule.Cli/Infrastructure/SmConfig.cs`, `cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs`; tests `tests/SimpleModule.Cli.Tests/SmConfigTests.cs`, `FrameworkCompatCheckerTests.cs`. + +- `SmConfig.Load(solutionRoot)` → reads `sm.json` (`{"registry": "url"}`); missing file/field → default `https://api.nuget.org/v3/index.json`. `Save` for completeness. +- `FrameworkCompatChecker.IsCompatible(range, version)` parsing `>=X.Y.Z[-pre] =X.Y.Z`, empty range → compatible-with-warning (`CompatResult { Compatible, Reason }`). SemVer compare incl. numeric segments + prerelease ordering. +- TDD: tests first, then impl, commit. + +### Task 2: CLI infra — manifest readers (no assembly load) + +**Files:** Create `cli/SimpleModule.Cli/Infrastructure/AssemblyManifestReader.cs` (System.Reflection.Metadata: find assembly-level custom attribute whose type name is `SimpleModule.Core.Modules.ModuleManifestAttribute`, decode the single string fixed arg from the value blob), `cli/SimpleModule.Cli/Infrastructure/NupkgManifestReader.cs` (ZipArchive: prefer `module-manifest.json` at package root, else first `lib/*/.dll` via AssemblyManifestReader), plus a tiny `ModuleManifestData` DTO (CLI-local POCO mirroring schema v1 — the CLI does not reference SimpleModule.Core). +**Tests:** `AssemblyManifestReaderTests` emits a temp assembly via `PersistedAssemblyBuilder` carrying a same-named attribute with JSON; `NupkgManifestReaderTests` zips a fixture. + +### Task 3: CLI infra — PackageReferenceManipulator + NuGetConfigManipulator + HostFrameworkVersion + +**Files:** Create `cli/SimpleModule.Cli/Infrastructure/PackageReferenceManipulator.cs` (CPM detect: Directory.Packages.props w/ `ManagePackageVersionsCentrally=true` → add/update `` + versionless `` in host csproj; non-CPM → inline `Version`; `Remove*` counterparts; idempotent), `NuGetConfigManipulator.cs` (ensure `nuget.config` at root has a named `` source; create file with ``-free defaults if missing), `HostFrameworkVersionResolver.cs` (CPM `SimpleModule.Core` PackageVersion → else `version.json` → else null). +**Tests:** temp-dir round-trips for CPM and non-CPM, idempotency, removal. + +### Task 4: CLI infra — NuGetClient + BundleExternalsValidator + +**Files:** Create `cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs` — given registry service-index URL: resolve `PackageBaseAddress/3.0.0` resource, `GetVersionsAsync(id)`, `DownloadNupkgAsync(id, version, destPath)`; local-directory sources bypass HTTP (`FindLocalNupkg(dir, id, version?)` choosing highest version). Create `BundleExternalsValidator.cs` — scan built JS files (wwwroot `*.pages.js` + sibling chunks): FAIL when a file contains an inlined-React marker (`Symbol.for("react.element")`, `Symbol.for("react.transitional.element")`, `react.production`, `react.development`, `__CLIENT_INTERNALS_DO_NOT_USE`); WARN when no file imports `react` at all. Returns structured violations. +**Tests:** URL/version selection for local feed; validator against fixture strings (externalized vs inlined). + +### Task 5: framework — migrate-only hook + +**Files:** Modify `framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs`: in `UseSimpleModuleInfrastructure`, `var migrateOnly = Environment.GetEnvironmentVariable("SIMPLEMODULE_MIGRATE_ONLY") == "1"`; run the DB-init block when `migrateOnly || `; after init, if migrateOnly → log summary and `Environment.Exit(0)` (documented CLI-only entry point; addresses #258 for installs). +**Tests:** existing suites stay green (hook is env-gated); note in docs. + +### Task 6: `sm pack` + +**Files:** Create `cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs` + `PackSettings` (`[module-path]` arg; `--output`, `--version`, `--skip-tests`, `--configuration` default Release). Register in Program.cs. +Pipeline (each step fails closed with an actionable message): +1. Resolve module dir (arg path, or `modules/`-style lookup via SolutionContext; must contain exactly one impl csproj, optionally a sibling `.Contracts`). +2. Frontend: if `package.json` → run `npx vite build --configLoader runner` (production default) in module dir; then `BundleExternalsValidator` on `wwwroot/` output. +3. `dotnet build -c Release [-p:Version=X]`. +4. Unless `--skip-tests`: `dotnet test ` when present. +5. Read manifest from built dll (AssemblyManifestReader): require parseable JSON, `schemaVersion==1`, `id == assembly name`; if `frontendEntry` non-null require the wwwroot bundle exists. Write `module-manifest.json` to the module project dir (packed via the `None Pack` item added in modules/Directory.Build.props + module template). +6. `dotnet pack -c Release --no-build -o ` for impl (and Contracts when present); print nupkg paths. +Also: add to `modules/Directory.Build.props` (and the CLI module template csproj) ``, and gitignore `module-manifest.json`. +**Tests:** step orchestration is in small static helpers (`PackPipeline`) tested directly: module-dir resolution, manifest validation rules, output path handling. Subprocess steps mocked behind a `ProcessRunner` seam (`Func` injection or interface) so tests don't shell out. + +### Task 7: `sm add` + +**Files:** Create `cli/SimpleModule.Cli/Commands/Add/AddCommand.cs` + settings (``, `--version`, `--source`, `--skip-migrations`, `--skip-doctor`). Register in Program.cs. +Pipeline: +1. `SolutionContext.Discover()`; resolve source: `--source` (dir or URL) → else `sm.json` registry. +2. Obtain nupkg (local find / NuGet download to temp) → `NupkgManifestReader` → manifest. No manifest → fail: "not a SimpleModule module package". +3. Compat gate BEFORE any file change: `HostFrameworkVersionResolver` + `FrameworkCompatChecker`; incompatible → fail with both versions printed. +4. Local dir source → `NuGetConfigManipulator.EnsureSource`. +5. `PackageReferenceManipulator.Add` (CPM-aware) into host csproj. +6. `dotnet restore` + `dotnet build` host (ProcessRunner). +7. Unless `--skip-migrations` and when `manifest.hasDbContext`: run host with `SIMPLEMODULE_MIGRATE_ONLY=1` (`dotnet run --project --no-build`); non-zero → report + exit 1. +8. Unless `--skip-doctor`: run doctor checks in-process — refactor `DoctorCommand` to expose `static List RunChecks(SolutionContext)` reused by both commands. +Print summary: module display name, version, schema, permissions count, frontend entry. +**Tests:** decision logic helpers (source resolution, compat gating, reference wiring) against temp dirs; ProcessRunner seam mocked. + +### Task 8: `sm remove` + +**Files:** Create `cli/SimpleModule.Cli/Commands/Remove/RemoveCommand.cs` (``). Look up manifest from `~/.nuget/packages///lib/*/dll` (best effort) BEFORE removal to name the schema; `PackageReferenceManipulator.Remove` from host csproj + CPM entry; ALWAYS print a prominent warning: database schema `` and its tables were left in place (data is not dropped) + what was left. Exit 0. +**Tests:** removal round-trip + warning content via captured console (AnsiConsole.Record or just test the helper that builds the message). + +### Task 9: `sm list` packaged-modules section + +**Files:** Modify `cli/SimpleModule.Cli/Commands/List/ListCommand.cs`: after the source-modules table, parse host csproj `PackageReference`s (+ CPM versions); for each, try manifest from the global packages cache (`NUGET_PACKAGES` env or `~/.nuget/packages`); render second table: Package, Version, Module, Framework compat (range + ✓/✗ vs host version). Packages without a manifest are skipped (not SimpleModule modules). +**Tests:** the csproj/CPM parsing helper + compat rendering decision. + +### Task 10: Session 2 checkpoint (scratch host) + +1. `VER=0.0.99-local` — pack framework packages referenced by the scaffolded host template (`Core`, `Database`, `Hosting`, `Generator`, + whatever the template lists) from the worktree with `-p:Version=$VER` into `$CLAUDE_JOB_DIR/tmp/feed2`. +2. `sm pack modules/FeatureFlags --version $VER --output feed2` (from the worktree solution). +3. `sm new project Demo` in a temp dir pinned to `$VER` (use the version option on `new project`; verify its name first), npm install. +4. `cd Demo && sm add SimpleModule.FeatureFlags --version $VER --source feed2` → expect: compat gate passes, nuget.config gains the feed, CPM entry added, build OK, migrate-only run OK, doctor green. +5. `dotnet test` in Demo; run Demo host; Playwright: log in, `/feature-flags/manage` renders. +6. `sm remove SimpleModule.FeatureFlags` → reference gone, schema warning printed. + +### Task 11: verification + docs + +- Full `dotnet build` (0 warnings), CLI tests (`dotnet run --project tests/SimpleModule.Cli.Tests`), all other suites, `npm run check`, `validate-pages`. +- Update `docs/site/cli/` with pack/add/remove/list reference page; extend `docs/site/advanced/module-packaging.md` (module-manifest.json in nupkg, migrate-only hook). +- Commits per task; checkpoint report; then `/code-review`. + +**Out of scope (Session 3):** publish/search/upgrade, doctor packaging checks beyond reuse, marketplace registration. diff --git a/framework/SimpleModule.Core/ModuleAttribute.cs b/framework/SimpleModule.Core/ModuleAttribute.cs index f545f403..1fe0f76f 100644 --- a/framework/SimpleModule.Core/ModuleAttribute.cs +++ b/framework/SimpleModule.Core/ModuleAttribute.cs @@ -10,6 +10,9 @@ public sealed class ModuleAttribute : Attribute public string RoutePrefix { get; set; } = ""; public string ViewPrefix { get; set; } = ""; + /// Human-readable module name for UIs and the module manifest. Defaults to . + public string DisplayName { get; set; } = ""; + public ModuleAttribute(string name, string version = "1.0.0") { Name = name; diff --git a/framework/SimpleModule.Core/Modules/IModuleManifestRegistry.cs b/framework/SimpleModule.Core/Modules/IModuleManifestRegistry.cs new file mode 100644 index 00000000..f93b7664 --- /dev/null +++ b/framework/SimpleModule.Core/Modules/IModuleManifestRegistry.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace SimpleModule.Core.Modules; + +/// +/// Runtime access to the compile-time manifests of all loaded modules. +/// Modules compiled before manifest emission existed simply have no entry. +/// +public interface IModuleManifestRegistry +{ + IReadOnlyList Manifests { get; } + + /// Returns the manifest for the given module name, or null when absent. + ModuleManifest? Get(string moduleName); +} diff --git a/framework/SimpleModule.Core/Modules/ModuleManifest.cs b/framework/SimpleModule.Core/Modules/ModuleManifest.cs new file mode 100644 index 00000000..89d1545f --- /dev/null +++ b/framework/SimpleModule.Core/Modules/ModuleManifest.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; + +namespace SimpleModule.Core.Modules; + +/// +/// Compile-time metadata describing a module: identity, framework compatibility, +/// declared permissions, frontend entry asset, and the domain events it publishes +/// and consumes. Emitted into each module assembly by SimpleModule.Generator as a +/// and read back via +/// . +/// +public sealed class ModuleManifest +{ + /// Manifest schema version this assembly was compiled against. + public int SchemaVersion { get; init; } + + /// Package/assembly identity, e.g. SimpleModule.FeatureFlags. + public string Id { get; init; } = ""; + + /// Module name from the [Module] attribute, e.g. FeatureFlags. + public string Name { get; init; } = ""; + + /// Human-readable name; defaults to when not customized. + public string DisplayName { get; init; } = ""; + + /// Module version from the [Module] attribute. + public string Version { get; init; } = ""; + + /// + /// SemVer range of SimpleModule.Core versions this module was built for, + /// e.g. >=0.0.38 <1.0.0. + /// + public string FrameworkCompat { get; init; } = ""; + + /// API route prefix, e.g. /api/feature-flags. + public string RoutePrefix { get; init; } = ""; + + /// View route prefix, e.g. /feature-flags. + public string ViewPrefix { get; init; } = ""; + + /// + /// Database schema/prefix name — the module name used as the + /// ModuleConnections configuration key. + /// + public string Schema { get; init; } = ""; + + /// Permission values declared by the module's permission classes. + public IReadOnlyList Permissions { get; init; } = []; + + /// + /// Static web asset path of the module's prebuilt frontend bundle relative to + /// the web root (e.g. _content/SimpleModule.X/SimpleModule.X.pages.js), + /// or null when the module ships no frontend pages. + /// + public string? FrontendEntry { get; init; } + + /// Inertia page names served by the module, e.g. X/Browse. + public IReadOnlyList Pages { get; init; } = []; + + /// Fully-qualified names of DomainEvent types declared by the module. + public IReadOnlyList EventsPublished { get; init; } = []; + + /// Fully-qualified names of DomainEvent types handled by the module. + public IReadOnlyList EventsConsumed { get; init; } = []; + + /// Whether the module owns its own DbContext. + public bool HasDbContext { get; init; } +} diff --git a/framework/SimpleModule.Core/Modules/ModuleManifestAttribute.cs b/framework/SimpleModule.Core/Modules/ModuleManifestAttribute.cs new file mode 100644 index 00000000..89c506e7 --- /dev/null +++ b/framework/SimpleModule.Core/Modules/ModuleManifestAttribute.cs @@ -0,0 +1,15 @@ +using System; + +namespace SimpleModule.Core.Modules; + +/// +/// Carries the compile-time module manifest JSON emitted by SimpleModule.Generator. +/// Assembly-level so tooling can read it via System.Reflection.Metadata without +/// loading the assembly. Source generators cannot add embedded resources, which is +/// why the manifest travels as an attribute rather than a resource stream. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] +public sealed class ModuleManifestAttribute(string json) : Attribute +{ + public string Json { get; } = json; +} diff --git a/framework/SimpleModule.Core/Modules/ModuleManifestReader.cs b/framework/SimpleModule.Core/Modules/ModuleManifestReader.cs new file mode 100644 index 00000000..9803fe3b --- /dev/null +++ b/framework/SimpleModule.Core/Modules/ModuleManifestReader.cs @@ -0,0 +1,74 @@ +using System; +using System.Reflection; +using System.Text.Json; + +namespace SimpleModule.Core.Modules; + +/// Thrown when a module manifest cannot be parsed or is incompatible. +public sealed class ModuleManifestException : Exception +{ + public ModuleManifestException(string message) + : base(message) { } + + public ModuleManifestException(string message, Exception innerException) + : base(message, innerException) { } + + public ModuleManifestException() { } +} + +/// +/// Reads instances from manifest JSON or from the +/// on a module assembly. +/// +public static class ModuleManifestReader +{ + /// Highest manifest schema version this framework build understands. + public const int CurrentSchemaVersion = 1; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + public static ModuleManifest Parse(string json) + { + ModuleManifest? manifest; + try + { + manifest = JsonSerializer.Deserialize(json, SerializerOptions); + } + catch (JsonException ex) + { + throw new ModuleManifestException("Module manifest is not valid JSON.", ex); + } + + if (manifest is null) + { + throw new ModuleManifestException("Module manifest JSON deserialized to null."); + } + + if (manifest.SchemaVersion > CurrentSchemaVersion) + { + throw new ModuleManifestException( + $"Module manifest schemaVersion {manifest.SchemaVersion} is newer than the " + + $"highest supported version {CurrentSchemaVersion}. Update the SimpleModule " + + "framework packages in the host to use this module." + ); + } + + return manifest; + } + + /// + /// Reads the manifest from , or returns null + /// when the assembly carries no (e.g. a + /// module compiled before manifest emission existed). + /// + public static ModuleManifest? TryRead(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + var attribute = assembly.GetCustomAttribute(); + return attribute is null ? null : Parse(attribute.Json); + } +} diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index cbbc651c..81ce92bf 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -48,10 +48,13 @@ internal readonly record struct DiscoveryData( ImmutableArray AgentToolProviders, ImmutableArray KnowledgeSources, ImmutableArray FormRequests, + ImmutableArray EventTypes, + ImmutableArray EventHandlers, ImmutableArray ContractsAssemblyNames, bool HasAgentsAssembly, bool HasRagAssembly, - string HostAssemblyName + string HostAssemblyName, + string CoreAssemblyVersion ) { public bool HasAnyAgentContent => @@ -81,9 +84,12 @@ string HostAssemblyName ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, ImmutableArray.Empty, false, false, + "", "" ); @@ -106,10 +112,13 @@ public bool Equals(DiscoveryData other) && AgentToolProviders.SequenceEqual(other.AgentToolProviders) && KnowledgeSources.SequenceEqual(other.KnowledgeSources) && FormRequests.SequenceEqual(other.FormRequests) + && EventTypes.SequenceEqual(other.EventTypes) + && EventHandlers.SequenceEqual(other.EventHandlers) && ContractsAssemblyNames.SequenceEqual(other.ContractsAssemblyNames) && HasAgentsAssembly == other.HasAgentsAssembly && HasRagAssembly == other.HasRagAssembly - && HostAssemblyName == other.HostAssemblyName; + && HostAssemblyName == other.HostAssemblyName + && CoreAssemblyVersion == other.CoreAssemblyVersion; } public override int GetHashCode() @@ -132,10 +141,13 @@ public override int GetHashCode() hash = HashHelper.HashArray(hash, AgentToolProviders); hash = HashHelper.HashArray(hash, KnowledgeSources); hash = HashHelper.HashArray(hash, FormRequests); + hash = HashHelper.HashArray(hash, EventTypes); + hash = HashHelper.HashArray(hash, EventHandlers); hash = HashHelper.HashArray(hash, ContractsAssemblyNames); hash = HashHelper.Combine(hash, HasAgentsAssembly.GetHashCode()); hash = HashHelper.Combine(hash, HasRagAssembly.GetHashCode()); hash = HashHelper.Combine(hash, (HostAssemblyName ?? "").GetHashCode()); + hash = HashHelper.Combine(hash, (CoreAssemblyVersion ?? "").GetHashCode()); return hash; } } diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs index 1a577891..714d3d2b 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs @@ -30,10 +30,13 @@ internal static DiscoveryData Build( List agentToolProviders, List knowledgeSources, List formRequests, + List eventTypes, + List eventHandlers, Dictionary contractsAssemblyMap, bool hasAgentsAssembly, bool hasRagAssembly, - string hostAssemblyName + string hostAssemblyName, + string coreAssemblyVersion ) { return new DiscoveryData( @@ -53,6 +56,8 @@ string hostAssemblyName m.HasConfigureRateLimits, m.RoutePrefix, m.ViewPrefix, + m.DisplayName, + m.Version, m.Endpoints.Select(e => new EndpointInfoRecord( e.FullyQualifiedName, e.RequiredPermissions.ToImmutableArray(), @@ -184,10 +189,13 @@ string hostAssemblyName f.Location )) .ToImmutableArray(), + eventTypes.ToImmutableArray(), + eventHandlers.ToImmutableArray(), contractsAssemblyMap.Keys.ToImmutableArray(), hasAgentsAssembly, hasRagAssembly, - hostAssemblyName + hostAssemblyName, + coreAssemblyVersion ); } } diff --git a/framework/SimpleModule.Generator/Discovery/Finders/EventFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/EventFinder.cs new file mode 100644 index 00000000..ca6a0020 --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/Finders/EventFinder.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +/// +/// Discovers domain events a module publishes (types implementing +/// SimpleModule.Core.Events.IEvent declared in the module's implementation +/// or contracts assembly) and consumes (first parameters of Wolverine-convention +/// handler methods: classes named *Handler/*Consumer with a public +/// Handle/HandleAsync/Consume/ConsumeAsync method). +/// +internal static class EventFinder +{ + private static readonly string[] HandlerMethodNames = + [ + "Handle", + "HandleAsync", + "Consume", + "ConsumeAsync", + ]; + + internal static void Discover( + List modules, + Dictionary moduleSymbols, + Dictionary contractsAssemblySymbols, + Dictionary contractsAssemblyMap, + CoreSymbols s, + List eventTypes, + List eventHandlers, + CancellationToken cancellationToken + ) + { + if (s.EventInterface is null) + return; + + // An assembly is walked once even if it declares several [Module] classes; + // its events are attributed to the first module encountered. + var walkedAssemblies = new HashSet(StringComparer.Ordinal); + + foreach (var module in modules) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var moduleSymbol)) + continue; + + var implAssembly = moduleSymbol.ContainingAssembly; + if (walkedAssemblies.Add(implAssembly.Name)) + { + Walk( + implAssembly.GlobalNamespace, + s.EventInterface, + module.ModuleName, + eventTypes, + eventHandlers, + cancellationToken + ); + } + } + + foreach (var kvp in contractsAssemblyMap) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!contractsAssemblySymbols.TryGetValue(kvp.Key, out var contractsAssembly)) + continue; + + if (walkedAssemblies.Add(contractsAssembly.Name)) + { + Walk( + contractsAssembly.GlobalNamespace, + s.EventInterface, + kvp.Value, + eventTypes, + eventHandlers, + cancellationToken + ); + } + } + } + + private static void Walk( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol eventInterface, + string moduleName, + List eventTypes, + List eventHandlers, + CancellationToken cancellationToken + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (member is INamespaceSymbol childNs) + { + Walk( + childNs, + eventInterface, + moduleName, + eventTypes, + eventHandlers, + cancellationToken + ); + continue; + } + + if (member is not INamedTypeSymbol typeSymbol || typeSymbol.TypeKind != TypeKind.Class) + continue; + + if ( + !typeSymbol.IsAbstract + && SymbolHelpers.ImplementsInterface(typeSymbol, eventInterface) + ) + { + eventTypes.Add( + new EventTypeRecord( + typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + moduleName + ) + ); + continue; + } + + if ( + typeSymbol.Name.EndsWith("Handler", StringComparison.Ordinal) + || typeSymbol.Name.EndsWith("Consumer", StringComparison.Ordinal) + ) + { + CollectHandledEvents(typeSymbol, eventInterface, moduleName, eventHandlers); + } + } + } + + private static void CollectHandledEvents( + INamedTypeSymbol handlerType, + INamedTypeSymbol eventInterface, + string moduleName, + List eventHandlers + ) + { + foreach (var member in handlerType.GetMembers()) + { + if ( + member is not IMethodSymbol method + || method.DeclaredAccessibility != Accessibility.Public + || method.IsStatic + || method.Parameters.Length == 0 + || Array.IndexOf(HandlerMethodNames, method.Name) < 0 + ) + continue; + + if ( + method.Parameters[0].Type is INamedTypeSymbol eventType + && SymbolHelpers.ImplementsInterface(eventType, eventInterface) + ) + { + eventHandlers.Add( + new EventHandlerRecord( + eventType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + moduleName + ) + ); + } + } + } +} diff --git a/framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs index 82d93654..2ac60deb 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs @@ -37,8 +37,13 @@ CancellationToken cancellationToken attr.ConstructorArguments.Length > 0 ? attr.ConstructorArguments[0].Value as string ?? "" : ""; + var moduleVersion = + attr.ConstructorArguments.Length > 1 + ? attr.ConstructorArguments[1].Value as string ?? "" + : ""; var routePrefix = ""; var viewPrefix = ""; + var displayName = ""; foreach (var namedArg in attr.NamedArguments) { if ( @@ -55,6 +60,13 @@ CancellationToken cancellationToken { viewPrefix = vPrefix; } + else if ( + namedArg.Key == "DisplayName" + && namedArg.Value.Value is string dName + ) + { + displayName = dName; + } } modules.Add( @@ -122,6 +134,8 @@ symbols.ModuleSettings is not null ), RoutePrefix = routePrefix, ViewPrefix = viewPrefix, + DisplayName = displayName, + Version = moduleVersion, AssemblyName = typeSymbol.ContainingAssembly.Name, Location = SymbolHelpers.GetSourceLocation(typeSymbol), } diff --git a/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs b/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs index c1888640..31898b9c 100644 --- a/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs +++ b/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs @@ -18,6 +18,8 @@ internal readonly record struct ModuleInfoRecord( bool HasConfigureRateLimits, string RoutePrefix, string ViewPrefix, + string DisplayName, + string Version, ImmutableArray Endpoints, ImmutableArray Views, SourceLocationRecord? Location @@ -39,6 +41,8 @@ public bool Equals(ModuleInfoRecord other) && HasConfigureRateLimits == other.HasConfigureRateLimits && RoutePrefix == other.RoutePrefix && ViewPrefix == other.ViewPrefix + && DisplayName == other.DisplayName + && Version == other.Version && Endpoints.SequenceEqual(other.Endpoints) && Views.SequenceEqual(other.Views) && Location == other.Location; @@ -61,6 +65,8 @@ public override int GetHashCode() hash = HashHelper.Combine(hash, HasConfigureRateLimits.GetHashCode()); hash = HashHelper.Combine(hash, (RoutePrefix ?? "").GetHashCode()); hash = HashHelper.Combine(hash, (ViewPrefix ?? "").GetHashCode()); + hash = HashHelper.Combine(hash, (DisplayName ?? "").GetHashCode()); + hash = HashHelper.Combine(hash, (Version ?? "").GetHashCode()); hash = HashHelper.HashArray(hash, Endpoints); hash = HashHelper.HashArray(hash, Views); hash = HashHelper.Combine(hash, Location.GetHashCode()); @@ -110,6 +116,13 @@ internal readonly record struct ModuleDependencyRecord( string ContractsAssemblyName ); +internal readonly record struct EventTypeRecord(string FullyQualifiedName, string ModuleName); + +internal readonly record struct EventHandlerRecord( + string EventFullyQualifiedName, + string ModuleName +); + internal readonly record struct IllegalModuleReferenceRecord( string ReferencingModuleName, string ReferencingAssemblyName, diff --git a/framework/SimpleModule.Generator/Discovery/Records/WorkingTypes.cs b/framework/SimpleModule.Generator/Discovery/Records/WorkingTypes.cs index f70ca313..d93f8c14 100644 --- a/framework/SimpleModule.Generator/Discovery/Records/WorkingTypes.cs +++ b/framework/SimpleModule.Generator/Discovery/Records/WorkingTypes.cs @@ -21,6 +21,8 @@ internal sealed class ModuleInfo public bool HasConfigureRateLimits { get; set; } public string RoutePrefix { get; set; } = ""; public string ViewPrefix { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string Version { get; set; } = ""; public List Endpoints { get; set; } = new(); public List Views { get; set; } = new(); public SourceLocationRecord? Location { get; set; } diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 45a42e41..0f1bd450 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -36,6 +36,19 @@ CancellationToken cancellationToken contractsAssemblies.Add(asm); } + // The referenced SimpleModule.Core version anchors the default framework + // compatibility range in emitted module manifests. + var coreAssemblyVersion = ""; + foreach (var identity in compilation.ReferencedAssemblyNames) + { + if (string.Equals(identity.Name, "SimpleModule.Core", StringComparison.Ordinal)) + { + coreAssemblyVersion = + $"{identity.Version.Major}.{identity.Version.Minor}.{identity.Version.Build}"; + break; + } + } + var modules = new List(); foreach (var assemblySymbol in refAssemblies) @@ -234,6 +247,21 @@ CancellationToken cancellationToken knowledgeSources ); + // Step 3i: Domain events published (IEvent implementors) and consumed + // (Wolverine-convention handler first parameters) per module + var eventTypes = new List(); + var eventHandlers = new List(); + EventFinder.Discover( + modules, + moduleSymbols, + contractsAssemblySymbols, + contractsAssemblyMap, + s, + eventTypes, + eventHandlers, + cancellationToken + ); + // Step 4: Detect dependencies and illegal references var dependencies = new List(); var illegalReferences = new List(); @@ -264,10 +292,13 @@ CancellationToken cancellationToken agentToolProviders, knowledgeSources, formRequests, + eventTypes, + eventHandlers, contractsAssemblyMap, s.HasAgentsAssembly, s.HasRagAssembly, - hostAssemblyName + hostAssemblyName, + coreAssemblyVersion ); } } diff --git a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs index d96b94d7..43974cc3 100644 --- a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs @@ -144,7 +144,11 @@ string ModuleName } else { - sb.AppendLine("public class HostDbContext("); + // Always partial: ValueConverterConventionsEmitter contributes a + // partial ConfigureConventions whenever Vogen value objects exist, + // independent of whether an Identity context is present (CS0260 + // in non-identity hosts otherwise). + sb.AppendLine("public partial class HostDbContext("); sb.AppendLine(" DbContextOptions options,"); sb.AppendLine(" IOptions dbOptions"); sb.AppendLine(") : DbContext(options)"); diff --git a/framework/SimpleModule.Generator/Emitters/ModuleManifestEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleManifestEmitter.cs new file mode 100644 index 00000000..34ef6364 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/ModuleManifestEmitter.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +/// +/// Emits the module manifest as an assembly-level +/// [assembly: ModuleManifest("{json}")] attribute into module-kind +/// compilations (projects built with SimpleModuleProjectKind=Module). +/// Source generators cannot add embedded resources, so the attribute is the +/// closest in-assembly equivalent — readable at runtime via reflection and by +/// tooling via System.Reflection.Metadata without loading the assembly. +/// +internal static class ModuleManifestEmitter +{ + internal const int SchemaVersion = 1; + + internal static void Emit( + SourceProductionContext context, + DiscoveryData data, + string frameworkCompatOverride + ) + { + // Only the module declared in THIS compilation gets a manifest; modules + // visible through references belong to other assemblies. The attribute + // allows one manifest per assembly, so a multi-module assembly (not a + // supported packaging shape) gets a manifest for its first module. + var module = data.Modules.FirstOrDefault(m => m.AssemblyName == data.HostAssemblyName); + if (module.ModuleName is null or "") + return; + + var json = BuildManifestJson(module, data, frameworkCompatOverride); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine( + $"[assembly: global::SimpleModule.Core.Modules.ModuleManifest({ToCSharpLiteral(json)})]" + ); + + context.AddSource("ModuleManifest.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } + + private static string BuildManifestJson( + ModuleInfoRecord module, + DiscoveryData data, + string frameworkCompatOverride + ) + { + var permissions = data + .PermissionClasses.Where(p => p.ModuleName == module.ModuleName) + .SelectMany(p => p.Fields) + .Where(f => f.IsConstString && !string.IsNullOrEmpty(f.Value)) + .Select(f => f.Value) + .Distinct(StringComparer.Ordinal) + .OrderBy(v => v, StringComparer.Ordinal) + .ToList(); + + var pages = module + .Views.Select(v => v.Page) + .Where(p => !string.IsNullOrEmpty(p)) + .Distinct(StringComparer.Ordinal) + .OrderBy(p => p, StringComparer.Ordinal) + .ToList(); + + var eventsPublished = data + .EventTypes.Where(e => e.ModuleName == module.ModuleName) + .Select(e => StripGlobal(e.FullyQualifiedName)) + .Distinct(StringComparer.Ordinal) + .OrderBy(e => e, StringComparer.Ordinal) + .ToList(); + + var eventsConsumed = data + .EventHandlers.Where(h => h.ModuleName == module.ModuleName) + .Select(h => StripGlobal(h.EventFullyQualifiedName)) + .Distinct(StringComparer.Ordinal) + .OrderBy(e => e, StringComparer.Ordinal) + .ToList(); + + var hasDbContext = data.DbContexts.Any(c => c.ModuleName == module.ModuleName); + + var frontendEntry = + module.Views.Length > 0 + ? $"_content/{module.AssemblyName}/{module.AssemblyName}.pages.js" + : null; + + var displayName = string.IsNullOrEmpty(module.DisplayName) + ? module.ModuleName + : module.DisplayName; + + var frameworkCompat = !string.IsNullOrEmpty(frameworkCompatOverride) + ? frameworkCompatOverride + : DefaultFrameworkCompat(data.CoreAssemblyVersion); + + var sb = new StringBuilder(); + sb.Append('{'); + AppendProperty(sb, "schemaVersion", SchemaVersion); + AppendProperty(sb, "id", module.AssemblyName); + AppendProperty(sb, "name", module.ModuleName); + AppendProperty(sb, "displayName", displayName); + AppendProperty(sb, "version", module.Version); + AppendProperty(sb, "frameworkCompat", frameworkCompat); + AppendProperty(sb, "routePrefix", module.RoutePrefix); + AppendProperty(sb, "viewPrefix", module.ViewPrefix); + AppendProperty(sb, "schema", module.ModuleName); + AppendArrayProperty(sb, "permissions", permissions); + if (frontendEntry is null) + { + sb.Append("\"frontendEntry\":null,"); + } + else + { + AppendProperty(sb, "frontendEntry", frontendEntry); + } + AppendArrayProperty(sb, "pages", pages); + AppendArrayProperty(sb, "eventsPublished", eventsPublished); + AppendArrayProperty(sb, "eventsConsumed", eventsConsumed); + sb.Append("\"hasDbContext\":").Append(hasDbContext ? "true" : "false"); + sb.Append('}'); + return sb.ToString(); + } + + private static string DefaultFrameworkCompat(string coreVersion) + { + if (string.IsNullOrEmpty(coreVersion)) + return ""; + + // The assembly identity carries no prerelease tag, so the referenced Core + // may actually be a prerelease of this version. "-0" is SemVer's smallest + // prerelease: the bound admits prereleases of coreVersion itself, encoding + // that semantic in the manifest rather than in each consumer. + var majorPart = coreVersion.Split('.')[0]; + return int.TryParse(majorPart, out var major) + ? $">={coreVersion}-0 <{major + 1}.0.0" + : $">={coreVersion}-0"; + } + + private static string StripGlobal(string fullyQualifiedName) => + fullyQualifiedName.StartsWith("global::", StringComparison.Ordinal) + ? fullyQualifiedName.Substring("global::".Length) + : fullyQualifiedName; + + private static void AppendProperty(StringBuilder sb, string name, int value) => + sb.Append('"').Append(name).Append("\":").Append(value).Append(','); + + private static void AppendProperty(StringBuilder sb, string name, string value) => + sb.Append('"').Append(name).Append("\":").Append(JsonString(value)).Append(','); + + private static void AppendArrayProperty(StringBuilder sb, string name, List values) + { + sb.Append('"').Append(name).Append("\":["); + for (var i = 0; i < values.Count; i++) + { + if (i > 0) + sb.Append(','); + sb.Append(JsonString(values[i])); + } + sb.Append("],"); + } + + private static string JsonString(string value) + { + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + foreach (var c in value) + { + switch (c) + { + case '"': + sb.Append("\\\""); + break; + case '\\': + sb.Append("\\\\"); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + default: + if (c < 0x20) + { + sb.Append("\\u") + .Append( + ((int)c).ToString( + "x4", + System.Globalization.CultureInfo.InvariantCulture + ) + ); + } + else + { + sb.Append(c); + } + break; + } + } + sb.Append('"'); + return sb.ToString(); + } + + /// + /// Renders the JSON as a verbatim C# string literal for the attribute argument. + /// + private static string ToCSharpLiteral(string json) => "@\"" + json.Replace("\"", "\"\"") + "\""; +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index 8bb49255..148145d5 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -39,13 +39,42 @@ public void Initialize(IncrementalGeneratorInitializationContext context) SymbolDiscovery.Extract(compilation, cancellationToken) ); + // MSBuild properties surfaced via : + // SimpleModuleProjectKind = "Module" switches the generator from host + // emission (AddModules, endpoint maps, ...) to emitting only the module + // manifest attribute into the module's own assembly. + // SimpleModuleFrameworkCompat overrides the manifest's compat range. + var optionsProvider = context.AnalyzerConfigOptionsProvider.Select( + static (provider, _) => + { + provider.GlobalOptions.TryGetValue( + "build_property.SimpleModuleProjectKind", + out var kind + ); + provider.GlobalOptions.TryGetValue( + "build_property.SimpleModuleFrameworkCompat", + out var compat + ); + return (Kind: kind ?? "", Compat: compat ?? ""); + } + ); + context.RegisterSourceOutput( - dataProvider, - static (spc, data) => + dataProvider.Combine(optionsProvider), + static (spc, pair) => { + var (data, options) = pair; if (data.Modules.Length == 0) return; + if ( + string.Equals(options.Kind, "Module", System.StringComparison.OrdinalIgnoreCase) + ) + { + ModuleManifestEmitter.Emit(spc, data, options.Compat); + return; + } + foreach (var emitter in Emitters) { emitter.Emit(spc, data); diff --git a/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs b/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs index 1b33770c..6c5d7580 100644 --- a/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs +++ b/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs @@ -1,9 +1,11 @@ using System.Text; +using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using SimpleModule.Core.Inertia; +using SimpleModule.Core.Modules; using SimpleModule.Core.Security; using SimpleModule.DevTools; @@ -21,9 +23,15 @@ public sealed class HtmlFileInertiaPageRenderer : IInertiaPageRenderer private readonly string _beforePlaceholderViteDev; private readonly string _afterPlaceholderViteDev; private readonly bool _isDevelopment; + private readonly string? _moduleAssetsJson; - public HtmlFileInertiaPageRenderer(IWebHostEnvironment env) + public HtmlFileInertiaPageRenderer( + IWebHostEnvironment env, + IModuleManifestRegistry manifestRegistry + ) { + _moduleAssetsJson = BuildModuleAssetsJson(manifestRegistry); + var path = Path.Combine(env.WebRootPath, "index.html"); var html = File.ReadAllText(path); @@ -82,10 +90,18 @@ public Task RenderPageAsync(HttpContext httpContext, string pageJson) ? "" : ""; + // Module bundle map for the client-side page resolver — lets it import the + // exact asset path from each module's manifest instead of probing + // /_content/ naming conventions. type="application/json" is inert data. + var moduleAssetsScript = _moduleAssetsJson is null + ? "" + : $""; + httpContext.Response.ContentType = "text/html; charset=utf-8"; return httpContext.Response.WriteAsync( string.Concat( before.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal), + moduleAssetsScript, $"", devScript, after.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal) @@ -93,6 +109,18 @@ public Task RenderPageAsync(HttpContext httpContext, string pageJson) ); } + private static string? BuildModuleAssetsJson(IModuleManifestRegistry manifestRegistry) + { + var assets = new SortedDictionary(StringComparer.Ordinal); + foreach (var manifest in manifestRegistry.Manifests) + { + if (!string.IsNullOrEmpty(manifest.FrontendEntry)) + assets[manifest.Name] = manifest.FrontendEntry; + } + + return assets.Count == 0 ? null : JsonSerializer.Serialize(assets); + } + private static string BuildModuleCssLinks(IWebHostEnvironment env, string version) { var contents = env.WebRootFileProvider.GetDirectoryContents("_content"); diff --git a/framework/SimpleModule.Hosting/Modules/ModuleManifestRegistry.cs b/framework/SimpleModule.Hosting/Modules/ModuleManifestRegistry.cs new file mode 100644 index 00000000..e2a41f53 --- /dev/null +++ b/framework/SimpleModule.Hosting/Modules/ModuleManifestRegistry.cs @@ -0,0 +1,50 @@ +using SimpleModule.Core; +using SimpleModule.Core.Modules; + +namespace SimpleModule.Hosting.Modules; + +/// +/// Builds the manifest registry from the registered +/// instances by reading each module assembly's . +/// +public sealed class ModuleManifestRegistry : IModuleManifestRegistry +{ + private readonly Dictionary _byName; + + public ModuleManifestRegistry(IEnumerable modules) + { + ArgumentNullException.ThrowIfNull(modules); + + _byName = new Dictionary(StringComparer.Ordinal); + var seenAssemblies = new HashSet(StringComparer.Ordinal); + foreach (var module in modules) + { + var assembly = module.GetType().Assembly; + if (!seenAssemblies.Add(assembly.FullName ?? assembly.GetName().Name ?? "")) + continue; + + ModuleManifest? manifest; + try + { + manifest = ModuleManifestReader.TryRead(assembly); + } + catch (ModuleManifestException) + { + // One unreadable manifest (newer schemaVersion, corrupt JSON) must + // not take down every page render — the module simply behaves like + // a pre-manifest module and falls back to convention resolution. + continue; + } + + if (manifest is not null && !_byName.ContainsKey(manifest.Name)) + _byName[manifest.Name] = manifest; + } + + Manifests = [.. _byName.Values]; + } + + public IReadOnlyList Manifests { get; } + + public ModuleManifest? Get(string moduleName) => + _byName.TryGetValue(moduleName, out var manifest) ? manifest : null; +} diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index c4c36e65..791d8815 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -10,6 +10,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Constants; using SimpleModule.Core.Exceptions; @@ -17,6 +19,7 @@ using SimpleModule.Core.Inertia; using SimpleModule.Core.Maintenance; using SimpleModule.Core.Menu; +using SimpleModule.Core.Modules; using SimpleModule.Core.RateLimiting; using SimpleModule.Core.Security; using SimpleModule.Database; @@ -27,6 +30,7 @@ using SimpleModule.Hosting.Inertia; using SimpleModule.Hosting.Maintenance; using SimpleModule.Hosting.Middleware; +using SimpleModule.Hosting.Modules; using SimpleModule.Hosting.RateLimiting; using Wolverine; using ZiggyCreatures.Caching.Fusion; @@ -113,6 +117,13 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( builder.Services.AddSingleton(); + // Compile-time module manifests, read from each module assembly's + // [assembly: ModuleManifest] attribute. Resolved lazily so registration + // order relative to the generated AddModules() does not matter. + builder.Services.AddSingleton(sp => new ModuleManifestRegistry( + sp.GetServices() + )); + // Unified caching abstraction (IFusionCache) shared across all modules. // Stampede-safe GetOrSetAsync built in; five-minute default entry duration. builder @@ -220,14 +231,24 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) // Database initialization // SQLite (file-based) always needs auto-initialization since the DB file may not exist. // Managed databases (PostgreSQL, SQL Server) skip this in production — apply migrations externally. + // SIMPLEMODULE_MIGRATE_ONLY=1 is the CLI's migration entry point (`sm add`/`sm upgrade`): + // it forces database initialization regardless of environment, then exits without + // serving traffic — the deterministic migration hook for installed packaged modules. + var migrateOnly = Environment.GetEnvironmentVariable("SIMPLEMODULE_MIGRATE_ONLY") == "1"; var smOptions = app.Services.GetRequiredService(); if ( - !app.Environment.IsProduction() + migrateOnly + || !app.Environment.IsProduction() || smOptions.DatabaseProvider == DatabaseProvider.Sqlite ) { using var scope = app.Services.CreateScope(); - var infos = scope.ServiceProvider.GetServices(); + // Host context FIRST: if a packaged module's MigrateAsync ran before the + // host's EnsureCreatedAsync on a fresh database, EnsureCreated would see a + // non-empty database and silently skip creating the host tables. + var infos = scope + .ServiceProvider.GetServices() + .OrderBy(i => i.ModuleName == DatabaseConstants.HostModuleName ? 0 : 1); foreach (var info in infos) { @@ -236,11 +257,14 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) // DbContexts with EF migrations use MigrateAsync; those without (e.g. scaffolded // module contexts that ship no migrations) fall back to EnsureCreatedAsync so - // their tables exist on first run. Only the Host context typically ships - // migrations; module contexts rely on the unified HostDbContext for schema. + // their tables exist on first run. In-repo module contexts ship no migrations + // and rely on the unified HostDbContext for schema, but packaged (installed) + // modules MUST bundle their own EF migrations — EnsureCreated cannot evolve + // an existing database across module versions. + var hasMigrations = db.Database.GetMigrations().Any(); if (info.ModuleName == DatabaseConstants.HostModuleName) { - if (db.Database.GetMigrations().Any()) + if (hasMigrations) { await db.Database.MigrateAsync(); } @@ -249,9 +273,24 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) await db.Database.EnsureCreatedAsync(); } } + else if (hasMigrations) + { + await db.Database.MigrateAsync(); + } } } + if (migrateOnly) + { + app.Logger.LogInformation( + "SIMPLEMODULE_MIGRATE_ONLY=1: database initialization complete; exiting without starting the server." + ); + // Graceful teardown (Wolverine, DbContext pools, SQLite WAL) before the + // hard exit — Environment.Exit alone would skip all disposal. + await app.DisposeAsync(); + Environment.Exit(0); + } + app.UseForwardedHeaders(); var errorHtmlPath = Path.Combine(app.Environment.WebRootPath, "error.html"); diff --git a/modules/Directory.Build.props b/modules/Directory.Build.props index d5a5b735..da5a8b75 100644 --- a/modules/Directory.Build.props +++ b/modules/Directory.Build.props @@ -9,4 +9,27 @@ + + + Module + + + + + + + + diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Migrations/20260610000000_AddFeatureFlagChangeLog.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Migrations/20260610000000_AddFeatureFlagChangeLog.cs new file mode 100644 index 00000000..4ed1f89c --- /dev/null +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Migrations/20260610000000_AddFeatureFlagChangeLog.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace SimpleModule.FeatureFlags.Migrations; + +/// +/// Reference example of a module-bundled EF migration (the packaging contract: +/// packaged modules ship their schema changes as migrations — see +/// docs/site/advanced/module-packaging.md). Applied automatically at host +/// startup and by `sm add`/`sm upgrade` migrate-only runs. +/// Module migrations must respect the schema-per-module convention +/// (ApplyModuleSchema): a "{Module}_" table prefix on SQLite, a lowercase +/// module schema on other providers. Raw idempotent SQL because module tables +/// may also have been created by the unified HostDbContext's EnsureCreated on +/// fresh development databases. +/// +[DbContext(typeof(FeatureFlagsDbContext))] +[Migration("20260610000000_AddFeatureFlagChangeLog")] +public sealed class AddFeatureFlagChangeLog : Migration +{ + private const string Columns = """ + "Id" TEXT NOT NULL PRIMARY KEY, + "FlagKey" TEXT NOT NULL, + "ChangedBy" TEXT NOT NULL, + "OldValue" TEXT NULL, + "NewValue" TEXT NULL, + "ChangedAtUtc" TEXT NOT NULL + """; + + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder.IsSqlite()) + { + migrationBuilder.Sql( + $"""CREATE TABLE IF NOT EXISTS "FeatureFlags_FeatureFlagChangeLog" ({Columns});""" + ); + } + else + { + migrationBuilder.Sql("""CREATE SCHEMA IF NOT EXISTS "featureflags";"""); + migrationBuilder.Sql( + $"""CREATE TABLE IF NOT EXISTS "featureflags"."FeatureFlagChangeLog" ({Columns});""" + ); + } + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder.IsSqlite()) + { + migrationBuilder.Sql("""DROP TABLE IF EXISTS "FeatureFlags_FeatureFlagChangeLog";"""); + } + else + { + migrationBuilder.Sql("""DROP TABLE IF EXISTS "featureflags"."FeatureFlagChangeLog";"""); + } + } +} diff --git a/packages/SimpleModule.Client/src/resolve-page.ts b/packages/SimpleModule.Client/src/resolve-page.ts index 753208be..6bc79ea2 100644 --- a/packages/SimpleModule.Client/src/resolve-page.ts +++ b/packages/SimpleModule.Client/src/resolve-page.ts @@ -1,8 +1,60 @@ -// Caches the assembly name that successfully served a module's bundle, keyed by -// the module's short name. Inertia calls resolvePage on every navigation, so -// without this the "wrong" candidate would 404 again on every page load for that -// module. We only ever store a name that actually resolved. -const resolvedAssemblies = new Map(); +interface BundleCandidate { + /** Base URL of the bundle (no cache-buster suffix). */ + url: string; + /** Assembly name, used in error messages only. */ + assemblyName: string; +} + +// Caches the candidate that successfully served a module's bundle, keyed by the +// module's short name. Inertia calls resolvePage on every navigation, so without +// this the "wrong" candidate would 404 again on every page load for that module. +// We only ever store a candidate whose URL actually resolved. +const resolvedBundles = new Map(); + +// Module → bundle path map injected into the HTML shell by the host from each +// module's compile-time manifest (