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/.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..a51be16958 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,18 @@ 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 +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 new file mode 100644 index 0000000000..ed231f6f41 --- /dev/null +++ b/README-CLI.md @@ -0,0 +1,636 @@ +# `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) · [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) + +--- + +## 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=` | +| 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` | +| `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 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 +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 +``` + +--- + +## 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`. + +--- + +## 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. +`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/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/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/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/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 0d38b28190..6111e9bd81 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -122,9 +122,10 @@ - - - + + + + @@ -144,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/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); 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/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..ac6d12dc6d --- /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: cancellationToken) + .ConfigureAwait(false); + + if (step.exitCode == 0) + { + step = await ProcessRunner + .CaptureWithErrorAsync("dotnet", $"pack \"{projectPath}\" -c Release --nologo {properties} -o \"{output}\"", + repoRoot, cancellationToken: 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..87165f7433 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); } @@ -269,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); } @@ -317,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; @@ -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..4fd979baf0 --- /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: 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: 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/Commands/UpgradeCommand.cs b/src/Tools/CLI/Commands/UpgradeCommand.cs new file mode 100644 index 0000000000..1340b1e8c0 --- /dev/null +++ b/src/Tools/CLI/Commands/UpgradeCommand.cs @@ -0,0 +1,388 @@ +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"; + + // 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)) + 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); + + await RestoreBranchAsync(project.Root, originalBranch, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// 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; + } + + /// + /// 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 + { + 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/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..5e681f5d88 100644 --- a/src/Tools/CLI/Infrastructure/FshConstants.cs +++ b/src/Tools/CLI/Infrastructure/FshConstants.cs @@ -6,6 +6,15 @@ 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"; + + // 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"; @@ -13,6 +22,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/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 4d2217ab6f..42652aad4c 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,52 @@ 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) + { + 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); + + string output = await outputTask.ConfigureAwait(false); + string error = await errorTask.ConfigureAwait(false); + + return (process.ExitCode, + trimOutput ? output.Trim() : output, + trimOutput ? error.Trim() : error); + } + 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..26deba9ef2 --- /dev/null +++ b/src/Tools/CLI/Infrastructure/ScaffoldedProject.cs @@ -0,0 +1,213 @@ +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; + } + + /// 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) + { + 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..e658bb59e6 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") @@ -26,8 +34,50 @@ 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."); + + // 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. --> -