From e9fb6fbbde2f364d9a8020457d89933f0d38f0b4 Mon Sep 17 00:00:00 2001 From: Eduardo Jimenez Date: Tue, 1 Sep 2026 15:14:51 -0300 Subject: [PATCH 1/7] fix(deps): bump Testcontainers to 4.14.0 to clear the SSH.NET advisory 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 --- src/Directory.Packages.props | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0d38b28190..415756d0c0 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -122,9 +122,10 @@ - - - + + + + From e94d513a68fd24177e5c63cd2e6011e1f7b6545e Mon Sep 17 00:00:00 2001 From: maxiar Date: Tue, 1 Sep 2026 15:16:52 -0300 Subject: [PATCH 2/7] fix(deps): bump Testcontainers to 4.14.0 to clear the SSH.NET advisory 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 --- src/Directory.Packages.props | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0d38b28190..415756d0c0 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -122,9 +122,10 @@ - - - + + + + From 294c7658bbfa1a950db96594ce773a9b3d789bb1 Mon Sep 17 00:00:00 2001 From: maxiar Date: Mon, 7 Sep 2026 11:42:51 -0300 Subject: [PATCH 3/7] feat(cli): opt-in framework packages, .agents scaffolding, and fork-local templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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) --- .github/workflows/template-smoke.yml | 71 +++ .template.config/template.json | 39 +- AGENTS.md | 9 + README-CLI.md | 483 ++++++++++++++++++ README-template.md | 20 + src/BuildingBlocks/Caching/Caching.csproj | 4 + src/BuildingBlocks/Core/Core.csproj | 4 + .../Eventing.Abstractions.csproj | 4 + src/BuildingBlocks/Eventing/Eventing.csproj | 4 + src/BuildingBlocks/Jobs/Jobs.csproj | 4 + src/BuildingBlocks/Mailing/Mailing.csproj | 4 + .../Persistence/Persistence.csproj | 4 + src/BuildingBlocks/Quota/Quota.csproj | 4 + src/BuildingBlocks/Shared/Shared.csproj | 4 + src/BuildingBlocks/Storage/Storage.csproj | 4 + src/BuildingBlocks/Web/Web.csproj | 4 + src/Directory.Build.props | 47 +- src/Directory.Build.targets | 85 +++ src/Directory.Packages.props | 26 + src/FSH.Starter.slnx | 6 + src/Tools/CLI/Commands/DoctorCommand.cs | 8 +- .../Framework/FrameworkCleanCacheCommand.cs | 27 + .../Framework/FrameworkListCommand.cs | 88 ++++ .../Framework/FrameworkPackCommand.cs | 258 ++++++++++ .../Framework/FrameworkSwapCommand.cs | 234 +++++++++ src/Tools/CLI/Commands/NewCommand.cs | 247 +++++++-- .../CLI/Commands/Self/SelfInstallCommand.cs | 139 +++++ .../CLI/Commands/Self/SelfUninstallCommand.cs | 30 ++ .../Infrastructure/FrameworkCacheCleaner.cs | 74 +++ src/Tools/CLI/Infrastructure/FrameworkFeed.cs | 115 +++++ src/Tools/CLI/Infrastructure/FshConstants.cs | 31 ++ src/Tools/CLI/Infrastructure/ProcessRunner.cs | 46 +- src/Tools/CLI/Infrastructure/RepoLocator.cs | 38 ++ .../CLI/Infrastructure/ScaffoldedProject.cs | 198 +++++++ .../CLI/Infrastructure/TemplateInstaller.cs | 128 +++++ src/Tools/CLI/Program.cs | 45 ++ templates/FullStackHero.NET.StarterKit.csproj | 12 +- 37 files changed, 2483 insertions(+), 65 deletions(-) create mode 100644 README-CLI.md create mode 100644 src/Directory.Build.targets create mode 100644 src/Tools/CLI/Commands/Framework/FrameworkCleanCacheCommand.cs create mode 100644 src/Tools/CLI/Commands/Framework/FrameworkListCommand.cs create mode 100644 src/Tools/CLI/Commands/Framework/FrameworkPackCommand.cs create mode 100644 src/Tools/CLI/Commands/Framework/FrameworkSwapCommand.cs create mode 100644 src/Tools/CLI/Commands/Self/SelfInstallCommand.cs create mode 100644 src/Tools/CLI/Commands/Self/SelfUninstallCommand.cs create mode 100644 src/Tools/CLI/Infrastructure/FrameworkCacheCleaner.cs create mode 100644 src/Tools/CLI/Infrastructure/FrameworkFeed.cs create mode 100644 src/Tools/CLI/Infrastructure/RepoLocator.cs create mode 100644 src/Tools/CLI/Infrastructure/ScaffoldedProject.cs create mode 100644 src/Tools/CLI/Infrastructure/TemplateInstaller.cs diff --git a/.github/workflows/template-smoke.yml b/.github/workflows/template-smoke.yml index ac2e7e4f80..6f14b11726 100644 --- a/.github/workflows/template-smoke.yml +++ b/.github/workflows/template-smoke.yml @@ -85,6 +85,77 @@ jobs: - name: Run Architecture tests on scaffolded output run: dotnet test "$RUNNER_TEMP/smoke/src/Tests/Architecture.Tests" -c Release --no-build + scaffold-framework-packages: + name: Scaffold (framework packages + agents) and build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + + # The scaffold has no BuildingBlocks source, so the framework packages must exist + # before it can restore. Build them into a folder feed first. + - name: Pack the FSH.Framework.* packages + run: | + dotnet run --project src/Tools/CLI -- framework pack \ + --feed "$RUNNER_TEMP/feed" --version 0.0.1-ci --push + + - name: Install template + run: dotnet new install . + + # Guards the (frameworkPackages) and (agents) gating: BuildingBlocks and + # Framework.Tests must be absent, .agents and AGENTS.md must be present, and the + # ProjectReference -> PackageReference swap in Directory.Build.targets must hold. + - name: Scaffold with framework packages and the agents kit + run: | + dotnet new fsh -n Smoke.Fx -o "$RUNNER_TEMP/fx" \ + --aspire false --frontend false --skipRestore true \ + --agents true --frameworkPackages true --frameworkVersion 0.0.1-ci + + # Written as a file rather than `dotnet nuget add source`, which would mutate + # user-level NuGet state; this keeps the job hermetic. `fsh new --framework-packages` + # writes the equivalent file for real projects. + - name: Point the scaffold at the feed + run: | + cat > "$RUNNER_TEMP/fx/NuGet.config" < + + + + + + + + XML + + - name: Assert the scaffold has the right shape + run: | + cd "$RUNNER_TEMP/fx" + test ! -d src/BuildingBlocks || { echo "BuildingBlocks should not be scaffolded"; exit 1; } + test ! -d src/Tests/Framework.Tests || { echo "Framework.Tests should not be scaffolded"; exit 1; } + test -f AGENTS.md || { echo "AGENTS.md missing"; exit 1; } + test -d .agents || { echo ".agents missing"; exit 1; } + + # Restore explicitly before building. The scaffold is created with --skipRestore, and + # letting the first build restore implicitly has it evaluate analyzer configuration + # before the assets file exists, which spuriously escalates suppressed rules. + - name: Restore against the framework packages + run: dotnet restore "$RUNNER_TEMP/fx/src/Smoke.Fx.slnx" + + - name: Build against the framework packages (warnings as errors) + run: dotnet build "$RUNNER_TEMP/fx/src/Smoke.Fx.slnx" -c Release -warnaserror --no-restore + scaffold-minimal: name: Scaffold (no Aspire, no React) and build runs-on: ubuntu-latest diff --git a/.template.config/template.json b/.template.config/template.json index f98401aff2..c161177e84 100644 --- a/.template.config/template.json +++ b/.template.config/template.json @@ -47,6 +47,25 @@ "description": "Internal: keep the fsh CLI project in the solution (FSH repo only).", "defaultValue": "false" }, + "agents": { + "type": "parameter", + "datatype": "bool", + "description": "Include the .agents AI rules/skills kit and AGENTS.md, CLAUDE.md, GEMINI.md.", + "defaultValue": "false" + }, + "frameworkPackages": { + "type": "parameter", + "datatype": "bool", + "description": "Consume BuildingBlocks as FSH.Framework.* NuGet packages instead of scaffolding their source.", + "defaultValue": "false" + }, + "frameworkVersion": { + "type": "parameter", + "datatype": "string", + "description": "Version of the FSH.Framework.* packages to consume (only used with frameworkPackages).", + "defaultValue": "0.0.0-local", + "replaces": "0.0.0-local" + }, "contactEmail": { "type": "parameter", "datatype": "string", @@ -122,7 +141,6 @@ "**/.vscode/**", "**/.vs/**", ".github/**", - ".agents/**", ".claude/**", ".devcontainer/**", ".git/**", @@ -150,8 +168,7 @@ "**/obj/**", ".mcp.json", "global.json", - "CLAUDE.md", - "GEMINI.md", + "README-CLI.md", "README.md", "LICENSE" ], @@ -170,6 +187,22 @@ "exclude": [ "clients/**" ] + }, + { + "condition": "(!agents)", + "exclude": [ + ".agents/**", + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md" + ] + }, + { + "condition": "(frameworkPackages)", + "exclude": [ + "src/BuildingBlocks/**", + "src/Tests/Framework.Tests/**" + ] } ] } diff --git a/AGENTS.md b/AGENTS.md index cbe60e9e1f..ee9ee0b77f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ front-ends and a CLI. Multitenancy, auth, auditing, billing, files, chat and mor | `src/Tests/` | Per-module tests, `Architecture.Tests` (NetArchTest), `Integration.Tests` (Testcontainers). | | `src/Tools/CLI` | The `fsh` CLI (Spectre.Console). | | `clients/admin`, `clients/dashboard` | The two React apps. | +| `README-CLI.md` | `fsh` CLI guide: scaffolding from a fork, shipping `.agents`, and the opt-in framework packages. | | `deploy/` | Infra (docker, terraform, dokploy). | ## Tech stack @@ -62,6 +63,14 @@ cd clients/admin && npm install && npm run dev # → http://localhost:5173 cd clients/dashboard && npm install && npm run dev # → http://localhost:5174 ``` +Optional, off by default — the kernel as NuGet packages instead of scaffolded source +(`README-CLI.md` has the full workflow): +```bash +dotnet run --project src/Tools/CLI -- self install # install `fsh` built from this source +fsh framework pack --push # build FSH.Framework.* into a local feed +fsh new MyApp --framework-packages --agents -o ../my-app # scaffold against it, with the .agents kit +``` + Migrations / seed (DbMigrator, separate step): ```bash dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply [--seed] diff --git a/README-CLI.md b/README-CLI.md new file mode 100644 index 0000000000..d55a2b95af --- /dev/null +++ b/README-CLI.md @@ -0,0 +1,483 @@ +# `fsh` CLI — forks, agents, and framework packages + +This is the maintainer guide for three opt-in capabilities of the `fsh` CLI: + +1. **Scaffolding from your own fork or branch**, instead of whatever template happens to be installed. +2. **Shipping the `.agents` kit** into generated projects, so AI tools have project context. +3. **Consuming `src/BuildingBlocks` as `FSH.Framework.*` NuGet packages** from a local feed, instead of copying its source into every project. + +> **Nothing here changes the default.** `fsh new MyApp` still produces exactly what it always +> did: a fully owned, detached source tree with no framework packages. Every capability below is +> off unless you ask for it. + +**Contents** — [Why](#why-framework-packages) · [Setup](#one-time-setup) · [Install `fsh`](#installing-fsh-from-this-source) · [Daily loop](#the-everyday-loop) · +[Fork scaffolding](#scaffolding-from-your-own-fork) · [Agents](#shipping-the-agents-kit) · +[Debugging](#debugging-into-buildingblocks) · [Troubleshooting](#troubleshooting) · +[Reference](#command-reference) · [Swapping modes](#swapping-an-existing-project-between-the-two-modes) + +--- + +## Why framework packages + +The starter kit's default distribution model is **source ownership**: you get every BuildingBlock +as source, wired by `ProjectReference`, with nothing to eject later. For a single product that is +the right call, and it stays the default. + +It stops scaling when you run *several* products off one kernel. Ten projects means ten copies of +`src/BuildingBlocks`, drifting apart, with every fix applied by hand N times. + +Framework packaging inverts that for teams in that position: the kernel is built once, published +to a NuGet feed, and consumed as versioned binaries. You keep owning the modules — only +BuildingBlocks becomes a package. + +| | Source ownership (default) | Framework packages (opt-in) | +|---|---|---| +| Kernel lives in | every project | one starter-kit clone | +| Fixing a kernel bug | edit N projects | pack once, bump N versions | +| Editing kernel code in-project | yes | no — edit it in the kit | +| Step-into debugging | trivially | yes, via embedded PDBs | +| Best for | one product | a platform team, several products | + +--- + +## One-time setup + +You need a starter-kit clone: `fsh framework` builds packages from BuildingBlocks source, so it +refuses to run anywhere else. + +```bash +git clone ~/dev/dotnet-starter-kit +cd ~/dev/dotnet-starter-kit + +# Build the 11 FSH.Framework.* packages, publish them to a local feed, and register that +# feed as a NuGet source. The feed directory is created if it does not exist. +dotnet run --project src/Tools/CLI -- framework pack --push --register-source +``` + +The feed defaults to `~/.fsh/local-nuget`. To put it somewhere else, either pass `--feed` every +time or set it once: + +```bash +# ~/.zshrc or ~/.bashrc +export FSH_LOCAL_FEED=~/dev/nuget-local # where framework packages live +export FSH_TEMPLATE_PATH=~/dev/dotnet-starter-kit # scaffold from your fork, not nuget.org +``` + +With those exported, later commands need no flags at all. + +### Running the CLI + +```bash +dotnet run --project src/Tools/CLI -- framework pack # always matches the source in front of you +dotnet tool restore && dotnet fsh framework pack # version pinned in .config/dotnet-tools.json +fsh framework pack # globally installed tool +``` + +The first form is exact but verbose, and it only works from inside the clone. The third is the one +you want day to day — but by default it is whatever `FullStackHero.CLI` is published on nuget.org, +which will not have your fork's changes. + +### Installing `fsh` from this source + +`fsh self install` closes that gap: it packs the CLI from the current working copy and installs it +as the global tool, so `fsh` *is* your fork. + +```bash +dotnet run --project src/Tools/CLI -- self install # once, to bootstrap +fsh self install # afterwards, to pick up CLI changes +``` + +``` +Repository: /Users/you/dev/dotnet-starter-kit +Version: 10.0.0-local.20260902T030247 + packed FullStackHero.CLI + installed global tool 'fsh' +``` + +Every command in this guide then works as written, from any directory: + +```bash +fsh new FS.Proxy --output /Users/you/dev/falconsoft/fs-proxy \ + --agents --framework-packages --framework-feed /Users/you/dev/nuget-local +``` + +And with `FSH_LOCAL_FEED` and `FSH_TEMPLATE_PATH` exported, down to: + +```bash +fsh new FS.Proxy --output /Users/you/dev/falconsoft/fs-proxy --agents --framework-packages +``` + +Notes: + +- The build is stamped `10.0.0-local.`, so `fsh --version` tells you whether you are + running your own build or the published one — and each install is a distinct version, so + `dotnet tool update` never mistakes a rebuild for "already current". +- **Re-run `fsh self install` after changing CLI source.** The global tool is a snapshot, not a + live link to the repo; while iterating on the CLI itself, `dotnet run --project src/Tools/CLI --` + is still the shorter loop. +- `fsh self uninstall` removes it. To return to the published build afterwards: + `dotnet tool install -g FullStackHero.CLI`. +- The tool lands in `~/.dotnet/tools`. If that is not on your `PATH`, the command says so and + prints the line to add. + +### Creating a project that uses the packages + +Running the CLI straight from the kit's source — the form to use while working on a fork, since +it always matches the code in front of you: + +```bash +dotnet run --project src/Tools/CLI -- new FS.Proxy \ + --agents --framework-packages \ + --framework-feed /Users/you/dev/nuget-local +``` + +Or, with the globally installed tool and `FSH_LOCAL_FEED` exported: + +```bash +fsh new FS.Proxy --agents --framework-packages +``` + +That scaffolds without `src/BuildingBlocks`, writes a `NuGet.config` pointing at your feed, and +pins the newest version found there. + +> Note the `--` after the project path. It separates `dotnet run`'s own arguments from the ones +> meant for the CLI; without it `dotnet run` tries to interpret them itself. + +### Choosing where the project is created + +By default the project is created in a folder named after it, **inside the current directory** — +so running the command from a starter-kit clone drops the new project inside the kit. Use +`-o` / `--output` to put it anywhere: + +```bash +dotnet run --project src/Tools/CLI -- new FS.Proxy \ + --output /Users/you/dev/falconsoft/fs-proxy \ + --agents --framework-packages +``` + +The directory is created if it does not exist, and the path is unrelated to the project name — so +`FS.Proxy` can live in `fs-proxy/`, as above. If the target exists and is not empty, `fsh new` +prompts before overwriting, and refuses outright under `--non-interactive`. + +--- + +## The everyday loop + +Change the kernel, republish, pick it up downstream: + +```bash +# 1. edit something in src/BuildingBlocks, in your kit clone +dotnet run --project src/Tools/CLI -- framework pack --push --clear-cache + +# -> Published 11 package(s) to /Users/you/dev/nuget-local +# -> Done. Consume with: +# dotnet build -p:UseFrameworkPackages=true -p:FshFrameworkVersion=10.0.0-local.20260901T194030 + +# 2. in the consuming project, take the new version +``` + +Each pack stamps a **new, unique version** (`10.0.0-local.`). That is deliberate: NuGet +caches packages by id *and* version, so republishing one version with different content silently +serves the old bits — the single most common local-feed trap. A fresh version every time makes +that impossible, and `--clear-cache` handles anything already extracted. + +To adopt a new version in a project, edit one line in `src/Directory.Packages.props`: + +```xml +10.0.0-local.20260901T194030 +``` + +Or override per build without touching the file: + +```bash +dotnet build -p:FshFrameworkVersion=10.0.0-local.20260901T194030 +``` + +To see what the feed currently holds: + +```bash +fsh framework list # newest of each package +fsh framework list --all # every version +``` + +### How the reference rewrite works + +There are no `PackageReference` lines to maintain. `src/Directory.Build.targets` rewrites any +`ProjectReference` pointing into `BuildingBlocks` into the matching `PackageReference` +(`..\..\BuildingBlocks\Core\Core.csproj` → `FSH.Framework.Core`), so the 33 project files are +identical in both modes. + +It switches on automatically when `src/BuildingBlocks` is **absent** — which is exactly the shape +of a project scaffolded with `--framework-packages`. Nothing to remember, and it cannot fall out +of sync with how the project was created. Force it either way with `-p:UseFrameworkPackages=true|false`. + +--- + +## Scaffolding from your own fork + +By default `fsh new` uses whatever FSH template is already installed and never upgrades it, so a +fork's fixes never reach your scaffolds. Four options change that: + +```bash +# from a working tree (what a contributor wants) +fsh new MyApp --template-path ~/dev/dotnet-starter-kit + +# from a locally packed template +dotnet pack templates/FullStackHero.NET.StarterKit.csproj -o ./nupkgs +fsh new MyApp --template-path ./nupkgs # a directory of .nupkg files works too + +# a specific published version, or a private feed +fsh new MyApp --template-version 10.0.1-rc.2 +fsh new MyApp --template-source https://my-feed/index.json + +# force a re-install of whatever is configured +fsh new MyApp --refresh-template +``` + +`--template-path` accepts a starter-kit checkout, a `.nupkg`, or a folder containing packed +nupkgs (the newest is used). Any of these also re-installs the template rather than reusing a +stale one, and uninstalls the previous copy first — two template packages sharing the identity +`FullStackHero.NET.StarterKit` make `dotnet new` fail with +`Sequence contains more than one matching element`. + +--- + +## Shipping the `.agents` kit + +```bash +fsh new MyApp --agents +``` + +This writes `.agents/` (43 files of rules, skills and workflows) plus `AGENTS.md`, `CLAUDE.md` and +`GEMINI.md` into the project, so Claude Code, Cursor, Gemini CLI and friends start with real +context instead of guessing. + +Without the flag, none of those four are written. That also fixes a long-standing wart: `AGENTS.md` +used to ship on its own, leaving every project with a rules index pointing at files that were never +copied. + +**One caveat.** The template engine rewrites tokens throughout the copied files, including these. +That is mostly what you want — `src/Host/FSH.Starter.Api` correctly becomes `src/Host/MyApp.Api`, +and namespace examples follow the rename. The cosmetic cost is that brand prose is rewritten too, +so "FullStackHero .NET Starter Kit" reads "MyApp .NET Starter Kit". Worth one skim after scaffolding. + +--- + +## Debugging into BuildingBlocks + +Packages built with the default `local` profile carry an **embedded PDB with the sources embedded +inside it**. Stepping into framework code needs no symbol server, no source checkout, and no +network — the source travels inside the DLL. + +This is why the local profile does not produce a `.snupkg`: a folder feed does not serve symbol +packages (only the NuGet.org symbol server does), so a `.snupkg` would be dead weight locally. + +### You must turn off "Just My Code" + +This is the step everyone misses. With it on, the debugger steps *over* framework code and the +embedded sources are never consulted. + +| Tool | Where | +|---|---| +| Visual Studio | Tools → Options → Debugging → General → uncheck **Enable Just My Code** | +| Rider | Settings → Build, Execution, Deployment → Debugger → uncheck **Enable Just My Code** | +| VS Code | `"justMyCode": false` in the configuration in `.vscode/launch.json` | + +```jsonc +// .vscode/launch.json +{ + "configurations": [ + { + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "justMyCode": false + } + ] +} +``` + +Then set a breakpoint in a module handler and step into any `FSH.Framework.*` call. + +### Publishing publicly instead + +If you push to a real NuGet server with a symbol server, use the other profile: + +```bash +fsh framework pack --profile public --version 10.1.0 +``` + +That produces a normal DLL plus a `.snupkg` with SourceLink, which is what nuget.org expects. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Kernel changes don't show up in the consuming project | the same version was restored from the NuGet cache | `fsh framework pack --push --clear-cache`, and make sure the project pins the new version | +| `NU1101: Unable to find package FSH.Framework.Core` | feed not registered, or `NuGet.config` points elsewhere | `fsh framework pack --register-source`; check `NuGet.config` in the project root | +| `NU1103: no stable version found` | local packages are prereleases | pin `FshFrameworkVersion` explicitly, or pack with `--profile public` and a stable `--version` | +| F11 steps over framework code | Just My Code is enabled | see [above](#you-must-turn-off-just-my-code) | +| `fsh new` keeps scaffolding the old template | the installed template is sticky and never auto-upgrades | `--refresh-template`, or `--template-path`, or `fsh update` | +| `Sequence contains more than one matching element` | two template packages share the FSH template identity | `dotnet new uninstall FullStackHero.NET.StarterKit`, then re-install (the CLI now does this for you) | +| Project scaffolds with `src/BuildingBlocks` and no `NuGet.config` despite passing the flags | template symbol names (`--frameworkPackages`) were used instead of CLI option names (`--framework-packages`) | use the CLI spelling; unknown options are now a hard error rather than silently ignored | +| `Framework feed not found: ~/.fsh/local-nuget` | no feed configured and packages live elsewhere | pass `--framework-feed ` or `export FSH_LOCAL_FEED=` | +| `fsh` runs but lacks the new options | the globally installed tool is the published build, not your fork | `dotnet run --project src/Tools/CLI -- self install` | +| `fsh: command not found` right after installing | `~/.dotnet/tools` is not on `PATH` | add it to your shell profile; `fsh self install` prints the exact line | +| Project was created inside the starter-kit clone | `fsh new` defaults to the current directory | pass `-o /path/to/project` | +| `Not inside a FullStackHero starter-kit repository` | `fsh framework` ran outside a clone | `cd` into a directory that has both `src/BuildingBlocks` and `.template.config` | +| `NU5026: the file ... .pdb is not found` when packing by hand | `--no-build` with an embedded PDB — there is no `.pdb` on disk to pack | drop `--no-build` (`fsh framework pack` already does) | + +--- + +## Command reference + +### `fsh framework pack` + +Builds the 11 BuildingBlocks projects as `FSH.Framework.*` packages. Must run inside a +starter-kit clone. + +| Option | Default | Notes | +|---|---|---| +| `-f, --feed ` | `$FSH_LOCAL_FEED`, else `~/.fsh/local-nuget` | created if missing | +| `-v, --version ` | `10.0.0-local.` | unique per run, on purpose | +| `-o, --output ` | `/artifacts/nupkgs` | where `.nupkg` files are written | +| `-p, --profile ` | `local` | `local` = embedded PDB + sources; `public` = `.snupkg` + SourceLink | +| `--push` | off | copy the packages into the feed | +| `--register-source` | off | `dotnet nuget add source` if not already registered | +| `--clear-cache` | off | purge `FSH.Framework.*` from the global-packages cache | +| `--dry-run` | off | print the plan and stop | + +### `fsh framework list` + +| Option | Default | +|---|---| +| `-f, --feed ` | `$FSH_LOCAL_FEED`, else `~/.fsh/local-nuget` | +| `--all` | off — show only the newest version of each package | + +### `fsh framework swap` + +Converts an existing project between the two modes. Runs inside the **project**, not a +starter-kit clone. + +| Option | Default | Notes | +|---|---|---| +| `-t, --to ` | — | required; which side to swap to | +| `--project ` | current directory | the project to convert | +| `--from ` | `$FSH_TEMPLATE_PATH` | kit/template to regenerate the kernel from (`--to source`) | +| `-f, --feed ` | `$FSH_LOCAL_FEED`, else `~/.fsh/local-nuget` | feed to consume from (`--to packages`) | +| `-v, --version ` | newest in the feed | version to pin (`--to packages`) | +| `-y, --yes` | off | skip the confirmation before deleting kernel source | +| `--dry-run` | off | print the plan and stop | + +### `fsh framework clean-cache` + +| Option | Default | +|---|---| +| `--dry-run` | off — list what would be removed without deleting | + +### `fsh self install` / `fsh self uninstall` + +Builds the global `fsh` tool from this repository. `install` must run inside a starter-kit clone. + +| Option | Default | Notes | +|---|---|---| +| `-v, --version ` | `10.0.0-local.` | stamped on both the package and `fsh --version` | +| `-o, --output ` | `/artifacts/nupkgs` | where the `.nupkg` is written | +| `--dry-run` | off | print the plan and stop | + +### `fsh new` — new options + +| Option | Env var | Notes | +|---|---|---| +| `-o, --output ` | `./` | where the project is created; created if missing | +| `--template-path ` | `FSH_TEMPLATE_PATH` | checkout, `.nupkg`, or folder of nupkgs | +| `--template-version ` | `FSH_TEMPLATE_VERSION` | installs `FullStackHero.NET.StarterKit::` | +| `--template-source ` | `FSH_TEMPLATE_SOURCE` | extra NuGet source for the install | +| `--refresh-template` | — | re-install even if a template is present | +| `--agents [true\|false]` | `FSH_AGENTS=1` | include `.agents` + `AGENTS.md`/`CLAUDE.md`/`GEMINI.md` | +| `--framework-packages [true\|false]` | — | consume BuildingBlocks as packages | +| `--framework-version ` | — | defaults to the newest in the feed | +| `--framework-feed ` | `FSH_LOCAL_FEED` | written into the project's `NuGet.config` | + +Every option resolves **flag → environment variable → default**. + +`--agents` and `--framework-packages` accept a bare flag or an explicit value, so both +`--agents` and `--agents true` work (and `--agents false` turns it off). Unknown options are a +hard error: these are the `fsh` option names, **not** the `dotnet new` symbol names — see the +next section for those. + +### Using it from `dotnet new` directly + +The CLI only forwards template symbols, so the template works standalone: + +```bash +dotnet new fsh -n MyApp --agents true --frameworkPackages true --frameworkVersion 10.0.0-local.20260901T194030 +dotnet nuget add source ~/dev/nuget-local --name fsh-local # the CLI does this part for you +``` + +--- + +## Swapping an existing project between the two modes + +Framework packaging is not a one-way door, and you do not have to decide at scaffold time. +`fsh framework swap` converts a project that already exists, in either direction. Unlike the other +`framework` commands it runs **inside the project**, not inside a starter-kit clone. + +```bash +cd /Users/you/dev/falconsoft/fs-proxy + +fsh framework swap --to source # bring src/BuildingBlocks back and own it +fsh framework swap --to packages # drop the source, consume FSH.Framework.* instead +``` + +Add `--dry-run` to see the plan first, or `--project ` to act on a project elsewhere. + +### `--to source` + +Regenerates `src/BuildingBlocks` (and `src/Tests/Framework.Tests`), adds them to the `.slnx`, and +removes the local feed from `NuGet.config`. `Directory.Build.targets` sees the source on disk and +stops rewriting references, so nothing else changes. + +The kernel is **regenerated from the template**, not copied out of a starter-kit clone. That +matters: the template rewrites tokens inside BuildingBlocks, and not all of them are cosmetic — +`MultitenancyConstants.Issuer`, for instance, is derived from the project name. A raw copy would +quietly install the starter kit's own JWT issuer into your project. The command scaffolds a +throwaway copy under your project's own name and takes the kernel from that. + +It needs a template to generate from, resolved exactly like `fsh new`: + +```bash +export FSH_TEMPLATE_PATH=~/dev/dotnet-starter-kit # your fork +fsh framework swap --to source + +fsh framework swap --to source --from ~/dev/dotnet-starter-kit # or pass it explicitly +``` + +Point it at the same kit the project came from, so the kernel you get back matches the packages +you were consuming. + +### `--to packages` + +Deletes `src/BuildingBlocks` and `src/Tests/Framework.Tests`, removes them from the `.slnx`, writes +`NuGet.config`, and pins `FshFrameworkVersion` to the newest version in the feed (or `--version`). + +Deleting the kernel source is the one irreversible step, so it prompts first; pass `--yes` in +scripts. `--to source` can regenerate the source, but any **local edits** you made to +BuildingBlocks are gone — move them into a module, or upstream them into your fork, first. + +```bash +fsh framework swap --to packages --feed ~/dev/nuget-local --yes +``` + +Both directions are idempotent: swapping to the mode a project is already in reports that and +exits successfully. + +### Doing it by hand + +Nothing about either mode is magic, if you would rather not use the command. To go back to source: +copy in a `src/BuildingBlocks` generated for your project name, add the projects to the `.slnx`, and +drop the local feed from `NuGet.config`. The `FSH.Framework.*` entries left in +`Directory.Packages.props` become inert — central package management ignores versions nothing +references. diff --git a/README-template.md b/README-template.md index d97f32625d..01c2d20113 100644 --- a/README-template.md +++ b/README-template.md @@ -67,6 +67,26 @@ deploy/ terraform/ AWS infrastructure (ECS, RDS, ElastiCache, S3) ``` + +## Framework packages + +This project consumes the FSH kernel as **`FSH.Framework.*` NuGet packages** rather than carrying +`src/BuildingBlocks` as source. The modules under `src/Modules` are still fully yours. + +- The feed serving those packages is listed in `NuGet.config` at the repository root. +- The version is pinned by `FshFrameworkVersion` in `src/Directory.Packages.props`. To move to a + newer build of the kernel, change that one value and restore. +- `src/Directory.Build.targets` maps the kernel references onto packages automatically; there are + no `PackageReference` lines to maintain. + +**To step into framework code**, turn **off** "Just My Code" in your debugger (VS/Rider: Debugging +settings; VS Code: `"justMyCode": false` in `.vscode/launch.json`). The packages ship an embedded +PDB with the sources inside, so no symbol server or source checkout is needed. + +Rebuilding or republishing the kernel is done from a starter-kit clone with `fsh framework pack` — +see `README-CLI.md` there. + + ## Database Migrations run automatically under Aspire. To apply them yourself: diff --git a/src/BuildingBlocks/Caching/Caching.csproj b/src/BuildingBlocks/Caching/Caching.csproj index 166fdf10a0..d2573ff573 100644 --- a/src/BuildingBlocks/Caching/Caching.csproj +++ b/src/BuildingBlocks/Caching/Caching.csproj @@ -3,6 +3,10 @@ FSH.Framework.Caching FSH.Framework.Caching + FSH.Framework.Caching + + true diff --git a/src/BuildingBlocks/Core/Core.csproj b/src/BuildingBlocks/Core/Core.csproj index 3c2e01bfdb..679cf37e76 100644 --- a/src/BuildingBlocks/Core/Core.csproj +++ b/src/BuildingBlocks/Core/Core.csproj @@ -3,6 +3,10 @@ FSH.Framework.Core FSH.Framework.Core + FSH.Framework.Core + + true diff --git a/src/BuildingBlocks/Eventing.Abstractions/Eventing.Abstractions.csproj b/src/BuildingBlocks/Eventing.Abstractions/Eventing.Abstractions.csproj index 516a0de675..43e296252f 100644 --- a/src/BuildingBlocks/Eventing.Abstractions/Eventing.Abstractions.csproj +++ b/src/BuildingBlocks/Eventing.Abstractions/Eventing.Abstractions.csproj @@ -6,6 +6,10 @@ enable FSH.Framework.Eventing.Abstractions FSH.Framework.Eventing.Abstractions + FSH.Framework.Eventing.Abstractions + + true Lightweight abstractions for FSH eventing - interfaces only, no implementation dependencies $(NoWarn);CA1711;CA1716 diff --git a/src/BuildingBlocks/Eventing/Eventing.csproj b/src/BuildingBlocks/Eventing/Eventing.csproj index ae26889f67..a6ac03b3b3 100644 --- a/src/BuildingBlocks/Eventing/Eventing.csproj +++ b/src/BuildingBlocks/Eventing/Eventing.csproj @@ -6,6 +6,10 @@ enable FSH.Framework.Eventing FSH.Framework.Eventing + FSH.Framework.Eventing + + true $(NoWarn);CA1711;CA1716;CA1031;S2139;S1066 diff --git a/src/BuildingBlocks/Jobs/Jobs.csproj b/src/BuildingBlocks/Jobs/Jobs.csproj index 67fe79f1a7..b487341b39 100644 --- a/src/BuildingBlocks/Jobs/Jobs.csproj +++ b/src/BuildingBlocks/Jobs/Jobs.csproj @@ -3,6 +3,10 @@ FSH.Framework.Jobs FSH.Framework.Jobs + FSH.Framework.Jobs + + true $(NoWarn);CA1031;S3376;S3993 diff --git a/src/BuildingBlocks/Mailing/Mailing.csproj b/src/BuildingBlocks/Mailing/Mailing.csproj index 7b558fb91b..5c5b06b56e 100644 --- a/src/BuildingBlocks/Mailing/Mailing.csproj +++ b/src/BuildingBlocks/Mailing/Mailing.csproj @@ -3,6 +3,10 @@ FSH.Framework.Mailing FSH.Framework.Mailing + FSH.Framework.Mailing + + true diff --git a/src/BuildingBlocks/Persistence/Persistence.csproj b/src/BuildingBlocks/Persistence/Persistence.csproj index a01f48bfc8..20c0d28a90 100644 --- a/src/BuildingBlocks/Persistence/Persistence.csproj +++ b/src/BuildingBlocks/Persistence/Persistence.csproj @@ -3,6 +3,10 @@ FSH.Framework.Persistence FSH.Framework.Persistence + FSH.Framework.Persistence + + true $(NoWarn);S4144;CS0618 diff --git a/src/BuildingBlocks/Quota/Quota.csproj b/src/BuildingBlocks/Quota/Quota.csproj index b26857fd05..9f190d9ab8 100644 --- a/src/BuildingBlocks/Quota/Quota.csproj +++ b/src/BuildingBlocks/Quota/Quota.csproj @@ -3,6 +3,10 @@ FSH.Framework.Quota FSH.Framework.Quota + FSH.Framework.Quota + + true diff --git a/src/BuildingBlocks/Shared/Shared.csproj b/src/BuildingBlocks/Shared/Shared.csproj index 35d63a65f7..2f470b6eab 100644 --- a/src/BuildingBlocks/Shared/Shared.csproj +++ b/src/BuildingBlocks/Shared/Shared.csproj @@ -3,6 +3,10 @@ FSH.Framework.Shared FSH.Framework.Shared + FSH.Framework.Shared + + true $(NoWarn);CA1716;CA1711;CA1019;CA1305;CA1002;CA2227 diff --git a/src/BuildingBlocks/Storage/Storage.csproj b/src/BuildingBlocks/Storage/Storage.csproj index 6289695e8b..cd87e9a5c8 100644 --- a/src/BuildingBlocks/Storage/Storage.csproj +++ b/src/BuildingBlocks/Storage/Storage.csproj @@ -3,6 +3,10 @@ FSH.Framework.Storage FSH.Framework.Storage + FSH.Framework.Storage + + true $(NoWarn);CA1031;CA1056;CA1002;CA2227;CA1812;CA1308;CA1062 diff --git a/src/BuildingBlocks/Web/Web.csproj b/src/BuildingBlocks/Web/Web.csproj index c84453709a..ee471de7cf 100644 --- a/src/BuildingBlocks/Web/Web.csproj +++ b/src/BuildingBlocks/Web/Web.csproj @@ -3,6 +3,10 @@ FSH.Framework.Web FSH.Framework.Web + FSH.Framework.Web + + true $(NoWarn);CA1805;CA1307;CA1308;S1854;CA1812;CA1305;CA2000 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index be9ac87f2f..f25f8a39d8 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -30,6 +30,31 @@ true + + + embedded + true + + false + false + + @@ -61,19 +86,13 @@ false - - - true - true - true - true - true - snupkg - + - - - - - diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets new file mode 100644 index 0000000000..4738a67502 --- /dev/null +++ b/src/Directory.Build.targets @@ -0,0 +1,85 @@ + + + + + + true + + + + + + <_FshFrameworkProjectRef Include="@(ProjectReference->WithMetadataValue('FshFramework','true'))" /> + + + + + + + true + true + true + true + true + snupkg + + + + + + + + + + false + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 415756d0c0..6111e9bd81 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -145,4 +145,30 @@ Remove once the SignalR backplane package depends on a patched version itself. --> + + + 0.0.0-local + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/FSH.Starter.slnx b/src/FSH.Starter.slnx index 998d538836..71d45de576 100644 --- a/src/FSH.Starter.slnx +++ b/src/FSH.Starter.slnx @@ -1,4 +1,7 @@ + + @@ -12,6 +15,7 @@ + @@ -73,7 +77,9 @@ + + diff --git a/src/Tools/CLI/Commands/DoctorCommand.cs b/src/Tools/CLI/Commands/DoctorCommand.cs index 355da0b897..9497c03c62 100644 --- a/src/Tools/CLI/Commands/DoctorCommand.cs +++ b/src/Tools/CLI/Commands/DoctorCommand.cs @@ -75,7 +75,13 @@ private static async Task CheckDotNetSdkAsync(CancellationToken can if (!ok) return new(".NET SDK", CheckStatus.Fail, "Not found. Install from https://dotnet.microsoft.com"); - bool supported = version.StartsWith("10.", StringComparison.Ordinal); + // Parse the major version rather than matching "10." — the message promises .NET 10+, + // and a literal prefix check would fail a perfectly good .NET 11 SDK. + bool supported = int.TryParse( + version.Split('.')[0], + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out int major) && major >= 10; return new(".NET SDK", supported ? CheckStatus.Pass : CheckStatus.Fail, supported ? $"v{version}" : $"v{version} (requires .NET 10+)"); } diff --git a/src/Tools/CLI/Commands/Framework/FrameworkCleanCacheCommand.cs b/src/Tools/CLI/Commands/Framework/FrameworkCleanCacheCommand.cs new file mode 100644 index 0000000000..c1df74a932 --- /dev/null +++ b/src/Tools/CLI/Commands/Framework/FrameworkCleanCacheCommand.cs @@ -0,0 +1,27 @@ +using System.ComponentModel; +using FSH.CLI.Infrastructure; +using Spectre.Console.Cli; + +namespace FSH.CLI.Commands.Framework; + +/// +/// Purges FSH.Framework.* from the NuGet global-packages cache, so the next restore +/// re-reads them from the feed. +/// +public sealed class FrameworkCleanCacheCommand : AsyncCommand +{ + public sealed class Settings : CommandSettings + { + [Description("List what would be removed without deleting anything.")] + [CommandOption("--dry-run")] + [DefaultValue(false)] + public bool DryRun { get; init; } + } + + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + + return await FrameworkCacheCleaner.ClearAsync(settings.DryRun, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Tools/CLI/Commands/Framework/FrameworkListCommand.cs b/src/Tools/CLI/Commands/Framework/FrameworkListCommand.cs new file mode 100644 index 0000000000..c104cfc635 --- /dev/null +++ b/src/Tools/CLI/Commands/Framework/FrameworkListCommand.cs @@ -0,0 +1,88 @@ +using System.ComponentModel; +using System.Globalization; +using FSH.CLI.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace FSH.CLI.Commands.Framework; + +/// +/// Lists the FSH.Framework.* packages present in a local feed — the quick answer to +/// "which build is my project actually restoring?". +/// +public sealed class FrameworkListCommand : AsyncCommand +{ + public sealed class Settings : CommandSettings + { + [Description("Feed directory to inspect. Defaults to $FSH_LOCAL_FEED, then ~/.fsh/local-nuget.")] + [CommandOption("-f|--feed")] + public string? Feed { get; init; } + + [Description("Show every version instead of only the newest of each package.")] + [CommandOption("--all")] + [DefaultValue(false)] + public bool All { get; init; } + } + + protected override Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + + string feed = FrameworkFeed.Resolve(settings.Feed); + + if (!Directory.Exists(feed)) + { + AnsiConsole.MarkupLine($"[{FshConstants.WarningColor}]Feed directory does not exist:[/] {feed.EscapeMarkup()}"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Create it by running 'fsh framework pack --push'.[/]"); + return Task.FromResult(1); + } + + var packages = Directory + .EnumerateFiles(feed, $"{FshConstants.FrameworkPackagePrefix}*.nupkg") + .Select(path => + { + (string Id, string Version)? parsed = FrameworkFeed.ParsePackageFileName(path); + return new + { + Id = parsed?.Id ?? Path.GetFileNameWithoutExtension(path), + Version = parsed?.Version ?? "?", + Modified = File.GetLastWriteTime(path) + }; + }) + .OrderBy(package => package.Id, StringComparer.Ordinal) + .ThenByDescending(package => package.Modified) + .ToList(); + + if (packages.Count == 0) + { + AnsiConsole.MarkupLine($"[{FshConstants.WarningColor}]No {FshConstants.FrameworkPackagePrefix}* packages in[/] {feed.EscapeMarkup()}"); + return Task.FromResult(0); + } + + if (!settings.All) + { + packages = [.. packages.GroupBy(package => package.Id, StringComparer.Ordinal).Select(group => group.First())]; + } + + var table = new Table().Border(TableBorder.Rounded).BorderColor(Color.Grey); + table.AddColumn("[bold]Package[/]"); + table.AddColumn("[bold]Version[/]"); + table.AddColumn("[bold]Packed[/]"); + + foreach (var package in packages) + { + table.AddRow( + package.Id.EscapeMarkup(), + $"[{FshConstants.AccentColor}]{package.Version.EscapeMarkup()}[/]", + package.Modified.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)); + } + + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Feed:[/] {feed.EscapeMarkup()}"); + AnsiConsole.Write(table); + + if (!settings.All) + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Showing the newest version of each package; use --all to see every version.[/]"); + + return Task.FromResult(0); + } +} diff --git a/src/Tools/CLI/Commands/Framework/FrameworkPackCommand.cs b/src/Tools/CLI/Commands/Framework/FrameworkPackCommand.cs new file mode 100644 index 0000000000..fc5b915200 --- /dev/null +++ b/src/Tools/CLI/Commands/Framework/FrameworkPackCommand.cs @@ -0,0 +1,258 @@ +using System.ComponentModel; +using FSH.CLI.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace FSH.CLI.Commands.Framework; + +/// +/// Packs the BuildingBlocks projects as FSH.Framework.* NuGet packages and, optionally, +/// publishes them to a local folder feed. +/// +public sealed class FrameworkPackCommand : AsyncCommand +{ + /// Pack flavour. Controls what debugging payload the packages carry. + public enum PackProfile + { + /// Embedded PDB + embedded sources; step-into works from a folder feed, offline. + Local, + + /// Separate .snupkg + SourceLink, for a real NuGet server with a symbol server. + Public + } + + public sealed class Settings : CommandSettings + { + [Description("Local feed directory to publish into. Defaults to $FSH_LOCAL_FEED, then ~/.fsh/local-nuget.")] + [CommandOption("-f|--feed")] + public string? Feed { get; init; } + + [Description("Package version. Defaults to a unique 10.0.0-local..")] + [CommandOption("-v|--version")] + public string? Version { get; init; } + + [Description("Directory to write .nupkg files to. Defaults to /artifacts/nupkgs.")] + [CommandOption("-o|--output")] + public string? Output { get; init; } + + [Description("Pack flavour: local (embedded PDB + sources, default) or public (snupkg + SourceLink).")] + [CommandOption("-p|--profile")] + [DefaultValue(PackProfile.Local)] + public PackProfile Profile { get; init; } + + [Description("Copy the packed .nupkg files into the feed.")] + [CommandOption("--push")] + [DefaultValue(false)] + public bool Push { get; init; } + + [Description("Register the feed as a NuGet source if it is not already registered.")] + [CommandOption("--register-source")] + [DefaultValue(false)] + public bool RegisterSource { get; init; } + + [Description("Purge FSH.Framework.* from the NuGet global-packages cache after packing.")] + [CommandOption("--clear-cache")] + [DefaultValue(false)] + public bool ClearCache { get; init; } + + [Description("Show what would happen without packing anything.")] + [CommandOption("--dry-run")] + [DefaultValue(false)] + public bool DryRun { get; init; } + } + + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + + string? repoRoot = RepoLocator.FindStarterKitRoot(); + if (repoRoot is null) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Not inside a FullStackHero starter-kit repository.[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]'fsh framework' builds packages from BuildingBlocks source, so it must run inside a clone[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}](a directory containing both src/BuildingBlocks and .template.config).[/]"); + return 1; + } + + string version = settings.Version ?? FrameworkFeed.NewLocalVersion(); + string output = settings.Output is { Length: > 0 } o + ? Path.GetFullPath(o) + : Path.Combine(repoRoot, "artifacts", "nupkgs"); + string feed = FrameworkFeed.Resolve(settings.Feed); + bool local = settings.Profile == PackProfile.Local; + + var summary = new Table().Border(TableBorder.Rounded).BorderColor(Color.Grey); + summary.AddColumn("[bold]Setting[/]"); + summary.AddColumn("[bold]Value[/]"); + summary.AddRow("Repository", repoRoot.EscapeMarkup()); + summary.AddRow("Version", $"[{FshConstants.AccentColor}]{version.EscapeMarkup()}[/]"); + summary.AddRow("Profile", local ? "local (embedded PDB + sources)" : "public (snupkg + SourceLink)"); + summary.AddRow("Output", output.EscapeMarkup()); + summary.AddRow("Feed", settings.Push ? feed.EscapeMarkup() : $"[{FshConstants.DimColor}](not pushing)[/]"); + summary.AddRow("Packages", FshConstants.FrameworkProjects.Length.ToString(System.Globalization.CultureInfo.InvariantCulture)); + AnsiConsole.Write(summary); + AnsiConsole.WriteLine(); + + if (settings.DryRun) + { + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Dry run — nothing was packed.[/]"); + return 0; + } + + Directory.CreateDirectory(output); + + // Pack leaves first, so a package always exists before the packages that depend on it. + var failures = new List(); + foreach (string project in FshConstants.FrameworkProjects) + { + string projectPath = Path.Combine(repoRoot, "src", "BuildingBlocks", project, $"{project}.csproj"); + if (!File.Exists(projectPath)) + { + failures.Add($"{project} (project not found)"); + continue; + } + + string properties = + $"-p:PackFshFramework=true -p:PackageVersion={version}" + + (local ? " -p:FshLocalPack=true" : string.Empty); + + // Build and pack as two steps so the build can be forced non-incremental. + // Switching profiles changes only compiler switches (embedded PDB + embedded + // sources vs a separate snupkg), which MSBuild's up-to-date check does not treat + // as a reason to recompile — so an incremental pack after a profile switch would + // happily ship the previous profile's binary and silently break step-into + // debugging. `dotnet pack` rejects --no-incremental, hence the separate build. + // The pack step deliberately does NOT pass --no-build: with an embedded PDB there + // is no .pdb on disk, and --no-build makes pack demand one (NU5026). Letting pack + // run the build targets is free here, since the build above just ran. + var step = await ProcessRunner + .CaptureWithErrorAsync("dotnet", $"build \"{projectPath}\" -c Release --no-incremental --nologo {properties}", + repoRoot, cancellationToken) + .ConfigureAwait(false); + + if (step.exitCode == 0) + { + step = await ProcessRunner + .CaptureWithErrorAsync("dotnet", $"pack \"{projectPath}\" -c Release --nologo {properties} -o \"{output}\"", + repoRoot, cancellationToken) + .ConfigureAwait(false); + } + + int exitCode = step.exitCode; + + if (exitCode == 0) + { + AnsiConsole.MarkupLine($" [{FshConstants.SuccessColor}]packed[/] {FshConstants.FrameworkPackagePrefix}{project.EscapeMarkup()}"); + } + else + { + AnsiConsole.MarkupLine($" [{FshConstants.ErrorColor}]failed[/] {FshConstants.FrameworkPackagePrefix}{project.EscapeMarkup()} (exit code {exitCode})"); + failures.Add(project); + + // Show what dotnet actually said; a bare "failed" leaves nothing to act on. + IEnumerable diagnostics = $"{step.output}\n{step.error}" + .Split('\n') + .Where(line => line.Contains("error", StringComparison.OrdinalIgnoreCase)) + .Take(3); + + foreach (string line in diagnostics) + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]{line.Trim().EscapeMarkup()}[/]"); + } + } + + if (failures.Count > 0) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]{failures.Count} package(s) failed to pack.[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Re-run a single project with 'dotnet pack' to see the full error output.[/]"); + return 1; + } + + if (settings.Push && !PublishToFeed(output, feed, version)) + return 1; + + if (settings.RegisterSource + && !await EnsureSourceRegisteredAsync(feed, cancellationToken).ConfigureAwait(false)) + { + return 1; + } + + if (settings.ClearCache) + await FrameworkCacheCleaner.ClearAsync(dryRun: false, cancellationToken).ConfigureAwait(false); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[{FshConstants.SuccessColor}]Done.[/] Consume with:"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]dotnet build -p:UseFrameworkPackages=true -p:FshFrameworkVersion={version.EscapeMarkup()}[/]"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]or pin FshFrameworkVersion in the consuming project's Directory.Packages.props[/]"); + + return 0; + } + + /// + /// Copies the packages into the feed as a flat folder feed. + /// + /// + /// A plain file copy rather than dotnet nuget push: pushing to a folder source writes + /// the hierarchical (V3) layout, which would sit awkwardly beside the flat layout most + /// hand-rolled local feeds already use. NuGet reads a flat folder feed happily, and a copy + /// is deterministic and trivially inspectable. + /// + private static bool PublishToFeed(string output, string feed, string version) + { + try + { + Directory.CreateDirectory(feed); + + int copied = 0; + foreach (string package in Directory.EnumerateFiles(output, $"{FshConstants.FrameworkPackagePrefix}*.{version}.nupkg")) + { + File.Copy(package, Path.Combine(feed, Path.GetFileName(package)), overwrite: true); + copied++; + } + + // The public profile emits symbol packages alongside; they belong in the feed too. + foreach (string symbols in Directory.EnumerateFiles(output, $"{FshConstants.FrameworkPackagePrefix}*.{version}.snupkg")) + { + File.Copy(symbols, Path.Combine(feed, Path.GetFileName(symbols)), overwrite: true); + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[{FshConstants.SuccessColor}]Published {copied} package(s)[/] to {feed.EscapeMarkup()}"); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Could not publish to '{feed.EscapeMarkup()}': {ex.Message.EscapeMarkup()}[/]"); + return false; + } + } + + private static async Task EnsureSourceRegisteredAsync(string feed, CancellationToken cancellationToken) + { + (bool listed, string sources) = await ProcessRunner + .CaptureAsync("dotnet", "nuget list source", cancellationToken) + .ConfigureAwait(false); + + if (listed && sources.Contains(feed, StringComparison.OrdinalIgnoreCase)) + { + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]NuGet source already registered.[/]"); + return true; + } + + int exitCode = await ProcessRunner.RunAsync( + "dotnet", + $"nuget add source \"{feed}\" --name {FshConstants.LocalFeedSourceName}", + showOutput: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (exitCode == 0) + { + AnsiConsole.MarkupLine($"[{FshConstants.SuccessColor}]Registered NuGet source[/] '{FshConstants.LocalFeedSourceName}'."); + return true; + } + + AnsiConsole.MarkupLine($"[{FshConstants.WarningColor}]Could not register the NuGet source (exit code {exitCode}).[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Add it manually: dotnet nuget add source \"{feed.EscapeMarkup()}\" --name {FshConstants.LocalFeedSourceName}[/]"); + return false; + } +} diff --git a/src/Tools/CLI/Commands/Framework/FrameworkSwapCommand.cs b/src/Tools/CLI/Commands/Framework/FrameworkSwapCommand.cs new file mode 100644 index 0000000000..92ac5c5532 --- /dev/null +++ b/src/Tools/CLI/Commands/Framework/FrameworkSwapCommand.cs @@ -0,0 +1,234 @@ +using System.ComponentModel; +using FSH.CLI.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace FSH.CLI.Commands.Framework; + +/// +/// Switches an existing scaffolded project between owning the kernel as source and consuming it +/// as FSH.Framework.* packages. +/// +public sealed class FrameworkSwapCommand : AsyncCommand +{ + public enum SwapTarget + { + /// Own src/BuildingBlocks as source. + Source, + + /// Consume the kernel from a NuGet feed. + Packages + } + + public sealed class Settings : CommandSettings + { + [Description("Which side to swap to: source or packages.")] + [CommandOption("-t|--to ")] + public SwapTarget To { get; init; } + + [Description("Project directory to convert. Defaults to the current directory.")] + [CommandOption("--project ")] + public string? Project { get; init; } + + [Description("Starter-kit checkout or template to take BuildingBlocks from. Env: FSH_TEMPLATE_PATH.")] + [CommandOption("--from ")] + public string? From { get; init; } + + [Description("Feed serving the FSH.Framework.* packages. Env: FSH_LOCAL_FEED.")] + [CommandOption("-f|--feed ")] + public string? Feed { get; init; } + + [Description("Package version to pin. Defaults to the newest in the feed.")] + [CommandOption("-v|--version ")] + public string? Version { get; init; } + + [Description("Skip the confirmation prompt before deleting kernel source.")] + [CommandOption("-y|--yes")] + [DefaultValue(false)] + public bool Yes { get; init; } + + [Description("Show what would change without touching anything.")] + [CommandOption("--dry-run")] + [DefaultValue(false)] + public bool DryRun { get; init; } + } + + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + + ScaffoldedProject? project = ScaffoldedProject.Locate(settings.Project); + if (project is null) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]No scaffolded project found here.[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Run this inside a project created by 'fsh new' (a directory with a single src/*.slnx),[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]or point at one with --project .[/]"); + return 1; + } + + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Project:[/] {project.Name.EscapeMarkup()} [{FshConstants.DimColor}]({project.Root.EscapeMarkup()})[/]"); + + return settings.To == SwapTarget.Source + ? await SwapToSourceAsync(project, settings, cancellationToken).ConfigureAwait(false) + : SwapToPackages(project, settings); + } + + /// + /// Brings src/BuildingBlocks back into the project. + /// + /// + /// The kernel is regenerated from the template rather than copied out of a starter-kit + /// clone, because the template rewrites tokens inside BuildingBlocks — including functional + /// ones such as MultitenancyConstants.Issuer. A raw copy would silently install the + /// starter kit's own issuer and brand strings into someone else's project. + /// + private static async Task SwapToSourceAsync(ScaffoldedProject project, Settings settings, CancellationToken cancellationToken) + { + if (project.HasBuildingBlocksSource) + { + AnsiConsole.MarkupLine($"[{FshConstants.SuccessColor}]Already on owned source[/] — src/BuildingBlocks is present. Nothing to do."); + return 0; + } + + if (settings.DryRun) + { + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Would generate src/BuildingBlocks (+ src/Tests/Framework.Tests) from the template,[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]add them to {Path.GetFileName(project.SolutionPath).EscapeMarkup()}, and drop the local feed from NuGet.config.[/]"); + return 0; + } + + if (!await TemplateInstaller.EnsureInstalledAsync( + settings.From, templateVersion: null, templateSource: null, + refresh: settings.From is not null, cancellationToken).ConfigureAwait(false)) + { + return 1; + } + + string staging = Path.Combine(Path.GetTempPath(), $"fsh-swap-{Guid.NewGuid():N}"); + + try + { + // Scaffold a throwaway copy under the SAME project name so every token the template + // substitutes lands on the same values this project already uses. + int scaffold = await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse(FshConstants.AccentColor)) + .StartAsync("Generating kernel source from the template...", async _ => + { + var result = await ProcessRunner.CaptureWithErrorAsync( + "dotnet", + $"new {FshConstants.TemplateShortName} -n \"{project.Name}\" -o \"{staging}\" " + + "--aspire false --frontend false --skipRestore true --force", + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (!Directory.Exists(Path.Combine(staging, "src", "BuildingBlocks"))) + { + foreach (string line in $"{result.output}\n{result.error}".Split('\n').Where(l => !string.IsNullOrWhiteSpace(l))) + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]{line.TrimEnd().EscapeMarkup()}[/]"); + return 1; + } + + return 0; + }).ConfigureAwait(false); + + if (scaffold != 0) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Could not generate kernel source from the template.[/]"); + return 1; + } + + ScaffoldedProject.CopyTree(Path.Combine(staging, "src", "BuildingBlocks"), project.BuildingBlocksPath); + AnsiConsole.MarkupLine($" [{FshConstants.SuccessColor}]added[/] src/BuildingBlocks"); + + string stagedTests = Path.Combine(staging, "src", "Tests", "Framework.Tests"); + bool tests = Directory.Exists(stagedTests); + if (tests) + { + ScaffoldedProject.CopyTree(stagedTests, project.FrameworkTestsPath); + AnsiConsole.MarkupLine($" [{FshConstants.SuccessColor}]added[/] src/Tests/Framework.Tests"); + } + + project.AddKernelToSolution(FshConstants.FrameworkProjects, tests); + AnsiConsole.MarkupLine($" [{FshConstants.SuccessColor}]updated[/] {Path.GetFileName(project.SolutionPath).EscapeMarkup()}"); + + if (project.RemoveLocalFeedSource()) + AnsiConsole.MarkupLine($" [{FshConstants.SuccessColor}]removed[/] NuGet.config (local framework feed no longer needed)"); + } + finally + { + try { if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true); } + catch (IOException) { /* a leftover temp directory is not worth failing the swap over */ } + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[{FshConstants.SuccessColor}]Now on owned source.[/] Directory.Build.targets stops rewriting references as soon as"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]src/BuildingBlocks exists, so nothing else needs changing. Build to confirm:[/]"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]dotnet build src/{project.Name.EscapeMarkup()}.slnx[/]"); + return 0; + } + + private static int SwapToPackages(ScaffoldedProject project, Settings settings) + { + if (!project.HasBuildingBlocksSource) + { + AnsiConsole.MarkupLine($"[{FshConstants.SuccessColor}]Already on packages[/] — there is no src/BuildingBlocks. Nothing to do."); + return 0; + } + + string feed = FrameworkFeed.Resolve(settings.Feed); + string? version = settings.Version ?? FrameworkFeed.GetLatestVersion(feed); + + if (version is null) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]No {FshConstants.FrameworkPackagePrefix}* packages in[/] {feed.EscapeMarkup()}"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Build them first, from a starter-kit clone: fsh framework pack --push[/]"); + return 1; + } + + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Feed:[/] {feed.EscapeMarkup()} [{FshConstants.DimColor}]Version:[/] {version.EscapeMarkup()}"); + + if (settings.DryRun) + { + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Would delete src/BuildingBlocks and src/Tests/Framework.Tests, update the solution,[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]write NuGet.config, and pin FshFrameworkVersion.[/]"); + return 0; + } + + // Deleting the kernel source is the one irreversible step here, so it is confirmed by + // default. `fsh framework swap --to source` can regenerate it, but any local edits to + // BuildingBlocks would be gone. + if (!settings.Yes) + { + AnsiConsole.MarkupLine($"[{FshConstants.WarningColor}]This deletes src/BuildingBlocks. Local edits to the kernel will be lost.[/]"); + + if (!AnsiConsole.Confirm("Continue?", defaultValue: false)) + return 1; + } + + Directory.Delete(project.BuildingBlocksPath, recursive: true); + AnsiConsole.MarkupLine($" [{FshConstants.SuccessColor}]removed[/] src/BuildingBlocks"); + + if (Directory.Exists(project.FrameworkTestsPath)) + { + // Framework.Tests references the kernel projects directly; leaving it behind would + // break the build the moment the source is gone. + Directory.Delete(project.FrameworkTestsPath, recursive: true); + AnsiConsole.MarkupLine($" [{FshConstants.SuccessColor}]removed[/] src/Tests/Framework.Tests"); + } + + project.RemoveKernelFromSolution(); + AnsiConsole.MarkupLine($" [{FshConstants.SuccessColor}]updated[/] {Path.GetFileName(project.SolutionPath).EscapeMarkup()}"); + + project.WriteNuGetConfig(feed); + AnsiConsole.MarkupLine($" [{FshConstants.SuccessColor}]wrote[/] NuGet.config"); + + AnsiConsole.MarkupLine(project.SetFrameworkVersion(version) + ? $" [{FshConstants.SuccessColor}]pinned[/] FshFrameworkVersion = {version.EscapeMarkup()}" + : $" [{FshConstants.DimColor}]FshFrameworkVersion already {version.EscapeMarkup()}[/]"); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[{FshConstants.SuccessColor}]Now on framework packages.[/] Build to confirm:"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]dotnet build src/{project.Name.EscapeMarkup()}.slnx[/]"); + return 0; + } +} diff --git a/src/Tools/CLI/Commands/NewCommand.cs b/src/Tools/CLI/Commands/NewCommand.cs index b9b4f7890c..6760f8d436 100644 --- a/src/Tools/CLI/Commands/NewCommand.cs +++ b/src/Tools/CLI/Commands/NewCommand.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Reflection; using System.Security.Cryptography; using FSH.CLI.Infrastructure; using Spectre.Console; @@ -47,6 +48,56 @@ public sealed class Settings : CommandSettings [CommandOption("--dry-run")] [DefaultValue(false)] public bool DryRun { get; init; } + + [Description("Install the template from a local directory or .nupkg instead of NuGet. Env: FSH_TEMPLATE_PATH.")] + [CommandOption("--template-path")] + public string? TemplatePath { get; init; } + + [Description("Install a specific template version from NuGet. Env: FSH_TEMPLATE_VERSION.")] + [CommandOption("--template-version")] + public string? TemplateVersion { get; init; } + + [Description("Additional NuGet source to install the template from. Env: FSH_TEMPLATE_SOURCE.")] + [CommandOption("--template-source")] + public string? TemplateSource { get; init; } + + [Description("Re-install the template even if one is already installed.")] + [CommandOption("--refresh-template")] + [DefaultValue(false)] + public bool RefreshTemplate { get; init; } + + [Description("Include the .agents AI rules/skills kit and AGENTS.md. Env: FSH_AGENTS=1.")] + [CommandOption("--agents [VALUE]")] + public FlagValue Agents { get; init; } = new(); + + [Description("Consume BuildingBlocks as FSH.Framework.* NuGet packages instead of scaffolding their source.")] + [CommandOption("--framework-packages [VALUE]")] + public FlagValue FrameworkPackages { get; init; } = new(); + + /// + /// True when the flag was passed, either bare (--framework-packages) or with an + /// explicit value (--framework-packages true). + /// + internal bool WantsFrameworkPackages => IsFlagSet(FrameworkPackages); + + /// + /// True when the flag was passed and not explicitly negated. + /// + /// + /// The underlying value is bool?, not bool, on purpose: Spectre leaves the + /// value at its default when a flag is passed bare, so with bool a plain + /// --agents would be indistinguishable from --agents false. With + /// bool?, bare means null, which reads as "yes". + /// + internal static bool IsFlagSet(FlagValue flag) => flag is { IsSet: true } && (flag.Value ?? true); + + [Description("Version of the FSH.Framework.* packages to pin. Defaults to the newest in the feed.")] + [CommandOption("--framework-version")] + public string? FrameworkVersion { get; init; } + + [Description("Local NuGet feed serving the FSH.Framework.* packages. Env: FSH_LOCAL_FEED.")] + [CommandOption("--framework-feed")] + public string? FrameworkFeedPath { get; init; } } protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) @@ -69,6 +120,20 @@ protected override async Task ExecuteAsync(CommandContext context, Settings bool frontend = await ResolveFrontendAsync(settings, cancellationToken).ConfigureAwait(false); + bool agents = await ResolveAgentsAsync(settings, cancellationToken).ConfigureAwait(false); + + // Framework packaging is opt-in and never prompted for: it is a deliberate, project-wide + // architecture choice, not a per-scaffold convenience. + string? frameworkFeed = settings.WantsFrameworkPackages ? FrameworkFeed.Resolve(settings.FrameworkFeedPath) : null; + string? frameworkVersion = settings.WantsFrameworkPackages + ? settings.FrameworkVersion + ?? FrameworkFeed.GetLatestVersion(frameworkFeed!) + ?? "0.0.0-local" + : null; + + if (settings.WantsFrameworkPackages && !ValidateFrameworkFeed(frameworkFeed!, settings.FrameworkVersion)) + return 1; + string output = settings.Output ?? Path.GetFullPath(name); // 2. Check for existing directory @@ -89,7 +154,7 @@ protected override async Task ExecuteAsync(CommandContext context, Settings } // 3. Print summary - PrintSummary(name, aspire, frontend, output, settings.DryRun); + PrintSummary(name, aspire, frontend, agents, output, frameworkVersion, frameworkFeed, settings.DryRun); if (settings.DryRun) { @@ -98,11 +163,16 @@ protected override async Task ExecuteAsync(CommandContext context, Settings } // 4. Ensure template is installed - if (!await EnsureTemplateInstalledAsync(cancellationToken).ConfigureAwait(false)) + if (!await TemplateInstaller.EnsureInstalledAsync( + settings.TemplatePath, settings.TemplateVersion, settings.TemplateSource, + settings.RefreshTemplate, cancellationToken).ConfigureAwait(false)) + { return 1; + } // 5. Scaffold project - int result = await ScaffoldProjectAsync(name, aspire, frontend, output, cancellationToken).ConfigureAwait(false); + int result = await ScaffoldProjectAsync( + name, aspire, frontend, agents, frameworkVersion, output, cancellationToken).ConfigureAwait(false); if (result != 0) { AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Scaffolding failed. Check the output above for errors.[/]"); @@ -113,6 +183,10 @@ protected override async Task ExecuteAsync(CommandContext context, Settings GenerateDevSecrets(name, output); bool dockerEnvReady = GenerateDockerEnv(output); + // 6b. Point the project at the feed serving its framework packages. + if (frameworkFeed is not null) + GenerateNuGetConfig(output, frameworkFeed); + // 7. Install frontend dependencies (npm install in both React apps) if (frontend && !settings.SkipInstall) { @@ -129,7 +203,7 @@ protected override async Task ExecuteAsync(CommandContext context, Settings await CheckForUpdatesAsync(cancellationToken).ConfigureAwait(false); // 10. Print next steps - PrintNextSteps(name, aspire, frontend, settings.SkipInstall, dockerEnvReady); + PrintNextSteps(name, aspire, frontend, settings.SkipInstall, dockerEnvReady, frameworkFeed); return 0; } @@ -179,52 +253,68 @@ private static async Task ResolveFrontendAsync(Settings settings, Cancella .ShowAsync(AnsiConsole.Console, cancellationToken).ConfigureAwait(false); } - private static void PrintSummary(string name, bool aspire, bool frontend, string output, bool dryRun) + private static async Task ResolveAgentsAsync(Settings settings, CancellationToken cancellationToken) { - AnsiConsole.WriteLine(); + if (Settings.IsFlagSet(settings.Agents)) return true; - string mode = dryRun ? " [yellow](dry run)[/]" : ""; - AnsiConsole.MarkupLine($"[bold]Creating project:[/] {name.EscapeMarkup()}{mode}"); - AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Aspire:[/] {(aspire ? "yes" : "no")}"); - AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Frontend:[/] {(frontend ? "yes (admin + dashboard)" : "no")}"); - AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Output:[/] {output.EscapeMarkup()}"); - AnsiConsole.WriteLine(); + string? fromEnvironment = Environment.GetEnvironmentVariable(FshConstants.AgentsEnvVar); + if (fromEnvironment is "1" or "true" or "TRUE" or "True") return true; + + if (settings.NonInteractive) return false; + + return await new ConfirmationPrompt($"[{FshConstants.AccentColor}]Include the .agents AI rules kit (AGENTS.md + rules/skills)?[/]") + { DefaultValue = false } + .ShowAsync(AnsiConsole.Console, cancellationToken).ConfigureAwait(false); } - private static async Task EnsureTemplateInstalledAsync(CancellationToken cancellationToken) + /// + /// Warns early when framework packaging is requested but the feed cannot serve it — a + /// scaffold that cannot restore is far more confusing than a message here. + /// + private static bool ValidateFrameworkFeed(string feed, string? explicitVersion) { - // Check if the template is already available. dotnet new list may return - // non-zero due to workload warnings, so check stdout content regardless. - (_, string listOutput) = await ProcessRunner.CaptureAsync( - "dotnet", $"new list {FshConstants.TemplateShortName}", - cancellationToken).ConfigureAwait(false); + if (!Directory.Exists(feed)) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Framework feed not found:[/] {feed.EscapeMarkup()}"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Build the packages first, from a starter-kit clone: fsh framework pack --push[/]"); + return false; + } - bool installed = listOutput.Contains(FshConstants.TemplateShortName, StringComparison.OrdinalIgnoreCase) - && listOutput.Contains("FullStackHero", StringComparison.OrdinalIgnoreCase); + if (explicitVersion is null && FrameworkFeed.GetLatestVersion(feed) is null) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]No {FshConstants.FrameworkPackagePrefix}* packages in[/] {feed.EscapeMarkup()}"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Build them first, from a starter-kit clone: fsh framework pack --push[/]"); + return false; + } - if (installed) return true; + return true; + } - AnsiConsole.MarkupLine($"[{FshConstants.WarningColor}]FSH template not found. Installing...[/]"); - await ProcessRunner.RunAsync( - "dotnet", $"new install {FshConstants.TemplatePackageId}", - cancellationToken: cancellationToken).ConfigureAwait(false); + private static void PrintSummary( + string name, bool aspire, bool frontend, bool agents, string output, + string? frameworkVersion, string? frameworkFeed, bool dryRun) + { + AnsiConsole.WriteLine(); - // Verify it actually installed (ignore exit code — workload warnings cause non-zero) - (_, string verifyOutput) = await ProcessRunner.CaptureAsync( - "dotnet", $"new list {FshConstants.TemplateShortName}", - cancellationToken).ConfigureAwait(false); + string mode = dryRun ? " [yellow](dry run)[/]" : ""; + AnsiConsole.MarkupLine($"[bold]Creating project:[/] {name.EscapeMarkup()}{mode}"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Aspire:[/] {(aspire ? "yes" : "no")}"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Frontend:[/] {(frontend ? "yes (admin + dashboard)" : "no")}"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Agents:[/] {(agents ? "yes (.agents + AGENTS.md)" : "no")}"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Framework:[/] {(frameworkVersion is null + ? "owned source (src/BuildingBlocks)" + : $"packages {frameworkVersion.EscapeMarkup()}")}"); - bool nowInstalled = verifyOutput.Contains("FullStackHero", StringComparison.OrdinalIgnoreCase); - if (!nowInstalled) - { - AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Failed to install template. Run manually:[/] dotnet new install {FshConstants.TemplatePackageId}"); - } + if (frameworkFeed is not null) + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Feed:[/] {frameworkFeed.EscapeMarkup()}"); - return nowInstalled; + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]Output:[/] {output.EscapeMarkup()}"); + AnsiConsole.WriteLine(); } private static async Task ScaffoldProjectAsync( - string name, bool aspire, bool frontend, string output, CancellationToken cancellationToken) + string name, bool aspire, bool frontend, bool agents, string? frameworkVersion, + string output, CancellationToken cancellationToken) { return await AnsiConsole.Status() .Spinner(Spinner.Known.Dots) @@ -233,8 +323,19 @@ private static async Task ScaffoldProjectAsync( { string aspireFlag = aspire ? "true" : "false"; string frontendFlag = frontend ? "true" : "false"; - string args = $"new {FshConstants.TemplateShortName} -n {name} -o \"{output}\" --aspire {aspireFlag} --frontend {frontendFlag} --force"; - await ProcessRunner.RunAsync("dotnet", args, showOutput: false, cancellationToken: cancellationToken) + string agentsFlag = agents ? "true" : "false"; + string args = + $"new {FshConstants.TemplateShortName} -n \"{name}\" -o \"{output}\" " + + $"--aspire {aspireFlag} --frontend {frontendFlag} --agents {agentsFlag}" + + (frameworkVersion is not null + ? $" --frameworkPackages true --frameworkVersion {frameworkVersion}" + : string.Empty) + + " --force"; + + // Named, not deconstructed with a discard: the enclosing status lambda already + // binds "_" to its StatusContext. + var scaffold = await ProcessRunner + .CaptureWithErrorAsync("dotnet", args, cancellationToken: cancellationToken) .ConfigureAwait(false); // dotnet new may return non-zero due to workload warnings even on success. @@ -247,7 +348,18 @@ await ProcessRunner.RunAsync("dotnet", args, showOutput: false, cancellationToke bool anySolution = Directory.Exists(Path.Combine(output, "src")) && Directory.GetFiles(Path.Combine(output, "src"), "*.slnx").Length > 0; - return anySolution ? 0 : 1; + if (anySolution) return 0; + + // Genuinely failed — show what dotnet new said. Swallowing this leaves the user + // with a bare "Scaffolding failed" and no way to find out why. + IEnumerable diagnostics = $"{scaffold.output}\n{scaffold.error}" + .Split('\n') + .Where(line => !string.IsNullOrWhiteSpace(line)); + + foreach (string line in diagnostics) + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]{line.TrimEnd().EscapeMarkup()}[/]"); + + return 1; }).ConfigureAwait(false); } @@ -394,6 +506,44 @@ private static string GenerateSecret(int length) return new string(chars); } + /// + /// Writes a NuGet.config pointing the scaffolded project at the feed serving its + /// FSH.Framework.* packages. + /// + /// + /// Written here rather than shipped in the template for two reasons: the feed path is only + /// known at scaffold time, and a NuGet.config living at the starter kit's own root would + /// hijack restore for the kit itself. Same post-scaffold approach as the docker .env. + /// + private static void GenerateNuGetConfig(string output, string feed) + { + string path = Path.Combine(output, "NuGet.config"); + if (File.Exists(path)) return; + + // so an inherited machine-level config cannot shadow the local feed. + string content = $""" + + + + + + + + + + """; + + try + { + File.WriteAllText(path, content); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + AnsiConsole.MarkupLine($"[{FshConstants.WarningColor}]Could not write NuGet.config: {ex.Message.EscapeMarkup()}[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Add the feed manually: dotnet nuget add source \"{feed.EscapeMarkup()}\"[/]"); + } + } + private static async Task CheckForUpdatesAsync(CancellationToken cancellationToken) { try @@ -401,7 +551,14 @@ private static async Task CheckForUpdatesAsync(CancellationToken cancellationTok string? latest = await NuGetClient.GetLatestVersionAsync( FshConstants.CliPackageId, cancellationToken).ConfigureAwait(false); - string currentVersion = typeof(NewCommand).Assembly.GetName().Version?.ToString(3) ?? "0.0.0"; + // Use the informational version (CI injects the real package version there), not + // AssemblyVersion, which is pinned to 10.0.0.0 in the csproj and would make every + // patch build nag about an "update" to itself. + string currentVersion = typeof(NewCommand).Assembly + .GetCustomAttribute()? + .InformationalVersion?.Split('+')[0] + ?? typeof(NewCommand).Assembly.GetName().Version?.ToString(3) + ?? "0.0.0"; if (VersionComparer.IsNewer(latest, currentVersion)) { @@ -416,7 +573,8 @@ private static async Task CheckForUpdatesAsync(CancellationToken cancellationTok } } - private static void PrintNextSteps(string name, bool aspire, bool frontend, bool skipInstall, bool dockerEnvReady) + private static void PrintNextSteps( + string name, bool aspire, bool frontend, bool skipInstall, bool dockerEnvReady, string? frameworkFeed) { AnsiConsole.WriteLine(); AnsiConsole.Write(new Rule($"[{FshConstants.SuccessColor}]Project created successfully![/]").RuleStyle(FshConstants.SuccessColor)); @@ -451,6 +609,13 @@ private static void PrintNextSteps(string name, bool aspire, bool frontend, bool if (dockerEnvReady) tree.AddNode($"[{FshConstants.DimColor}]Self-host:[/] cd deploy/docker && docker compose up -d --build [{FshConstants.DimColor}](secrets pre-generated in .env)[/]"); + if (frameworkFeed is not null) + { + tree.AddNode($"[{FshConstants.DimColor}]Framework feed:[/] {frameworkFeed.EscapeMarkup()} [{FshConstants.DimColor}](see NuGet.config)[/]"); + // Everyone hits this once: without it the debugger silently steps over framework code. + tree.AddNode($"[{FshConstants.DimColor}]To step into framework code, turn OFF \"Just My Code\" in your debugger.[/]"); + } + tree.AddNode($"[{FshConstants.DimColor}]Documentation:[/] {FshConstants.DocsUrl}"); AnsiConsole.Write(tree); diff --git a/src/Tools/CLI/Commands/Self/SelfInstallCommand.cs b/src/Tools/CLI/Commands/Self/SelfInstallCommand.cs new file mode 100644 index 0000000000..a4c4a27e74 --- /dev/null +++ b/src/Tools/CLI/Commands/Self/SelfInstallCommand.cs @@ -0,0 +1,139 @@ +using System.ComponentModel; +using FSH.CLI.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace FSH.CLI.Commands.Self; + +/// +/// Packs this repository's CLI and installs it as the global fsh tool, so the working +/// copy can be driven with fsh ... instead of dotnet run --project src/Tools/CLI --. +/// +public sealed class SelfInstallCommand : AsyncCommand +{ + public sealed class Settings : CommandSettings + { + [Description("Version to stamp on the locally built tool. Defaults to 10.0.0-local..")] + [CommandOption("-v|--version ")] + public string? Version { get; init; } + + [Description("Directory to write the .nupkg to. Defaults to /artifacts/nupkgs.")] + [CommandOption("-o|--output ")] + public string? Output { get; init; } + + [Description("Show what would happen without packing or installing.")] + [CommandOption("--dry-run")] + [DefaultValue(false)] + public bool DryRun { get; init; } + } + + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + + string? repoRoot = RepoLocator.FindStarterKitRoot(); + if (repoRoot is null) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Not inside a FullStackHero starter-kit repository.[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]'fsh self install' builds the tool from CLI source, so it must run inside a clone.[/]"); + return 1; + } + + // A distinct prerelease version keeps the locally built tool from being confused with + // whatever FullStackHero.CLI version is published on nuget.org, and makes each install + // a different version so `dotnet tool update` never treats it as already current. + string version = settings.Version ?? FrameworkFeed.NewLocalVersion(); + string output = settings.Output is { Length: > 0 } o + ? Path.GetFullPath(o) + : Path.Combine(repoRoot, "artifacts", "nupkgs"); + string projectPath = Path.Combine(repoRoot, "src", "Tools", "CLI", "FSH.CLI.csproj"); + + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Repository:[/] {repoRoot.EscapeMarkup()}"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Version:[/] [{FshConstants.AccentColor}]{version.EscapeMarkup()}[/]"); + + if (settings.DryRun) + { + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Would pack {FshConstants.CliPackageId} into {output.EscapeMarkup()} and install it globally.[/]"); + return 0; + } + + Directory.CreateDirectory(output); + + var pack = await ProcessRunner.CaptureWithErrorAsync( + "dotnet", + // -p:Version too, not just PackageVersion: it feeds AssemblyInformationalVersion, + // which is what `fsh --version` prints. Without it a locally installed build reports + // the repo's 10.0.0 and is indistinguishable from the published tool. + $"pack \"{projectPath}\" -c Release --nologo -p:Version={version} -p:PackageVersion={version} -o \"{output}\"", + repoRoot, cancellationToken).ConfigureAwait(false); + + if (pack.exitCode != 0) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Packing the CLI failed.[/]"); + ReportDiagnostics(pack.output, pack.error); + return 1; + } + + AnsiConsole.MarkupLine($" [{FshConstants.SuccessColor}]packed[/] {FshConstants.CliPackageId}"); + + // `tool update` installs when absent and upgrades when present, so one call covers both. + var install = await ProcessRunner.CaptureWithErrorAsync( + "dotnet", + $"tool update -g {FshConstants.CliPackageId} --version {version} --add-source \"{output}\"", + repoRoot, cancellationToken).ConfigureAwait(false); + + if (install.exitCode != 0) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Installing the global tool failed.[/]"); + ReportDiagnostics(install.output, install.error); + return 1; + } + + AnsiConsole.MarkupLine($" [{FshConstants.SuccessColor}]installed[/] global tool '{FshConstants.ToolCommandName}'"); + AnsiConsole.WriteLine(); + + WarnIfToolsDirectoryNotOnPath(); + + AnsiConsole.MarkupLine($"[{FshConstants.SuccessColor}]Done.[/] The '{FshConstants.ToolCommandName}' command now runs this working copy:"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]{FshConstants.ToolCommandName} new MyApp --agents --framework-packages[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Re-run 'fsh self install' after changing CLI source; 'fsh self uninstall' removes it.[/]"); + + return 0; + } + + private static void ReportDiagnostics(string output, string error) + { + IEnumerable lines = $"{output}\n{error}" + .Split('\n') + .Where(line => line.Contains("error", StringComparison.OrdinalIgnoreCase)) + .Take(3); + + foreach (string line in lines) + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]{line.Trim().EscapeMarkup()}[/]"); + } + + /// + /// A freshly installed global tool is invisible until its directory is on PATH, which is the + /// usual reason "command not found" follows a successful install. + /// + private static void WarnIfToolsDirectoryNotOnPath() + { + string toolsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dotnet", "tools"); + + string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + bool onPath = path + .Split(Path.PathSeparator) + .Any(entry => string.Equals( + entry.TrimEnd(Path.DirectorySeparatorChar), + toolsDirectory.TrimEnd(Path.DirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase)); + + if (onPath) return; + + AnsiConsole.MarkupLine($"[{FshConstants.WarningColor}]{toolsDirectory.EscapeMarkup()} is not on your PATH.[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Add it to your shell profile, otherwise '{FshConstants.ToolCommandName}' will not be found:[/]"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]export PATH=\"$PATH:{toolsDirectory.EscapeMarkup()}\"[/]"); + AnsiConsole.WriteLine(); + } +} diff --git a/src/Tools/CLI/Commands/Self/SelfUninstallCommand.cs b/src/Tools/CLI/Commands/Self/SelfUninstallCommand.cs new file mode 100644 index 0000000000..23aa29ad8d --- /dev/null +++ b/src/Tools/CLI/Commands/Self/SelfUninstallCommand.cs @@ -0,0 +1,30 @@ +using FSH.CLI.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace FSH.CLI.Commands.Self; + +/// +/// Removes the globally installed fsh tool, whether it came from nuget.org or from +/// fsh self install. +/// +public sealed class SelfUninstallCommand : AsyncCommand +{ + protected override async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + var result = await ProcessRunner.CaptureWithErrorAsync( + "dotnet", $"tool uninstall -g {FshConstants.CliPackageId}", + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.exitCode != 0) + { + AnsiConsole.MarkupLine($"[{FshConstants.WarningColor}]Could not uninstall the global tool.[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]It may not be installed: dotnet tool list -g[/]"); + return 1; + } + + AnsiConsole.MarkupLine($"[{FshConstants.SuccessColor}]Removed[/] the global '{FshConstants.ToolCommandName}' tool."); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]To go back to the published build: dotnet tool install -g {FshConstants.CliPackageId}[/]"); + return 0; + } +} diff --git a/src/Tools/CLI/Infrastructure/FrameworkCacheCleaner.cs b/src/Tools/CLI/Infrastructure/FrameworkCacheCleaner.cs new file mode 100644 index 0000000000..406882430a --- /dev/null +++ b/src/Tools/CLI/Infrastructure/FrameworkCacheCleaner.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using Spectre.Console; + +namespace FSH.CLI.Infrastructure; + +/// +/// Removes FSH.Framework.* from the NuGet global-packages cache. +/// +/// +/// Shared by fsh framework pack --clear-cache and fsh framework clean-cache. +/// This is the fix for the most common local-feed failure: NuGet keys its cache on +/// id + version, so a rebuilt package that reuses a version is never re-extracted and the +/// consuming project silently keeps compiling against the old bits. +/// +internal static class FrameworkCacheCleaner +{ + internal static async Task ClearAsync(bool dryRun, CancellationToken cancellationToken) + { + string? globalPackages = await FrameworkFeed.GetGlobalPackagesFolderAsync(cancellationToken).ConfigureAwait(false); + + if (globalPackages is null) + { + AnsiConsole.MarkupLine($"[{FshConstants.WarningColor}]Could not determine the NuGet global-packages folder; nothing cleared.[/]"); + return 1; + } + + // Package folders on disk are lower-cased by NuGet. + string prefix = FshConstants.FrameworkPackagePrefix.ToUpperInvariant(); + var targets = Directory + .EnumerateDirectories(globalPackages) + .Where(directory => Path.GetFileName(directory) + .ToUpperInvariant() + .StartsWith(prefix, StringComparison.Ordinal)) + .OrderBy(directory => directory, StringComparer.Ordinal) + .ToList(); + + if (targets.Count == 0) + { + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]No {FshConstants.FrameworkPackagePrefix}* packages in the cache.[/]"); + return 0; + } + + int removed = 0; + foreach (string target in targets) + { + string name = Path.GetFileName(target); + + if (dryRun) + { + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]would remove[/] {name.EscapeMarkup()}"); + removed++; + continue; + } + + try + { + Directory.Delete(target, recursive: true); + AnsiConsole.MarkupLine($" [{FshConstants.SuccessColor}]removed[/] {name.EscapeMarkup()}"); + removed++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + AnsiConsole.MarkupLine($" [{FshConstants.WarningColor}]skipped[/] {name.EscapeMarkup()}: {ex.Message.EscapeMarkup()}"); + } + } + + AnsiConsole.MarkupLine( + dryRun + ? $"[{FshConstants.DimColor}]{removed.ToString(CultureInfo.InvariantCulture)} cached package(s) would be removed.[/]" + : $"[{FshConstants.SuccessColor}]Cleared {removed.ToString(CultureInfo.InvariantCulture)} cached package(s).[/]"); + + return 0; + } +} diff --git a/src/Tools/CLI/Infrastructure/FrameworkFeed.cs b/src/Tools/CLI/Infrastructure/FrameworkFeed.cs new file mode 100644 index 0000000000..5df932671c --- /dev/null +++ b/src/Tools/CLI/Infrastructure/FrameworkFeed.cs @@ -0,0 +1,115 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace FSH.CLI.Infrastructure; + +/// +/// Shared resolution logic for the local NuGet feed that serves the opt-in +/// FSH.Framework.* packages. Used by both the producing side +/// (fsh framework pack) and the consuming side (fsh new --framework-packages) +/// so the two can never disagree about where packages live. +/// +internal static partial class FrameworkFeed +{ + // Splits "FSH.Framework.Eventing.Abstractions.10.0.0-local.20260901T143000" into id and + // version. Anchoring the version on the first "major.minor.patch" triple keeps both dotted + // package ids (Eventing.Abstractions) and dotted prerelease labels (-local.) intact. + [GeneratedRegex(@"^(?.+?)\.(?\d+\.\d+\.\d+(?:[-+].*)?)$", RegexOptions.ExplicitCapture)] + private static partial Regex PackageFileName { get; } + + /// + /// Parses a .nupkg path into its package id and version, or if the + /// file name does not look like a NuGet package. + /// + internal static (string Id, string Version)? ParsePackageFileName(string path) + { + Match match = PackageFileName.Match(Path.GetFileNameWithoutExtension(path)); + + return match.Success + ? (match.Groups["id"].Value, match.Groups["version"].Value) + : null; + } + + /// + /// Newest FSH.Framework.Core version in the feed, by pack time. Used to default + /// the version a scaffolded project pins, so the common case needs no version flag at all. + /// + internal static string? GetLatestVersion(string feed) + { + if (!Directory.Exists(feed)) + return null; + + return Directory + .EnumerateFiles(feed, $"{FshConstants.FrameworkPackagePrefix}Core.*.nupkg") + .OrderByDescending(File.GetLastWriteTimeUtc) + .Select(path => ParsePackageFileName(path)?.Version) + .FirstOrDefault(version => version is not null); + } + + /// + /// Resolves the feed directory: explicit path, then FSH_LOCAL_FEED, then + /// ~/.fsh/local-nuget. Never returns a relative path. + /// + internal static string Resolve(string? explicitPath) + { + if (!string.IsNullOrWhiteSpace(explicitPath)) + return Path.GetFullPath(explicitPath); + + string? fromEnvironment = Environment.GetEnvironmentVariable(FshConstants.LocalFeedEnvVar); + if (!string.IsNullOrWhiteSpace(fromEnvironment)) + return Path.GetFullPath(fromEnvironment); + + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".fsh", + "local-nuget"); + } + + /// + /// Builds a unique, sortable prerelease version, e.g. 10.0.0-local.20260901T143000. + /// + /// + /// NuGet caches by id+version in the global-packages folder, so re-publishing the same + /// version with changed content silently serves stale bits — the classic local-feed trap. + /// A fresh version per pack sidesteps it entirely. Build metadata (+sha) cannot be + /// used instead: SemVer ignores it when comparing versions. The literal T keeps the + /// timestamp an alphanumeric identifier, which sorts lexically and avoids any ambiguity + /// around very long numeric prerelease identifiers. + /// + internal static string NewLocalVersion(string baseVersion = "10.0.0") + { + // Read the clock once: two reads could straddle midnight and stamp a date that does + // not belong to the time beside it. + DateTime timestamp = DateTime.UtcNow; + + return string.Create(CultureInfo.InvariantCulture, $"{baseVersion}-local.{timestamp:yyyyMMdd}T{timestamp:HHmmss}"); + } + + /// + /// Asks the SDK where the global-packages folder is, rather than assuming + /// ~/.nuget/packages — it is relocatable via NUGET_PACKAGES and nuget.config. + /// + internal static async Task GetGlobalPackagesFolderAsync(CancellationToken cancellationToken) + { + (bool success, string output) = await ProcessRunner + .CaptureAsync("dotnet", "nuget locals global-packages --list", cancellationToken) + .ConfigureAwait(false); + + if (!success || string.IsNullOrWhiteSpace(output)) + return null; + + // Output is "global-packages: " (or "info : global-packages: " on some SDKs). + // Split on the LAST colon that precedes a path so a Windows drive letter survives. + foreach (string line in output.Split('\n')) + { + int marker = line.IndexOf("global-packages:", StringComparison.OrdinalIgnoreCase); + if (marker < 0) continue; + + string path = line[(marker + "global-packages:".Length)..].Trim(); + if (path.Length > 0 && Directory.Exists(path)) + return path; + } + + return null; + } +} diff --git a/src/Tools/CLI/Infrastructure/FshConstants.cs b/src/Tools/CLI/Infrastructure/FshConstants.cs index f00364f23e..b6e221c043 100644 --- a/src/Tools/CLI/Infrastructure/FshConstants.cs +++ b/src/Tools/CLI/Infrastructure/FshConstants.cs @@ -6,6 +6,7 @@ internal static class FshConstants internal const string CliPackageId = "FullStackHero.CLI"; internal const string TemplatePackageId = "FullStackHero.NET.StarterKit"; internal const string TemplateShortName = "fsh"; + internal const string ToolCommandName = "fsh"; // URLs internal const string NuGetFlatContainerUrl = "https://api.nuget.org/v3-flatcontainer"; @@ -13,6 +14,36 @@ internal static class FshConstants internal const string ReleaseNotesUrl = $"{GitHubRepoUrl}/releases"; internal const string DocsUrl = "https://fullstackhero.net"; + // Environment-variable fallbacks. Resolution order everywhere is: flag -> env var -> default. + // Env vars rather than a persisted config file keep this CI-friendly and stateless. + internal const string TemplatePathEnvVar = "FSH_TEMPLATE_PATH"; + internal const string TemplateVersionEnvVar = "FSH_TEMPLATE_VERSION"; + internal const string TemplateSourceEnvVar = "FSH_TEMPLATE_SOURCE"; + internal const string LocalFeedEnvVar = "FSH_LOCAL_FEED"; + internal const string AgentsEnvVar = "FSH_AGENTS"; + + // Opt-in framework packaging (see src/Directory.Build.targets). + internal const string FrameworkPackagePrefix = "FSH.Framework."; + internal const string LocalFeedSourceName = "fsh-local"; + + // The BuildingBlocks projects, in dependency order (leaves first) so that a pack run + // always produces a package before the packages that depend on it. Folder name maps + // 1:1 onto the package id: -> FSH.Framework.. + internal static readonly string[] FrameworkProjects = + [ + "Core", + "Eventing.Abstractions", + "Shared", + "Caching", + "Mailing", + "Persistence", + "Quota", + "Jobs", + "Storage", + "Eventing", + "Web" + ]; + // Default ports internal const int ApiHttpPort = 5030; internal const int ApiHttpsPort = 7030; diff --git a/src/Tools/CLI/Infrastructure/ProcessRunner.cs b/src/Tools/CLI/Infrastructure/ProcessRunner.cs index 4d2217ab6f..02d8a432c7 100644 --- a/src/Tools/CLI/Infrastructure/ProcessRunner.cs +++ b/src/Tools/CLI/Infrastructure/ProcessRunner.cs @@ -77,10 +77,14 @@ internal static async Task RunAsync( catch { /* process may have already exited */ } }); - string output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + // Drain both pipes concurrently. stderr is redirected, so leaving it unread can + // deadlock a child that writes enough to fill the pipe buffer. + Task outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + await Task.WhenAll(outputTask, errorTask).ConfigureAwait(false); await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - return (process.ExitCode == 0, output.Trim()); + return (process.ExitCode == 0, (await outputTask.ConfigureAwait(false)).Trim()); } catch (OperationCanceledException) { @@ -92,6 +96,44 @@ internal static async Task RunAsync( } } + /// + /// Runs a process and captures stdout, stderr and the exit code separately, for callers + /// that need to show the user why something failed rather than just that it did. + /// + internal static async Task<(int exitCode, string output, string error)> CaptureWithErrorAsync( + string fileName, + string arguments, + string? workingDirectory = null, + CancellationToken cancellationToken = default) + { + var psi = new ProcessStartInfo(fileName, arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory ?? Directory.GetCurrentDirectory() + }; + + using var process = Process.Start(psi); + if (process is null) return (1, string.Empty, string.Empty); + + using var registration = cancellationToken.Register(() => + { + try { process.Kill(entireProcessTree: true); } + catch { /* process may have already exited */ } + }); + + Task outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + await Task.WhenAll(outputTask, errorTask).ConfigureAwait(false); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + return (process.ExitCode, + (await outputTask.ConfigureAwait(false)).Trim(), + (await errorTask.ConfigureAwait(false)).Trim()); + } + private static async Task StreamOutputAsync(StreamReader reader, string color) { while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line) diff --git a/src/Tools/CLI/Infrastructure/RepoLocator.cs b/src/Tools/CLI/Infrastructure/RepoLocator.cs new file mode 100644 index 0000000000..55fac5ab9e --- /dev/null +++ b/src/Tools/CLI/Infrastructure/RepoLocator.cs @@ -0,0 +1,38 @@ +namespace FSH.CLI.Infrastructure; + +/// +/// Locates the root of a FullStackHero starter-kit checkout. +/// +/// +/// The fsh framework commands are maintainer commands: they build packages from +/// BuildingBlocks source and only make sense inside a starter-kit clone. Resolving the root +/// by walking up from the working directory (rather than assuming it) also contains the one +/// real hazard of shipping maintainer commands in a consumer tool — a globally installed +/// fsh being pointed at an unrelated directory. +/// +internal static class RepoLocator +{ + /// + /// Walks up from looking for a starter-kit root, identified + /// by src/BuildingBlocks and .template.config sitting side by side. Requiring + /// both avoids matching an unrelated repository that happens to have one of them. + /// + /// The absolute repository root, or if there is none. + internal static string? FindStarterKitRoot(string? startDirectory = null) + { + DirectoryInfo? directory = new(startDirectory ?? Directory.GetCurrentDirectory()); + + while (directory is not null) + { + if (Directory.Exists(Path.Combine(directory.FullName, "src", "BuildingBlocks")) + && Directory.Exists(Path.Combine(directory.FullName, ".template.config"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + return null; + } +} diff --git a/src/Tools/CLI/Infrastructure/ScaffoldedProject.cs b/src/Tools/CLI/Infrastructure/ScaffoldedProject.cs new file mode 100644 index 0000000000..b16f24739e --- /dev/null +++ b/src/Tools/CLI/Infrastructure/ScaffoldedProject.cs @@ -0,0 +1,198 @@ +using System.Text.RegularExpressions; +using Spectre.Console; + +namespace FSH.CLI.Infrastructure; + +/// +/// Reads and edits the few files that decide whether a scaffolded project consumes the FSH +/// kernel as source or as FSH.Framework.* packages. +/// +internal sealed partial class ScaffoldedProject +{ + private ScaffoldedProject(string root, string name, string solutionPath) + { + Root = root; + Name = name; + SolutionPath = solutionPath; + } + + /// Absolute path to the project root (the directory containing src). + internal string Root { get; } + + /// Project name, taken from the solution file name. + internal string Name { get; } + + internal string SolutionPath { get; } + + internal string BuildingBlocksPath => Path.Combine(Root, "src", "BuildingBlocks"); + + internal string FrameworkTestsPath => Path.Combine(Root, "src", "Tests", "Framework.Tests"); + + internal string NuGetConfigPath => Path.Combine(Root, "NuGet.config"); + + internal string PackagesPropsPath => Path.Combine(Root, "src", "Directory.Packages.props"); + + /// True when the kernel is present as source, i.e. the project is not in package mode. + internal bool HasBuildingBlocksSource => Directory.Exists(BuildingBlocksPath); + + /// + /// Walks up from looking for a scaffolded project, + /// identified by exactly one src/*.slnx. + /// + internal static ScaffoldedProject? Locate(string? startDirectory = null) + { + DirectoryInfo? directory = new(Path.GetFullPath(startDirectory ?? Directory.GetCurrentDirectory())); + + while (directory is not null) + { + string src = Path.Combine(directory.FullName, "src"); + if (Directory.Exists(src)) + { + string[] solutions = Directory.GetFiles(src, "*.slnx"); + if (solutions.Length == 1) + { + return new ScaffoldedProject( + directory.FullName, + Path.GetFileNameWithoutExtension(solutions[0]), + solutions[0]); + } + } + + directory = directory.Parent; + } + + return null; + } + + // The block, however it is indented or line-broken. + [GeneratedRegex(@"[ \t]*.*?\s*?\r?\n", + RegexOptions.Singleline | RegexOptions.ExplicitCapture)] + private static partial Regex BuildingBlocksFolder { get; } + + [GeneratedRegex(@"[ \t]*]*/>\s*?\r?\n", + RegexOptions.ExplicitCapture)] + private static partial Regex FrameworkTestsEntry { get; } + + /// Adds the kernel projects back into the solution, in source order. + internal void AddKernelToSolution(IReadOnlyList projects, bool includeFrameworkTests) + { + string solution = File.ReadAllText(SolutionPath); + + if (!BuildingBlocksFolder.IsMatch(solution)) + { + string entries = string.Concat(projects + .OrderBy(project => project, StringComparer.Ordinal) + .Select(project => $" \n")); + + solution = solution.Replace( + "\n", + $"\n \n{entries} \n", + StringComparison.Ordinal); + } + + if (includeFrameworkTests && !FrameworkTestsEntry.IsMatch(solution)) + { + solution = solution.Replace( + " \n", + " \n" + + " \n", + StringComparison.Ordinal); + } + + File.WriteAllText(SolutionPath, solution); + } + + /// Removes the kernel projects from the solution. + internal void RemoveKernelFromSolution() + { + string solution = File.ReadAllText(SolutionPath); + solution = BuildingBlocksFolder.Replace(solution, string.Empty); + solution = FrameworkTestsEntry.Replace(solution, string.Empty); + File.WriteAllText(SolutionPath, solution); + } + + /// + /// Writes the NuGet.config that points the project at the feed serving its framework packages. + /// + internal void WriteNuGetConfig(string feed) + { + // so an inherited machine-level config cannot shadow the local feed. + string content = $""" + + + + + + + + + + """; + + File.WriteAllText(NuGetConfigPath, content); + } + + /// + /// Drops the local framework feed from NuGet.config, removing the file outright when that + /// was the only thing it added (which is the case for a CLI-generated one). + /// + internal bool RemoveLocalFeedSource() + { + if (!File.Exists(NuGetConfigPath)) return false; + + string content = File.ReadAllText(NuGetConfigPath); + if (!content.Contains(FshConstants.LocalFeedSourceName, StringComparison.Ordinal)) + return false; + + File.Delete(NuGetConfigPath); + return true; + } + + /// Pins the version of the FSH.Framework.* packages the project consumes. + internal bool SetFrameworkVersion(string version) + { + if (!File.Exists(PackagesPropsPath)) return false; + + string content = File.ReadAllText(PackagesPropsPath); + string updated = FrameworkVersionElement.Replace( + content, + $"{version}", + 1); + + if (string.Equals(content, updated, StringComparison.Ordinal)) return false; + + File.WriteAllText(PackagesPropsPath, updated); + return true; + } + + [GeneratedRegex(@"[^<]*", + RegexOptions.ExplicitCapture)] + private static partial Regex FrameworkVersionElement { get; } + + /// Copies a directory tree, skipping build output. + internal static void CopyTree(string source, string destination) + { + foreach (string directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories)) + { + if (IsBuildOutput(directory)) continue; + Directory.CreateDirectory(directory.Replace(source, destination, StringComparison.Ordinal)); + } + + Directory.CreateDirectory(destination); + + foreach (string file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + if (IsBuildOutput(file)) continue; + + string target = file.Replace(source, destination, StringComparison.Ordinal); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target, overwrite: true); + } + } + + private static bool IsBuildOutput(string path) => + path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || path.EndsWith($"{Path.DirectorySeparatorChar}bin", StringComparison.Ordinal) + || path.EndsWith($"{Path.DirectorySeparatorChar}obj", StringComparison.Ordinal); +} diff --git a/src/Tools/CLI/Infrastructure/TemplateInstaller.cs b/src/Tools/CLI/Infrastructure/TemplateInstaller.cs new file mode 100644 index 0000000000..a408ca391c --- /dev/null +++ b/src/Tools/CLI/Infrastructure/TemplateInstaller.cs @@ -0,0 +1,128 @@ +using Spectre.Console; + +namespace FSH.CLI.Infrastructure; + +/// +/// Installs the FSH dotnet new template, honouring local-path / version / source +/// overrides. Shared by fsh new and fsh framework swap so both resolve the +/// template the same way. +/// +internal static class TemplateInstaller +{ + /// + /// Makes sure a template is installed, honouring any explicit source/version override. + /// + /// + /// Without an override this keeps the historical behaviour: any installed FSH template is + /// accepted as-is. That is deliberately sticky, and it is also why the overrides exist — + /// a contributor working on a fork, or anyone whose installed template has gone stale, + /// otherwise has no way to make `fsh new` use anything else. + /// + /// Exit codes are ignored throughout because `dotnet new` returns non-zero for unrelated + /// workload warnings; success is confirmed by re-listing the templates instead. + /// + internal static async Task EnsureInstalledAsync( + string? templatePath, string? templateVersion, string? templateSource, bool refresh, + CancellationToken cancellationToken) + { + string? path = FromSettingOrEnvironment(templatePath, FshConstants.TemplatePathEnvVar); + string? version = FromSettingOrEnvironment(templateVersion, FshConstants.TemplateVersionEnvVar); + string? source = FromSettingOrEnvironment(templateSource, FshConstants.TemplateSourceEnvVar); + bool overridden = path is not null || version is not null || source is not null || refresh; + + if (!overridden) + { + (_, string listOutput) = await ProcessRunner.CaptureAsync( + "dotnet", $"new list {FshConstants.TemplateShortName}", + cancellationToken).ConfigureAwait(false); + + bool installed = listOutput.Contains(FshConstants.TemplateShortName, StringComparison.OrdinalIgnoreCase) + && listOutput.Contains("FullStackHero", StringComparison.OrdinalIgnoreCase); + + if (installed) return true; + + AnsiConsole.MarkupLine($"[{FshConstants.WarningColor}]FSH template not found. Installing...[/]"); + } + + string target = FshConstants.TemplatePackageId; + if (path is not null) + target = $"\"{ResolveTemplatePath(path)}\""; + else if (version is not null) + target = $"{FshConstants.TemplatePackageId}::{version}"; + + string arguments = $"new install {target} --force" + + (source is not null ? $" --add-source \"{source}\"" : string.Empty); + + if (overridden) + { + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Installing template: {target.EscapeMarkup()}[/]"); + + // Uninstall first. Two packages that share the template identity + // "FullStackHero.NET.StarterKit" make the template engine throw + // ("Sequence contains more than one matching element") on the next `dotnet new fsh`, + // and installing from a new source without removing the old one is precisely how + // that state arises. Both forms are removed: the NuGet id, and the path we are + // about to install (re-installing the same folder otherwise duplicates it). + foreach (string uninstallTarget in (string[])[FshConstants.TemplatePackageId, target]) + { + await ProcessRunner.CaptureAsync( + "dotnet", $"new uninstall {uninstallTarget}", cancellationToken).ConfigureAwait(false); + } + } + + await ProcessRunner.RunAsync("dotnet", arguments, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Verify by re-listing rather than trusting the exit code (see remarks). + (_, string verifyOutput) = await ProcessRunner.CaptureAsync( + "dotnet", $"new list {FshConstants.TemplateShortName}", + cancellationToken).ConfigureAwait(false); + + bool nowInstalled = verifyOutput.Contains("FullStackHero", StringComparison.OrdinalIgnoreCase); + if (!nowInstalled) + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Failed to install template. Run manually:[/] dotnet {arguments.EscapeMarkup()}"); + + return nowInstalled; + } + + /// + /// Resolves what to hand dotnet new install for a local template path. + /// + /// + /// Accepts all three shapes people reasonably pass: a .nupkg file, a starter-kit checkout + /// (identified by its .template.config), or a folder of packed nupkgs — in which case the + /// newest matching package is chosen, since "-o ./nupkgs" is exactly where `dotnet pack` + /// puts it and installing a bare folder of packages otherwise fails with a confusing + /// "no templates found in package". + /// + internal static string ResolveTemplatePath(string path) + { + string full = Path.GetFullPath(path); + + if (File.Exists(full)) + return full; + + if (Directory.Exists(full)) + { + if (Directory.Exists(Path.Combine(full, ".template.config"))) + return full; + + string? newest = Directory + .EnumerateFiles(full, $"{FshConstants.TemplatePackageId}*.nupkg") + .MaxBy(File.GetLastWriteTimeUtc); + + if (newest is not null) + return newest; + } + + return full; + } + + /// Setting, else environment variable, else . + internal static string? FromSettingOrEnvironment(string? value, string environmentVariable) + { + if (!string.IsNullOrWhiteSpace(value)) return value; + + string? fromEnvironment = Environment.GetEnvironmentVariable(environmentVariable); + return string.IsNullOrWhiteSpace(fromEnvironment) ? null : fromEnvironment; + } +} diff --git a/src/Tools/CLI/Program.cs b/src/Tools/CLI/Program.cs index 02b84f90f7..6c1a974767 100644 --- a/src/Tools/CLI/Program.cs +++ b/src/Tools/CLI/Program.cs @@ -1,5 +1,7 @@ using System.Reflection; using FSH.CLI.Commands; +using FSH.CLI.Commands.Framework; +using FSH.CLI.Commands.Self; using Spectre.Console.Cli; // Strip the +gitsha build-metadata suffix so `fsh --version` prints a clean version. @@ -15,6 +17,12 @@ config.SetApplicationName("fsh"); config.SetApplicationVersion(cliVersion); + // Fail on unknown options instead of collecting them into Remaining. Without this a + // mistyped or template-style flag (--frameworkPackages instead of --framework-packages) + // is silently discarded, and the user gets a project that quietly ignores what they asked + // for — far worse than an error. + config.UseStrictParsing(); + config.AddCommand("new") .WithDescription("Create a new FullStackHero .NET project.") .WithExample("new", "MyApp") @@ -28,6 +36,43 @@ config.AddCommand("update") .WithDescription("Update the FSH CLI tool and dotnet new template to the latest version."); + + // Build the CLI from this working copy and install it as the global `fsh` tool, so the + // rest of these commands can be typed as `fsh ...` instead of `dotnet run --project ... --`. + config.AddBranch("self", self => + { + self.SetDescription("Manage the globally installed fsh tool built from this repository."); + + self.AddCommand("install") + .WithDescription("Pack this repository's CLI and install it as the global 'fsh' tool.") + .WithExample("self", "install"); + + self.AddCommand("uninstall") + .WithDescription("Remove the globally installed 'fsh' tool."); + }); + + // Maintainer commands: they build FSH.Framework.* packages from BuildingBlocks source, + // so they only run inside a starter-kit clone (see RepoLocator). + config.AddBranch("framework", framework => + { + framework.SetDescription("Build and publish the FSH.Framework.* packages (opt-in framework packaging)."); + + framework.AddCommand("pack") + .WithDescription("Pack the BuildingBlocks projects and optionally publish them to a local feed.") + .WithExample("framework", "pack", "--push", "--clear-cache") + .WithExample("framework", "pack", "--feed", "/path/to/feed", "--register-source"); + + framework.AddCommand("list") + .WithDescription("List the FSH.Framework.* packages available in the local feed."); + + framework.AddCommand("swap") + .WithDescription("Switch an existing project between owned kernel source and framework packages.") + .WithExample("framework", "swap", "--to", "source") + .WithExample("framework", "swap", "--to", "packages", "--project", "../my-app"); + + framework.AddCommand("clean-cache") + .WithDescription("Purge FSH.Framework.* from the NuGet global-packages cache."); + }); }); return await app.RunAsync(args).ConfigureAwait(false); diff --git a/templates/FullStackHero.NET.StarterKit.csproj b/templates/FullStackHero.NET.StarterKit.csproj index 1153ce443e..f3b9d19d8c 100644 --- a/templates/FullStackHero.NET.StarterKit.csproj +++ b/templates/FullStackHero.NET.StarterKit.csproj @@ -58,12 +58,16 @@ *.tfstate, audit-dlq dumps, release-nupkgs, .claude worktrees, *.user). The previous `..\**\*` filesystem glob with a denylist shipped 583 MB incl. Terraform state. Filtering is pushed into git via :(exclude) pathspecs — on top of git-tracking we - drop this pack project (templates/), the docs leftover, and repo-internal AI/CI - tooling (.github, .agents, .vscode, .devcontainer, superpowers) a consumer's - scaffold doesn't need. The root .template.config IS tracked, so it ships. + drop this pack project (templates/), the docs leftover, and repo-internal CI/editor + tooling (.github, .vscode, .devcontainer, superpowers) a consumer's scaffold doesn't + need. The root .template.config IS tracked, so it ships. + + .agents/ DOES ship in the package (it is the AI rules/skills kit that AGENTS.md + indexes) but reaches a scaffold only when the `agents` symbol is on; template.json + gates it there, together with AGENTS.md, CLAUDE.md and GEMINI.md. --> - From 51b48922725eb38ea774c9959a143d097513703a Mon Sep 17 00:00:00 2001 From: maxiar Date: Wed, 9 Sep 2026 15:47:04 -0300 Subject: [PATCH 4/7] feat(cli): add `fsh upgrade` to update an existing project to a newer template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .claude/settings.json | 8 + README-CLI.md | 67 +++- .../Framework/FrameworkPackCommand.cs | 4 +- src/Tools/CLI/Commands/NewCommand.cs | 4 +- .../CLI/Commands/Self/SelfInstallCommand.cs | 4 +- src/Tools/CLI/Commands/UpgradeCommand.cs | 358 ++++++++++++++++++ src/Tools/CLI/Infrastructure/FshConstants.cs | 8 + src/Tools/CLI/Infrastructure/GitRunner.cs | 92 +++++ src/Tools/CLI/Infrastructure/ProcessRunner.cs | 12 +- .../CLI/Infrastructure/ScaffoldedProject.cs | 15 + src/Tools/CLI/Program.cs | 5 + 11 files changed, 568 insertions(+), 9 deletions(-) create mode 100644 .claude/settings.json create mode 100644 src/Tools/CLI/Commands/UpgradeCommand.cs create mode 100644 src/Tools/CLI/Infrastructure/GitRunner.cs diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..a22aed4693 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git checkout *)", + "Bash(dotnet test *)" + ] + } +} diff --git a/README-CLI.md b/README-CLI.md index d55a2b95af..62b8152a8f 100644 --- a/README-CLI.md +++ b/README-CLI.md @@ -13,7 +13,7 @@ This is the maintainer guide for three opt-in capabilities of the `fsh` CLI: **Contents** — [Why](#why-framework-packages) · [Setup](#one-time-setup) · [Install `fsh`](#installing-fsh-from-this-source) · [Daily loop](#the-everyday-loop) · [Fork scaffolding](#scaffolding-from-your-own-fork) · [Agents](#shipping-the-agents-kit) · [Debugging](#debugging-into-buildingblocks) · [Troubleshooting](#troubleshooting) · -[Reference](#command-reference) · [Swapping modes](#swapping-an-existing-project-between-the-two-modes) +[Reference](#command-reference) · [Upgrading](#updating-an-existing-project-to-a-newer-template) · [Swapping modes](#swapping-an-existing-project-between-the-two-modes) --- @@ -323,6 +323,8 @@ That produces a normal DLL plus a `.snupkg` with SourceLink, which is what nuget | `Sequence contains more than one matching element` | two template packages share the FSH template identity | `dotnet new uninstall FullStackHero.NET.StarterKit`, then re-install (the CLI now does this for you) | | Project scaffolds with `src/BuildingBlocks` and no `NuGet.config` despite passing the flags | template symbol names (`--frameworkPackages`) were used instead of CLI option names (`--framework-packages`) | use the CLI spelling; unknown options are now a hard error rather than silently ignored | | `Framework feed not found: ~/.fsh/local-nuget` | no feed configured and packages live elsewhere | pass `--framework-feed ` or `export FSH_LOCAL_FEED=` | +| `Could not find the original scaffold commit` | project created with `--git false`, or history squashed | `fsh upgrade --from-scaffold ` | +| `Working tree has uncommitted changes` | upgrade lands as a merge and needs a clean start | commit or stash first | | `fsh` runs but lacks the new options | the globally installed tool is the published build, not your fork | `dotnet run --project src/Tools/CLI -- self install` | | `fsh: command not found` right after installing | `~/.dotnet/tools` is not on `PATH` | add it to your shell profile; `fsh self install` prints the exact line | | Project was created inside the starter-kit clone | `fsh new` defaults to the current directory | pass `-o /path/to/project` | @@ -356,6 +358,21 @@ starter-kit clone. | `-f, --feed ` | `$FSH_LOCAL_FEED`, else `~/.fsh/local-nuget` | | `--all` | off — show only the newest version of each package | +### `fsh upgrade` + +Updates an existing project to a newer template as a reviewable git merge. Runs inside the +**project**. + +| Option | Default | Notes | +|---|---|---| +| `--project ` | current directory | project to upgrade | +| `--from-scaffold ` | the commit `fsh new` made | common ancestor for the merge | +| `-b, --branch ` | `fsh/template-upgrade` | branch the template changes land on | +| `--template-path ` | `$FSH_TEMPLATE_PATH` | template to upgrade *to* | +| `--template-version ` | `$FSH_TEMPLATE_VERSION` | version to upgrade *to* | +| `--merge` | off | merge into the current branch instead of stopping at the branch | +| `--dry-run` | off | print the plan and stop | + ### `fsh framework swap` Converts an existing project between the two modes. Runs inside the **project**, not a @@ -419,6 +436,54 @@ dotnet nuget add source ~/dev/nuget-local --name fsh-local # the CLI does this --- +## Updating an existing project to a newer template + +`fsh new` creates a project; `fsh upgrade` brings one that already exists up to date with a newer +template — new modules, provider support, infrastructure changes — without losing your work. + +```bash +cd /Users/you/dev/falconsoft/fs-proxy +fsh upgrade --dry-run # what it would regenerate +fsh upgrade --template-path ~/dev/dotnet-starter-kit # put the changes on a branch +fsh upgrade --merge # ...and merge them straight away +``` + +### Why it is a merge, not a re-scaffold + +Re-generating over a project that has moved on would overwrite it. Instead the upgrade is a +genuine three-way merge, and git does the hard part: + +- **The common ancestor** is the pristine scaffold commit `fsh new` created (found by its message, + or given with `--from-scaffold `). +- **The new state** is a fresh scaffold of the *same* project — same name, same options — from the + new template. +- Committing that on a branch rooted at the ancestor and merging it forward preserves your changes + and surfaces genuine collisions as ordinary conflicts, instead of silently clobbering them. + +Everything happens in a temporary git worktree, so your checkout is untouched until you merge. The +command stops at the branch by default and prints the `git merge` to run. + +### It recovers the original options itself + +You do not restate how the project was scaffolded — that would be a chance to regenerate a +differently-shaped project. The options are read back from the shape of the baseline commit: +`src/Host/.AppHost/` means aspire, `clients/` means frontend, `.agents/` means the agents kit, +and a missing `src/BuildingBlocks/` means framework packages (pinned to the version the project +currently uses). + +Two files get special handling, because `fsh new` writes them *after* the template runs and they +are committed in the baseline: `NuGet.config` and the per-project dev signing key in +`appsettings.Development.json`. A plain regeneration would delete the first and revert the second to +the shared placeholder, so both are carried forward from the baseline and never appear in the diff. + +### Requirements + +A git repository with a clean working tree, and the original scaffold commit still in history. If +`fsh new --git false` was used, or history was squashed, point at the right commit with +`--from-scaffold`. + +--- + ## Swapping an existing project between the two modes Framework packaging is not a one-way door, and you do not have to decide at scaffold time. diff --git a/src/Tools/CLI/Commands/Framework/FrameworkPackCommand.cs b/src/Tools/CLI/Commands/Framework/FrameworkPackCommand.cs index fc5b915200..ac6d12dc6d 100644 --- a/src/Tools/CLI/Commands/Framework/FrameworkPackCommand.cs +++ b/src/Tools/CLI/Commands/Framework/FrameworkPackCommand.cs @@ -127,14 +127,14 @@ protected override async Task ExecuteAsync(CommandContext context, Settings // run the build targets is free here, since the build above just ran. var step = await ProcessRunner .CaptureWithErrorAsync("dotnet", $"build \"{projectPath}\" -c Release --no-incremental --nologo {properties}", - repoRoot, cancellationToken) + repoRoot, cancellationToken: cancellationToken) .ConfigureAwait(false); if (step.exitCode == 0) { step = await ProcessRunner .CaptureWithErrorAsync("dotnet", $"pack \"{projectPath}\" -c Release --nologo {properties} -o \"{output}\"", - repoRoot, cancellationToken) + repoRoot, cancellationToken: cancellationToken) .ConfigureAwait(false); } diff --git a/src/Tools/CLI/Commands/NewCommand.cs b/src/Tools/CLI/Commands/NewCommand.cs index 6760f8d436..87165f7433 100644 --- a/src/Tools/CLI/Commands/NewCommand.cs +++ b/src/Tools/CLI/Commands/NewCommand.cs @@ -381,7 +381,7 @@ await ProcessRunner.RunAsync("git", "symbolic-ref HEAD refs/heads/main", output, .ConfigureAwait(false); await ProcessRunner.RunAsync("git", "add -A", output, showOutput: false, cancellationToken: cancellationToken) .ConfigureAwait(false); - await ProcessRunner.RunAsync("git", "commit -m \"Initial project from FullStackHero .NET Starter Kit\"", output, showOutput: false, cancellationToken: cancellationToken) + await ProcessRunner.RunAsync("git", $"commit -m \"{FshConstants.InitialCommitMessage}\"", output, showOutput: false, cancellationToken: cancellationToken) .ConfigureAwait(false); }).ConfigureAwait(false); } @@ -429,7 +429,7 @@ private static void GenerateDevSecrets(string name, string output) string appsettingsDev = Path.Combine(output, "src", "Host", $"{name}.Api", "appsettings.Development.json"); if (!File.Exists(appsettingsDev)) return; - const string placeholder = "fsh-dev-only-do-not-use-in-prod-32+chars-min"; + const string placeholder = FshConstants.DevSigningKeyPlaceholder; string content = File.ReadAllText(appsettingsDev); if (!content.Contains(placeholder, StringComparison.Ordinal)) return; diff --git a/src/Tools/CLI/Commands/Self/SelfInstallCommand.cs b/src/Tools/CLI/Commands/Self/SelfInstallCommand.cs index a4c4a27e74..4fd979baf0 100644 --- a/src/Tools/CLI/Commands/Self/SelfInstallCommand.cs +++ b/src/Tools/CLI/Commands/Self/SelfInstallCommand.cs @@ -65,7 +65,7 @@ protected override async Task ExecuteAsync(CommandContext context, Settings // which is what `fsh --version` prints. Without it a locally installed build reports // the repo's 10.0.0 and is indistinguishable from the published tool. $"pack \"{projectPath}\" -c Release --nologo -p:Version={version} -p:PackageVersion={version} -o \"{output}\"", - repoRoot, cancellationToken).ConfigureAwait(false); + repoRoot, cancellationToken: cancellationToken).ConfigureAwait(false); if (pack.exitCode != 0) { @@ -80,7 +80,7 @@ protected override async Task ExecuteAsync(CommandContext context, Settings var install = await ProcessRunner.CaptureWithErrorAsync( "dotnet", $"tool update -g {FshConstants.CliPackageId} --version {version} --add-source \"{output}\"", - repoRoot, cancellationToken).ConfigureAwait(false); + repoRoot, cancellationToken: cancellationToken).ConfigureAwait(false); if (install.exitCode != 0) { diff --git a/src/Tools/CLI/Commands/UpgradeCommand.cs b/src/Tools/CLI/Commands/UpgradeCommand.cs new file mode 100644 index 0000000000..6c37136c68 --- /dev/null +++ b/src/Tools/CLI/Commands/UpgradeCommand.cs @@ -0,0 +1,358 @@ +using System.ComponentModel; +using FSH.CLI.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace FSH.CLI.Commands; + +/// +/// Brings an already-generated project up to date with a newer version of the template. +/// +/// +/// Works as a three-way merge rather than a re-scaffold. The pristine scaffold commit that +/// fsh new created is the common ancestor; a freshly generated scaffold of the same +/// project - same name, same options - is the new state. Committing that new state on a branch +/// rooted at the ancestor lets git do the merge, so local changes are preserved and genuine +/// collisions surface as ordinary conflicts instead of being silently overwritten. +/// +/// Everything happens in a detached git worktree, so the caller's working directory is never +/// touched until they merge. +/// +public sealed class UpgradeCommand : AsyncCommand +{ + public sealed class Settings : CommandSettings + { + [Description("Project directory to upgrade. Defaults to the current directory.")] + [CommandOption("--project ")] + public string? Project { get; init; } + + [Description("Commit holding the pristine scaffold. Defaults to the one 'fsh new' created.")] + [CommandOption("--from-scaffold ")] + public string? FromScaffold { get; init; } + + [Description("Branch to put the template update on. Defaults to fsh/template-upgrade.")] + [CommandOption("-b|--branch ")] + public string? Branch { get; init; } + + [Description("Template to upgrade to: a checkout, .nupkg, or folder of nupkgs. Env: FSH_TEMPLATE_PATH.")] + [CommandOption("--template-path ")] + public string? TemplatePath { get; init; } + + [Description("Template version to upgrade to. Env: FSH_TEMPLATE_VERSION.")] + [CommandOption("--template-version ")] + public string? TemplateVersion { get; init; } + + [Description("Merge the update into the current branch instead of stopping at the branch.")] + [CommandOption("--merge")] + [DefaultValue(false)] + public bool Merge { get; init; } + + [Description("Show what would be regenerated without creating a branch.")] + [CommandOption("--dry-run")] + [DefaultValue(false)] + public bool DryRun { get; init; } + } + + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + + ScaffoldedProject? project = ScaffoldedProject.Locate(settings.Project); + if (project is null) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]No scaffolded project found here.[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Run this inside a project created by 'fsh new', or pass --project .[/]"); + return 1; + } + + if (!await GitRunner.IsRepositoryAsync(project.Root, cancellationToken).ConfigureAwait(false)) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]{project.Root.EscapeMarkup()} is not a git repository.[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]The upgrade is a git merge against the original scaffold commit, so history is required.[/]"); + return 1; + } + + // A dirty tree would make the merge result impossible to separate from local edits. + if (!await GitRunner.IsCleanAsync(project.Root, cancellationToken).ConfigureAwait(false)) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Working tree has uncommitted changes.[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Commit or stash them first: the upgrade lands as a merge and must start from a clean state.[/]"); + return 1; + } + + string? baseline = settings.FromScaffold + ?? await GitRunner.FindScaffoldCommitAsync(project.Root, cancellationToken).ConfigureAwait(false); + + if (baseline is null) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Could not find the original scaffold commit.[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Looked for a commit named \"{FshConstants.InitialCommitMessage}\".[/]"); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Point at it explicitly with --from-scaffold .[/]"); + return 1; + } + + ScaffoldOptions options = await DetectScaffoldOptionsAsync(project, baseline, cancellationToken).ConfigureAwait(false); + + var summary = new Table().Border(TableBorder.Rounded).BorderColor(Color.Grey); + summary.AddColumn("[bold]Setting[/]"); + summary.AddColumn("[bold]Value[/]"); + summary.AddRow("Project", $"{project.Name.EscapeMarkup()} [{FshConstants.DimColor}]({project.Root.EscapeMarkup()})[/]"); + summary.AddRow("Scaffold commit", $"[{FshConstants.AccentColor}]{baseline[..Math.Min(8, baseline.Length)]}[/]"); + summary.AddRow("Options", options.Describe()); + AnsiConsole.Write(summary); + AnsiConsole.WriteLine(); + + if (settings.DryRun) + { + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]Would regenerate the scaffold with these options and put the diff on a branch.[/]"); + return 0; + } + + if (!await TemplateInstaller.EnsureInstalledAsync( + settings.TemplatePath, settings.TemplateVersion, templateSource: null, + refresh: true, cancellationToken).ConfigureAwait(false)) + { + return 1; + } + + string staging = Path.Combine(Path.GetTempPath(), $"fsh-upgrade-{Guid.NewGuid():N}"); + string worktree = Path.Combine(Path.GetTempPath(), $"fsh-worktree-{Guid.NewGuid():N}"); + string branch = settings.Branch ?? "fsh/template-upgrade"; + + try + { + if (!await GenerateScaffoldAsync(project, options, staging, cancellationToken).ConfigureAwait(false)) + return 1; + + await ReconcileGeneratedFilesAsync(project, baseline, staging, cancellationToken).ConfigureAwait(false); + + return await CommitAndReportAsync( + project, baseline, branch, staging, worktree, settings.Merge, cancellationToken).ConfigureAwait(false); + } + finally + { + await GitRunner.RunAsync(project.Root, $"worktree remove --force \"{worktree}\"", cancellationToken).ConfigureAwait(false); + TryDelete(staging); + TryDelete(worktree); + } + } + + /// + /// The options a scaffold was created with, recovered from the shape of the baseline tree + /// rather than remembered state, so an upgrade cannot regenerate a differently-shaped project. + /// + private sealed record ScaffoldOptions(bool Aspire, bool Frontend, bool Agents, bool FrameworkPackages, string? FrameworkVersion) + { + internal string Describe() + { + List parts = + [ + Aspire ? "aspire" : "no aspire", + Frontend ? "frontend" : "no frontend", + Agents ? "agents" : "no agents", + FrameworkPackages ? $"framework packages {FrameworkVersion}" : "owned source" + ]; + + return string.Join(", ", parts).EscapeMarkup(); + } + } + + private static async Task DetectScaffoldOptionsAsync( + ScaffoldedProject project, string baseline, CancellationToken cancellationToken) + { + IReadOnlyList tree = await GitRunner.ListTreeAsync(project.Root, baseline, cancellationToken).ConfigureAwait(false); + + bool Has(string prefix) => tree.Any(path => path.StartsWith(prefix, StringComparison.Ordinal)); + + return new ScaffoldOptions( + Aspire: Has($"src/Host/{project.Name}.AppHost/"), + Frontend: Has("clients/"), + Agents: Has(".agents/"), + FrameworkPackages: !Has("src/BuildingBlocks/"), + FrameworkVersion: project.GetFrameworkVersion()); + } + + private static async Task GenerateScaffoldAsync( + ScaffoldedProject project, ScaffoldOptions options, string staging, CancellationToken cancellationToken) + { + string arguments = + $"new {FshConstants.TemplateShortName} -n \"{project.Name}\" -o \"{staging}\" " + + $"--aspire {Flag(options.Aspire)} --frontend {Flag(options.Frontend)} --agents {Flag(options.Agents)} " + + "--skipRestore true --force" + + (options.FrameworkPackages && options.FrameworkVersion is not null + ? $" --frameworkPackages true --frameworkVersion {options.FrameworkVersion}" + : string.Empty); + + int result = await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse(FshConstants.AccentColor)) + .StartAsync("Regenerating the scaffold from the new template...", async _ => + { + var scaffold = await ProcessRunner + .CaptureWithErrorAsync("dotnet", arguments, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (Directory.Exists(Path.Combine(staging, "src"))) return 0; + + foreach (string line in $"{scaffold.output}\n{scaffold.error}".Split('\n').Where(l => !string.IsNullOrWhiteSpace(l))) + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]{line.TrimEnd().EscapeMarkup()}[/]"); + + return 1; + }).ConfigureAwait(false); + + if (result != 0) + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Could not regenerate the scaffold.[/]"); + + return result == 0; + + static string Flag(bool value) => value ? "true" : "false"; + } + + /// + /// Restores the files fsh new writes after the template runs. + /// + /// + /// These are committed in the baseline but are not template output, so a plain regeneration + /// would show them as deletions or reversions: NuGet.config would be deleted, and the + /// project's unique dev signing key would be replaced by the shared placeholder. Carrying the + /// baseline values forward keeps the diff to genuine template changes. + /// + private static async Task ReconcileGeneratedFilesAsync( + ScaffoldedProject project, string baseline, string staging, CancellationToken cancellationToken) + { + string? nugetConfig = await GitRunner + .ShowFileAsync(project.Root, baseline, "NuGet.config", cancellationToken).ConfigureAwait(false); + + if (nugetConfig is not null) + await File.WriteAllTextAsync(Path.Combine(staging, "NuGet.config"), nugetConfig, cancellationToken).ConfigureAwait(false); + + foreach (string host in Directory.Exists(Path.Combine(staging, "src", "Host")) + ? Directory.GetDirectories(Path.Combine(staging, "src", "Host")) + : []) + { + string relative = $"src/Host/{Path.GetFileName(host)}/appsettings.Development.json"; + string generated = Path.Combine(staging, relative.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(generated)) continue; + + string content = await File.ReadAllTextAsync(generated, cancellationToken).ConfigureAwait(false); + if (!content.Contains(FshConstants.DevSigningKeyPlaceholder, StringComparison.Ordinal)) continue; + + string? original = await GitRunner + .ShowFileAsync(project.Root, baseline, relative, cancellationToken).ConfigureAwait(false); + + if (original is null) continue; + + string? key = ExtractSigningKey(original); + if (key is null) continue; + + await File.WriteAllTextAsync( + generated, + content.Replace(FshConstants.DevSigningKeyPlaceholder, key, StringComparison.Ordinal), + cancellationToken).ConfigureAwait(false); + } + } + + private static string? ExtractSigningKey(string appsettings) + { + const string marker = "\"SigningKey\":"; + int start = appsettings.IndexOf(marker, StringComparison.Ordinal); + if (start < 0) return null; + + int open = appsettings.IndexOf('"', start + marker.Length); + if (open < 0) return null; + + int close = appsettings.IndexOf('"', open + 1); + return close > open ? appsettings[(open + 1)..close] : null; + } + + private static async Task CommitAndReportAsync( + ScaffoldedProject project, string baseline, string branch, string staging, string worktree, + bool merge, CancellationToken cancellationToken) + { + // A separate worktree rooted at the scaffold commit: the caller's checkout is untouched. + (bool created, string worktreeOutput) = await GitRunner + .RunAsync(project.Root, $"worktree add -B {branch} \"{worktree}\" {baseline}", cancellationToken) + .ConfigureAwait(false); + + if (!created) + { + AnsiConsole.MarkupLine($"[{FshConstants.ErrorColor}]Could not create the upgrade worktree.[/]"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]{worktreeOutput.Trim().EscapeMarkup()}[/]"); + return 1; + } + + // The worktree is a pristine checkout of the scaffold, so replacing its contents wholesale + // is safe - there is no build output or local state to preserve. + foreach (string entry in Directory.EnumerateFileSystemEntries(worktree)) + { + if (Path.GetFileName(entry) is ".git") continue; + + if (Directory.Exists(entry)) Directory.Delete(entry, recursive: true); + else File.Delete(entry); + } + + ScaffoldedProject.CopyTree(staging, worktree); + + await GitRunner.RunAsync(worktree, "add -A", cancellationToken).ConfigureAwait(false); + + (bool staged, string status) = await GitRunner + .RunAsync(worktree, "status --porcelain", cancellationToken).ConfigureAwait(false); + + if (staged && string.IsNullOrWhiteSpace(status)) + { + AnsiConsole.MarkupLine($"[{FshConstants.SuccessColor}]Already up to date[/] - the template produces the same output as your scaffold."); + await GitRunner.RunAsync(project.Root, $"branch -D {branch}", cancellationToken).ConfigureAwait(false); + return 0; + } + + await GitRunner.RunAsync( + worktree, $"commit -q -m \"chore: update {project.Name} to the latest FSH template\"", cancellationToken) + .ConfigureAwait(false); + + (_, string stat) = await GitRunner + .RunAsync(worktree, $"diff --stat {baseline} HEAD", cancellationToken).ConfigureAwait(false); + + AnsiConsole.MarkupLine($"[{FshConstants.SuccessColor}]Template changes committed on[/] [{FshConstants.AccentColor}]{branch.EscapeMarkup()}[/]"); + foreach (string line in stat.Split('\n').TakeLast(1).Where(l => !string.IsNullOrWhiteSpace(l))) + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]{line.Trim().EscapeMarkup()}[/]"); + + AnsiConsole.WriteLine(); + + if (!merge) + { + AnsiConsole.MarkupLine("Review it, then merge:"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]git diff {baseline[..Math.Min(8, baseline.Length)]}..{branch.EscapeMarkup()}[/]"); + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]git merge {branch.EscapeMarkup()}[/]"); + return 0; + } + + (bool merged, string mergeOutput) = await GitRunner + .RunAsync(project.Root, $"merge --no-edit {branch}", cancellationToken).ConfigureAwait(false); + + foreach (string line in mergeOutput.Split('\n').Where(l => !string.IsNullOrWhiteSpace(l)).Take(6)) + AnsiConsole.MarkupLine($" [{FshConstants.DimColor}]{line.Trim().EscapeMarkup()}[/]"); + + if (merged) + { + AnsiConsole.MarkupLine($"[{FshConstants.SuccessColor}]Merged.[/] Build to confirm: dotnet build src/{project.Name.EscapeMarkup()}.slnx"); + return 0; + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[{FshConstants.WarningColor}]Merge stopped on conflicts.[/] Resolve them, then 'git commit'."); + AnsiConsole.MarkupLine($"[{FshConstants.DimColor}]To back out entirely: git merge --abort[/]"); + return 1; + } + + private static void TryDelete(string directory) + { + try + { + if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); + } + catch (IOException) + { + // A leftover temp directory is not worth failing the command over. + } + } +} diff --git a/src/Tools/CLI/Infrastructure/FshConstants.cs b/src/Tools/CLI/Infrastructure/FshConstants.cs index b6e221c043..5e681f5d88 100644 --- a/src/Tools/CLI/Infrastructure/FshConstants.cs +++ b/src/Tools/CLI/Infrastructure/FshConstants.cs @@ -8,6 +8,14 @@ internal static class FshConstants internal const string TemplateShortName = "fsh"; internal const string ToolCommandName = "fsh"; + // Written by `fsh new` when it initialises the repository. `fsh upgrade` looks for this + // message to find the pristine scaffold commit to diff against. + internal const string InitialCommitMessage = "Initial project from FullStackHero .NET Starter Kit"; + + // Shared placeholder in appsettings.Development.json that `fsh new` replaces with a unique + // per-project key. Upgrades must preserve the project's key, not reintroduce the placeholder. + internal const string DevSigningKeyPlaceholder = "fsh-dev-only-do-not-use-in-prod-32+chars-min"; + // URLs internal const string NuGetFlatContainerUrl = "https://api.nuget.org/v3-flatcontainer"; internal const string GitHubRepoUrl = "https://github.com/fullstackhero/dotnet-starter-kit"; diff --git a/src/Tools/CLI/Infrastructure/GitRunner.cs b/src/Tools/CLI/Infrastructure/GitRunner.cs new file mode 100644 index 0000000000..a5e3ac4563 --- /dev/null +++ b/src/Tools/CLI/Infrastructure/GitRunner.cs @@ -0,0 +1,92 @@ +namespace FSH.CLI.Infrastructure; + +/// +/// Thin wrapper over the git commands fsh upgrade needs, always scoped to an +/// explicit repository directory. +/// +internal static class GitRunner +{ + internal static async Task<(bool ok, string output)> RunAsync( + string repository, string arguments, CancellationToken cancellationToken, bool trimOutput = true) + { + var result = await ProcessRunner + .CaptureWithErrorAsync("git", arguments, repository, trimOutput, cancellationToken) + .ConfigureAwait(false); + + return (result.exitCode == 0, string.IsNullOrEmpty(result.output) ? result.error : result.output); + } + + internal static async Task IsRepositoryAsync(string directory, CancellationToken cancellationToken) + { + (bool ok, string output) = await RunAsync(directory, "rev-parse --is-inside-work-tree", cancellationToken) + .ConfigureAwait(false); + + return ok && output.Trim().Equals("true", StringComparison.OrdinalIgnoreCase); + } + + /// True when the working tree has no staged or unstaged changes. + internal static async Task IsCleanAsync(string repository, CancellationToken cancellationToken) + { + (bool ok, string output) = await RunAsync(repository, "status --porcelain", cancellationToken) + .ConfigureAwait(false); + + return ok && string.IsNullOrWhiteSpace(output); + } + + internal static async Task CurrentBranchAsync(string repository, CancellationToken cancellationToken) + { + (bool ok, string output) = await RunAsync(repository, "rev-parse --abbrev-ref HEAD", cancellationToken) + .ConfigureAwait(false); + + return ok ? output.Trim() : null; + } + + /// + /// Finds the pristine scaffold commit - the one fsh new created - by its message. + /// The oldest match wins, so a later commit quoting the message cannot shadow it. + /// + internal static async Task FindScaffoldCommitAsync(string repository, CancellationToken cancellationToken) + { + // " ": a commit hash never contains a space, so one split is unambiguous. + // %x20 rather than a literal space: the argument string is split on whitespace before it + // reaches git, so "--format=%H %s" would arrive as two separate arguments. + (bool ok, string output) = await RunAsync( + repository, "log --reverse --format=%H%x20%s", cancellationToken).ConfigureAwait(false); + + if (!ok) return null; + + foreach (string line in output.Split('\n')) + { + string[] parts = line.Trim().Split(' ', 2); + if (parts.Length == 2 && parts[1].Trim().Equals(FshConstants.InitialCommitMessage, StringComparison.Ordinal)) + return parts[0]; + } + + return null; + } + + /// Paths tracked at a given commit. + internal static async Task> ListTreeAsync( + string repository, string reference, CancellationToken cancellationToken) + { + (bool ok, string output) = await RunAsync( + repository, $"ls-tree -r --name-only {reference}", cancellationToken).ConfigureAwait(false); + + return ok + ? [.. output.Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0)] + : []; + } + + /// Contents of a single file at a given commit, or null when it did not exist. + internal static async Task ShowFileAsync( + string repository, string reference, string path, CancellationToken cancellationToken) + { + // Not trimmed: this content is written straight back to disk, and dropping the trailing + // newline would show the restored file as modified in every subsequent diff. + (bool ok, string output) = await RunAsync( + repository, $"show {reference}:\"{path}\"", cancellationToken, trimOutput: false) + .ConfigureAwait(false); + + return ok ? output : null; + } +} diff --git a/src/Tools/CLI/Infrastructure/ProcessRunner.cs b/src/Tools/CLI/Infrastructure/ProcessRunner.cs index 02d8a432c7..42652aad4c 100644 --- a/src/Tools/CLI/Infrastructure/ProcessRunner.cs +++ b/src/Tools/CLI/Infrastructure/ProcessRunner.cs @@ -100,10 +100,15 @@ internal static async Task RunAsync( /// Runs a process and captures stdout, stderr and the exit code separately, for callers /// that need to show the user why something failed rather than just that it did. /// + /// + /// Trim surrounding whitespace from the captured streams. Pass when + /// the output is file content being written back to disk, where a trailing newline matters. + /// internal static async Task<(int exitCode, string output, string error)> CaptureWithErrorAsync( string fileName, string arguments, string? workingDirectory = null, + bool trimOutput = true, CancellationToken cancellationToken = default) { var psi = new ProcessStartInfo(fileName, arguments) @@ -129,9 +134,12 @@ internal static async Task RunAsync( await Task.WhenAll(outputTask, errorTask).ConfigureAwait(false); await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + string output = await outputTask.ConfigureAwait(false); + string error = await errorTask.ConfigureAwait(false); + return (process.ExitCode, - (await outputTask.ConfigureAwait(false)).Trim(), - (await errorTask.ConfigureAwait(false)).Trim()); + trimOutput ? output.Trim() : output, + trimOutput ? error.Trim() : error); } private static async Task StreamOutputAsync(StreamReader reader, string color) diff --git a/src/Tools/CLI/Infrastructure/ScaffoldedProject.cs b/src/Tools/CLI/Infrastructure/ScaffoldedProject.cs index b16f24739e..26deba9ef2 100644 --- a/src/Tools/CLI/Infrastructure/ScaffoldedProject.cs +++ b/src/Tools/CLI/Infrastructure/ScaffoldedProject.cs @@ -148,6 +148,21 @@ internal bool RemoveLocalFeedSource() return true; } + /// Reads the currently pinned FSH.Framework.* version, if the project has one. + internal string? GetFrameworkVersion() + { + if (!File.Exists(PackagesPropsPath)) return null; + + Match match = FrameworkVersionElement.Match(File.ReadAllText(PackagesPropsPath)); + if (!match.Success) return null; + + string value = match.Value; + int start = value.IndexOf('>', StringComparison.Ordinal) + 1; + int end = value.LastIndexOf('<'); + + return end > start ? value[start..end] : null; + } + /// Pins the version of the FSH.Framework.* packages the project consumes. internal bool SetFrameworkVersion(string version) { diff --git a/src/Tools/CLI/Program.cs b/src/Tools/CLI/Program.cs index 6c1a974767..e658bb59e6 100644 --- a/src/Tools/CLI/Program.cs +++ b/src/Tools/CLI/Program.cs @@ -34,6 +34,11 @@ config.AddCommand("info") .WithDescription("Show CLI and template version information."); + config.AddCommand("upgrade") + .WithDescription("Update an existing project to the latest template, as a reviewable git merge.") + .WithExample("upgrade", "--dry-run") + .WithExample("upgrade", "--project", "../my-app", "--merge"); + config.AddCommand("update") .WithDescription("Update the FSH CLI tool and dotnet new template to the latest version."); From ef5e1e987b3462d17cb8671f88e94da44f583be2 Mon Sep 17 00:00:00 2001 From: maxiar Date: Wed, 9 Sep 2026 16:30:28 -0300 Subject: [PATCH 5/7] fix(tests): skip kernel file checks when BuildingBlocks ships as packages 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) --- .../BuildingBlocksIndependenceTests.cs | 15 +++++----- .../KernelSourceOnlyFactAttribute.cs | 25 ++++++++++++++++ src/Tools/CLI/Commands/UpgradeCommand.cs | 30 +++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 src/Tests/Architecture.Tests/KernelSourceOnlyFactAttribute.cs diff --git a/src/Tests/Architecture.Tests/BuildingBlocksIndependenceTests.cs b/src/Tests/Architecture.Tests/BuildingBlocksIndependenceTests.cs index 509c2f9671..e3f586470d 100644 --- a/src/Tests/Architecture.Tests/BuildingBlocksIndependenceTests.cs +++ b/src/Tests/Architecture.Tests/BuildingBlocksIndependenceTests.cs @@ -18,6 +18,9 @@ public class BuildingBlocksIndependenceTests { private static readonly string SolutionRoot = ModuleArchitectureTestsFixture.SolutionRoot; + private static readonly string BuildingBlocksRoot = Path.Combine(SolutionRoot, "src", "BuildingBlocks"); + + private static readonly Assembly[] BuildingBlockAssemblies = [ typeof(IFshCore).Assembly, // Core @@ -69,13 +72,11 @@ public void BuildingBlocks_Should_Not_Depend_On_Hosts() } } - [Fact] + [KernelSourceOnlyFact] public void BuildingBlocks_Projects_Should_Not_Reference_Modules_Directly() { - string buildingBlocksRoot = Path.Combine(SolutionRoot, "src", "BuildingBlocks"); - var projects = Directory - .GetFiles(buildingBlocksRoot, "*.csproj", SearchOption.AllDirectories) + .GetFiles(BuildingBlocksRoot, "*.csproj", SearchOption.AllDirectories) .ToArray(); projects.Length.ShouldBeGreaterThan(0); @@ -117,7 +118,7 @@ public void BuildingBlocks_Projects_Should_Not_Reference_Modules_Directly() $"Violations: {string.Join(", ", violations)}"); } - [Fact] + [KernelSourceOnlyFact] public void Core_BuildingBlock_Should_Be_Dependency_Free() { // Core should only depend on .NET BCL and Mediator abstractions @@ -130,7 +131,7 @@ public void Core_BuildingBlock_Should_Be_Dependency_Free() "mscorlib" ]; - string coreProjectPath = Path.Combine(SolutionRoot, "src", "BuildingBlocks", "Core", "Core.csproj"); + string coreProjectPath = Path.Combine(BuildingBlocksRoot, "Core", "Core.csproj"); var document = XDocument.Load(coreProjectPath); var packageReferences = document @@ -210,7 +211,7 @@ private static void CheckBuildingBlockDependencies( string[] allowedDependencies, List violations) { - string projectPath = Path.Combine(SolutionRoot, "src", "BuildingBlocks", projectName, $"{projectName}.csproj"); + string projectPath = Path.Combine(BuildingBlocksRoot, projectName, $"{projectName}.csproj"); if (!File.Exists(projectPath)) { diff --git a/src/Tests/Architecture.Tests/KernelSourceOnlyFactAttribute.cs b/src/Tests/Architecture.Tests/KernelSourceOnlyFactAttribute.cs new file mode 100644 index 0000000000..a6d856b219 --- /dev/null +++ b/src/Tests/Architecture.Tests/KernelSourceOnlyFactAttribute.cs @@ -0,0 +1,25 @@ +using Xunit; + +namespace Architecture.Tests; + +/// +/// A fact that runs only when the kernel is present as source. +/// +/// +/// For checks that read the BuildingBlocks project files directly. A project scaffolded with +/// framework packaging consumes the kernel as FSH.Framework.* NuGet packages and has no +/// src/BuildingBlocks directory, so there is nothing to read - skipping states that plainly +/// instead of passing an assertion that never ran. Assembly-level architecture checks are +/// unaffected and keep running in both modes, since the assemblies come from the packages. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class KernelSourceOnlyFactAttribute : FactAttribute +{ + public KernelSourceOnlyFactAttribute() + { + if (!Directory.Exists(Path.Combine(ModuleArchitectureTestsFixture.SolutionRoot, "src", "BuildingBlocks"))) + { + Skip = "Kernel is consumed as NuGet packages; there are no BuildingBlocks project files to inspect."; + } + } +} diff --git a/src/Tools/CLI/Commands/UpgradeCommand.cs b/src/Tools/CLI/Commands/UpgradeCommand.cs index 6c37136c68..1340b1e8c0 100644 --- a/src/Tools/CLI/Commands/UpgradeCommand.cs +++ b/src/Tools/CLI/Commands/UpgradeCommand.cs @@ -119,6 +119,11 @@ protected override async Task ExecuteAsync(CommandContext context, Settings string worktree = Path.Combine(Path.GetTempPath(), $"fsh-worktree-{Guid.NewGuid():N}"); string branch = settings.Branch ?? "fsh/template-upgrade"; + // Remembered so the caller's checkout can be put back no matter how this exits. A tool + // that creates worktrees and branches in someone's repository must never leave them on a + // branch they did not ask for, and "it shouldn't happen" is not a guarantee. + string? originalBranch = await GitRunner.CurrentBranchAsync(project.Root, cancellationToken).ConfigureAwait(false); + try { if (!await GenerateScaffoldAsync(project, options, staging, cancellationToken).ConfigureAwait(false)) @@ -134,6 +139,8 @@ protected override async Task ExecuteAsync(CommandContext context, Settings await GitRunner.RunAsync(project.Root, $"worktree remove --force \"{worktree}\"", cancellationToken).ConfigureAwait(false); TryDelete(staging); TryDelete(worktree); + + await RestoreBranchAsync(project.Root, originalBranch, cancellationToken).ConfigureAwait(false); } } @@ -344,6 +351,29 @@ await GitRunner.RunAsync( return 1; } + /// + /// Puts the repository back on the branch it started on, if anything moved it. + /// + /// + /// A successful --merge already ends on the original branch, so this is a no-op there. + /// + private static async Task RestoreBranchAsync( + string repository, string? originalBranch, CancellationToken cancellationToken) + { + if (originalBranch is null or "HEAD") return; + + string? current = await GitRunner.CurrentBranchAsync(repository, cancellationToken).ConfigureAwait(false); + if (current is null || string.Equals(current, originalBranch, StringComparison.Ordinal)) return; + + (bool ok, _) = await GitRunner + .RunAsync(repository, $"checkout {originalBranch}", cancellationToken).ConfigureAwait(false); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine(ok + ? $"[{FshConstants.DimColor}]Restored the checkout to '{originalBranch.EscapeMarkup()}'.[/]" + : $"[{FshConstants.WarningColor}]Left on '{current.EscapeMarkup()}'; expected '{originalBranch.EscapeMarkup()}'. Run: git checkout {originalBranch.EscapeMarkup()}[/]"); + } + private static void TryDelete(string directory) { try From a00033489e3ca3fc37aea086bba82c9aea86e242 Mon Sep 17 00:00:00 2001 From: maxiar Date: Wed, 9 Sep 2026 16:31:17 -0300 Subject: [PATCH 6/7] docs(cli): add the end-to-end upgrade runbook `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) --- AGENTS.md | 4 +++ README-CLI.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ee9ee0b77f..a51be16958 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,8 +69,12 @@ Optional, off by default — the kernel as NuGet packages instead of scaffolded dotnet run --project src/Tools/CLI -- self install # install `fsh` built from this source fsh framework pack --push # build FSH.Framework.* into a local feed fsh new MyApp --framework-packages --agents -o ../my-app # scaffold against it, with the .agents kit +fsh upgrade # bring an existing project up to date ``` +Upgrading a project needs the kernel republished *and* the template merged; `README-CLI.md` +has the end-to-end runbook. + Migrations / seed (DbMigrator, separate step): ```bash dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply [--seed] diff --git a/README-CLI.md b/README-CLI.md index 62b8152a8f..ed231f6f41 100644 --- a/README-CLI.md +++ b/README-CLI.md @@ -13,7 +13,7 @@ This is the maintainer guide for three opt-in capabilities of the `fsh` CLI: **Contents** — [Why](#why-framework-packages) · [Setup](#one-time-setup) · [Install `fsh`](#installing-fsh-from-this-source) · [Daily loop](#the-everyday-loop) · [Fork scaffolding](#scaffolding-from-your-own-fork) · [Agents](#shipping-the-agents-kit) · [Debugging](#debugging-into-buildingblocks) · [Troubleshooting](#troubleshooting) · -[Reference](#command-reference) · [Upgrading](#updating-an-existing-project-to-a-newer-template) · [Swapping modes](#swapping-an-existing-project-between-the-two-modes) +[Reference](#command-reference) · [Upgrading](#updating-an-existing-project-to-a-newer-template) · [Runbook](#runbook-upgrading-a-project-end-to-end) · [Swapping modes](#swapping-an-existing-project-between-the-two-modes) --- @@ -323,6 +323,8 @@ That produces a normal DLL plus a `.snupkg` with SourceLink, which is what nuget | `Sequence contains more than one matching element` | two template packages share the FSH template identity | `dotnet new uninstall FullStackHero.NET.StarterKit`, then re-install (the CLI now does this for you) | | Project scaffolds with `src/BuildingBlocks` and no `NuGet.config` despite passing the flags | template symbol names (`--frameworkPackages`) were used instead of CLI option names (`--framework-packages`) | use the CLI spelling; unknown options are now a hard error rather than silently ignored | | `Framework feed not found: ~/.fsh/local-nuget` | no feed configured and packages live elsewhere | pass `--framework-feed ` or `export FSH_LOCAL_FEED=` | +| Upgraded project no longer compiles | the template merge landed but the kernel packages were not republished | do step 1 of the [runbook](#runbook-upgrading-a-project-end-to-end), then bump `FshFrameworkVersion` | +| `pack --push` reports success but the project restores old packages | `FSH_LOCAL_FEED` unset, so it published to `~/.fsh/local-nuget` | export it, re-pack, and delete the stray feed | | `Could not find the original scaffold commit` | project created with `--git false`, or history squashed | `fsh upgrade --from-scaffold ` | | `Working tree has uncommitted changes` | upgrade lands as a merge and needs a clean start | commit or stash first | | `fsh` runs but lacks the new options | the globally installed tool is the published build, not your fork | `dotnet run --project src/Tools/CLI -- self install` | @@ -484,6 +486,92 @@ A git repository with a clean working tree, and the original scaffold commit sti --- +## Runbook: upgrading a project end to end + +`fsh upgrade` moves the **owned source** — modules, hosts, tests, infrastructure. For a project on +framework packages, that is only part of the story: the kernel arrives through the feed, not +through the merge. Doing only one half is the most common way to end up with a project that will +not compile. + +A real example: the change that added SQL Server support touched 145 files, of which **19 were +BuildingBlocks**. Merging the other 126 without republishing those 19 leaves the merged code +calling framework APIs that the pinned packages do not have. + +So there are three steps, in this order. + +### 0. Set the environment once + +```bash +# ~/.zshrc or ~/.bashrc +export FSH_LOCAL_FEED=/Users/you/dev/nuget-local # where the framework packages live +export FSH_TEMPLATE_PATH=/Users/you/dev/dotnet-starter-kit # scaffold/upgrade from your fork +``` + +Do not skip this. Without `FSH_LOCAL_FEED`, `fsh framework pack --push` publishes to the default +`~/.fsh/local-nuget` while your project's `NuGet.config` keeps pointing somewhere else — the pack +reports success and the project restores the old packages. If it happens, delete the stray feed so +it cannot be picked up later. + +### 1. Republish the kernel — in the starter-kit clone + +```bash +cd ~/dev/dotnet-starter-kit +git pull # get the changes you want +fsh self install # only if the CLI itself changed +fsh framework pack --push --clear-cache +# -> Published 11 package(s) to /Users/you/dev/nuget-local +# -> 10.0.0-local.20260909T192142 <- note this version +``` + +Keep that version string; step 3 needs it. `--clear-cache` matters: NuGet caches by id+version, and +without it a rebuilt package can be served from cache instead of the feed. + +### 2. Merge the template changes — in the project + +```bash +cd ~/dev/falconsoft/fs-proxy +fsh upgrade --dry-run # confirm the detected scaffold commit and options +fsh upgrade # puts the template diff on fsh/template-upgrade +git merge fsh/template-upgrade +``` + +Your checkout is untouched until that `git merge`. Conflicts are ordinary git conflicts, and they +cluster where you customised something the template also changed — `DbMigrator/Program.cs` is the +usual one, since registering a module edits it (golden rule 2). Resolve, `git add`, `git commit`. + +Add `--merge` to have the command run the merge for you. + +### 3. Move to the new kernel — in the project + +```bash +# src/Directory.Packages.props +10.0.0-local.20260909T192142 +``` + +That is the version from step 1. Then verify: + +```bash +dotnet build src/.slnx -warnaserror +dotnet test src/Tests/Architecture.Tests +``` + +### Reading the result + +Some test failures after an upgrade are the point, not a regression. `MigrationDriftTests` will +fail for every `DbContext` you own that has no migrations for a newly added provider — that is the +suite telling you what work the upgrade created, and it is the natural next task. + +Tests marked `[KernelSourceOnlyFact]` report as **skipped** on a package-mode project. That is +correct: they read the BuildingBlocks `.csproj` files, which do not exist when the kernel ships as +packages. The assembly-level architecture checks still run. + +### If a project is on owned source instead + +Steps 1 and 3 do not apply — the kernel comes in through the merge with everything else. Run step 2 +alone. + +--- + ## Swapping an existing project between the two modes Framework packaging is not a one-way door, and you do not have to decide at scaffold time. From 98946e6a7dcc812b8d7fd745ebd3054814ca5159 Mon Sep 17 00:00:00 2001 From: maxiar Date: Wed, 9 Sep 2026 22:43:12 -0300 Subject: [PATCH 7/7] fix(security): read the Data Protection identity from configuration, not a constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/BuildingBlocks/Caching/Extensions.cs | 13 +++++++- .../DataProtectionApplicationName.cs | 32 +++++++++++++++++++ src/Host/FSH.Starter.Api/appsettings.json | 10 ++++++ .../DemoSeed/DemoSeeder.cs | 4 ++- src/Host/FSH.Starter.DbMigrator/Program.cs | 6 +++- 5 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 src/BuildingBlocks/Core/DataProtection/DataProtectionApplicationName.cs diff --git a/src/BuildingBlocks/Caching/Extensions.cs b/src/BuildingBlocks/Caching/Extensions.cs index bdfd138781..0c8fbe6c75 100644 --- a/src/BuildingBlocks/Caching/Extensions.cs +++ b/src/BuildingBlocks/Caching/Extensions.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.DataProtection; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Configuration; @@ -60,9 +61,19 @@ public static IServiceCollection AddHeroCaching(this IServiceCollection services // Persist Data Protection keys (auth cookies, reset/confirmation tokens, antiforgery) to // Redis so multi-instance hosts share a key ring and tokens survive rolling restarts. + // + // The application name is what Data Protection isolates keys by, so it MUST differ per + // application. It is read from configuration rather than hard-coded here: this file + // also ships as a compiled FSH.Framework.Caching package, where the template's token + // substitution cannot reach it, so a literal would make every project built on the + // package share one key ring - and two such apps pointed at the same Redis could + // decrypt each other's auth cookies and tokens. appsettings.json IS scaffolded source, + // so the value there is renamed per project in both distribution modes. services.AddDataProtection() .PersistKeysToStackExchangeRedis(sharedMultiplexer, "DataProtection-Keys") - .SetApplicationName("FSH.Starter"); + .SetApplicationName( + DataProtectionApplicationName.Resolve( + configuration[DataProtectionApplicationName.ConfigurationKey])); } // HybridCache auto-composes with whatever IDistributedCache is registered above. diff --git a/src/BuildingBlocks/Core/DataProtection/DataProtectionApplicationName.cs b/src/BuildingBlocks/Core/DataProtection/DataProtectionApplicationName.cs new file mode 100644 index 0000000000..9722bec73f --- /dev/null +++ b/src/BuildingBlocks/Core/DataProtection/DataProtectionApplicationName.cs @@ -0,0 +1,32 @@ +using System.Reflection; + +namespace FSH.Framework.Core.DataProtection; + +/// +/// Resolves the Data Protection application name, which is what Data Protection isolates keys by. +/// +/// +/// Read from configuration rather than hard-coded, because the framework also ships as compiled +/// NuGet packages where the template's token substitution cannot reach a literal. A literal would +/// make every project built on those packages share one key ring, and two such apps pointed at the +/// same store could decrypt each other's auth cookies and tokens. appsettings.json IS scaffolded +/// source, so the value there is renamed per project in both distribution modes. +/// +public static class DataProtectionApplicationName +{ + /// Configuration key holding the application name. + public const string ConfigurationKey = "DataProtection:ApplicationName"; + + /// + /// The configured value, else the entry assembly name. + /// + /// + /// Takes the already-read value rather than IConfiguration so Core keeps its + /// deliberately minimal dependency set. The fallback errs towards isolation: a host that forgot + /// the setting gets its own key ring rather than silently joining someone else's. + /// + public static string Resolve(string? configuredValue) => + !string.IsNullOrWhiteSpace(configuredValue) + ? configuredValue + : Assembly.GetEntryAssembly()?.GetName().Name ?? "FSH.Starter"; +} diff --git a/src/Host/FSH.Starter.Api/appsettings.json b/src/Host/FSH.Starter.Api/appsettings.json index 293fdfebb6..d67ecf494e 100644 --- a/src/Host/FSH.Starter.Api/appsettings.json +++ b/src/Host/FSH.Starter.Api/appsettings.json @@ -64,6 +64,16 @@ "OriginOptions": { "OriginUrl": "https://localhost:7030" }, + "DataProtection": { + // Data Protection isolates keys by application name, so this MUST be unique per application. + // Two apps sharing it against one Redis can decrypt each other's auth cookies and tokens. + "ApplicationName": "FSH.Starter" + }, + "Multitenancy": { + // Issuer recorded on the root tenant. Lives here rather than as a constant in the framework + // so it is per-project in both distribution modes. + "RootIssuer": "mukesh.murugan" + }, "CachingOptions": { "Redis": "" }, diff --git a/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs b/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs index 350efbdf43..c8cf026dac 100644 --- a/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs +++ b/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs @@ -275,7 +275,9 @@ private async Task SeedRootSuperAdminAsync(CancellationToken cancellationToken) name: MultitenancyConstants.Root.Name, connectionString: string.Empty, adminEmail: MultitenancyConstants.Root.EmailAddress, - issuer: MultitenancyConstants.Root.Issuer); + // See DbMigrator/Program.cs: the constant is only the fallback, since it cannot be + // renamed inside a compiled package. + issuer: _config["Multitenancy:RootIssuer"] ?? MultitenancyConstants.Root.Issuer); await SeedUsersInTenantAsync(rootTenant, BuildRootUsers(), [], cancellationToken).ConfigureAwait(false); } diff --git a/src/Host/FSH.Starter.DbMigrator/Program.cs b/src/Host/FSH.Starter.DbMigrator/Program.cs index dbdd12f345..2ac0492ff1 100644 --- a/src/Host/FSH.Starter.DbMigrator/Program.cs +++ b/src/Host/FSH.Starter.DbMigrator/Program.cs @@ -235,7 +235,11 @@ await Console.Out.WriteLineAsync(string.Create( MultitenancyConstants.Root.Name, connectionString: string.Empty, MultitenancyConstants.Root.EmailAddress, - issuer: MultitenancyConstants.Root.Issuer); + // From configuration, not the framework constant: BuildingBlocks also ships as a + // compiled package where the template cannot rename a literal, so the constant is + // only the last-resort default. appsettings.json is scaffolded source and is + // renamed per project in both modes. + issuer: builder.Configuration["Multitenancy:RootIssuer"] ?? MultitenancyConstants.Root.Issuer); rootTenant.SetValidity(TimeProvider.System.GetUtcNow().UtcDateTime.AddYears(1)); await tenantDb.TenantInfo.AddAsync(rootTenant, CancellationToken.None).ConfigureAwait(false); await tenantDb.SaveChangesAsync(CancellationToken.None).ConfigureAwait(false);