Skip to content

feat(cli): opt-in framework packages, .agents scaffolding, and fork-local templates - #1372

Open
maxiar wants to merge 9 commits into
fullstackhero:mainfrom
maxiar:feat/cli-framework-packages
Open

feat(cli): opt-in framework packages, .agents scaffolding, and fork-local templates#1372
maxiar wants to merge 9 commits into
fullstackhero:mainfrom
maxiar:feat/cli-framework-packages

Conversation

@maxiar

@maxiar maxiar commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Three opt-in additions to the fsh CLI, aimed at running several products off one kernel.
Check Open Discussion: #1373

The default path does not move. fsh new MyApp produces exactly what it produced before — every new behaviour sits behind a template symbol that defaults to false and a CLI flag that is off. A new template-smoke.yml job proves it, and the existing two jobs are untouched.

1. Scaffolding from your own fork

fsh new accepted whatever FSH template happened to be installed and never upgraded it (EnsureTemplateInstalledAsync short-circuits on any match), installing FullStackHero.NET.StarterKit with a bare package id — no version, no source. A contributor working on a fork had no way to scaffold from their own branch.

fsh new MyApp --template-path ~/dev/dotnet-starter-kit   # checkout, .nupkg, or folder of nupkgs
fsh new MyApp --template-version 10.0.1-rc.2
fsh new MyApp --template-source https://my-feed/index.json
fsh new MyApp --refresh-template

Each has an env-var fallback (FSH_TEMPLATE_PATH, …). Overriding also uninstalls the previous template first — two packages sharing the identity FullStackHero.NET.StarterKit make dotnet new fail with Sequence contains more than one matching element.

2. Shipping the .agents kit

.agents/ was excluded twice — the pack pathspec in templates/FullStackHero.NET.StarterKit.csproj and template.json — while AGENTS.md was in neither exclude list. Every scaffolded project therefore shipped a guide whose entire rules index pointed at files that were never copied.

--agents now ships .agents/ (43 files) plus AGENTS.md, CLAUDE.md, GEMINI.md. Without it, AGENTS.md is correctly withheld too, closing the dangling reference in the current default output.

3. BuildingBlocks as FSH.Framework.* packages

fsh framework pack --push --clear-cache            # build the 11 packages into a local feed
fsh new MyApp --framework-packages                 # scaffold against them
fsh framework swap --to source|packages            # convert an existing project either way
fsh framework list / clean-cache
fsh self install                                   # build the CLI from source as the global `fsh`

src/Directory.Build.targets (new) rewrites any ProjectReference into BuildingBlocks as the matching PackageReference, so all 33 project files are byte-identical in both modes and new modules get it for free. It keys off src/BuildingBlocks being absent — which is exactly the shape a package-mode scaffold has — so it cannot drift from how the project was created. -p:UseFrameworkPackages=true|false forces either way.

Two decisions worth reviewing

Packages carry an embedded PDB with embedded sources, not a .snupkg. A folder feed does not serve symbol packages — only the NuGet.org symbol server does — so a .snupkg cannot give step-into debugging locally. Embedding puts the PDB and the sources inside the DLL, so F11 works offline with no source checkout. The public profile (--profile public) keeps the conventional .snupkg + SourceLink for real feeds.

swap --to source regenerates the kernel from the template rather than copying it from a kit clone. The template rewrites tokens inside BuildingBlocks, and not all are cosmetic — MultitenancyConstants.Issuer is derived from the project name. A raw copy would silently install the starter kit's own JWT issuer into someone else's project. The command scaffolds a throwaway copy under the target project's name and lifts the kernel from that. Verified: swapping FS.Proxy yields Issuer = "fs-proxy", not "mukesh.murugan".

Pre-existing bugs fixed along the way

  • FullStackHero.CLI has never shipped a .snupkg. Directory.Build.props declares "Reproducible, debuggable packages for the projects that DO ship (CLI)" behind Condition="'$(IsPackable)' == 'true'" — but that file is imported before the csproj sets IsPackable, and MSBuild evaluates properties in document order, so IncludeSymbols stayed empty and SymbolPackageFormat fell back to the SDK default. Moved to Directory.Build.targets, where the value is final. The CLI now packs a real .snupkg.
  • ProcessRunner.CaptureAsync redirected stderr without draining it — a latent deadlock for any child that fills the pipe buffer.
  • fsh doctor failed any SDK not starting with "10.", despite the message promising ".NET 10+".
  • The update check compared against AssemblyVersion, pinned to 10.0.0.0 in the csproj, so a 10.0.1 build nagged about upgrading to itself.
  • Failures were unreadable. new printed "Scaffolding failed" while discarding the dotnet new output that explained why, and unknown options were silently dropped into Spectre's remaining-args bag rather than rejected — a mistyped flag quietly produced the wrong project. Strict parsing is now on; --agents/--framework-packages accept both the bare flag and an explicit true/false.

Verification

  • Generated project (no BuildingBlocks, packages from a local feed): dotnet build -warnaserror → 0 warnings, 0 errors
  • Default scaffold unchanged and builds clean; .agents/NuGet.config/README-CLI.md correctly absent
  • Restored FSH.Framework.Core.dll carries the embedded PDB (32,256 vs 15,360 bytes)
  • Both pack profiles correct back-to-back with no clean — this needs a forced non-incremental build, since a profile switch changes only compiler switches and MSBuild would otherwise ship the previous profile's binary
  • swap round-trip on three projects (born-in-packages, born-in-source, and a fresh example), each direction building clean; idempotent, guarded outside a project
  • Repo builds; 55 architecture + 301 unit tests pass
  • New smoke job simulated locally end-to-end

Not included

Golden rule 10: this is user-facing, so the separate docs repo (github.com/fullstackhero/docs) and a src/content/docs/changelog/ entry still need updating. README-CLI.md is written to port into it.

Note for upstreaming

src/Directory.Build.props documents source ownership as the distribution model, and backend.yml records that per-module packs were removed once for contradicting it. This does not reverse that: source ownership stays the default and framework packaging is opt-in. If proposed upstream, items 1 and 2 are uncontroversial and should go first as separate PRs; item 3 is the one that needs the conversation.

🤖 Generated with Claude Code

Eduardo Jimenez and others added 8 commits September 1, 2026 15:14
Testcontainers 4.11.0 depends on SSH.NET 2025.1.0, which carries a
high-severity advisory (GHSA-q939-rpr3-3284: ScpClient recursive download
allows arbitrary file write via server-controlled SCP filenames). NuGetAudit
raises NU1903 and, under TreatWarningsAsErrors, fails restore for
Integration.Tests and Integration.Middleware.Tests.

Testcontainers 4.14.0 already depends on the patched SSH.NET 2026.0.0, so
bumping the three Testcontainers.* packages clears the advisory without a
transitive pin that would later need removing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Testcontainers 4.11.0 depends on SSH.NET 2025.1.0, which carries a
high-severity advisory (GHSA-q939-rpr3-3284: ScpClient recursive download
allows arbitrary file write via server-controlled SCP filenames). NuGetAudit
raises NU1903 and, under TreatWarningsAsErrors, fails restore for
Integration.Tests and Integration.Middleware.Tests.

Testcontainers 4.14.0 already depends on the patched SSH.NET 2026.0.0, so
bumping the three Testcontainers.* packages clears the advisory without a
transitive pin that would later need removing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…otnet-starter-kit into fix/testcontainers-ssh-net-advisory
fix(deps): bump Testcontainers to 4.14.0 to clear the SSH.NET advisory
…ocal templates

Three additions to the `fsh` CLI, all opt-in. Default `fsh new` output is
unchanged — same files, same modes — and the template smoke test proves it.

Template acquisition
  `fsh new` accepted whatever FSH template was already installed and never
  upgraded it, with no way to point at anything else. Adds --template-path
  (checkout, .nupkg, or folder of nupkgs), --template-version,
  --template-source and --refresh-template, each with an env-var fallback,
  so a contributor can scaffold from their own fork.

.agents kit
  `.agents/` was excluded twice (pack pathspec + template.json) while
  AGENTS.md shipped on its own, leaving every scaffold with a rules index
  pointing at files that were never copied. `--agents` now ships all four;
  without it, AGENTS.md is correctly withheld too.

Framework packages
  `--framework-packages` consumes src/BuildingBlocks as FSH.Framework.*
  NuGet packages from a local feed instead of copying its source into every
  project. src/Directory.Build.targets rewrites BuildingBlocks
  ProjectReferences into PackageReferences, keyed off the absence of the
  source tree, so the 33 project files are identical in both modes.
  New commands: `fsh framework pack|list|swap|clean-cache`, plus
  `fsh self install` to build the CLI from source as the global tool.

Packages carry an embedded PDB with embedded sources: a folder feed does not
serve .snupkg, so embedding is what actually makes step-into debugging work.
Each pack stamps a unique 10.0.0-local.<timestamp> version because NuGet
caches by id+version and would otherwise serve stale bits.

Also fixes, all pre-existing:
  - FullStackHero.CLI never shipped a .snupkg. The packaging block in
    Directory.Build.props is conditioned on $(IsPackable), but that file is
    imported before the csproj sets it, so it never applied. Moved to
    Directory.Build.targets, where the value is final.
  - ProcessRunner.CaptureAsync redirected stderr without draining it, which
    can deadlock a child that fills the pipe buffer.
  - `fsh doctor` failed any SDK not starting with "10." despite promising
    .NET 10+.
  - The update check compared against AssemblyVersion, pinned to 10.0.0.0,
    so every patch build nagged about itself.
  - `new` reported "Scaffolding failed" while discarding the dotnet output
    that explained why. Unknown options were silently ignored rather than
    rejected; strict parsing is now on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… template

`fsh new` creates a project and `fsh framework swap` changes its mode, but there
was no way to bring an already-generated project up to date with the template it
came from. Re-scaffolding overwrites; hand-merging a template that renames every
token is impractical. This closes that gap.

The upgrade is a three-way merge rather than a re-scaffold. The pristine scaffold
commit `fsh new` created is the common ancestor, and a freshly generated scaffold
of the same project — same name, same options — is the new state. Committing that
on a branch rooted at the ancestor lets git merge it forward, so local work is
preserved and genuine collisions surface as ordinary conflicts. It runs in a
temporary worktree, so the caller's checkout is untouched until they merge.

Scaffold options are recovered from the shape of the baseline tree rather than
restated, so an upgrade cannot regenerate a differently-shaped project: an
AppHost directory means aspire, clients/ means frontend, .agents/ means the
agents kit, and a missing src/BuildingBlocks means framework packages.

Two files need special handling because `fsh new` writes them after the template
runs and they are committed in the baseline. Regenerating plainly would delete
NuGet.config and revert the per-project dev signing key to the shared
placeholder, so both are carried forward and never appear in the diff.

Also adds ProcessRunner.CaptureWithErrorAsync(trimOutput:) — restoring a file
from git needs its bytes intact, and trimming dropped the trailing newline,
which showed the restored file as modified in every later diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ages

Two BuildingBlocksIndependenceTests read the BuildingBlocks .csproj files from
disk. A project scaffolded with --framework-packages has no src/BuildingBlocks
at all, so those tests threw from the moment such a project was created - a gap
in the framework-packages feature, not something the projects did wrong.

They now carry [KernelSourceOnlyFact] and report as skipped in package mode.
Assembly-level architecture checks in the same class are untouched and keep
running in both modes, because those assemblies come from the packages.

Uses a FactAttribute subclass rather than Assert.Skip, which needs xUnit v3;
this mirrors the PostgresOnlyFact/SqlServerOnlyFact pattern the MSSQL work
introduced in Integration.Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`fsh upgrade` merges the owned source, but on a framework-packages project the
kernel arrives through the feed instead. Documenting only the command leaves the
most common failure undocumented: merging the template without republishing the
packages, which produces a project that no longer compiles.

Adds the three-step runbook (republish kernel, merge template, move the pin),
records the FSH_LOCAL_FEED footgun that silently publishes to the wrong feed, and
explains which post-upgrade test failures are the expected signal rather than a
regression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maxiar

maxiar commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Pushed three more commits (37 → 42 files). They close a gap the original PR left open: it added a way to create a project on framework packages, but no way to bring an existing one forward.

fsh upgrade — updates an already-generated project to a newer template. It is a three-way merge, not a re-scaffold: the pristine scaffold commit fsh new created is the common ancestor, a freshly generated scaffold of the same project is the new state, and git merges them forward. Local work is preserved and genuine collisions surface as ordinary conflicts. It runs in a temporary worktree, so the caller's checkout is untouched until they merge, and it recovers the original scaffold options from the shape of the baseline tree rather than asking for them again.

A bug fix in the feature this PR already shipped. Two BuildingBlocksIndependenceTests read the BuildingBlocks .csproj files from disk. A project scaffolded with --framework-packages has no src/BuildingBlocks at all, so those tests threw from the moment such a project was created. They now carry [KernelSourceOnlyFact] and skip in package mode; the assembly-level checks in the same class still run, since those assemblies come from the packages. This mirrors the PostgresOnlyFact pattern rather than using Assert.Skip, which needs xUnit v3.

Docs — an end-to-end upgrade runbook. Worth calling out because the non-obvious part is that fsh upgrade moves only the owned source; on a framework-packages project the kernel arrives through the feed. Upgrading is three steps, and doing only the merge leaves a project that will not compile.

Exercised on a real project

Not a synthetic test: a ~100-commit application on framework packages, upgraded to a template revision that touched 145 files. Result was one conflict, in DbMigrator/Program.cs — exactly where a registered module and the template both edit the same file. Both directions build with -warnaserror, 0 warnings.

Heads-up on CI

This branch will fail to build on NU1902 until #1375 merges — Microsoft.SourceLink.GitHub 8.0.0 pulls Microsoft.Build.Tasks.Git 8.0.0, which has picked up an advisory since this PR was opened. That is unrelated to anything here; it currently fails main too. With that one-line bump applied locally, this branch builds clean and Architecture.Tests pass 55/55.

🤖 Generated with Claude Code

…not a constant

BuildingBlocks hard-codes the Data Protection application name ("FSH.Starter")
and the root tenant's issuer ("mukesh.murugan"). In source mode the template
renames both when scaffolding, so each project gets its own and there is no
problem — which is why this has never mattered on main.

It matters once the framework also ships as the compiled FSH.Framework.*
packages this PR introduces, because the template's token substitution cannot
reach a literal inside a DLL. Verified in the published binaries:

  fsh.framework.caching -> contains "FSH.Starter"
  fsh.framework.shared  -> contains "mukesh.murugan"

Data Protection isolates keys BY the application name, so every project built on
the packages shared one key ring: two of them pointed at the same Redis could
decrypt each other's auth cookies, password-reset and confirmation tokens.

Both values now come from appsettings.json, which IS scaffolded source and is
renamed per project in both distribution modes. The constants remain only as
last-resort defaults, and the application name falls back to the entry assembly
name rather than a shared literal, so a host that forgot the setting gets its
own key ring instead of silently joining someone else's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maxiar

maxiar commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Pushed one more commit: the Data Protection application name is now read from configuration instead of being a constant in BuildingBlocks.

It belongs in this PR rather than as a standalone fix, because on main today there is no bug — the template renames FSH.Starter when scaffolding, so every project gets its own value. The problem appears only once the framework also ships as the compiled FSH.Framework.* packages this PR introduces, since token substitution cannot reach a literal inside a DLL. Verified in the published binaries:

fsh.framework.caching → contains "FSH.Starter"
fsh.framework.shared  → contains "mukesh.murugan"

Data Protection isolates keys by the application name, so every project built on those packages shared one key ring — two of them pointed at the same Redis could decrypt each other's auth cookies and password-reset tokens. The root tenant's issuer leaked the same way.

Both now come from appsettings.json, which is scaffolded source and is renamed per project in both distribution modes. The constants remain as last-resort defaults, and the application name falls back to the entry assembly name rather than a shared literal — so a host that forgot the setting gets its own key ring instead of silently joining someone else's.

Related: #1376 builds on this to add a database-backed key store, and depends on this PR for DataProtectionApplicationName.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant