Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f2037dd
feat(core): module manifest model, attribute and reader
antosubash Jun 10, 2026
6fe8813
feat(generator): discover domain events and Wolverine-convention hand…
antosubash Jun 10, 2026
caf2b3b
feat(generator): emit module manifest attribute for module-kind projects
antosubash Jun 10, 2026
e832463
build(modules): run source generator in module-kind on module projects
antosubash Jun 10, 2026
8c4d5c4
feat(hosting): module manifest registry and frontend asset map injection
antosubash Jun 10, 2026
d1d0d9d
feat(client): resolve module bundles via sm-module-assets manifest map
antosubash Jun 10, 2026
4bfbfb5
feat(hosting): apply EF migrations for module DbContexts that bundle …
antosubash Jun 10, 2026
1be976f
docs: module packaging contract (manifest schema v1, nupkg layout, ex…
antosubash Jun 10, 2026
2115ab1
chore: record module-packaging session 1 plan and review in tasks/tod…
antosubash Jun 10, 2026
835ba02
feat(cli): sm.json registry config and framework compat checker
antosubash Jun 10, 2026
3504835
feat(cli): read module manifests from assemblies and nupkgs without l…
antosubash Jun 10, 2026
46f444c
feat(cli): CPM-aware package reference, nuget.config and host version…
antosubash Jun 10, 2026
671e680
feat(cli): minimal NuGet V3 client and frontend externals validator
antosubash Jun 10, 2026
7dc2ff4
feat(hosting): SIMPLEMODULE_MIGRATE_ONLY hook for deterministic CLI-d…
antosubash Jun 10, 2026
50dbbdf
feat(hosting): SIMPLEMODULE_MIGRATE_ONLY hook for deterministic CLI-d…
antosubash Jun 10, 2026
216ee95
feat(hosting): SIMPLEMODULE_MIGRATE_ONLY hook for deterministic CLI-d…
antosubash Jun 10, 2026
4c5c0f6
feat(cli): sm pack — build, validate and pack modules into nupkgs
antosubash Jun 10, 2026
c1de987
feat(cli): sm add, sm remove and packaged-modules section in sm list
antosubash Jun 10, 2026
db87767
fix(cli): bare frameworkCompat lower bounds include prereleases of th…
antosubash Jun 10, 2026
c59129d
fix(generator): emit partial HostDbContext in non-identity hosts (CS0…
antosubash Jun 10, 2026
41e8767
fix(scripts): do not create source module dirs for package-installed …
antosubash Jun 10, 2026
a64f0b4
feat(cli): scaffold module-kind manifest emission for downstream modules
antosubash Jun 10, 2026
423f2aa
docs(cli): packaging command reference; session 2 task log
antosubash Jun 10, 2026
26da25d
fix: address session 2 code-review findings
antosubash Jun 10, 2026
7e38672
feat(cli): sm publish, sm search, sm upgrade and packaging doctor checks
antosubash Jun 10, 2026
ef25d8c
feat(cli): session 3 — publish/search/upgrade, packaging doctor check…
antosubash Jun 10, 2026
e98edb7
fix: address session 3 code-review findings
antosubash Jun 10, 2026
15fc01c
fix(cli): pack success line prints the package version, not the manif…
antosubash Jun 10, 2026
679fea8
test(e2e): module asset manifest smoke spec
antosubash Jun 10, 2026
d292df1
fix(client): narrow module-assets cache return type for strict typecheck
antosubash Jun 10, 2026
6ef4a33
chore: add QA report and verification evidence
antosubash Jun 10, 2026
c9c3233
Merge branch 'main' into worktree-module-packaging-s1
antosubash Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -451,3 +451,4 @@ baseline/
# Verification artifacts
.verify/
.qa/
module-manifest.json
23 changes: 23 additions & 0 deletions .qa/reports/qa-report-iteration-1.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file modified .verify/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
248 changes: 248 additions & 0 deletions cli/SimpleModule.Cli/Commands/Add/AddCommand.cs
Original file line number Diff line number Diff line change
@@ -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, "<package-id>")]
[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; }
}

/// <summary>
/// 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.
/// </summary>
public sealed class AddCommand : AsyncCommand<AddSettings>
{
public override async Task<int> 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<string, string> { ["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 <host>'):\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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using SimpleModule.Cli.Infrastructure;

namespace SimpleModule.Cli.Commands.Doctor.Checks;

/// <summary>
/// Detects bundles that inline host-provided libraries (a duplicate React copy
/// breaks hooks at runtime). Same validation `sm pack` enforces, surfaced
/// during development.
/// </summary>
public sealed class ModuleBundleExternalsCheck : IDoctorCheck
{
public IEnumerable<CheckResult> 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"
);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using SimpleModule.Cli.Infrastructure;

namespace SimpleModule.Cli.Commands.Doctor.Checks;

/// <summary>
/// Validates installed packaged modules: the manifest must be readable, at a
/// supported schemaVersion, and framework-compatible with this host.
/// </summary>
public sealed class PackagedModuleManifestCheck : IDoctorCheck
{
private const int SupportedSchemaVersion = 1;

public IEnumerable<CheckResult> 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})"
);
}
}
}
Loading
Loading